diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index 0c6d9f6..5299191 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -66,3 +66,16 @@ jobs: - name: Tauri build (deb + AppImage) run: cargo tauri build --config '{"build":{"beforeBuildCommand":""}}' working-directory: desktop/src-tauri + + # Make the built .deb + .AppImage downloadable from the run (for hand-testing). + # continue-on-error: the Forgejo artifact backend may not be configured yet; a + # failed upload must not fail the build itself. + - name: Upload bundles + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: thoughtsync-linux + path: | + desktop/src-tauri/target/release/bundle/appimage/*.AppImage + desktop/src-tauri/target/release/bundle/deb/*.deb + if-no-files-found: warn diff --git a/desktop/src-tauri/src/integration.rs b/desktop/src-tauri/src/integration.rs new file mode 100644 index 0000000..414551e --- /dev/null +++ b/desktop/src-tauri/src/integration.rs @@ -0,0 +1,154 @@ +//! 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. + 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(); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c03b073..4aedc4a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,13 +1,21 @@ //! ThoughtSync desktop (Tauri v2). //! -//! The window loads the shared Vue 3 frontend (`../../frontend`). Today this is the -//! shell that boots the UI; the Rust core will own the on-device SQLite store -//! (M10.4) and the opt-in sync engine (M10.7), which the frontend reaches through -//! the `frontend/src/adapters/` seam (M10.3) over Tauri `invoke`. +//! The window loads the shared Vue 3 frontend (`../../frontend`). The Rust core will +//! own the on-device SQLite store (M10.4) and the opt-in sync engine (M10.7), which +//! the frontend reaches through the `frontend/src/adapters/` seam (M10.3) over Tauri +//! `invoke`. Today it exposes desktop integration (menu-entry install for the Linux +//! AppImage) and boots the UI. + +mod integration; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + integration::integration_status, + integration::integrate_desktop, + integration::unintegrate_desktop, + ]) .run(tauri::generate_context!()) .expect("error while running the ThoughtSync desktop app"); } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 0223b71..5594984 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ "beforeBuildCommand": "cd \"$(git rev-parse --show-toplevel)/frontend\" && npm ci && npm run build" }, "app": { + "withGlobalTauri": true, "windows": [ { "label": "main", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a690645..8d15d1f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,8 +1,10 @@ diff --git a/frontend/src/components/DesktopIntegrationPrompt.vue b/frontend/src/components/DesktopIntegrationPrompt.vue new file mode 100644 index 0000000..124f610 --- /dev/null +++ b/frontend/src/components/DesktopIntegrationPrompt.vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/src/desktop/bridge.ts b/frontend/src/desktop/bridge.ts new file mode 100644 index 0000000..53f8e73 --- /dev/null +++ b/frontend/src/desktop/bridge.ts @@ -0,0 +1,38 @@ +// Bridge to the Tauri desktop core. On the web build there is no Tauri runtime, so +// isDesktop() is false and none of these calls run. Uses the global injected by +// `withGlobalTauri` (tauri.conf.json), so the shared frontend needs no +// @tauri-apps/api dependency and the web bundle is unaffected. + +interface TauriGlobal { + core: { invoke: (cmd: string, args?: Record) => Promise }; +} + +declare global { + interface Window { + __TAURI__?: TauriGlobal; + } +} + +export interface IntegrationStatus { + is_appimage: boolean; + is_integrated: boolean; + appimage_path: string | null; +} + +/** True when running inside the Tauri desktop shell (vs. the web build). */ +export function isDesktop(): boolean { + return typeof window !== "undefined" && !!window.__TAURI__; +} + +function invoke(cmd: string, args?: Record): Promise { + const tauri = window.__TAURI__; + if (!tauri) return Promise.reject(new Error("Not running in the desktop app.")); + return tauri.core.invoke(cmd, args); +} + +// Desktop (Linux AppImage) self-integration: add/remove an applications-menu entry. +export const desktop = { + integrationStatus: () => invoke("integration_status"), + integrate: () => invoke("integrate_desktop"), + unintegrate: () => invoke("unintegrate_desktop"), +}; diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index d078b45..304d24a 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -5,6 +5,7 @@ import { useUiStore } from "../stores/ui"; import BaseButton from "../components/BaseButton.vue"; import BaseInput from "../components/BaseInput.vue"; import Icon from "../components/Icon.vue"; +import { isDesktop, desktop as desktopBridge, type IntegrationStatus } from "../desktop/bridge"; // Per-user (not admin) management of linked native clients — the Tauri desktop and // Android apps authenticate sync with a device bearer token issued here. @@ -17,6 +18,10 @@ const creating = ref(false); // The freshly-issued plaintext token — shown ONCE (never retrievable again). const freshToken = ref(""); +// Desktop-app self-integration (Linux AppImage only; the card is hidden otherwise). +const desktopStatus = ref(null); +const desktopBusy = ref(false); + async function load() { error.value = ""; try { @@ -63,7 +68,37 @@ function fmt(iso: string | null): string { return new Date(iso).toLocaleString(); } -onMounted(load); +async function loadDesktop() { + if (!isDesktop()) return; + try { + desktopStatus.value = await desktopBridge.integrationStatus(); + } catch { + desktopStatus.value = null; + } +} + +async function toggleIntegration() { + desktopBusy.value = true; + try { + desktopStatus.value = desktopStatus.value?.is_integrated + ? await desktopBridge.unintegrate() + : await desktopBridge.integrate(); + ui.showToast( + desktopStatus.value?.is_integrated + ? "Added to your applications menu." + : "Removed from your applications menu.", + ); + } catch (e) { + ui.showToast((e as { message?: string }).message ?? "Couldn't update the menu entry."); + } finally { + desktopBusy.value = false; + } +} + +onMounted(() => { + void load(); + void loadDesktop(); +});