- Split main.rs into 10 focused modules for better maintainability - Add 37 unit tests covering desktop entry, Tauri config, validation, Chromium backend, and app config serialization - Update version to v0.1.1-alpha.1
341 lines
12 KiB
Rust
341 lines
12 KiB
Rust
use anyhow::{Context, Result};
|
|
use directories::BaseDirs;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::chromium::{
|
|
backend_str, build_chromium_exec, chromium_profile_dir, profile_scope_str,
|
|
resolve_chromium_binary,
|
|
};
|
|
use crate::types::{Backend, DeskifyAppConfig, ProfileScope};
|
|
use crate::validation::{is_deskify_desktop_entry, validate_remove_id};
|
|
|
|
pub fn deskify_data_dir() -> Result<PathBuf> {
|
|
let base_dirs =
|
|
BaseDirs::new().ok_or_else(|| anyhow::anyhow!("Could not find system BaseDirs"))?;
|
|
Ok(base_dirs.data_local_dir().join("deskify"))
|
|
}
|
|
|
|
pub fn app_config_path(id: &str) -> Result<PathBuf> {
|
|
validate_remove_id(id)?;
|
|
Ok(deskify_data_dir()?
|
|
.join("apps")
|
|
.join(format!("{}.json", id)))
|
|
}
|
|
|
|
pub fn read_app_config(id: &str) -> Result<Option<DeskifyAppConfig>> {
|
|
let path = app_config_path(id)?;
|
|
if !path.exists() {
|
|
return Ok(None);
|
|
}
|
|
let content = fs::read_to_string(&path)
|
|
.with_context(|| format!("Failed to read app config {}", path.display()))?;
|
|
match serde_json::from_str::<DeskifyAppConfig>(&content) {
|
|
Ok(cfg) => Ok(Some(cfg)),
|
|
Err(err) => {
|
|
eprintln!(
|
|
"Warning: Failed to parse app config {} ({}). Falling back to desktop entry metadata.",
|
|
path.display(),
|
|
err
|
|
);
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn write_app_config_from_args(args: &crate::types::BuildArgs) -> Result<()> {
|
|
validate_remove_id(&args.internal_id)?;
|
|
let path = app_config_path(&args.internal_id)?;
|
|
if let Some(dir) = path.parent() {
|
|
fs::create_dir_all(dir).context("Failed to create app config directory")?;
|
|
}
|
|
let cfg = DeskifyAppConfig {
|
|
schema_version: 1,
|
|
id: args.internal_id.clone(),
|
|
name: args.name.clone(),
|
|
url: args.url.clone(),
|
|
backend: args.backend,
|
|
browser_bin: args.browser_bin.clone(),
|
|
profile_scope: args.profile_scope,
|
|
fullscreen: args.fullscreen,
|
|
no_decorations: args.no_decorations,
|
|
user_agent: args.user_agent.clone(),
|
|
width: args.width,
|
|
height: args.height,
|
|
dark_mode: args.dark_mode,
|
|
};
|
|
fs::write(
|
|
&path,
|
|
serde_json::to_string_pretty(&cfg).context("Failed to serialize app config")?,
|
|
)
|
|
.with_context(|| format!("Failed to write app config {}", path.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn remove_app_config(id: &str) -> Result<()> {
|
|
let path = app_config_path(id)?;
|
|
if path.exists() {
|
|
fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
|
|
println!("Removed app config: {:?}", path);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn list_apps_with_options(verbose: bool) -> Result<()> {
|
|
let base_dirs =
|
|
BaseDirs::new().ok_or_else(|| anyhow::anyhow!("Could not find system BaseDirs"))?;
|
|
let applications_dir = base_dirs.data_local_dir().join("applications");
|
|
|
|
println!("Installed Deskify Apps:");
|
|
let mut found = false;
|
|
|
|
if applications_dir.exists() {
|
|
for entry in
|
|
fs::read_dir(applications_dir).context("Failed to read applications directory")?
|
|
{
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.extension().and_then(|s| s.to_str()) == Some("desktop")
|
|
&& let Ok(content) = fs::read_to_string(&path)
|
|
&& is_deskify_desktop_entry(&content)
|
|
{
|
|
let name = content
|
|
.lines()
|
|
.find(|l| l.starts_with("Name="))
|
|
.and_then(|l| l.strip_prefix("Name="))
|
|
.unwrap_or("Unknown");
|
|
|
|
let internal_id = path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("Unknown");
|
|
let backend = content
|
|
.lines()
|
|
.find(|l| l.starts_with("X-Deskify-Backend="))
|
|
.and_then(|l| l.strip_prefix("X-Deskify-Backend="))
|
|
.unwrap_or("legacy");
|
|
|
|
if verbose && validate_remove_id(internal_id).is_ok() {
|
|
if let Some(cfg) = read_app_config(internal_id)? {
|
|
println!(
|
|
"- {} (Internal ID: {}, Backend: {}, URL: {})",
|
|
name,
|
|
internal_id,
|
|
backend_str(cfg.backend),
|
|
cfg.url
|
|
);
|
|
} else {
|
|
let url = content
|
|
.lines()
|
|
.find(|l| l.starts_with("X-Deskify-URL="))
|
|
.and_then(|l| l.strip_prefix("X-Deskify-URL="));
|
|
if let Some(url) = url {
|
|
println!(
|
|
"- {} (Internal ID: {}, Backend: {}, URL: {})",
|
|
name, internal_id, backend, url
|
|
);
|
|
} else {
|
|
println!(
|
|
"- {} (Internal ID: {}, Backend: {})",
|
|
name, internal_id, backend
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
println!(
|
|
"- {} (Internal ID: {}, Backend: {})",
|
|
name, internal_id, backend
|
|
);
|
|
}
|
|
found = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
println!("No apps found.");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn read_desktop_entry_value(id: &str, key: &str) -> Result<Option<String>> {
|
|
validate_remove_id(id)?;
|
|
let base_dirs =
|
|
BaseDirs::new().ok_or_else(|| anyhow::anyhow!("Could not find system BaseDirs"))?;
|
|
let desktop_path = base_dirs
|
|
.data_local_dir()
|
|
.join("applications")
|
|
.join(format!("{}.desktop", id));
|
|
if !desktop_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
let content = fs::read_to_string(&desktop_path)
|
|
.with_context(|| format!("Failed to read desktop entry {}", desktop_path.display()))?;
|
|
if !is_deskify_desktop_entry(&content) {
|
|
return Ok(None);
|
|
}
|
|
let prefix = format!("{}=", key);
|
|
Ok(content
|
|
.lines()
|
|
.find(|line| line.starts_with(&prefix))
|
|
.and_then(|line| line.strip_prefix(&prefix))
|
|
.map(str::to_string))
|
|
}
|
|
|
|
pub fn read_installed_app_display_name(id: &str) -> Result<Option<String>> {
|
|
validate_remove_id(id)?;
|
|
let base_dirs =
|
|
BaseDirs::new().ok_or_else(|| anyhow::anyhow!("Could not find system BaseDirs"))?;
|
|
let desktop_path = base_dirs
|
|
.data_local_dir()
|
|
.join("applications")
|
|
.join(format!("{}.desktop", id));
|
|
if !desktop_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
let content = fs::read_to_string(&desktop_path)
|
|
.with_context(|| format!("Failed to read desktop entry {}", desktop_path.display()))?;
|
|
if !is_deskify_desktop_entry(&content) {
|
|
return Ok(None);
|
|
}
|
|
let name = content
|
|
.lines()
|
|
.find(|line| line.starts_with("Name="))
|
|
.and_then(|line| line.strip_prefix("Name="))
|
|
.map(str::to_string);
|
|
Ok(name)
|
|
}
|
|
|
|
pub fn remove_app_with_options(safe_name: &str, remove_profile: bool) -> Result<()> {
|
|
validate_remove_id(safe_name)?;
|
|
|
|
let base_dirs =
|
|
BaseDirs::new().ok_or_else(|| anyhow::anyhow!("Could not find system BaseDirs"))?;
|
|
|
|
let executable_dir = base_dirs
|
|
.executable_dir()
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| base_dirs.home_dir().join(".local/bin"));
|
|
let data_local_dir = base_dirs.data_local_dir();
|
|
|
|
let binary_path = executable_dir.join(safe_name);
|
|
if binary_path.exists() {
|
|
fs::remove_file(&binary_path).context("Failed to remove binary")?;
|
|
println!("Removed binary: {:?}", binary_path);
|
|
} else {
|
|
println!("Binary not found: {:?}", binary_path);
|
|
}
|
|
|
|
let desktop_path = data_local_dir
|
|
.join("applications")
|
|
.join(format!("{}.desktop", safe_name));
|
|
if desktop_path.exists() {
|
|
fs::remove_file(&desktop_path).context("Failed to remove .desktop file")?;
|
|
println!("Removed desktop entry: {:?}", desktop_path);
|
|
} else {
|
|
println!("Desktop entry not found: {:?}", desktop_path);
|
|
}
|
|
|
|
let icon_path = data_local_dir
|
|
.join("icons/hicolor/128x128/apps")
|
|
.join(format!("{}.png", safe_name));
|
|
if icon_path.exists() {
|
|
fs::remove_file(&icon_path).context("Failed to remove icon")?;
|
|
println!("Removed icon: {:?}", icon_path);
|
|
} else {
|
|
println!("Icon not found: {:?}", icon_path);
|
|
}
|
|
|
|
let profile_path = data_local_dir.join("deskify/profiles").join(safe_name);
|
|
if remove_profile {
|
|
if profile_path.exists() {
|
|
fs::remove_dir_all(&profile_path).context("Failed to remove Chromium profile")?;
|
|
println!("Removed Chromium profile: {:?}", profile_path);
|
|
} else {
|
|
println!("Chromium profile not found: {:?}", profile_path);
|
|
}
|
|
} else if profile_path.exists() {
|
|
println!("Keeping Chromium profile: {:?}", profile_path);
|
|
}
|
|
|
|
println!("Successfully removed app '{}'.", safe_name);
|
|
let _ = remove_app_config(safe_name);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn print_generated_config(args: &crate::types::BuildArgs) -> Result<()> {
|
|
match args.backend {
|
|
Backend::Tauri => {
|
|
let config = crate::tauri::build_tauri_config_value(args, &args.internal_id, false);
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&config)
|
|
.context("Failed to serialize generated config")?
|
|
);
|
|
}
|
|
Backend::Chromium => {
|
|
let browser = resolve_chromium_binary(args.browser_bin.as_deref())?;
|
|
let profile_dir = chromium_profile_dir(&args.internal_id)?;
|
|
let profile_dir = match args.profile_scope {
|
|
ProfileScope::Isolated => Some(profile_dir),
|
|
ProfileScope::Shared => None,
|
|
};
|
|
let exec = build_chromium_exec(args, &browser, profile_dir.as_deref());
|
|
let value = serde_json::json!({
|
|
"backend": "chromium",
|
|
"name": args.name,
|
|
"id": args.internal_id,
|
|
"url": args.url,
|
|
"browser": browser.display().to_string(),
|
|
"profileScope": profile_scope_str(args.profile_scope),
|
|
"exec": exec,
|
|
});
|
|
println!(
|
|
"{}",
|
|
serde_json::to_string_pretty(&value)
|
|
.context("Failed to serialize generated config")?
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn print_build_plan(action: &str, args: &crate::types::BuildArgs, existing_id: Option<&str>) {
|
|
let id = existing_id.unwrap_or(&args.internal_id);
|
|
println!("{} plan:", action);
|
|
if let Some(id) = existing_id {
|
|
println!("- Existing app ID: {}", id);
|
|
}
|
|
println!("- URL: {}", args.url);
|
|
println!("- Name: {}", args.name);
|
|
println!("- Internal ID: {}", id);
|
|
match args.backend {
|
|
Backend::Tauri => {
|
|
println!("- Backend: tauri (system WebView)");
|
|
println!("- Local build: cargo tauri build (temporary generated project)");
|
|
println!("- Install targets: local binary + XDG icon + .desktop entry");
|
|
}
|
|
Backend::Chromium => {
|
|
println!("- Backend: chromium (installed browser runtime)");
|
|
println!("- Local build: none");
|
|
println!("- Install targets: XDG icon + .desktop entry (+ optional isolated profile)");
|
|
println!("- Profile scope: {}", profile_scope_str(args.profile_scope));
|
|
if let Some(path) = &args.browser_bin {
|
|
println!("- Browser binary: {}", path);
|
|
} else {
|
|
println!("- Browser binary: auto-detect from PATH");
|
|
}
|
|
if args.no_decorations {
|
|
println!("- Note: --no-decorations is not guaranteed in Chromium app mode");
|
|
}
|
|
}
|
|
}
|
|
if args.fullscreen {
|
|
println!("- Window: fullscreen enabled");
|
|
}
|
|
if args.no_decorations {
|
|
println!("- Window: native decorations disabled");
|
|
}
|
|
}
|