From cf45e231197ced2979459f56b2844aea58b7c063 Mon Sep 17 00:00:00 2001 From: Song Anbang Date: Mon, 19 Oct 2026 15:28:40 +0800 Subject: [PATCH 1/2] Linux autostart: an unpacked AppImage points at its binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package that unpacks the AppImage and starts its AppRun (the AUR thinkwatch-lite-bin, --appimage-extract) keeps the bundle marker the bundler patched into the binary, but no AppImage runtime ever set $APPIMAGE for it. `program` trusted the marker, asked for $APPIMAGE, found nothing and refused to write the entry — "The location of the AppImage file could not be determined (APPIMAGE is not set)" — so the launch-at-login toggle never turned on. Ask for $APPIMAGE only when it is actually there; otherwise point the entry at current_exe, the same line `update::linux_kind` draws for the updater. The pure part now takes the bundle type and $APPIMAGE as arguments, like `update::linux_kind`, so the new test runs everywhere. --- src-tauri/src/autostart/linux.rs | 99 +++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/autostart/linux.rs b/src-tauri/src/autostart/linux.rs index 1f4fb684..ba6220c1 100644 --- a/src-tauri/src/autostart/linux.rs +++ b/src-tauri/src/autostart/linux.rs @@ -13,8 +13,10 @@ //! - writes `$XDG_CONFIG_HOME/autostart/.desktop` (falling back to //! `~/.config`), with `Exec` encoded per the Desktop Entry Specification //! (section "The Exec key", plus the string escapes of "Possible value types"); -//! - points it at `$APPIMAGE` for an AppImage (the running binary lives under a -//! per-launch `/tmp/.mount_*` directory) and at the installed binary otherwise; +//! - points it at `$APPIMAGE` when an AppImage runtime set it (the running +//! binary lives under a per-launch `/tmp/.mount_*` directory) and at the +//! installed binary otherwise — including an unpacked AppImage, which keeps +//! the bundler's marker but has no `$APPIMAGE`; //! - treats `Hidden=true` and `X-GNOME-Autostart-enabled=false` as off, which is //! how GNOME and KDE record "disabled" without deleting the file; //! - rewrites the entry on every launch when it points somewhere else (the @@ -48,7 +50,11 @@ impl Autostart { // there; a development build gets the desktop's generic icon. Self { file, - program: program(std::env::current_exe().ok()), + program: program( + tauri::utils::platform::bundle_type(), + std::env::var_os("APPIMAGE").as_deref(), + std::env::current_exe().ok(), + ), name: app.package_info().name.clone(), icon: app.config().identifier.clone(), } @@ -162,28 +168,34 @@ impl Autostart { /// by everything an AppImage starts, so a development build launched from, say, /// an AppImage terminal emulator would see someone else's `$APPIMAGE`. The bundle /// type is patched into the binary by the bundler, so it cannot be inherited. -fn program(exe: Option) -> Result { +/// +/// **But the bundle type alone is not an AppImage either.** A package that +/// unpacks the AppImage and starts its `AppRun` — the AUR `thinkwatch-lite-bin`, +/// `--appimage-extract`, a deb built from one — keeps the marker the bundler +/// patched into the binary, while no AppImage runtime ever set `$APPIMAGE` for +/// it. There is no file to point the entry at, so it points at the running +/// binary, exactly as `update::linux_kind` refuses to treat that copy as an +/// AppImage for the updater. +fn program( + bundle: Option, + appimage: Option<&std::ffi::OsStr>, + exe: Option, +) -> Result { use tauri::utils::config::BundleType; - let path = if tauri::utils::platform::bundle_type() == Some(BundleType::AppImage) { - std::env::var_os("APPIMAGE") + let path = match bundle { + Some(BundleType::AppImage) => appimage .map(PathBuf::from) .filter(|p| p.is_absolute()) - .ok_or_else(|| { - tr!( - "未能确定 AppImage 文件的位置(APPIMAGE 未设置)", - "The location of the AppImage file could not be determined (APPIMAGE is not set)" - ) - .to_string() - })? - } else { - exe.ok_or_else(|| { - tr!( - "未能确定应用程序的位置", - "The location of the application could not be determined" - ) - .to_string() - })? - }; + .or(exe), + _ => exe, + } + .ok_or_else(|| { + tr!( + "未能确定应用程序的位置", + "The location of the application could not be determined" + ) + .to_string() + })?; path.into_os_string().into_string().map_err(|p| { tr!( format!("应用程序路径不是有效的 UTF-8:{}", p.to_string_lossy()), @@ -481,6 +493,49 @@ mod tests { "/home/a/\\\"$`%%\\/x.AppImage", ]; + /// An AppImage the runtime started is pointed at by `$APPIMAGE`; a package + /// that unpacked the AppImage keeps the bundle marker but has no `$APPIMAGE` + /// (the AUR package, `--appimage-extract`), and the entry then points at the + /// running binary — the same line `update::linux_kind` draws for the updater. + #[test] + fn the_bundle_marker_alone_is_not_an_appimage() { + use std::ffi::OsStr; + use tauri::utils::config::BundleType; + let image = OsStr::new("/home/a/Apps/ThinkWatch-Lite.AppImage"); + let exe = || { + Some(PathBuf::from( + "/opt/thinkwatch-lite-bin/usr/bin/thinkwatch-lite", + )) + }; + + // Started by the runtime: the entry starts the AppImage file + assert_eq!( + program(Some(BundleType::AppImage), Some(image), exe()).unwrap(), + "/home/a/Apps/ThinkWatch-Lite.AppImage" + ); + // Unpacked: marker, no `$APPIMAGE` — the installed binary is the program + assert_eq!( + program(Some(BundleType::AppImage), None, exe()).unwrap(), + "/opt/thinkwatch-lite-bin/usr/bin/thinkwatch-lite" + ); + // An empty or relative value is no location either + for bad in ["", "rel/x.AppImage"] { + assert_eq!( + program(Some(BundleType::AppImage), Some(OsStr::new(bad)), exe()).unwrap(), + "/opt/thinkwatch-lite-bin/usr/bin/thinkwatch-lite", + "{bad:?}" + ); + } + // Not this bundle type: `$APPIMAGE` is somebody else's, never used + assert_eq!( + program(None, Some(image), exe()).unwrap(), + "/opt/thinkwatch-lite-bin/usr/bin/thinkwatch-lite" + ); + // Neither a location nor a running binary + assert!(program(Some(BundleType::AppImage), None, None).is_err()); + assert!(program(None, None, None).is_err()); + } + #[test] fn plain_paths_are_still_quoted_and_plain_args_are_not() { assert_eq!( From 798a34a0a5377bf6b063fa89b67b3bc18caf1854 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:56:15 +0800 Subject: [PATCH 2/2] Linux autostart: an unpacked AppImage starts its AppRun An unpacked AppImage keeps the bundle marker but has no $APPIMAGE, and the previous commit pointed the entry at the running binary for it. Started that way it skips AppRun, which changes into usr/ and puts the bundled libraries first. The bundled WebKit needs both: the bundler rewrites /usr to ././ in it, so it looks for its helper processes relative to the working directory, and a session manager starts autostart entries from somewhere else. Point the entry at /AppRun instead, which is also what the AUR package's launcher starts, and at the binary only when there is no AppRun next to it. The mounted-AppImage check (the marker plus an absolute $APPIMAGE) now lives in one place, update::mounted_appimage, used by the updater, the menu entry and autostart. linux_kind used to accept any non-empty value. The new tests use Unix paths and a temporary AppDir, so they are #[cfg(unix)] like the module's other path tests: on Windows "/home/..." is not absolute. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/autostart/linux.rs | 142 +++++++++++++++++-------------- src-tauri/src/desktop_entry.rs | 17 ++-- src-tauri/src/update.rs | 64 +++++++++++--- 3 files changed, 137 insertions(+), 86 deletions(-) diff --git a/src-tauri/src/autostart/linux.rs b/src-tauri/src/autostart/linux.rs index ba6220c1..b87e89d6 100644 --- a/src-tauri/src/autostart/linux.rs +++ b/src-tauri/src/autostart/linux.rs @@ -13,10 +13,9 @@ //! - writes `$XDG_CONFIG_HOME/autostart/.desktop` (falling back to //! `~/.config`), with `Exec` encoded per the Desktop Entry Specification //! (section "The Exec key", plus the string escapes of "Possible value types"); -//! - points it at `$APPIMAGE` when an AppImage runtime set it (the running -//! binary lives under a per-launch `/tmp/.mount_*` directory) and at the -//! installed binary otherwise — including an unpacked AppImage, which keeps -//! the bundler's marker but has no `$APPIMAGE`; +//! - points it at `$APPIMAGE` for an AppImage (the running binary lives under a +//! per-launch `/tmp/.mount_*` directory), at the AppDir's `AppRun` for an +//! unpacked one, and at the installed binary otherwise; //! - treats `Hidden=true` and `X-GNOME-Autostart-enabled=false` as off, which is //! how GNOME and KDE record "disabled" without deleting the file; //! - rewrites the entry on every launch when it points somewhere else (the @@ -164,30 +163,28 @@ impl Autostart { /// What the entry should start. /// -/// **Asks the bundle type, not the environment alone.** `$APPIMAGE` is inherited -/// by everything an AppImage starts, so a development build launched from, say, -/// an AppImage terminal emulator would see someone else's `$APPIMAGE`. The bundle -/// type is patched into the binary by the bundler, so it cannot be inherited. -/// -/// **But the bundle type alone is not an AppImage either.** A package that -/// unpacks the AppImage and starts its `AppRun` — the AUR `thinkwatch-lite-bin`, -/// `--appimage-extract`, a deb built from one — keeps the marker the bundler -/// patched into the binary, while no AppImage runtime ever set `$APPIMAGE` for -/// it. There is no file to point the entry at, so it points at the running -/// binary, exactly as `update::linux_kind` refuses to treat that copy as an -/// AppImage for the updater. +/// - **An AppImage the runtime mounted**: the `.AppImage` file. Which one that is +/// comes from the bundle marker and `$APPIMAGE` together +/// (`update::mounted_appimage`). +/// - **An unpacked AppImage** (the AUR `thinkwatch-lite-bin`, +/// `--appimage-extract`): the AppDir's `AppRun`, never the binary itself. +/// It keeps the bundle marker but has no `$APPIMAGE`. `AppRun` changes into +/// `usr/` and puts the bundled libraries first, and the bundled WebKit needs +/// both: the bundler rewrites `/usr` to `././` in it, so it looks for its +/// helper processes relative to the working directory. That is also what the +/// AUR package's own launcher starts. +/// - **Anything else**: the running binary. fn program( bundle: Option, appimage: Option<&std::ffi::OsStr>, exe: Option, ) -> Result { use tauri::utils::config::BundleType; - let path = match bundle { - Some(BundleType::AppImage) => appimage - .map(PathBuf::from) - .filter(|p| p.is_absolute()) - .or(exe), - _ => exe, + let unpacked = bundle == Some(BundleType::AppImage); + let path = match crate::update::mounted_appimage(bundle, appimage) { + Some(file) => Some(file), + None if unpacked => exe.map(|exe| app_run(&exe).unwrap_or(exe)), + None => exe, } .ok_or_else(|| { tr!( @@ -207,6 +204,13 @@ fn program( }) } +/// `/AppRun` for a binary at `/usr/bin/`, when it is there. +fn app_run(exe: &Path) -> Option { + let bin = exe.parent().filter(|d| d.ends_with("usr/bin"))?; + let app_run = bin.parent()?.parent()?.join("AppRun"); + app_run.is_file().then_some(app_run) +} + /// `$XDG_CONFIG_HOME/autostart`, or `$HOME/.config/autostart`. /// /// Per the XDG Base Directory Specification a relative `$XDG_CONFIG_HOME` is @@ -493,49 +497,6 @@ mod tests { "/home/a/\\\"$`%%\\/x.AppImage", ]; - /// An AppImage the runtime started is pointed at by `$APPIMAGE`; a package - /// that unpacked the AppImage keeps the bundle marker but has no `$APPIMAGE` - /// (the AUR package, `--appimage-extract`), and the entry then points at the - /// running binary — the same line `update::linux_kind` draws for the updater. - #[test] - fn the_bundle_marker_alone_is_not_an_appimage() { - use std::ffi::OsStr; - use tauri::utils::config::BundleType; - let image = OsStr::new("/home/a/Apps/ThinkWatch-Lite.AppImage"); - let exe = || { - Some(PathBuf::from( - "/opt/thinkwatch-lite-bin/usr/bin/thinkwatch-lite", - )) - }; - - // Started by the runtime: the entry starts the AppImage file - assert_eq!( - program(Some(BundleType::AppImage), Some(image), exe()).unwrap(), - "/home/a/Apps/ThinkWatch-Lite.AppImage" - ); - // Unpacked: marker, no `$APPIMAGE` — the installed binary is the program - assert_eq!( - program(Some(BundleType::AppImage), None, exe()).unwrap(), - "/opt/thinkwatch-lite-bin/usr/bin/thinkwatch-lite" - ); - // An empty or relative value is no location either - for bad in ["", "rel/x.AppImage"] { - assert_eq!( - program(Some(BundleType::AppImage), Some(OsStr::new(bad)), exe()).unwrap(), - "/opt/thinkwatch-lite-bin/usr/bin/thinkwatch-lite", - "{bad:?}" - ); - } - // Not this bundle type: `$APPIMAGE` is somebody else's, never used - assert_eq!( - program(None, Some(image), exe()).unwrap(), - "/opt/thinkwatch-lite-bin/usr/bin/thinkwatch-lite" - ); - // Neither a location nor a running binary - assert!(program(Some(BundleType::AppImage), None, None).is_err()); - assert!(program(None, None, None).is_err()); - } - #[test] fn plain_paths_are_still_quoted_and_plain_args_are_not() { assert_eq!( @@ -688,6 +649,57 @@ mod tests { assert_eq!(autostart_dir(None, None), None); } + /// The mounted AppImage file; an unpacked AppImage's `AppRun`, not its bare + /// binary; the running binary for anything else. Unix paths only, as above. + #[cfg(unix)] + #[test] + fn the_entry_starts_the_appimage_or_its_app_run() { + use std::ffi::OsStr; + use tauri::utils::config::BundleType; + const APPIMAGE: Option = Some(BundleType::AppImage); + let (_tmp, dir) = tempdir(); + let exe = dir.join("usr/bin/thinkwatch-lite"); + let app_run = dir.join("AppRun"); + let image = OsStr::new("/home/a/Apps/ThinkWatch-Lite.AppImage"); + let s = |p: &Path| p.to_str().unwrap().to_owned(); + + // Mounted by the runtime: the AppImage file + assert_eq!( + program(APPIMAGE, Some(image), Some(exe.clone())).unwrap(), + "/home/a/Apps/ThinkWatch-Lite.AppImage" + ); + // Unpacked, before there is an `AppRun` to find: the binary + assert_eq!(program(APPIMAGE, None, Some(exe.clone())).unwrap(), s(&exe)); + // Unpacked (no, empty or relative `$APPIMAGE`): `AppRun` + std::fs::write(&app_run, "").unwrap(); + for v in [ + None, + Some(OsStr::new("")), + Some(OsStr::new("rel/x.AppImage")), + ] { + assert_eq!( + program(APPIMAGE, v, Some(exe.clone())).unwrap(), + s(&app_run), + "{v:?}" + ); + } + // No marker: the binary, even next to an `AppRun`, and someone else's + // `$APPIMAGE` is never used + assert_eq!( + program(None, Some(image), Some(exe.clone())).unwrap(), + s(&exe) + ); + // A binary outside `usr/bin` has no AppDir around it + let elsewhere = dir.join("thinkwatch-lite"); + assert_eq!( + program(APPIMAGE, None, Some(elsewhere.clone())).unwrap(), + s(&elsewhere) + ); + // Neither a location nor a running binary + assert!(program(APPIMAGE, None, None).is_err()); + assert!(program(None, None, None).is_err()); + } + /// Writes, reads back, is switched off by "GNOME", re-enabled in place, and /// repaired after a "move" — against a temporary directory, on any unix. #[cfg(unix)] diff --git a/src-tauri/src/desktop_entry.rs b/src-tauri/src/desktop_entry.rs index 2116802a..4eb40931 100644 --- a/src-tauri/src/desktop_entry.rs +++ b/src-tauri/src/desktop_entry.rs @@ -80,18 +80,13 @@ fn data_home( } /// The running AppImage file. `None` for anything else (a development build, -/// or an AppImage unpacked with `--appimage-extract`). -/// -/// The bundle type comes first for the same reason as in `update::linux_kind`: -/// `$APPIMAGE` is inherited by everything an AppImage starts. +/// or an AppImage unpacked with `--appimage-extract`); see +/// `update::mounted_appimage`. pub(crate) fn appimage() -> Option { - use tauri::utils::config::BundleType; - if tauri::utils::platform::bundle_type() != Some(BundleType::AppImage) { - return None; - } - std::env::var_os("APPIMAGE") - .map(PathBuf::from) - .filter(|p| p.is_absolute()) + crate::update::mounted_appimage( + tauri::utils::platform::bundle_type(), + std::env::var_os("APPIMAGE").as_deref(), + ) } /// The whole file. diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs index 674041c1..c2d4a8f8 100644 --- a/src-tauri/src/update.rs +++ b/src-tauri/src/update.rs @@ -178,12 +178,31 @@ pub fn official_build() -> bool { option_env!("TW_OFFICIAL_BUILD") == Some("1") } +/// 运行时挂载起来跑的那个 AppImage 文件。 +/// +/// **打包标记和 `APPIMAGE` 两样都要。**`APPIMAGE` 会被 AppImage 拉起来的一切 +/// 继承,只看它会拿到别人的文件;标记打在二进制里,继承不来。可只有标记也 +/// 不够:解开来跑的(AUR 的 `thinkwatch-lite-bin`、`--appimage-extract`)标记 +/// 照样是 AppImage,但运行时挂载时才设 `APPIMAGE`,这时根本没有那个文件。 +/// +/// 更新([`linux_kind`])、菜单条目(`desktop_entry`)、自启项 +/// (`autostart::linux`)都按这一条判断。 +pub fn mounted_appimage( + bundle: Option, + appimage: Option<&std::ffi::OsStr>, +) -> Option { + if bundle != Some(tauri::utils::config::BundleType::AppImage) { + return None; + } + appimage + .map(std::path::PathBuf::from) + .filter(|p| p.is_absolute()) +} + /// Linux 上这一份是怎么装上来的。 /// -/// **AppImage 还要看 `APPIMAGE` 在不在。**同一个打过补丁的二进制,用户用 -/// `--appimage-extract` 解开之后直接跑,标记照样是 AppImage,而这时根本没有 -/// 一个可以原地替换的 AppImage 文件 —— 插件会去替换正在跑的那个可执行文件。 -/// 运行时(AppImage 的 runtime)挂载时才设这个变量。 +/// **只有挂载起来跑的 AppImage 自己更新**([`mounted_appimage`])。解开来跑的 +/// 没有一个可以原地替换的 AppImage 文件 —— 插件会去替换正在跑的那个可执行文件。 /// /// deb、rpm 都不发,自己打出来的也不自己更新。 #[cfg(target_os = "linux")] @@ -191,12 +210,9 @@ pub fn linux_kind( bundle: Option, appimage: Option<&std::ffi::OsStr>, ) -> Install { - use tauri::utils::config::BundleType; - match bundle { - Some(BundleType::AppImage) if appimage.is_some_and(|p| !p.is_empty()) => { - Install::Standalone - } - _ => Install::Dev, + match mounted_appimage(bundle, appimage) { + Some(_) => Install::Standalone, + None => Install::Dev, } } @@ -482,6 +498,34 @@ mod tests { ); } + /// 标记和一个绝对路径的 `APPIMAGE` 缺一不可(Windows 上 `/home/…` 不算绝对路径)。 + #[cfg(unix)] + #[test] + fn a_mounted_appimage_needs_the_marker_and_the_variable() { + use std::ffi::OsStr; + use tauri::utils::config::BundleType; + let image = OsStr::new("/home/u/Apps/ThinkWatch-Lite.AppImage"); + assert_eq!( + mounted_appimage(Some(BundleType::AppImage), Some(image)), + Some(std::path::PathBuf::from(image)) + ); + // 解开来跑的:有标记、没有变量 + for v in [ + None, + Some(OsStr::new("")), + Some(OsStr::new("rel/x.AppImage")), + ] { + assert_eq!( + mounted_appimage(Some(BundleType::AppImage), v), + None, + "{v:?}" + ); + } + // 没有标记,环境里那个 `APPIMAGE` 是别人的 + assert_eq!(mounted_appimage(None, Some(image)), None); + assert_eq!(mounted_appimage(Some(BundleType::Deb), Some(image)), None); + } + /// Linux 上信打包时写进去的标记,不信路径和环境变量。 #[cfg(target_os = "linux")] #[test]