diff --git a/.gitattributes b/.gitattributes index edccb7a0..b3ebfcb8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ # Keep platform-specific scripts with safe line endings. *.sh text eol=lf +*.command text eol=lf *.ps1 text eol=crlf *.bat text eol=crlf diff --git a/.github/workflows/desktop-release-package.yml b/.github/workflows/desktop-release-package.yml index 59a4b784..88b35a86 100644 --- a/.github/workflows/desktop-release-package.yml +++ b/.github/workflows/desktop-release-package.yml @@ -283,6 +283,9 @@ jobs: chmod +x "$PKG/GenericAgent.AppImage" cp desktop_release/scripts/linux/install_linux.sh "$RUNTIME/install_linux.sh" chmod +x "$RUNTIME/install_linux.sh" + # Uninstaller: top-level entry next to the AppImage. + cp desktop_release/scripts/linux/uninstall.sh "$PKG/uninstall.sh" + chmod +x "$PKG/uninstall.sh" # Embedded Python (python-build-standalone, linux x86_64, 3.12 install_only). PBS_URL="$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest \ @@ -479,6 +482,9 @@ jobs: cp desktop_release/scripts/macos/install_macos.sh "$RUNTIME/install_macos.sh" chmod +x "$RUNTIME/install_macos.sh" + # Uninstaller: double-clickable .command next to the .app. + cp desktop_release/scripts/macos/uninstall.command "$PKG/uninstall.command" + chmod +x "$PKG/uninstall.command" # Embedded Python (python-build-standalone, macOS, 3.12 install_only; aarch64 preferred). PBS_URL="$(python3 - <<'PY' diff --git a/desktop_release/scripts/linux/uninstall.sh b/desktop_release/scripts/linux/uninstall.sh new file mode 100644 index 00000000..ed0c083c --- /dev/null +++ b/desktop_release/scripts/linux/uninstall.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# GenericAgent Desktop — portable uninstall (Linux). +# +# Removes everything THIS portable bundle put on the machine, then deletes the +# bundle folder itself: +# 1. Stop the bundle's processes (GUI + bridge/conductor/scheduler python) — +# only processes whose exe/cmdline live inside this bundle, so a second +# install on the same machine is left alone. +# 2. Remove the desktop shortcut (GenericAgent.desktop) — only when its Exec +# points into this bundle. +# 3. Remove ~/.ga_desktop_settings.json (shared settings; other bundles rebuild +# it on next launch). +# 4. Remove the WebKitGTK data dir (~/.local/share + ~/.cache for the app id). +# 5. Delete the bundle folder. +set -u + +BUNDLE="$(cd "$(dirname "$0")" && pwd)" +APP_ID="com.genericagent.app" + +echo "============================================================" +echo " GenericAgent Desktop - Uninstall" +echo "============================================================" +echo +echo "This will completely remove GenericAgent from this computer:" +echo " - stop its background services (bridge 14168 / conductor 8900)" +echo " - delete the desktop shortcut" +echo " - delete settings (~/.ga_desktop_settings.json)" +echo " - delete WebView data (~/.local/share/$APP_ID, ~/.cache/$APP_ID)" +echo " - delete THIS folder and everything in it:" +echo " $BUNDLE" +echo +echo "This cannot be undone." +echo +read -r -p "Type Y to uninstall, anything else to cancel: " CONFIRM +case "$CONFIRM" in + y|Y) ;; + *) echo; echo "Cancelled. Nothing was changed."; exit 0 ;; +esac + +echo +echo "==> Stopping GenericAgent backend services" +# Best-effort graceful exit first. +curl -fsS -m 3 -X POST "http://127.0.0.1:14168/services/bridge/exit" >/dev/null 2>&1 || true +sleep 1 + +# Kill any process whose exe or command line lives inside this bundle — never touch +# a second install (different path). Skip our own uninstall shell and its parent. +for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do + [ "$pid" = "$$" ] && continue + [ "$pid" = "$PPID" ] && continue + exe="$(readlink -f "/proc/$pid/exe" 2>/dev/null || true)" + cl="$( (cat "/proc/$pid/cmdline" 2>/dev/null || true) | tr '\0' ' ')" + case "$exe $cl" in + *"$BUNDLE"*) kill -9 "$pid" 2>/dev/null && echo " killed PID $pid" ;; + esac +done +echo "[OK] backend stopped" + +echo "==> Removing desktop shortcut" +removed_shortcut=0 +for f in "$HOME/.local/share/applications/GenericAgent.desktop" \ + "${XDG_DESKTOP_DIR:-$HOME/Desktop}/GenericAgent.desktop" \ + "$HOME/桌面/GenericAgent.desktop"; do + [ -f "$f" ] || continue + if grep -qF "$BUNDLE" "$f" 2>/dev/null; then + rm -f "$f" && { echo "[OK] removed $f"; removed_shortcut=1; } + else + echo " $f points to another bundle; left in place" + fi +done +[ "$removed_shortcut" = 0 ] && echo " no desktop shortcut for this bundle found" + +echo "==> Removing settings file" +if [ -f "$HOME/.ga_desktop_settings.json" ]; then + rm -f "$HOME/.ga_desktop_settings.json" && echo "[OK] removed ~/.ga_desktop_settings.json" +else + echo " no settings file found" +fi + +echo "==> Removing WebView data" +for d in "$HOME/.local/share/$APP_ID" "$HOME/.cache/$APP_ID"; do + if [ -d "$d" ]; then rm -rf "$d" && echo "[OK] removed $d"; fi +done + +echo "==> Removing the bundle folder" +cd /tmp || cd / +if rm -rf "$BUNDLE" 2>/dev/null && [ ! -e "$BUNDLE" ]; then + echo "[OK] removed $BUNDLE" +else + # A still-mounted AppImage or busy file can block removal; retry detached after we exit. + nohup bash -c 'for i in $(seq 1 20); do rm -rf "'"$BUNDLE"'" 2>/dev/null; [ -e "'"$BUNDLE"'" ] || exit 0; sleep 1; done' >/dev/null 2>&1 & + echo "[OK] bundle folder will be removed after exit: $BUNDLE" +fi + +echo +echo "GenericAgent has been uninstalled." diff --git a/desktop_release/scripts/macos/uninstall.command b/desktop_release/scripts/macos/uninstall.command new file mode 100644 index 00000000..dc23f2ce --- /dev/null +++ b/desktop_release/scripts/macos/uninstall.command @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# GenericAgent Desktop — portable uninstall (macOS). Double-clickable in Finder (.command). +# +# Removes everything THIS portable bundle put on the machine, then deletes the +# bundle folder itself: +# 1. Stop the bundle's processes (GUI + bridge/conductor/scheduler python) — +# only processes whose command line lives inside this bundle. +# 2. Remove the desktop alias (~/Desktop/GenericAgent.app) — only when it links +# into this bundle. +# 3. Remove ~/.ga_desktop_settings.json (shared settings). +# 4. Remove the WKWebView data for the app id under ~/Library. +# 5. Delete the bundle folder. +set -u + +BUNDLE="$(cd "$(dirname "$0")" && pwd)" +APP_ID="com.genericagent.app" + +echo "============================================================" +echo " GenericAgent Desktop - Uninstall" +echo "============================================================" +echo +echo "This will completely remove GenericAgent from this computer:" +echo " - stop its background services (bridge 14168 / conductor 8900)" +echo " - delete the desktop alias" +echo " - delete settings (~/.ga_desktop_settings.json)" +echo " - delete WebView data (~/Library/.../$APP_ID)" +echo " - delete THIS folder and everything in it:" +echo " $BUNDLE" +echo +echo "This cannot be undone." +echo +read -r -p "Type Y to uninstall, anything else to cancel: " CONFIRM +case "$CONFIRM" in + y|Y) ;; + *) echo; echo "Cancelled. Nothing was changed."; exit 0 ;; +esac + +echo +echo "==> Stopping GenericAgent backend services" +curl -fsS -m 3 -X POST "http://127.0.0.1:14168/services/bridge/exit" >/dev/null 2>&1 || true +sleep 1 + +# Kill any process whose command line lives inside this bundle (no /proc on macOS, +# so match on the full argv via ps). Scoped to the bundle path → other installs untouched. +selfpid=$$ +ps -axo pid=,command= 2>/dev/null | while read -r pid cmd; do + [ "$pid" = "$selfpid" ] && continue + case "$cmd" in + *"$BUNDLE"*) kill -9 "$pid" 2>/dev/null && echo " killed PID $pid" ;; + esac +done +echo "[OK] backend stopped" + +echo "==> Removing desktop alias" +link="$HOME/Desktop/GenericAgent.app" +if [ -L "$link" ]; then + case "$(readlink "$link")" in + "$BUNDLE"*) rm -f "$link" && echo "[OK] removed $link" ;; + *) echo " desktop alias points to another bundle; left in place" ;; + esac +else + echo " no desktop alias found" +fi + +echo "==> Removing settings file" +if [ -f "$HOME/.ga_desktop_settings.json" ]; then + rm -f "$HOME/.ga_desktop_settings.json" && echo "[OK] removed ~/.ga_desktop_settings.json" +else + echo " no settings file found" +fi + +echo "==> Removing WebView data" +for d in "$HOME/Library/WebKit/$APP_ID" \ + "$HOME/Library/Caches/$APP_ID" \ + "$HOME/Library/Application Support/$APP_ID" \ + "$HOME/Library/HTTPStorages/$APP_ID" \ + "$HOME/Library/Saved Application State/$APP_ID.savedState"; do + if [ -e "$d" ]; then rm -rf "$d" && echo "[OK] removed $d"; fi +done +rm -f "$HOME/Library/Preferences/$APP_ID.plist" 2>/dev/null || true + +echo "==> Removing the bundle folder" +cd /tmp || cd / +if rm -rf "$BUNDLE" 2>/dev/null && [ ! -e "$BUNDLE" ]; then + echo "[OK] removed $BUNDLE" +else + nohup bash -c 'for i in $(seq 1 20); do rm -rf "'"$BUNDLE"'" 2>/dev/null; [ -e "'"$BUNDLE"'" ] || exit 0; sleep 1; done' >/dev/null 2>&1 & + echo "[OK] bundle folder will be removed after exit: $BUNDLE" +fi + +echo +echo "GenericAgent has been uninstalled." diff --git a/frontends/desktop/src-tauri/src/lib.rs b/frontends/desktop/src-tauri/src/lib.rs index 502622f3..2329dea7 100644 --- a/frontends/desktop/src-tauri/src/lib.rs +++ b/frontends/desktop/src-tauri/src/lib.rs @@ -218,48 +218,87 @@ fn ensure_desktop_shortcut() { let _ = cmd.status(); } -#[cfg(not(windows))] +#[cfg(target_os = "linux")] +fn ensure_desktop_shortcut() { + // Launch target: the AppImage path when running as one, else the current exe. Writing the + // current path on every enabled launch keeps a relocated bundle's launcher valid. + let Some(target) = std::env::var_os("APPIMAGE").map(PathBuf::from) + .or_else(|| std::env::current_exe().ok()) else { return; }; + let exec = target.to_string_lossy().replace('"', ""); + let entry = format!( + "[Desktop Entry]\nType=Application\nName=GenericAgent\nComment=GenericAgent Desktop\n\ + Exec=\"{exec}\"\nIcon={exec}\nTerminal=false\nCategories=Utility;Development;\n", + exec = exec + ); + let write_desktop = |path: &std::path::Path| { + if std::fs::write(path, &entry).is_ok() { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)); + } + }; + if let Some(home) = dirs::home_dir() { + let apps = home.join(".local/share/applications"); + let _ = std::fs::create_dir_all(&apps); + write_desktop(&apps.join("GenericAgent.desktop")); + } + if let Some(desktop) = dirs::desktop_dir() { + let _ = std::fs::create_dir_all(&desktop); + let f = desktop.join("GenericAgent.desktop"); + write_desktop(&f); + // GNOME marks unknown launchers "untrusted"; flag ours so it runs on double-click. Best effort. + let _ = Command::new("gio") + .args(["set", &f.to_string_lossy(), "metadata::trusted", "true"]) + .status(); + } +} + +#[cfg(target_os = "macos")] +fn ensure_desktop_shortcut() { + // The .app is the launchable unit; drop a symlink to it on the Desktop. + let Ok(exe) = std::env::current_exe() else { return; }; + let mut app: Option = None; + let mut d = exe.parent(); + while let Some(dir) = d { + if dir.extension().and_then(|s| s.to_str()) == Some("app") { app = Some(dir.to_path_buf()); break; } + d = dir.parent(); + } + let (Some(app), Some(desktop)) = (app, dirs::desktop_dir()) else { return; }; + let link = desktop.join("GenericAgent.app"); + let _ = std::fs::remove_file(&link); + let _ = std::os::unix::fs::symlink(&app, &link); +} + +#[cfg(all(not(windows), not(target_os = "linux"), not(target_os = "macos")))] fn ensure_desktop_shortcut() {} -/// First-run shortcut handling. Windows portable bundles only. Self-heals the shortcut path on -/// every enabled launch (cheap, no UI). The first-run ASK is driven by the frontend (see the -/// `shortcut_should_ask` / `shortcut_decide` commands): a native dialog from this background -/// startup thread has no parent window and gets buried behind the main window on first launch, -/// so the prompt is owned by the web UI instead, which always renders on top. +/// First-run shortcut handling for portable bundles (all platforms). Self-heals the shortcut +/// path on every enabled launch (cheap, no UI). The first-run ASK is driven by the frontend +/// (see the `shortcut_should_ask` / `shortcut_decide` commands): a native dialog from this +/// background startup thread has no parent window and gets buried behind the main window on +/// first launch, so the prompt is owned by the web UI instead, which always renders on top. fn maybe_setup_shortcut() { - #[cfg(windows)] - { - if bundle_root().is_none() { - return; - } - // Only self-heal when the user already opted in. Never prompt here. - if read_shortcut_pref() == Some(true) { - ensure_desktop_shortcut(); - } + if bundle_root().is_none() { + return; + } + // Only self-heal when the user already opted in. Never prompt here. + if read_shortcut_pref() == Some(true) { + ensure_desktop_shortcut(); } } /// Frontend asks whether to show the first-run "create desktop shortcut?" prompt. -/// True only on a Windows portable bundle whose preference has never been set. +/// True only on a portable bundle whose preference has never been set. #[tauri::command] fn shortcut_should_ask() -> bool { - #[cfg(windows)] - { - return bundle_root().is_some() && read_shortcut_pref().is_none(); - } - #[cfg(not(windows))] - { false } + bundle_root().is_some() && read_shortcut_pref().is_none() } /// Frontend reports the user's choice. Persists it and creates the shortcut when enabled. #[tauri::command] fn shortcut_decide(create: bool) { write_shortcut_pref(create); - #[cfg(windows)] - { - if create { - ensure_desktop_shortcut(); - } + if create { + ensure_desktop_shortcut(); } }