Files
deskify/src/app.rs
Sebastian Palencsar 1fafa13e4a Refactor code into modular structure and add comprehensive tests
- 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
2026-05-04 18:25:42 +02:00

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");
}
}