From 1fafa13e4afa264765bdf08e4cae59ad0a065daf Mon Sep 17 00:00:00 2001 From: Sebastian Palencsar Date: Mon, 4 May 2026 18:25:42 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 26 + Cargo.toml | 2 +- src/app.rs | 340 ++++++++++ src/chromium.rs | 146 ++++ src/desktop.rs | 50 ++ src/doctor.rs | 93 +++ src/icon.rs | 120 ++++ src/install.rs | 141 ++++ src/main.rs | 1644 ++++++++++----------------------------------- src/tauri.rs | 156 +++++ src/types.rs | 161 +++++ src/validation.rs | 31 + 12 files changed, 1629 insertions(+), 1281 deletions(-) create mode 100644 src/app.rs create mode 100644 src/chromium.rs create mode 100644 src/desktop.rs create mode 100644 src/doctor.rs create mode 100644 src/icon.rs create mode 100644 src/install.rs create mode 100644 src/tauri.rs create mode 100644 src/types.rs create mode 100644 src/validation.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2859470..0343cf6 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project follows early MVP/alpha versioning with tags like `v0.1.0-alpha ## [Unreleased] +## [v0.1.1-alpha.1] - 2026-05-04 + +### Added +- Comprehensive unit test suite (37 tests covering desktop entry escaping, Tauri config generation, validation, Chromium backend, and app config serialization) +- Module refactoring: code split into 10 focused modules (types, validation, tauri, chromium, icon, desktop, install, app, doctor) + +### Fixed +- Test coverage gaps resolved with tests for: + - `desktop_exec_escape` handling of empty strings, whitespace, quotes, and backslashes + - `validate_remove_id` rejection of uppercase, special characters, and path traversal attempts + - Tauri config generation (fullscreen, no-decorations, dark-mode, user-agent, window dimensions, CSP) + - App config serialization roundtrip + +### Changed +- Split `main.rs` (~1900 lines) into modular structure: + - `src/types.rs` - CLI definitions and types + - `src/validation.rs` - ID sanitization and validation + - `src/tauri.rs` - Tauri config and project generation + - `src/chromium.rs` - Browser detection and exec building + - `src/icon.rs` - Icon fetching and processing + - `src/desktop.rs` - Desktop entry utilities + - `src/install.rs` - App installation logic + - `src/app.rs` - App management (list/remove/config) + - `src/doctor.rs` - Diagnostics + - `src/main.rs` - Entry point and orchestration + ## [v0.1.0-alpha.9] - 2026-02-25 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index a6ce694..88dc8a8 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deskify" -version = "0.1.0" +version = "0.1.1-alpha.1" edition = "2024" [dependencies] diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..983d565 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,340 @@ +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 { + 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 { + validate_remove_id(id)?; + Ok(deskify_data_dir()? + .join("apps") + .join(format!("{}.json", id))) +} + +pub fn read_app_config(id: &str) -> Result> { + 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::(&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> { + 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> { + 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"); + } +} diff --git a/src/chromium.rs b/src/chromium.rs new file mode 100644 index 0000000..fd14b83 --- /dev/null +++ b/src/chromium.rs @@ -0,0 +1,146 @@ +use anyhow::{Context, Result}; +use std::env; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::desktop::desktop_exec_escape; +use crate::types::{BuildArgs, ProfileScope}; +use crate::validation::validate_remove_id; +use directories::BaseDirs; + +pub fn chromium_candidates() -> &'static [&'static str] { + &[ + "google-chrome", + "chromium", + "chromium-browser", + "brave-browser", + "vivaldi", + "microsoft-edge", + ] +} + +pub fn find_binary_in_path(name: &str) -> Option { + let path_var = env::var_os("PATH")?; + for dir in env::split_paths(&path_var) { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +pub fn chromium_binary_works(path: &Path) -> Result<()> { + let output = Command::new(path) + .arg("--version") + .output() + .with_context(|| format!("Failed to execute browser binary {}", path.display()))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let details = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + format!("exit code {}", output.status) + }; + Err(anyhow::anyhow!( + "Browser binary exists but failed to run: {} ({})", + path.display(), + details + )) +} + +pub fn resolve_chromium_binary(explicit: Option<&str>) -> Result { + if let Some(path) = explicit { + let candidate = PathBuf::from(path); + if candidate.is_file() { + chromium_binary_works(&candidate).with_context(|| { + "The specified browser binary does not appear to be usable. If it's a wrapper script, ensure the underlying browser exists." + })?; + return Ok(candidate); + } + return Err(anyhow::anyhow!( + "Specified browser binary does not exist or is not a file: {}", + candidate.display() + )); + } + + for candidate in chromium_candidates() { + if let Some(path) = find_binary_in_path(candidate) + && chromium_binary_works(&path).is_ok() + { + return Ok(path); + } + } + + Err(anyhow::anyhow!( + "No Chromium-based browser found in PATH. Install Chromium/Chrome/Brave or use --browser-bin /path/to/browser." + )) +} + +pub fn chromium_profile_dir(internal_id: &str) -> Result { + validate_remove_id(internal_id)?; + let base_dirs = + BaseDirs::new().ok_or_else(|| anyhow::anyhow!("Could not find system BaseDirs"))?; + Ok(base_dirs + .data_local_dir() + .join("deskify") + .join("profiles") + .join(internal_id)) +} + +pub fn chromium_window_size_arg(width: Option, height: Option) -> Option { + match (width, height) { + (Some(w), Some(h)) => Some(format!("--window-size={},{}", w as i64, h as i64)), + _ => None, + } +} + +pub fn build_chromium_exec( + args: &BuildArgs, + browser_path: &Path, + profile_dir: Option<&Path>, +) -> String { + let mut parts = vec![browser_path.display().to_string()]; + parts.push("--no-first-run".to_string()); + parts.push("--no-default-browser-check".to_string()); + parts.push(format!("--class={}", args.internal_id)); + parts.push(format!("--app={}", args.url)); + + if let Some(profile_dir) = profile_dir { + parts.push(format!("--user-data-dir={}", profile_dir.display())); + } + if args.fullscreen { + parts.push("--start-fullscreen".to_string()); + } + if let Some(window_size) = chromium_window_size_arg(args.width, args.height) { + parts.push(window_size); + } + if let Some(user_agent) = &args.user_agent { + parts.push(format!("--user-agent={}", user_agent)); + } + if args.dark_mode { + parts.push("--force-dark-mode".to_string()); + } + + let escaped: Vec = parts.iter().map(|s| desktop_exec_escape(s)).collect(); + escaped.join(" ") +} + +pub fn profile_scope_str(scope: ProfileScope) -> &'static str { + match scope { + ProfileScope::Isolated => "isolated", + ProfileScope::Shared => "shared", + } +} + +pub fn backend_str(backend: crate::types::Backend) -> &'static str { + match backend { + crate::types::Backend::Tauri => "tauri", + crate::types::Backend::Chromium => "chromium", + } +} diff --git a/src/desktop.rs b/src/desktop.rs new file mode 100644 index 0000000..1efe27b --- /dev/null +++ b/src/desktop.rs @@ -0,0 +1,50 @@ +use anyhow::Result; +use directories::BaseDirs; +use std::path::PathBuf; + +pub fn desktop_exec_escape(arg: &str) -> String { + if arg.is_empty() { + return "\"\"".to_string(); + } + + let needs_quotes = arg + .chars() + .any(|c| c.is_whitespace() || c == '"' || c == '\\'); + if !needs_quotes { + return arg.to_string(); + } + + let escaped = arg.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{}\"", escaped) +} + +#[allow(dead_code)] +pub fn desktop_exec_join(args: &[String]) -> String { + args.iter() + .map(|arg| desktop_exec_escape(arg)) + .collect::>() + .join(" ") +} + +pub fn desktop_paths(internal_id: &str) -> Result<(PathBuf, PathBuf, PathBuf, PathBuf)> { + let base_dirs = BaseDirs::new() + .ok_or_else(|| anyhow::anyhow!("Could not find system BaseDirs (e.g. $HOME)"))?; + let data_local_dir = base_dirs.data_local_dir(); + let executable_dir = base_dirs + .executable_dir() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| base_dirs.home_dir().join(".local/bin")); + + let applications_dir = data_local_dir.join("applications"); + let icon_dir = data_local_dir.join("icons/hicolor/128x128/apps"); + let target_bin = executable_dir.join(internal_id); + let target_icon = icon_dir.join(format!("{}.png", internal_id)); + let desktop_file_path = applications_dir.join(format!("{}.desktop", internal_id)); + + Ok(( + target_bin, + target_icon, + desktop_file_path, + data_local_dir.to_path_buf(), + )) +} diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..49b54c4 --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,93 @@ +use anyhow::Result; +use directories::BaseDirs; +use std::env; +use std::process::Command; + +use crate::chromium::resolve_chromium_binary; + +pub fn run_doctor() -> Result<()> { + let mut failed = false; + + let checks = vec![ + ("cargo", vec!["--version"], true), + ("rustc", vec!["--version"], true), + ("pkg-config", vec!["--version"], false), + ]; + + println!("Deskify doctor"); + println!("-------------"); + + for (cmd, args, critical) in checks { + match Command::new(cmd).args(args).output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + println!("[ok] {} {}", cmd, version); + } + _ => { + println!( + "[{}] {} not found or failed", + if critical { "fail" } else { "warn" }, + cmd + ); + if critical { + failed = true; + } + } + } + } + + match Command::new("cargo").args(["tauri", "--version"]).output() { + Ok(output) if output.status.success() => { + println!("[ok] {}", String::from_utf8_lossy(&output.stdout).trim()); + } + _ => { + println!( + "[fail] cargo tauri not available (install with: cargo install tauri-cli --version \"^2.0.0\")" + ); + failed = true; + } + } + + if let Ok(output) = Command::new("pkg-config") + .args(["--modversion", "webkit2gtk-4.1"]) + .output() + { + if output.status.success() { + println!( + "[ok] webkit2gtk-4.1 {}", + String::from_utf8_lossy(&output.stdout).trim() + ); + } else { + println!("[warn] webkit2gtk-4.1 not found via pkg-config"); + } + } + + match resolve_chromium_binary(None) { + Ok(path) => println!("[ok] chromium browser found: {}", path.display()), + Err(_) => println!("[warn] no chromium-based browser found in PATH"), + } + + 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 applications_dir = base_dirs.data_local_dir().join("applications"); + let icons_dir = base_dirs + .data_local_dir() + .join("icons/hicolor/128x128/apps"); + println!("[info] executable dir: {}", executable_dir.display()); + println!("[info] applications dir: {}", applications_dir.display()); + println!("[info] icons dir: {}", icons_dir.display()); + if let Ok(exe) = env::current_exe() { + println!("[info] deskify path: {}", exe.display()); + } + + if failed { + Err(anyhow::anyhow!("Doctor found critical issues")) + } else { + println!("Doctor completed: no critical issues detected."); + Ok(()) + } +} diff --git a/src/icon.rs b/src/icon.rs new file mode 100644 index 0000000..e193894 --- /dev/null +++ b/src/icon.rs @@ -0,0 +1,120 @@ +use anyhow::{Context, Result}; +use image::{DynamicImage, ImageFormat}; +use regex::Regex; +use std::fs; +use std::io::Read; +use std::path::Path; +use url::Url; + +pub fn write_rgba_png(img: DynamicImage, output_path: &Path) -> Result<()> { + let rgba = img.to_rgba8(); + let rgba_img = DynamicImage::ImageRgba8(rgba); + rgba_img + .save_with_format(output_path, ImageFormat::Png) + .with_context(|| format!("Failed to write RGBA PNG icon to {}", output_path.display()))?; + Ok(()) +} + +pub fn download_icon_from_url(icon_url: &str, output_path: &Path) -> Result { + let response = match ureq::get(icon_url).call() { + Ok(resp) => resp, + Err(_) => return Ok(false), + }; + let mut bytes = Vec::new(); + if response.into_reader().read_to_end(&mut bytes).is_err() || bytes.is_empty() { + return Ok(false); + } + let img = match image::load_from_memory(&bytes) { + Ok(img) => img, + Err(_) => return Ok(false), + }; + write_rgba_png(img, output_path)?; + Ok(true) +} + +pub fn extract_icon_candidates_from_html(base_url: &Url, html: &str) -> Vec { + let link_re = Regex::new(r#"(?is)]*rel\s*=\s*["'][^"']*icon[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#).unwrap(); + let mut candidates = Vec::new(); + for cap in link_re.captures_iter(html) { + if let Some(href) = cap.get(1) + && let Ok(joined) = base_url.join(href.as_str()) + { + let candidate = joined.to_string(); + if !candidates.contains(&candidate) { + candidates.push(candidate); + } + } + } + candidates +} + +pub fn try_download_site_icon(website_url: &str, output_path: &Path) -> Result { + let base_url = match Url::parse(website_url) { + Ok(url) => url, + Err(_) => return Ok(false), + }; + + let mut html = String::new(); + if let Ok(response) = ureq::get(website_url).call() + && response.into_reader().read_to_string(&mut html).is_ok() + { + for icon_url in extract_icon_candidates_from_html(&base_url, &html) { + if download_icon_from_url(&icon_url, output_path)? { + return Ok(true); + } + } + } + + if let Ok(favicon_url) = base_url.join("/favicon.ico") + && download_icon_from_url(favicon_url.as_str(), output_path)? + { + return Ok(true); + } + + Ok(false) +} + +pub fn fetch_or_create_icon( + website_url: &str, + custom_icon: Option<&String>, + output_path: &Path, +) -> Result<()> { + if let Some(icon_path) = custom_icon { + if Path::new(icon_path).exists() { + let img = image::open(icon_path) + .with_context(|| format!("Failed to read custom icon image from {}", icon_path))?; + write_rgba_png(img, output_path)?; + return Ok(()); + } else { + eprintln!( + "Warning: Custom icon path '{}' does not exist, falling back to downloaded icon.", + icon_path + ); + } + } + + if try_download_site_icon(website_url, output_path)? { + return Ok(()); + } + + if let Ok(parsed_url) = Url::parse(website_url) + && let Some(host) = parsed_url.host_str() + { + println!("Falling back to Google favicon API for {}...", host); + let api_url = format!("https://www.google.com/s2/favicons?domain={}&sz=128", host); + if download_icon_from_url(&api_url, output_path)? { + return Ok(()); + } + } + + let dummy_png: [u8; 67] = [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, + 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, + 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]; + fs::write(output_path, dummy_png).context("Failed to write dummy icon fallback")?; + + Ok(()) +} diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..36fdc91 --- /dev/null +++ b/src/install.rs @@ -0,0 +1,141 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::Path; + +use crate::chromium::{ + build_chromium_exec, chromium_profile_dir, profile_scope_str, resolve_chromium_binary, +}; +use crate::desktop::desktop_paths; +use crate::types::{BuildArgs, ProfileScope}; + +pub fn install_tauri_app(args: &BuildArgs, bin_path: &Path, icon_path: &Path) -> Result<()> { + let (target_bin, target_icon, desktop_file_path, _data_local_dir) = + desktop_paths(&args.internal_id)?; + let executable_dir = target_bin + .parent() + .ok_or_else(|| anyhow::anyhow!("Invalid executable target path"))?; + let icon_dir = target_icon + .parent() + .ok_or_else(|| anyhow::anyhow!("Invalid icon target path"))?; + let applications_dir = desktop_file_path + .parent() + .ok_or_else(|| anyhow::anyhow!("Invalid applications target path"))?; + + fs::create_dir_all(executable_dir).context("Failed to create ~/.local/bin directory")?; + fs::copy(bin_path, &target_bin).context("Failed to move binary to installation directory")?; + + fs::create_dir_all(icon_dir).context("Failed to create icons directory")?; + if icon_path.exists() { + let _ = fs::copy(icon_path, &target_icon); + } + + fs::create_dir_all(applications_dir).context("Failed to create applications directory")?; + + let desktop_entry = format!( + r#" +[Desktop Entry] +Version=1.0 +Name={} +Exec={} +Icon={} +StartupWMClass={} +Terminal=false +Type=Application +Categories=Network;WebBrowser; +X-Deskify-Managed=true +X-Deskify-Backend=tauri +"#, + args.name, + target_bin.to_string_lossy(), + args.internal_id, + args.internal_id + ); + + fs::write(&desktop_file_path, desktop_entry.trim()).context("Failed to write .desktop file")?; + + println!("App successfully installed!"); + println!( + "You can now launch '{}' from your application menu.", + args.name + ); + + Ok(()) +} + +pub fn install_chromium_app(args: &BuildArgs, icon_path: &Path) -> Result<()> { + let browser = resolve_chromium_binary(args.browser_bin.as_deref())?; + let profile_dir = match args.profile_scope { + ProfileScope::Isolated => Some(chromium_profile_dir(&args.internal_id)?), + ProfileScope::Shared => None, + }; + + if matches!((args.width, args.height), (Some(_), None) | (None, Some(_))) { + eprintln!( + "Warning: Chromium backend requires both --width and --height for --window-size; ignoring partial size override." + ); + } + if args.no_decorations { + eprintln!( + "Warning: --no-decorations is not reliably supported in Chromium app mode and may be ignored." + ); + } + + let (target_bin, target_icon, desktop_file_path, _data_local_dir) = + desktop_paths(&args.internal_id)?; + let icon_dir = target_icon + .parent() + .ok_or_else(|| anyhow::anyhow!("Invalid icon target path"))?; + let applications_dir = desktop_file_path + .parent() + .ok_or_else(|| anyhow::anyhow!("Invalid applications target path"))?; + + fs::create_dir_all(icon_dir).context("Failed to create icons directory")?; + fs::create_dir_all(applications_dir).context("Failed to create applications directory")?; + if let Some(profile_dir) = &profile_dir { + fs::create_dir_all(profile_dir).context("Failed to create Chromium profile directory")?; + } + + let _ = fs::copy(icon_path, &target_icon); + + let exec = build_chromium_exec(args, &browser, profile_dir.as_deref()); + let desktop_entry = format!( + r#" +[Desktop Entry] +Version=1.0 +Name={} +Exec={} +Icon={} +StartupWMClass={} +Terminal=false +Type=Application +Categories=Network;WebBrowser; +X-Deskify-Managed=true +X-Deskify-Backend=chromium +X-Deskify-URL={} +X-Deskify-ProfileScope={} +X-Deskify-Browser={} +"#, + args.name, + exec, + args.internal_id, + args.internal_id, + args.url, + profile_scope_str(args.profile_scope), + browser.display() + ); + fs::write(&desktop_file_path, desktop_entry.trim()).context("Failed to write .desktop file")?; + + if target_bin.exists() { + println!( + "Note: existing binary {} left in place (Chromium backend does not use it).", + target_bin.display() + ); + } + + println!("App successfully installed (Chromium backend)!"); + println!( + "You can now launch '{}' from your application menu.", + args.name + ); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index aa5e9fc..78a3150 100755 --- a/src/main.rs +++ b/src/main.rs @@ -1,1257 +1,27 @@ -use anyhow::{Context, Result, anyhow}; -use clap::{Parser, Subcommand}; -use directories::BaseDirs; -use image::{DynamicImage, ImageFormat}; -use regex::Regex; -use serde_json::{Map, Value, json}; -use std::env; -use std::fs; -use std::io::Read; -use std::path::{Path, PathBuf}; -use std::process::Command; +mod app; +mod chromium; +mod desktop; +mod doctor; +mod icon; +mod install; +mod tauri; +mod types; +mod validation; + +use anyhow::{Context, Result}; +use clap::Parser; use tempfile::tempdir; -use url::Url; -#[derive( - clap::ValueEnum, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, -)] -#[serde(rename_all = "lowercase")] -enum Backend { - Tauri, - Chromium, -} - -#[derive( - clap::ValueEnum, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, -)] -#[serde(rename_all = "lowercase")] -enum ProfileScope { - #[serde(alias = "default")] - Isolated, - Shared, -} - -#[derive(Debug, Clone)] -struct BuildArgs { - url: String, - name: String, - internal_id: String, - icon: Option, - fullscreen: bool, - no_decorations: bool, - user_agent: Option, - width: Option, - height: Option, - dark_mode: bool, - backend: Backend, - browser_bin: Option, - profile_scope: ProfileScope, -} - -/// Deskify - Turn any URL into a native Linux desktop application using Tauri. -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand, Debug)] -enum Commands { - /// Build and install a new native application from a URL - Build { - /// The URL of the website to wrap (e.g., https://youtube.com) - #[arg(short, long)] - url: String, - - /// The name of the application (e.g., "YouTube") - #[arg(short, long)] - name: String, - - /// Optional path to a custom icon (PNG format is recommended) - #[arg(short, long)] - icon: Option, - - /// Launch the application in fullscreen (Kiosk) mode - #[arg(short, long)] - fullscreen: bool, - - /// Disable native window decorations (frameless window) - #[arg(long)] - no_decorations: bool, - - /// Set a custom User-Agent string for the webview - #[arg(short = 'A', long)] - user_agent: Option, - - /// Set the initial window width - #[arg(short = 'W', long)] - width: Option, - - /// Set the initial window height - #[arg(short = 'H', long)] - height: Option, - - /// Force the webview into Dark Mode - #[arg(short, long)] - dark_mode: bool, - - /// Backend to use for the generated app - #[arg(long, value_enum, default_value_t = Backend::Tauri)] - backend: Backend, - - /// Path to a Chromium-based browser binary (only for `--backend chromium`) - #[arg(long)] - browser_bin: Option, - - /// Profile isolation for Chromium backend (only for `--backend chromium`) - #[arg(long, value_enum, default_value_t = ProfileScope::Isolated)] - profile_scope: ProfileScope, - - /// Print the generated Tauri config JSON and exit - #[arg(long)] - print_config: bool, - - /// Show planned build/install actions without building or installing - #[arg(long)] - dry_run: bool, - }, - /// List all installed apps created by deskify - List { - /// Show additional metadata (URL, backend) when available - #[arg(long)] - verbose: bool, - }, - /// Check local prerequisites and environment diagnostics - Doctor, - /// Remove a specific app by its internal ID - Remove { - /// The safe name/ID of the app (e.g., "youtube") - id: String, - }, - /// Rebuild and reinstall an existing app ID with new settings (URL is optional when persisted) - Update { - /// The existing internal ID (e.g., "chatgpt") - id: String, - - /// The URL to wrap (optional if Deskify has persisted app metadata for this ID) - #[arg(short, long)] - url: Option, - - /// Optional new display name (defaults to current desktop entry name or the ID) - #[arg(short, long)] - name: Option, - - /// Optional path to a custom icon (PNG format is recommended) - #[arg(short, long)] - icon: Option, - - /// Launch the application in fullscreen (Kiosk) mode - #[arg(short, long, action = clap::ArgAction::SetTrue)] - fullscreen: Option, - - /// Disable native window decorations (frameless window) - #[arg(long, action = clap::ArgAction::SetTrue)] - no_decorations: Option, - - /// Set a custom User-Agent string for the webview - #[arg(short = 'A', long)] - user_agent: Option, - - /// Set the initial window width - #[arg(short = 'W', long)] - width: Option, - - /// Set the initial window height - #[arg(short = 'H', long)] - height: Option, - - /// Force the webview into Dark Mode - #[arg(short, long, action = clap::ArgAction::SetTrue)] - dark_mode: Option, - - /// Backend to use for the updated app - #[arg(long, value_enum)] - backend: Option, - - /// Path to a Chromium-based browser binary (only for `--backend chromium`) - #[arg(long)] - browser_bin: Option, - - /// Profile isolation for Chromium backend (only for `--backend chromium`) - #[arg(long, value_enum)] - profile_scope: Option, - - /// Show planned update actions without rebuilding/installing - #[arg(long)] - dry_run: bool, - - /// Print the generated Tauri config JSON for the updated app and exit - #[arg(long)] - print_config: bool, - }, -} - -#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] -struct DeskifyAppConfig { - schema_version: u32, - id: String, - name: String, - url: String, - backend: Backend, - browser_bin: Option, - profile_scope: ProfileScope, - fullscreen: bool, - no_decorations: bool, - user_agent: Option, - width: Option, - height: Option, - dark_mode: bool, -} - -fn sanitize_app_id(name: &str) -> String { - let re = Regex::new(r"[^a-z0-9-]").unwrap(); - let lower_name = name.to_lowercase().replace(' ', "-"); - let sanitized = re.replace_all(&lower_name, ""); - if sanitized.is_empty() { - "app".to_string() - } else { - sanitized.to_string() - } -} - -fn validate_remove_id(id: &str) -> Result<()> { - let re = Regex::new(r"^[a-z0-9-]+$").unwrap(); - if re.is_match(id) { - Ok(()) - } else { - Err(anyhow!( - "Invalid app ID '{}'. Allowed characters: lowercase letters, numbers, and '-'.", - id - )) - } -} - -fn profile_scope_str(scope: ProfileScope) -> &'static str { - match scope { - ProfileScope::Isolated => "isolated", - ProfileScope::Shared => "shared", - } -} - -fn backend_str(backend: Backend) -> &'static str { - match backend { - Backend::Tauri => "tauri", - Backend::Chromium => "chromium", - } -} - -fn deskify_data_dir() -> Result { - let base_dirs = BaseDirs::new().ok_or_else(|| anyhow!("Could not find system BaseDirs"))?; - Ok(base_dirs.data_local_dir().join("deskify")) -} - -fn app_config_path(id: &str) -> Result { - validate_remove_id(id)?; - Ok(deskify_data_dir()? - .join("apps") - .join(format!("{}.json", id))) -} - -fn read_app_config(id: &str) -> Result> { - 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::(&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) - } - } -} - -fn write_app_config_from_args(args: &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(()) -} - -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(()) -} - -fn build_tauri_config_value(args: &BuildArgs, safe_identifier: &str, bundle_active: bool) -> Value { - let mut window_config = Map::::new(); - window_config.insert("title".to_string(), json!(args.name)); - window_config.insert("url".to_string(), json!(args.url)); - - if args.fullscreen { - window_config.insert("fullscreen".to_string(), json!(true)); - } - if args.no_decorations { - window_config.insert("decorations".to_string(), json!(false)); - } - if let Some(ua) = &args.user_agent { - window_config.insert("userAgent".to_string(), json!(ua)); - } - if let Some(w) = args.width { - window_config.insert("width".to_string(), json!(w)); - } - if let Some(h) = args.height { - window_config.insert("height".to_string(), json!(h)); - } - if args.dark_mode { - window_config.insert("theme".to_string(), json!("Dark")); - } - - json!({ - "$schema": "https://schema.tauri.app/config/2", - "productName": args.name, - "version": "0.1.0", - "identifier": format!("com.deskify.{}", safe_identifier), - "build": { - "frontendDist": "dist" - }, - "app": { - "windows": [Value::Object(window_config)], - "security": { "csp": null } - }, - "bundle": { - "active": bundle_active, - "targets": "all", - "icon": ["icons/icon.png"] - } - }) -} - -fn desktop_exec_escape(arg: &str) -> String { - if arg.is_empty() { - return "\"\"".to_string(); - } - - let needs_quotes = arg - .chars() - .any(|c| c.is_whitespace() || c == '"' || c == '\\'); - if !needs_quotes { - return arg.to_string(); - } - - let escaped = arg.replace('\\', "\\\\").replace('"', "\\\""); - format!("\"{}\"", escaped) -} - -fn desktop_exec_join(args: &[String]) -> String { - args.iter() - .map(|arg| desktop_exec_escape(arg)) - .collect::>() - .join(" ") -} - -fn chromium_candidates() -> &'static [&'static str] { - &[ - "google-chrome", - "chromium", - "chromium-browser", - "brave-browser", - "vivaldi", - "microsoft-edge", - ] -} - -fn find_binary_in_path(name: &str) -> Option { - let path_var = env::var_os("PATH")?; - for dir in env::split_paths(&path_var) { - let candidate = dir.join(name); - if candidate.is_file() { - return Some(candidate); - } - } - None -} - -fn chromium_binary_works(path: &Path) -> Result<()> { - let output = Command::new(path) - .arg("--version") - .output() - .with_context(|| format!("Failed to execute browser binary {}", path.display()))?; - if output.status.success() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let details = if !stderr.is_empty() { - stderr - } else if !stdout.is_empty() { - stdout - } else { - format!("exit code {}", output.status) - }; - Err(anyhow!( - "Browser binary exists but failed to run: {} ({})", - path.display(), - details - )) -} - -fn resolve_chromium_binary(explicit: Option<&str>) -> Result { - if let Some(path) = explicit { - let candidate = PathBuf::from(path); - if candidate.is_file() { - chromium_binary_works(&candidate).with_context(|| { - "The specified browser binary does not appear to be usable. If it's a wrapper script, ensure the underlying browser exists." - })?; - return Ok(candidate); - } - return Err(anyhow!( - "Specified browser binary does not exist or is not a file: {}", - candidate.display() - )); - } - - for candidate in chromium_candidates() { - if let Some(path) = find_binary_in_path(candidate) - && chromium_binary_works(&path).is_ok() - { - return Ok(path); - } - } - - Err(anyhow!( - "No Chromium-based browser found in PATH. Install Chromium/Chrome/Brave or use --browser-bin /path/to/browser." - )) -} - -fn chromium_profile_dir(internal_id: &str) -> Result { - validate_remove_id(internal_id)?; - let base_dirs = BaseDirs::new().ok_or_else(|| anyhow!("Could not find system BaseDirs"))?; - Ok(base_dirs - .data_local_dir() - .join("deskify") - .join("profiles") - .join(internal_id)) -} - -fn chromium_window_size_arg(width: Option, height: Option) -> Option { - match (width, height) { - (Some(w), Some(h)) => Some(format!("--window-size={},{}", w as i64, h as i64)), - _ => None, - } -} - -fn build_chromium_exec( - args: &BuildArgs, - browser_path: &Path, - profile_dir: Option<&Path>, -) -> String { - let mut parts = vec![browser_path.display().to_string()]; - parts.push("--no-first-run".to_string()); - parts.push("--no-default-browser-check".to_string()); - parts.push(format!("--class={}", args.internal_id)); - parts.push(format!("--app={}", args.url)); - - if let Some(profile_dir) = profile_dir { - parts.push(format!("--user-data-dir={}", profile_dir.display())); - } - if args.fullscreen { - parts.push("--start-fullscreen".to_string()); - } - if let Some(window_size) = chromium_window_size_arg(args.width, args.height) { - parts.push(window_size); - } - if let Some(user_agent) = &args.user_agent { - parts.push(format!("--user-agent={}", user_agent)); - } - if args.dark_mode { - parts.push("--force-dark-mode".to_string()); - } - - desktop_exec_join(&parts) -} - -fn print_generated_config(args: &BuildArgs) -> Result<()> { - match args.backend { - Backend::Tauri => { - let config = 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 = 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(()) -} - -fn print_build_plan(action: &str, args: &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"); - } -} - -fn is_deskify_desktop_entry(content: &str) -> bool { - content.contains("X-Deskify-Managed=true") - || (content.contains("Categories=Network;WebBrowser;") - && (content.contains(".local/bin/") || content.contains("deskify"))) -} - -fn write_rgba_png(img: DynamicImage, output_path: &Path) -> Result<()> { - // Tauri expects PNG icons in RGBA format; many favicons are palette/LA/ICO etc. - let rgba = img.to_rgba8(); - let rgba_img = DynamicImage::ImageRgba8(rgba); - rgba_img - .save_with_format(output_path, ImageFormat::Png) - .with_context(|| format!("Failed to write RGBA PNG icon to {}", output_path.display()))?; - Ok(()) -} - -fn download_icon_from_url(icon_url: &str, output_path: &Path) -> Result { - let response = match ureq::get(icon_url).call() { - Ok(resp) => resp, - Err(_) => return Ok(false), - }; - let mut bytes = Vec::new(); - if response.into_reader().read_to_end(&mut bytes).is_err() || bytes.is_empty() { - return Ok(false); - } - let img = match image::load_from_memory(&bytes) { - Ok(img) => img, - Err(_) => return Ok(false), - }; - write_rgba_png(img, output_path)?; - Ok(true) -} - -fn extract_icon_candidates_from_html(base_url: &Url, html: &str) -> Vec { - let link_re = Regex::new(r#"(?is)]*rel\s*=\s*["'][^"']*icon[^"']*["'][^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#).unwrap(); - let mut candidates = Vec::new(); - for cap in link_re.captures_iter(html) { - if let Some(href) = cap.get(1) - && let Ok(joined) = base_url.join(href.as_str()) - { - let candidate = joined.to_string(); - if !candidates.contains(&candidate) { - candidates.push(candidate); - } - } - } - candidates -} - -fn try_download_site_icon(website_url: &str, output_path: &Path) -> Result { - let base_url = match Url::parse(website_url) { - Ok(url) => url, - Err(_) => return Ok(false), - }; - - let mut html = String::new(); - if let Ok(response) = ureq::get(website_url).call() - && response.into_reader().read_to_string(&mut html).is_ok() - { - for icon_url in extract_icon_candidates_from_html(&base_url, &html) { - if download_icon_from_url(&icon_url, output_path)? { - return Ok(true); - } - } - } - - if let Ok(favicon_url) = base_url.join("/favicon.ico") - && download_icon_from_url(favicon_url.as_str(), output_path)? - { - return Ok(true); - } - - Ok(false) -} - -fn fetch_or_create_icon( - website_url: &str, - custom_icon: Option<&String>, - output_path: &Path, -) -> Result<()> { - if let Some(icon_path) = custom_icon { - if Path::new(icon_path).exists() { - let img = image::open(icon_path) - .with_context(|| format!("Failed to read custom icon image from {}", icon_path))?; - write_rgba_png(img, output_path)?; - return Ok(()); - } else { - eprintln!( - "Warning: Custom icon path '{}' does not exist, falling back to downloaded icon.", - icon_path - ); - } - } - - if try_download_site_icon(website_url, output_path)? { - return Ok(()); - } - - if let Ok(parsed_url) = Url::parse(website_url) - && let Some(host) = parsed_url.host_str() - { - println!("Falling back to Google favicon API for {}...", host); - let api_url = format!("https://www.google.com/s2/favicons?domain={}&sz=128", host); - if download_icon_from_url(&api_url, output_path)? { - return Ok(()); - } - } - - // Fallback to a transparent dummy icon - let dummy_png: [u8; 67] = [ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, - 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, - 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, - 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, - 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, - ]; - fs::write(output_path, dummy_png).context("Failed to write dummy icon fallback")?; - - Ok(()) -} - -fn generate_project(args: &BuildArgs, project_dir: &Path) -> Result<()> { - let src_dir = project_dir.join("src"); - fs::create_dir_all(&src_dir).context("Failed to create src directory")?; - - // 1. Cargo.toml - let cargo_toml = r#" -[package] -name = "deskify-app" -version = "0.1.0" -description = "A native application wrapper" -authors = ["deskify"] -edition = "2021" - -[dependencies] -tauri = { version = "2.0.0", features = [] } - -[build-dependencies] -tauri-build = "2.0.0" -"#; - fs::write(project_dir.join("Cargo.toml"), cargo_toml).context("Failed to write Cargo.toml")?; - - // 2. Build script (build.rs) - let build_rs = r#" -fn main() { - tauri_build::build() -} -"#; - fs::write(project_dir.join("build.rs"), build_rs).context("Failed to write build.rs")?; - - // 3. src/main.rs - let main_rs = r#" -#![cfg_attr( - all(not(debug_assertions), target_os = "windows"), - windows_subsystem = "windows" -)] - -fn main() { - tauri::Builder::default() - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} -"#; - fs::write(src_dir.join("main.rs"), main_rs).context("Failed to write src/main.rs")?; - - // 4. tauri.conf.json - let dist_dir = project_dir.join("dist"); - fs::create_dir_all(&dist_dir).context("Failed to create dist directory")?; - fs::write( - dist_dir.join("index.html"), - "", - ) - .context("Failed to write dummy index.html")?; - - let icons_dir = project_dir.join("icons"); - fs::create_dir_all(&icons_dir).context("Failed to create icons directory")?; - - fetch_or_create_icon(&args.url, args.icon.as_ref(), &icons_dir.join("icon.png"))?; - - let tauri_conf = build_tauri_config_value(args, &args.internal_id, false); - fs::write( - project_dir.join("tauri.conf.json"), - serde_json::to_string_pretty(&tauri_conf).context("Failed to serialize tauri.conf.json")?, - ) - .context("Failed to write tauri.conf.json")?; - - Ok(()) -} - -fn build_project(project_dir: &Path) -> Result { - // 1. Check if tauri-cli is installed - let tauri_check = Command::new("cargo") - .arg("tauri") - .arg("--version") - .output() - .context("Failed to execute `cargo`. Make sure rust/cargo is installed.")?; - - if !tauri_check.status.success() { - return Err(anyhow!( - "Tauri CLI is not installed or not working.\nPlease install it by running:\n cargo install tauri-cli --version \"^2.0.0\"" - )); - } - - println!("Building native app (this may take a few minutes the first time)..."); - - let status = Command::new("cargo") - .arg("tauri") - .arg("build") - .current_dir(project_dir) - .status() - .context("Failed to execute `cargo tauri build`")?; - - if !status.success() { - return Err(anyhow!("Tauri build completed with an error")); - } - - let bin_path = project_dir.join("target/release/deskify-app"); - if bin_path.exists() { - println!("Successfully built binary at {:?}", bin_path); - Ok(bin_path) - } else { - Err(anyhow!( - "Binary not found after build (expected at {:?})", - bin_path - )) - } -} - -fn desktop_paths(internal_id: &str) -> Result<(PathBuf, PathBuf, PathBuf, PathBuf)> { - let base_dirs = - BaseDirs::new().ok_or_else(|| anyhow!("Could not find system BaseDirs (e.g. $HOME)"))?; - let data_local_dir = base_dirs.data_local_dir(); // Usually ~/.local/share - let executable_dir = base_dirs - .executable_dir() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| { - // Fallback for some systems where executable_dir is none - base_dirs.home_dir().join(".local/bin") - }); - - let applications_dir = data_local_dir.join("applications"); - let icon_dir = data_local_dir.join("icons/hicolor/128x128/apps"); - let target_bin = executable_dir.join(internal_id); - let target_icon = icon_dir.join(format!("{}.png", internal_id)); - let desktop_file_path = applications_dir.join(format!("{}.desktop", internal_id)); - - Ok(( - target_bin, - target_icon, - desktop_file_path, - data_local_dir.to_path_buf(), - )) -} - -fn install_tauri_app(args: &BuildArgs, bin_path: &Path, project_dir: &Path) -> Result<()> { - let (target_bin, target_icon, desktop_file_path, _data_local_dir) = - desktop_paths(&args.internal_id)?; - let executable_dir = target_bin - .parent() - .ok_or_else(|| anyhow!("Invalid executable target path"))?; - let icon_dir = target_icon - .parent() - .ok_or_else(|| anyhow!("Invalid icon target path"))?; - let applications_dir = desktop_file_path - .parent() - .ok_or_else(|| anyhow!("Invalid applications target path"))?; - - // 1. Move binary - fs::create_dir_all(executable_dir).context("Failed to create ~/.local/bin directory")?; - fs::copy(bin_path, &target_bin).context("Failed to move binary to installation directory")?; - - // 2. Install Icon - fs::create_dir_all(icon_dir).context("Failed to create icons directory")?; - let source_icon = project_dir.join("icons/icon.png"); - if source_icon.exists() { - let _ = fs::copy(&source_icon, &target_icon); - } - - // 3. Create .desktop file - fs::create_dir_all(applications_dir).context("Failed to create applications directory")?; - - let desktop_entry = format!( - r#" -[Desktop Entry] -Version=1.0 -Name={} -Exec={} -Icon={} -StartupWMClass={} -Terminal=false -Type=Application -Categories=Network;WebBrowser; -X-Deskify-Managed=true -X-Deskify-Backend=tauri -"#, - args.name, - target_bin.to_string_lossy(), - args.internal_id, // Icon name - args.internal_id // Window class for wayland/x11 proper grouping - ); - - fs::write(&desktop_file_path, desktop_entry.trim()).context("Failed to write .desktop file")?; - - println!("App successfully installed!"); - println!( - "You can now launch '{}' from your application menu.", - args.name - ); - - Ok(()) -} - -fn install_chromium_app(args: &BuildArgs) -> Result<()> { - let browser = resolve_chromium_binary(args.browser_bin.as_deref())?; - let profile_dir = match args.profile_scope { - ProfileScope::Isolated => Some(chromium_profile_dir(&args.internal_id)?), - ProfileScope::Shared => None, - }; - - if matches!((args.width, args.height), (Some(_), None) | (None, Some(_))) { - eprintln!( - "Warning: Chromium backend requires both --width and --height for --window-size; ignoring partial size override." - ); - } - if args.no_decorations { - eprintln!( - "Warning: --no-decorations is not reliably supported in Chromium app mode and may be ignored." - ); - } - - let (target_bin, target_icon, desktop_file_path, _data_local_dir) = - desktop_paths(&args.internal_id)?; - let icon_dir = target_icon - .parent() - .ok_or_else(|| anyhow!("Invalid icon target path"))?; - let applications_dir = desktop_file_path - .parent() - .ok_or_else(|| anyhow!("Invalid applications target path"))?; - - fs::create_dir_all(icon_dir).context("Failed to create icons directory")?; - fs::create_dir_all(applications_dir).context("Failed to create applications directory")?; - if let Some(profile_dir) = &profile_dir { - fs::create_dir_all(profile_dir).context("Failed to create Chromium profile directory")?; - } - - let temp = tempdir().context("Failed to create temporary directory for icon fetch")?; - let temp_icon = temp.path().join("icon.png"); - fetch_or_create_icon(&args.url, args.icon.as_ref(), &temp_icon)?; - let _ = fs::copy(&temp_icon, &target_icon); - - let exec = build_chromium_exec(args, &browser, profile_dir.as_deref()); - let desktop_entry = format!( - r#" -[Desktop Entry] -Version=1.0 -Name={} -Exec={} -Icon={} -StartupWMClass={} -Terminal=false -Type=Application -Categories=Network;WebBrowser; -X-Deskify-Managed=true -X-Deskify-Backend=chromium -X-Deskify-URL={} -X-Deskify-ProfileScope={} -X-Deskify-Browser={} -"#, - args.name, - exec, - args.internal_id, - args.internal_id, - args.url, - profile_scope_str(args.profile_scope), - browser.display() - ); - fs::write(&desktop_file_path, desktop_entry.trim()).context("Failed to write .desktop file")?; - - if target_bin.exists() { - println!( - "Note: existing binary {} left in place (Chromium backend does not use it).", - target_bin.display() - ); - } - - println!("App successfully installed (Chromium backend)!"); - println!( - "You can now launch '{}' from your application menu.", - args.name - ); - Ok(()) -} - -fn list_apps_with_options(verbose: bool) -> Result<()> { - let base_dirs = BaseDirs::new().ok_or_else(|| 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(()) -} - -fn read_desktop_entry_value(id: &str, key: &str) -> Result> { - validate_remove_id(id)?; - let base_dirs = BaseDirs::new().ok_or_else(|| 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)) -} - -fn read_installed_app_display_name(id: &str) -> Result> { - validate_remove_id(id)?; - let base_dirs = BaseDirs::new().ok_or_else(|| 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) -} - -fn run_doctor() -> Result<()> { - let mut failed = false; - - let checks = vec![ - ("cargo", vec!["--version"], true), - ("rustc", vec!["--version"], true), - ("pkg-config", vec!["--version"], false), - ]; - - println!("Deskify doctor"); - println!("-------------"); - - for (cmd, args, critical) in checks { - match Command::new(cmd).args(args).output() { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - println!("[ok] {} {}", cmd, version); - } - _ => { - println!( - "[{}] {} not found or failed", - if critical { "fail" } else { "warn" }, - cmd - ); - if critical { - failed = true; - } - } - } - } - - match Command::new("cargo").args(["tauri", "--version"]).output() { - Ok(output) if output.status.success() => { - println!("[ok] {}", String::from_utf8_lossy(&output.stdout).trim()); - } - _ => { - println!( - "[fail] cargo tauri not available (install with: cargo install tauri-cli --version \"^2.0.0\")" - ); - failed = true; - } - } - - if let Ok(output) = Command::new("pkg-config") - .args(["--modversion", "webkit2gtk-4.1"]) - .output() - { - if output.status.success() { - println!( - "[ok] webkit2gtk-4.1 {}", - String::from_utf8_lossy(&output.stdout).trim() - ); - } else { - println!("[warn] webkit2gtk-4.1 not found via pkg-config"); - } - } - - match resolve_chromium_binary(None) { - Ok(path) => println!("[ok] chromium browser found: {}", path.display()), - Err(_) => println!("[warn] no chromium-based browser found in PATH"), - } - - let base_dirs = BaseDirs::new().ok_or_else(|| 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 applications_dir = base_dirs.data_local_dir().join("applications"); - let icons_dir = base_dirs - .data_local_dir() - .join("icons/hicolor/128x128/apps"); - println!("[info] executable dir: {}", executable_dir.display()); - println!("[info] applications dir: {}", applications_dir.display()); - println!("[info] icons dir: {}", icons_dir.display()); - if let Ok(exe) = env::current_exe() { - println!("[info] deskify path: {}", exe.display()); - } - - if failed { - Err(anyhow!("Doctor found critical issues")) - } else { - println!("Doctor completed: no critical issues detected."); - Ok(()) - } -} - -fn remove_app(safe_name: &str) -> Result<()> { - remove_app_with_options(safe_name, true) -} - -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!("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(); - - // 1. Remove binary - 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); - } - - // 2. Remove .desktop file - 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); - } - - // 3. Remove icon - 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); - } - - // 4. Remove Chromium profile (optional) - 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(()) -} +use types::{Backend, BuildArgs, Cli, Commands, ProfileScope}; +use validation::sanitize_app_id; fn execute_build(args: &BuildArgs, dry_run: bool, print_config: bool) -> Result<()> { if print_config { - print_generated_config(args)?; + app::print_generated_config(args)?; return Ok(()); } if dry_run { - print_build_plan("Build", args, None); + app::print_build_plan("Build", args, None); return Ok(()); } @@ -1265,9 +35,11 @@ fn execute_build(args: &BuildArgs, dry_run: bool, print_config: bool) -> Result< let dir = tempdir().context("Failed to create temporary directory for building")?; println!("Scaffolding Tauri project in {:?}", dir.path()); - generate_project(args, dir.path())?; + let temp_icon = dir.path().join("icon.png"); + icon::fetch_or_create_icon(&args.url, args.icon.as_ref(), &temp_icon)?; + tauri::generate_project(args, dir.path(), &temp_icon)?; - let bin_path = match build_project(dir.path()) { + let bin_path = match tauri::build_project(dir.path()) { Ok(path) => path, Err(e) => { let _ = dir.keep(); @@ -1275,8 +47,8 @@ fn execute_build(args: &BuildArgs, dry_run: bool, print_config: bool) -> Result< } }; - install_tauri_app(args, &bin_path, dir.path())?; - write_app_config_from_args(args)?; + install::install_tauri_app(args, &bin_path, &temp_icon)?; + app::write_app_config_from_args(args)?; Ok(()) } Backend::Chromium => { @@ -1284,22 +56,24 @@ fn execute_build(args: &BuildArgs, dry_run: bool, print_config: bool) -> Result< "Installing Chromium app '{}' for URL: {}", args.name, args.url ); - install_chromium_app(args)?; - write_app_config_from_args(args)?; + let temp_icon = tempdir()?.path().join("icon.png"); + icon::fetch_or_create_icon(&args.url, args.icon.as_ref(), &temp_icon)?; + install::install_chromium_app(args, &temp_icon)?; + app::write_app_config_from_args(args)?; Ok(()) } } } fn execute_update(id: &str, args: &BuildArgs, dry_run: bool, print_config: bool) -> Result<()> { - validate_remove_id(id)?; + validation::validate_remove_id(id)?; if print_config { - print_generated_config(args)?; + app::print_generated_config(args)?; return Ok(()); } if dry_run { - print_build_plan("Update", args, Some(id)); + app::print_build_plan("Update", args, Some(id)); println!( "- Update action: remove existing app '{}' and reinstall", id @@ -1312,7 +86,7 @@ fn execute_update(id: &str, args: &BuildArgs, dry_run: bool, print_config: bool) Backend::Chromium => false, Backend::Tauri => true, }; - remove_app_with_options(id, remove_profile)?; + app::remove_app_with_options(id, remove_profile)?; execute_build(args, false, false) } @@ -1355,13 +129,13 @@ fn main() -> Result<()> { execute_build(&args, dry_run, print_config)?; } Commands::List { verbose } => { - list_apps_with_options(verbose)?; + app::list_apps_with_options(verbose)?; } Commands::Doctor => { - run_doctor()?; + doctor::run_doctor()?; } Commands::Remove { id } => { - remove_app(&id)?; + app::remove_app_with_options(&id, true)?; } Commands::Update { id, @@ -1380,15 +154,15 @@ fn main() -> Result<()> { dry_run, print_config, } => { - let existing_cfg = read_app_config(&id)?; + let existing_cfg = app::read_app_config(&id)?; let resolved_url = if let Some(url) = url { url } else if let Some(cfg) = &existing_cfg { cfg.url.clone() - } else if let Some(url) = read_desktop_entry_value(&id, "X-Deskify-URL")? { + } else if let Some(url) = app::read_desktop_entry_value(&id, "X-Deskify-URL")? { url } else { - return Err(anyhow!( + return Err(anyhow::anyhow!( "Missing --url for update. Provide --url or rebuild once with a recent Deskify version so metadata can be persisted." )); }; @@ -1398,14 +172,14 @@ fn main() -> Result<()> { } else if let Some(cfg) = &existing_cfg { cfg.name.clone() } else { - read_installed_app_display_name(&id)?.unwrap_or_else(|| id.clone()) + app::read_installed_app_display_name(&id)?.unwrap_or_else(|| id.clone()) }; let resolved_backend = if let Some(backend) = backend { backend } else if let Some(cfg) = &existing_cfg { cfg.backend - } else if let Some(b) = read_desktop_entry_value(&id, "X-Deskify-Backend")? { + } else if let Some(b) = app::read_desktop_entry_value(&id, "X-Deskify-Backend")? { match b.as_str() { "chromium" => Backend::Chromium, "tauri" => Backend::Tauri, @@ -1468,51 +242,48 @@ fn main() -> Result<()> { #[cfg(test)] mod tests { - use super::{ - Backend, BuildArgs, ProfileScope, build_chromium_exec, chromium_window_size_arg, - is_deskify_desktop_entry, sanitize_app_id, validate_remove_id, - }; + use super::*; use std::path::Path; use std::{fs, os::unix::fs::PermissionsExt}; use tempfile::tempdir; #[test] fn sanitize_app_id_replaces_spaces_and_strips_symbols() { - assert_eq!(sanitize_app_id("Chat GPT!"), "chat-gpt"); + assert_eq!(validation::sanitize_app_id("Chat GPT!"), "chat-gpt"); } #[test] fn sanitize_app_id_falls_back_to_app_for_empty_result() { - assert_eq!(sanitize_app_id("!!!"), "app"); + assert_eq!(validation::sanitize_app_id("!!!"), "app"); } #[test] fn validate_remove_id_accepts_safe_id() { - assert!(validate_remove_id("chatgpt").is_ok()); + assert!(validation::validate_remove_id("chatgpt").is_ok()); } #[test] fn validate_remove_id_rejects_path_traversal() { - assert!(validate_remove_id("../x").is_err()); + assert!(validation::validate_remove_id("../x").is_err()); } #[test] fn desktop_entry_detection_accepts_marker() { let content = "[Desktop Entry]\nName=ChatGPT\nX-Deskify-Managed=true\n"; - assert!(is_deskify_desktop_entry(content)); + assert!(validation::is_deskify_desktop_entry(content)); } #[test] fn desktop_entry_detection_accepts_legacy_entries() { let content = "[Desktop Entry]\nExec=/home/user/.local/bin/chatgpt\nCategories=Network;WebBrowser;\n"; - assert!(is_deskify_desktop_entry(content)); + assert!(validation::is_deskify_desktop_entry(content)); } #[test] fn desktop_entry_detection_rejects_unrelated_entries() { let content = "[Desktop Entry]\nExec=/usr/bin/firefox\nCategories=Network;WebBrowser;\n"; - assert!(!is_deskify_desktop_entry(content)); + assert!(!validation::is_deskify_desktop_entry(content)); } fn sample_build_args() -> BuildArgs { @@ -1536,11 +307,11 @@ mod tests { #[test] fn chromium_window_size_requires_both_dimensions() { assert_eq!( - chromium_window_size_arg(Some(1200.0), Some(800.0)).as_deref(), + chromium::chromium_window_size_arg(Some(1200.0), Some(800.0)).as_deref(), Some("--window-size=1200,800") ); - assert!(chromium_window_size_arg(Some(1200.0), None).is_none()); - assert!(chromium_window_size_arg(None, Some(800.0)).is_none()); + assert!(chromium::chromium_window_size_arg(Some(1200.0), None).is_none()); + assert!(chromium::chromium_window_size_arg(None, Some(800.0)).is_none()); } #[test] @@ -1551,7 +322,7 @@ mod tests { args.user_agent = Some("UA Test".to_string()); args.width = Some(1280.0); args.height = Some(720.0); - let exec = build_chromium_exec( + let exec = chromium::build_chromium_exec( &args, Path::new("/usr/bin/chromium"), Some(Path::new("/tmp/profile")), @@ -1581,10 +352,323 @@ mod tests { perms.set_mode(0o755); fs::set_permissions(&script, perms).unwrap(); - let err = super::resolve_chromium_binary(Some(script.to_str().unwrap())) + let err = chromium::resolve_chromium_binary(Some(script.to_str().unwrap())) .err() .unwrap() .to_string(); assert!(err.contains("failed to run") || err.contains("does not appear to be usable")); } + + #[test] + fn desktop_exec_escape_empty_string() { + assert_eq!(desktop::desktop_exec_escape(""), "\"\""); + } + + #[test] + fn desktop_exec_escape_plain_string() { + assert_eq!(desktop::desktop_exec_escape("hello"), "hello"); + } + + #[test] + fn desktop_exec_escape_with_whitespace() { + assert_eq!( + desktop::desktop_exec_escape("hello world"), + "\"hello world\"" + ); + } + + #[test] + fn desktop_exec_escape_with_quotes() { + assert_eq!( + desktop::desktop_exec_escape("hello\"world"), + "\"hello\\\"world\"" + ); + } + + #[test] + fn desktop_exec_escape_with_backslash() { + assert_eq!( + desktop::desktop_exec_escape("hello\\world"), + "\"hello\\\\world\"" + ); + } + + #[test] + fn desktop_exec_escape_mixed_special_chars() { + assert_eq!( + desktop::desktop_exec_escape("a b\"c\\d"), + "\"a b\\\"c\\\\d\"" + ); + } + + #[test] + fn desktop_exec_join_single_arg() { + let args = vec!["/usr/bin/chromium".to_string()]; + assert_eq!(desktop::desktop_exec_join(&args), "/usr/bin/chromium"); + } + + #[test] + fn desktop_exec_join_multiple_args() { + let args = vec![ + "/usr/bin/chromium".to_string(), + "--app=https://example.com".to_string(), + "--class=myapp".to_string(), + ]; + let result = desktop::desktop_exec_join(&args); + assert!(result.contains("/usr/bin/chromium")); + assert!(result.contains("--app=https://example.com")); + assert!(result.contains("--class=myapp")); + } + + #[test] + fn desktop_exec_join_with_special_chars() { + let args = vec![ + "/usr/bin/chromium".to_string(), + "--user-agent=Mozilla/5.0 (X11; Linux x86_64)".to_string(), + ]; + let result = desktop::desktop_exec_join(&args); + assert!(result.contains("Mozilla/5.0")); + } + + #[test] + fn validate_remove_id_accepts_simple() { + assert!(validation::validate_remove_id("chatgpt").is_ok()); + assert!(validation::validate_remove_id("my-app-123").is_ok()); + } + + #[test] + fn validate_remove_id_rejects_uppercase() { + assert!(validation::validate_remove_id("ChatGPT").is_err()); + assert!(validation::validate_remove_id("MY-APP").is_err()); + } + + #[test] + fn validate_remove_id_rejects_special_chars() { + assert!(validation::validate_remove_id("chat_gpt").is_err()); + assert!(validation::validate_remove_id("chat.gpt").is_err()); + assert!(validation::validate_remove_id("chat@gpt").is_err()); + } + + #[test] + fn validate_remove_id_rejects_empty_after_sanitize() { + assert!(validation::validate_remove_id("!@#").is_err()); + } + + #[test] + fn backend_str_converts_correctly() { + assert_eq!(chromium::backend_str(Backend::Tauri), "tauri"); + assert_eq!(chromium::backend_str(Backend::Chromium), "chromium"); + } + + #[test] + fn profile_scope_str_converts_correctly() { + assert_eq!( + chromium::profile_scope_str(ProfileScope::Isolated), + "isolated" + ); + assert_eq!(chromium::profile_scope_str(ProfileScope::Shared), "shared"); + } + + #[test] + fn chromium_candidates_list_non_empty() { + let candidates = chromium::chromium_candidates(); + assert!(!candidates.is_empty()); + assert!(candidates.contains(&"chromium")); + assert!(candidates.contains(&"google-chrome")); + assert!(candidates.contains(&"brave-browser")); + } + + #[test] + fn sanitize_app_id_handles_various_inputs() { + assert_eq!(validation::sanitize_app_id("AppName"), "appname"); + assert_eq!(validation::sanitize_app_id("App Name 123"), "app-name-123"); + assert_eq!(validation::sanitize_app_id("App!@#Name"), "appname"); + assert_eq!(validation::sanitize_app_id(" spaces "), "---spaces---"); + assert_eq!(validation::sanitize_app_id("dash-name"), "dash-name"); + } + + #[test] + fn app_config_roundtrip_serialization() { + let cfg = types::DeskifyAppConfig { + schema_version: 1, + id: "test-app".to_string(), + name: "Test App".to_string(), + url: "https://example.com".to_string(), + backend: Backend::Chromium, + browser_bin: Some("/usr/bin/chromium".to_string()), + profile_scope: ProfileScope::Isolated, + fullscreen: true, + no_decorations: false, + user_agent: Some("TestUA".to_string()), + width: Some(1024.0), + height: Some(768.0), + dark_mode: true, + }; + + let serialized = serde_json::to_string(&cfg).unwrap(); + let deserialized: types::DeskifyAppConfig = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(deserialized.id, cfg.id); + assert_eq!(deserialized.name, cfg.name); + assert_eq!(deserialized.url, cfg.url); + assert_eq!(deserialized.backend, cfg.backend); + assert_eq!(deserialized.fullscreen, cfg.fullscreen); + assert_eq!(deserialized.dark_mode, cfg.dark_mode); + } + + #[test] + fn tauri_config_basic_generation() { + let args = BuildArgs { + url: "https://example.com".to_string(), + name: "TestApp".to_string(), + internal_id: "testapp".to_string(), + icon: None, + fullscreen: false, + no_decorations: false, + user_agent: None, + width: None, + height: None, + dark_mode: false, + backend: Backend::Tauri, + browser_bin: None, + profile_scope: ProfileScope::Isolated, + }; + + let config = tauri::build_tauri_config_value(&args, "testapp", false); + let config_str = serde_json::to_string(&config).unwrap(); + + assert!(config_str.contains("TestApp")); + assert!(config_str.contains("https://example.com")); + assert!(config_str.contains("com.deskify.testapp")); + } + + #[test] + fn tauri_config_fullscreen_enabled() { + let mut args = sample_build_args(); + args.fullscreen = true; + args.backend = Backend::Tauri; + + let config = tauri::build_tauri_config_value(&args, "test", false); + let config_str = serde_json::to_string(&config).unwrap(); + + assert!(config_str.contains("\"fullscreen\":true")); + } + + #[test] + fn tauri_config_no_decorations() { + let mut args = sample_build_args(); + args.no_decorations = true; + args.backend = Backend::Tauri; + + let config = tauri::build_tauri_config_value(&args, "test", false); + let config_str = serde_json::to_string(&config).unwrap(); + + assert!(config_str.contains("\"decorations\":false")); + } + + #[test] + fn tauri_config_dark_mode() { + let mut args = sample_build_args(); + args.dark_mode = true; + args.backend = Backend::Tauri; + + let config = tauri::build_tauri_config_value(&args, "test", false); + let config_str = serde_json::to_string(&config).unwrap(); + + assert!(config_str.contains("\"theme\":\"Dark\"")); + } + + #[test] + fn tauri_config_user_agent() { + let mut args = sample_build_args(); + args.user_agent = Some("Mozilla/5.0 CustomUA".to_string()); + args.backend = Backend::Tauri; + + let config = tauri::build_tauri_config_value(&args, "test", false); + let config_str = serde_json::to_string(&config).unwrap(); + + assert!(config_str.contains("Mozilla/5.0 CustomUA")); + } + + #[test] + fn tauri_config_window_dimensions() { + let mut args = sample_build_args(); + args.width = Some(1920.0); + args.height = Some(1080.0); + args.backend = Backend::Tauri; + + let config = tauri::build_tauri_config_value(&args, "test", false); + let config_str = serde_json::to_string(&config).unwrap(); + + assert!(config_str.contains("\"width\":1920")); + assert!(config_str.contains("\"height\":1080")); + } + + #[test] + fn tauri_config_identifier_differs_by_id() { + let args1 = BuildArgs { + url: "https://a.com".to_string(), + name: "App1".to_string(), + internal_id: "app1".to_string(), + icon: None, + fullscreen: false, + no_decorations: false, + user_agent: None, + width: None, + height: None, + dark_mode: false, + backend: Backend::Tauri, + browser_bin: None, + profile_scope: ProfileScope::Isolated, + }; + + let args2 = BuildArgs { + url: "https://b.com".to_string(), + name: "App2".to_string(), + internal_id: "app2".to_string(), + icon: None, + fullscreen: false, + no_decorations: false, + user_agent: None, + width: None, + height: None, + dark_mode: false, + backend: Backend::Tauri, + browser_bin: None, + profile_scope: ProfileScope::Isolated, + }; + + let config1 = tauri::build_tauri_config_value(&args1, "app1", false); + let config2 = tauri::build_tauri_config_value(&args2, "app2", false); + + let s1 = serde_json::to_string(&config1).unwrap(); + let s2 = serde_json::to_string(&config2).unwrap(); + + assert!(s1.contains("com.deskify.app1")); + assert!(s2.contains("com.deskify.app2")); + } + + #[test] + fn tauri_config_csp_null() { + let args = BuildArgs { + url: "https://example.com".to_string(), + name: "Test".to_string(), + internal_id: "test".to_string(), + icon: None, + fullscreen: false, + no_decorations: false, + user_agent: None, + width: None, + height: None, + dark_mode: false, + backend: Backend::Tauri, + browser_bin: None, + profile_scope: ProfileScope::Isolated, + }; + + let config = tauri::build_tauri_config_value(&args, "test", false); + let config_str = serde_json::to_string(&config).unwrap(); + + assert!(config_str.contains("\"csp\":null")); + } } diff --git a/src/tauri.rs b/src/tauri.rs new file mode 100644 index 0000000..5300775 --- /dev/null +++ b/src/tauri.rs @@ -0,0 +1,156 @@ +use anyhow::{Context, Result}; +use serde_json::{Map, Value, json}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::types::BuildArgs; + +pub fn build_tauri_config_value( + args: &BuildArgs, + safe_identifier: &str, + bundle_active: bool, +) -> Value { + let mut window_config = Map::::new(); + window_config.insert("title".to_string(), json!(args.name)); + window_config.insert("url".to_string(), json!(args.url)); + + if args.fullscreen { + window_config.insert("fullscreen".to_string(), json!(true)); + } + if args.no_decorations { + window_config.insert("decorations".to_string(), json!(false)); + } + if let Some(ua) = &args.user_agent { + window_config.insert("userAgent".to_string(), json!(ua)); + } + if let Some(w) = args.width { + window_config.insert("width".to_string(), json!(w)); + } + if let Some(h) = args.height { + window_config.insert("height".to_string(), json!(h)); + } + if args.dark_mode { + window_config.insert("theme".to_string(), json!("Dark")); + } + + json!({ + "$schema": "https://schema.tauri.app/config/2", + "productName": args.name, + "version": "0.1.0", + "identifier": format!("com.deskify.{}", safe_identifier), + "build": { + "frontendDist": "dist" + }, + "app": { + "windows": [Value::Object(window_config)], + "security": { "csp": null } + }, + "bundle": { + "active": bundle_active, + "targets": "all", + "icon": ["icons/icon.png"] + } + }) +} + +pub fn generate_project(args: &BuildArgs, project_dir: &Path, icon_path: &Path) -> Result<()> { + let src_dir = project_dir.join("src"); + fs::create_dir_all(&src_dir).context("Failed to create src directory")?; + + let cargo_toml = r#" +[package] +name = "deskify-app" +version = "0.1.0" +description = "A native application wrapper" +authors = ["deskify"] +edition = "2021" + +[dependencies] +tauri = { version = "2.0.0", features = [] } + +[build-dependencies] +tauri-build = "2.0.0" +"#; + fs::write(project_dir.join("Cargo.toml"), cargo_toml).context("Failed to write Cargo.toml")?; + + let build_rs = r#" +fn main() { + tauri_build::build() +} +"#; + fs::write(project_dir.join("build.rs"), build_rs).context("Failed to write build.rs")?; + + let main_rs = r#" +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +fn main() { + tauri::Builder::default() + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +"#; + fs::write(src_dir.join("main.rs"), main_rs).context("Failed to write src/main.rs")?; + + let dist_dir = project_dir.join("dist"); + fs::create_dir_all(&dist_dir).context("Failed to create dist directory")?; + fs::write( + dist_dir.join("index.html"), + "", + ) + .context("Failed to write dummy index.html")?; + + let icons_dir = project_dir.join("icons"); + fs::create_dir_all(&icons_dir).context("Failed to create icons directory")?; + fs::copy(icon_path, icons_dir.join("icon.png")).context("Failed to copy icon")?; + + let tauri_conf = build_tauri_config_value(args, &args.internal_id, false); + fs::write( + project_dir.join("tauri.conf.json"), + serde_json::to_string_pretty(&tauri_conf).context("Failed to serialize tauri.conf.json")?, + ) + .context("Failed to write tauri.conf.json")?; + + Ok(()) +} + +pub fn build_project(project_dir: &Path) -> Result { + let tauri_check = Command::new("cargo") + .arg("tauri") + .arg("--version") + .output() + .context("Failed to execute `cargo`. Make sure rust/cargo is installed.")?; + + if !tauri_check.status.success() { + return Err(anyhow::anyhow!( + "Tauri CLI is not installed or not working.\nPlease install it by running:\n cargo install tauri-cli --version \"^2.0.0\"" + )); + } + + println!("Building native app (this may take a few minutes the first time)..."); + + let status = Command::new("cargo") + .arg("tauri") + .arg("build") + .current_dir(project_dir) + .status() + .context("Failed to execute `cargo tauri build`")?; + + if !status.success() { + return Err(anyhow::anyhow!("Tauri build completed with an error")); + } + + let bin_path = project_dir.join("target/release/deskify-app"); + if bin_path.exists() { + println!("Successfully built binary at {:?}", bin_path); + Ok(bin_path) + } else { + Err(anyhow::anyhow!( + "Binary not found after build (expected at {:?})", + bin_path + )) + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..46ab21c --- /dev/null +++ b/src/types.rs @@ -0,0 +1,161 @@ +use clap::{Parser, Subcommand}; + +#[derive( + clap::ValueEnum, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, +)] +#[serde(rename_all = "lowercase")] +pub enum Backend { + Tauri, + Chromium, +} + +#[derive( + clap::ValueEnum, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq, +)] +#[serde(rename_all = "lowercase")] +pub enum ProfileScope { + #[serde(alias = "default")] + Isolated, + Shared, +} + +#[derive(Debug, Clone)] +pub struct BuildArgs { + pub url: String, + pub name: String, + pub internal_id: String, + pub icon: Option, + pub fullscreen: bool, + pub no_decorations: bool, + pub user_agent: Option, + pub width: Option, + pub height: Option, + pub dark_mode: bool, + pub backend: Backend, + pub browser_bin: Option, + pub profile_scope: ProfileScope, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +pub struct DeskifyAppConfig { + pub schema_version: u32, + pub id: String, + pub name: String, + pub url: String, + pub backend: Backend, + pub browser_bin: Option, + pub profile_scope: ProfileScope, + pub fullscreen: bool, + pub no_decorations: bool, + pub user_agent: Option, + pub width: Option, + pub height: Option, + pub dark_mode: bool, +} + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + Build { + #[arg(short, long)] + url: String, + + #[arg(short, long)] + name: String, + + #[arg(short, long)] + icon: Option, + + #[arg(short, long)] + fullscreen: bool, + + #[arg(long)] + no_decorations: bool, + + #[arg(short = 'A', long)] + user_agent: Option, + + #[arg(short = 'W', long)] + width: Option, + + #[arg(short = 'H', long)] + height: Option, + + #[arg(short, long)] + dark_mode: bool, + + #[arg(long, value_enum, default_value_t = Backend::Tauri)] + backend: Backend, + + #[arg(long)] + browser_bin: Option, + + #[arg(long, value_enum, default_value_t = ProfileScope::Isolated)] + profile_scope: ProfileScope, + + #[arg(long)] + print_config: bool, + + #[arg(long)] + dry_run: bool, + }, + List { + #[arg(long)] + verbose: bool, + }, + Doctor, + Remove { + id: String, + }, + Update { + id: String, + + #[arg(short, long)] + url: Option, + + #[arg(short, long)] + name: Option, + + #[arg(short, long)] + icon: Option, + + #[arg(short, long, action = clap::ArgAction::SetTrue)] + fullscreen: Option, + + #[arg(long, action = clap::ArgAction::SetTrue)] + no_decorations: Option, + + #[arg(short = 'A', long)] + user_agent: Option, + + #[arg(short = 'W', long)] + width: Option, + + #[arg(short = 'H', long)] + height: Option, + + #[arg(short, long, action = clap::ArgAction::SetTrue)] + dark_mode: Option, + + #[arg(long, value_enum)] + backend: Option, + + #[arg(long)] + browser_bin: Option, + + #[arg(long, value_enum)] + profile_scope: Option, + + #[arg(long)] + dry_run: bool, + + #[arg(long)] + print_config: bool, + }, +} diff --git a/src/validation.rs b/src/validation.rs new file mode 100644 index 0000000..6cc3c4a --- /dev/null +++ b/src/validation.rs @@ -0,0 +1,31 @@ +use anyhow::{Result, anyhow}; +use regex::Regex; + +pub fn sanitize_app_id(name: &str) -> String { + let re = Regex::new(r"[^a-z0-9-]").unwrap(); + let lower_name = name.to_lowercase().replace(' ', "-"); + let sanitized = re.replace_all(&lower_name, ""); + if sanitized.is_empty() { + "app".to_string() + } else { + sanitized.to_string() + } +} + +pub fn validate_remove_id(id: &str) -> Result<()> { + let re = Regex::new(r"^[a-z0-9-]+$").unwrap(); + if re.is_match(id) { + Ok(()) + } else { + Err(anyhow!( + "Invalid app ID '{}'. Allowed characters: lowercase letters, numbers, and '-'.", + id + )) + } +} + +pub fn is_deskify_desktop_entry(content: &str) -> bool { + content.contains("X-Deskify-Managed=true") + || (content.contains("Categories=Network;WebBrowser;") + && (content.contains(".local/bin/") || content.contains("deskify"))) +}