//! Linux AppImage desktop integration. //! //! A bare AppImage is a portable file with no menu entry — deleting it uninstalls //! the app. These commands let ThoughtSync install itself into the application menu //! (a `.desktop` launcher + icon) and keep a stable copy in `~/Applications`, so it //! behaves like an installed app, and remove that again. No external helper //! (AppImageLauncher / Gear Lever) is required. //! //! Everything keys off the `$APPIMAGE` env var (set only when running from an //! AppImage), so on a non-AppImage run — the dev build, a `.deb` install, or a //! future Windows/macOS build — the status simply reports `is_appimage: false` and //! the actions are never offered by the frontend. use std::fs; use std::path::{Path, PathBuf}; use serde::Serialize; // The 1024px brand icon, embedded so integration never depends on the AppImage's // internal layout. Written to the user icon dir on integrate. const APP_ICON: &[u8] = include_bytes!("../app-icon.png"); const DESKTOP_ENTRY_NAME: &str = "thoughtsync.desktop"; const ICON_FILE_NAME: &str = "thoughtsync.png"; const INSTALLED_APPIMAGE_NAME: &str = "ThoughtSync.AppImage"; #[derive(Serialize)] pub struct IntegrationStatus { /// Running from an AppImage (`$APPIMAGE` is set). is_appimage: bool, /// Our `.desktop` launcher is present in the user applications dir. is_integrated: bool, /// Path of the currently-running AppImage, if any. appimage_path: Option, } fn home() -> Option { std::env::var_os("HOME").map(PathBuf::from) } fn appimage_path() -> Option { std::env::var_os("APPIMAGE").map(PathBuf::from) } fn desktop_entry_path() -> Option { home().map(|h| h.join(".local/share/applications").join(DESKTOP_ENTRY_NAME)) } fn icon_path() -> Option { home().map(|h| h.join(".local/share/icons").join(ICON_FILE_NAME)) } #[tauri::command] pub fn integration_status() -> IntegrationStatus { let ai = appimage_path(); let is_integrated = desktop_entry_path().map(|p| p.exists()).unwrap_or(false); IntegrationStatus { is_appimage: ai.is_some(), is_integrated, appimage_path: ai.map(|p| p.to_string_lossy().into_owned()), } } #[tauri::command] pub fn integrate_desktop() -> Result { let current = appimage_path().ok_or("Not running as an AppImage.")?; let home = home().ok_or("Cannot resolve the home directory.")?; // Give the AppImage a stable home so it can't be lost from Downloads. Copy (not // move): the running file is memory-mapped, so a copy is always safe. let apps_dir = home.join("Applications"); fs::create_dir_all(&apps_dir).map_err(|e| format!("create ~/Applications: {e}"))?; let installed = apps_dir.join(INSTALLED_APPIMAGE_NAME); if current != installed { fs::copy(¤t, &installed).map_err(|e| format!("copy AppImage: {e}"))?; set_executable(&installed)?; } // Icon (embedded) -> user icon dir. let icon = icon_path().ok_or("Cannot resolve the icon path.")?; if let Some(parent) = icon.parent() { fs::create_dir_all(parent).map_err(|e| format!("create icon dir: {e}"))?; } fs::write(&icon, APP_ICON).map_err(|e| format!("write icon: {e}"))?; // .desktop launcher -> user applications dir. Icon/Exec are absolute paths, so no // icon-theme lookup is needed. // // StartupWMClass is the exception: it must be the BINARY name, because GTK // derives the window's WM_CLASS from the executable and the desktop matches the // two to group the taskbar icon. Not the product name, and not this AppImage's // filename — AppRun execs `usr/bin/thoughtsync` inside it (issue 2075). let entry = desktop_entry_path().ok_or("Cannot resolve the applications dir.")?; if let Some(parent) = entry.parent() { fs::create_dir_all(parent).map_err(|e| format!("create applications dir: {e}"))?; } let contents = format!( "[Desktop Entry]\n\ Type=Application\n\ Name=ThoughtSync\n\ Comment=Capture a fleeting thought in a second\n\ Exec={exec} %U\n\ Icon={icon}\n\ Terminal=false\n\ Categories=Utility;Office;\n\ StartupWMClass=thoughtsync\n", exec = installed.to_string_lossy(), icon = icon.to_string_lossy(), ); fs::write(&entry, contents).map_err(|e| format!("write .desktop: {e}"))?; refresh_desktop_database(&home); Ok(integration_status()) } #[tauri::command] pub fn unintegrate_desktop() -> Result { let home = home().ok_or("Cannot resolve the home directory.")?; if let Some(entry) = desktop_entry_path() { remove_if_exists(&entry)?; } if let Some(icon) = icon_path() { remove_if_exists(&icon)?; } // Intentionally keep ~/Applications/ThoughtSync.AppImage: it may be the running // binary, and removing it would delete the app the user is using. Un-integrate // only removes the menu entry. refresh_desktop_database(&home); Ok(integration_status()) } fn remove_if_exists(p: &Path) -> Result<(), String> { if p.exists() { fs::remove_file(p).map_err(|e| format!("remove {}: {e}", p.display()))?; } Ok(()) } #[cfg(unix)] fn set_executable(p: &Path) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; let mut perm = fs::metadata(p) .map_err(|e| format!("stat: {e}"))? .permissions(); perm.set_mode(0o755); fs::set_permissions(p, perm).map_err(|e| format!("chmod: {e}")) } #[cfg(not(unix))] fn set_executable(_p: &Path) -> Result<(), String> { Ok(()) } // Best-effort: refresh the desktop database so the entry appears promptly. The entry // still works on next login without it, so failures are ignored. fn refresh_desktop_database(home: &Path) { let apps = home.join(".local/share/applications"); let _ = std::process::Command::new("update-desktop-database") .arg(&apps) .output(); }