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 @@
+
+
+
+
+
+
+ Add ThoughtSync to your applications?
+
+
+ We'll add a menu entry so you can launch ThoughtSync from your app launcher, and keep a stable
+ copy in ~/Applications so it isn't lost from Downloads.
+ You can undo this any time from your account.
+
+
+ Not now
+ Add to applications
+
+
+
+
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();
+});
@@ -91,6 +126,31 @@ onMounted(load);
paste it into the app when it asks to connect. You can revoke a device at any time.
+
+
+
+
Desktop app
+
+ {{
+ desktopStatus.is_integrated
+ ? "ThoughtSync is in your applications menu."
+ : "Add ThoughtSync to your applications menu to launch it like an installed app."
+ }}
+
+
+
+ {{ desktopStatus.is_integrated ? "Remove" : "Add to applications" }}
+
+
+