From ab81dfd7e27b21518a75d30e0fc70444bad61094 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 23 Oct 2026 03:24:27 +0700 Subject: [PATCH 1/3] fix: quote the executable path in the Linux start-at-login entry The autostart plugin wrote the AppImage path into Exec unquoted, so a path with spaces never started while the menu still showed the item checked. Linux now writes ~/.config/autostart/UsageMonitor.desktop itself, quoting and escaping the path for both desktop-entry layers, and keeps the file name so existing entries still read as enabled. --- Cargo.toml | 3 +- src/core/desktop_entry.rs | 124 ++++++++++++++++++++++++++++++++++++++ src/core/mod.rs | 1 + src/menu.rs | 25 +++++++- src/platform/linux.rs | 49 +++++++++++++++ src/runtime.rs | 4 +- 6 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 src/core/desktop_entry.rs diff --git a/Cargo.toml b/Cargo.toml index 72e4a67..88b0b19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,8 @@ tauri-plugin-updater = "2" # Keep the major in step with the plugin's (`cargo tree -i reqwest`). reqwest = { version = "0.13", default-features = false, features = ["socks"] } -[target.'cfg(not(windows))'.dependencies] +# Windows and Linux write their start-at-login entries themselves. +[target.'cfg(not(any(windows, target_os = "linux")))'.dependencies] tauri-plugin-autostart = "2" [target.'cfg(unix)'.dependencies] diff --git a/src/core/desktop_entry.rs b/src/core/desktop_entry.rs new file mode 100644 index 0000000..5d16aa6 --- /dev/null +++ b/src/core/desktop_entry.rs @@ -0,0 +1,124 @@ +//! Freedesktop desktop entries: the start-at-login file written to +//! `~/.config/autostart` on Linux. +//! +//! An `Exec` argument goes through two escaping layers. The Exec layer +//! quotes the path, backslash-escapes `"`, `` ` ``, `$` and `\` inside the +//! quotes and doubles `%` so it is not read as a field code. The value is +//! then a desktop-entry string, whose own escaping turns each `\` into `\\` +//! (and newline, tab and carriage return into `\n`, `\t`, `\r`, so a path +//! cannot end the line and add keys). + +/// The `Exec` value that runs `path` with no arguments, escaped for both +/// layers, ready to follow `Exec=`. +pub fn exec_value(path: &str) -> String { + let mut quoted = String::with_capacity(path.len() + 2); + quoted.push('"'); + for c in path.chars() { + match c { + '"' | '`' | '$' | '\\' => { + quoted.push('\\'); + quoted.push(c); + } + '%' => quoted.push_str("%%"), + _ => quoted.push(c), + } + } + quoted.push('"'); + string_value("ed) +} + +/// `value` escaped as a desktop-entry string. +fn string_value(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for c in value.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + '\r' => out.push_str("\\r"), + _ => out.push(c), + } + } + out +} + +/// The autostart entry that starts `path` at login, named `name`. The keys +/// other than `Exec` match what earlier releases wrote through the +/// autostart plugin. +pub fn autostart_entry(name: &str, path: &str) -> String { + let name = string_value(name); + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Version=1.0\n\ + Name={name}\n\ + Comment={name} startup script\n\ + Exec={}\n\ + StartupNotify=false\n\ + Terminal=false\n", + exec_value(path) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_path_is_quoted() { + assert_eq!( + exec_value("/usr/bin/usage-monitor"), + "\"/usr/bin/usage-monitor\"" + ); + } + + #[test] + fn path_with_a_space_stays_one_argument() { + assert_eq!( + exec_value("/home/me/My Apps/usage-monitor.AppImage"), + "\"/home/me/My Apps/usage-monitor.AppImage\"" + ); + } + + #[test] + fn dollar_is_escaped_for_exec_then_for_the_string() { + // Exec layer: `\$`; string layer doubles the backslash. + assert_eq!(exec_value("/opt/a$b/app"), "\"/opt/a\\\\$b/app\""); + } + + #[test] + fn quote_backtick_and_backslash_are_escaped() { + assert_eq!(exec_value("/a\"b"), "\"/a\\\\\"b\""); + assert_eq!(exec_value("/a`b"), "\"/a\\\\`b\""); + // `\` becomes `\\` in the Exec layer, then `\\\\` as a string. + assert_eq!(exec_value("/a\\b"), "\"/a\\\\\\\\b\""); + } + + #[test] + fn percent_is_not_a_field_code() { + assert_eq!(exec_value("/opt/100%/app"), "\"/opt/100%%/app\""); + } + + #[test] + fn newline_cannot_start_a_new_key() { + let entry = autostart_entry("UsageMonitor", "/tmp/x\nHidden=true/app"); + assert!(!entry.lines().any(|line| line == "Hidden=true/app\"")); + assert!(entry.contains("Exec=\"/tmp/x\\nHidden=true/app\"\n")); + } + + #[test] + fn entry_keeps_the_keys_earlier_releases_wrote() { + let entry = autostart_entry("UsageMonitor", "/home/me/My Apps/u.AppImage"); + assert_eq!( + entry, + "[Desktop Entry]\n\ + Type=Application\n\ + Version=1.0\n\ + Name=UsageMonitor\n\ + Comment=UsageMonitor startup script\n\ + Exec=\"/home/me/My Apps/u.AppImage\"\n\ + StartupNotify=false\n\ + Terminal=false\n" + ); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 10518b7..a1edeb8 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,6 +1,7 @@ //! Portable logic with no OS or Tauri dependency, unit-tested on every host. pub mod color; +pub mod desktop_entry; pub mod geom; pub mod i18n; pub mod menu_model; diff --git a/src/menu.rs b/src/menu.rs index da4e072..d236221 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -13,7 +13,7 @@ use tauri::menu::{ CheckMenuItem, IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu, }; use tauri::{AppHandle, Manager, State, Wry}; -#[cfg(not(windows))] +#[cfg(not(any(windows, target_os = "linux")))] use tauri_plugin_autostart::{AutoLaunchManager, ManagerExt}; use crate::core::i18n::LocaleStrings; @@ -114,8 +114,27 @@ fn set_autostart(_app: &AppHandle, enable: bool) -> Result<(), String> { result.map_err(|err| err.to_string()) } +/// Linux bypasses the autostart plugin too: it writes the AppImage path +/// into `Exec` unquoted, so a path with spaces never starts (see +/// `platform::linux::set_autostart`). +#[cfg(target_os = "linux")] +fn autostart_state(_app: &AppHandle) -> Result { + crate::platform::linux::autostart_enabled(crate::runtime::AUTOSTART_NAME) + .map_err(|err| err.to_string()) +} + +#[cfg(target_os = "linux")] +fn set_autostart(app: &AppHandle, enable: bool) -> Result<(), String> { + crate::platform::linux::set_autostart( + crate::runtime::AUTOSTART_NAME, + app.env().appimage, + enable, + ) + .map_err(|err| err.to_string()) +} + /// Without the plugin (it failed to set up) start at login reads as off. -#[cfg(not(windows))] +#[cfg(not(any(windows, target_os = "linux")))] fn autostart_state(app: &AppHandle) -> Result { if app.try_state::().is_none() { return Ok(false); @@ -123,7 +142,7 @@ fn autostart_state(app: &AppHandle) -> Result { app.autolaunch().is_enabled().map_err(|err| err.to_string()) } -#[cfg(not(windows))] +#[cfg(not(any(windows, target_os = "linux")))] fn set_autostart(app: &AppHandle, enable: bool) -> Result<(), String> { if app.try_state::().is_none() { return Err("autostart plugin is not available".into()); diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 9e2219b..88955a2 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,6 +1,8 @@ //! Linux-only helpers. use std::ffi::OsString; +use std::io; +use std::path::PathBuf; use std::sync::OnceLock; use x11rb::connection::Connection; @@ -8,6 +10,7 @@ use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt, KeyButMask, Window} use x11rb::rust_connection::RustConnection; use super::FullscreenCheck; +use crate::core::desktop_entry; /// The AppIndicator libraries the tray backend loads, in its own order. The /// unversioned names come from the backend's `backcompat` feature (on by @@ -262,6 +265,52 @@ fn read_color_scheme() -> Option { } } +/// The start-at-login entry, `~/.config/autostart/.desktop`: the file +/// the autostart plugin wrote in earlier releases, so an entry made by one +/// of them still reads as enabled and is removed by a disable. +fn autostart_file(name: &str) -> io::Result { + let home = dirs::home_dir() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no home directory"))?; + Ok(home + .join(".config") + .join("autostart") + .join(format!("{name}.desktop"))) +} + +/// Whether the start-at-login entry `name` exists. +pub fn autostart_enabled(name: &str) -> io::Result { + Ok(autostart_file(name)?.exists()) +} + +/// Write or remove the start-at-login entry `name`. Enabling starts +/// `appimage` when the process runs from an AppImage (its mounted +/// executable moves on every run), otherwise the current executable. The +/// path is quoted in `Exec`, so a path with spaces or shell characters +/// starts the right file. +pub fn set_autostart(name: &str, appimage: Option, enable: bool) -> io::Result<()> { + let file = autostart_file(name)?; + if !enable { + return match std::fs::remove_file(&file) { + Err(err) if err.kind() != io::ErrorKind::NotFound => Err(err), + _ => Ok(()), + }; + } + let exe = match appimage { + Some(path) => PathBuf::from(path), + None => std::env::current_exe()?, + }; + let exe = exe.to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("executable path is not UTF-8: {}", exe.display()), + ) + })?; + if let Some(dir) = file.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&file, desktop_entry::autostart_entry(name, exe)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/runtime.rs b/src/runtime.rs index 09f2b07..c784a54 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -867,8 +867,8 @@ pub fn run() { let builder = tauri::Builder::default() // First, so a second instance exits before anything else starts. .plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {})); - // Windows writes its own current-user entry (see `menu::autostart_enabled`). - #[cfg(not(windows))] + // Windows and Linux write their own entries (see `menu::autostart_enabled`). + #[cfg(not(any(windows, target_os = "linux")))] let builder = { let autostart = tauri_plugin_autostart::Builder::new().app_name(AUTOSTART_NAME); #[cfg(target_os = "macos")] From 8328b147d7ecb91b858a89832850abc45ef43af1 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 23 Oct 2026 03:24:27 +0700 Subject: [PATCH 2/3] fix: keep the diagnose log private in the user's state directory On Unix the --diagnose log moves from a world-readable file in the temp directory to $XDG_STATE_HOME/usage-monitor/usage-monitor.log (falling back to the cache, then temp, directory), in a 0700 folder owned by the user, with the file at 0600. Windows keeps %TEMP%. --- README.md | 2 +- src/diag/mod.rs | 144 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5f42a42..ee8fc19 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ This writes a log file, truncated on each start, to: | OS | Log file | |----|----------| | Windows | `%TEMP%\usage-monitor.log` | -| Linux | `$TMPDIR/usage-monitor.log`, usually `/tmp/usage-monitor.log` | +| Linux | `$XDG_STATE_HOME/usage-monitor/usage-monitor.log`, usually `~/.local/state/usage-monitor/usage-monitor.log` (readable by your user only) | Settings are saved to: diff --git a/src/diag/mod.rs b/src/diag/mod.rs index b022699..ea7b92a 100644 --- a/src/diag/mod.rs +++ b/src/diag/mod.rs @@ -1,8 +1,11 @@ // Diagnostic logging facade backed by `log` + `simplelog`. // // `init(true)` redirects every `log::info!`/`log::warn!`/`log::error!` call -// across the crate to a file in `%TEMP%`. With `init(false)` (the default, -// i.e. no `--diagnose` flag) logging is a no-op. +// across the crate to the file `log_path()` names: `%TEMP%` on Windows, and +// on Unix a private `usage-monitor` folder (mode 0700) in the user's state +// directory (`$XDG_STATE_HOME`, by default `~/.local/state`), with the file +// itself readable by the owner only (mode 0600). With `init(false)` (the +// default, i.e. no `--diagnose` flag) logging is a no-op. use std::fs::File; use std::path::PathBuf; @@ -11,21 +14,84 @@ use simplelog::{CombinedLogger, ConfigBuilder, LevelFilter, WriteLogger}; const LOG_FILE_NAME: &str = "usage-monitor.log"; +/// The folder that holds the log on Unix, inside the state directory. +#[cfg(unix)] +const LOG_DIR_NAME: &str = "usage-monitor"; + /// The updater plugin logs the proxy URL, credentials included, at debug /// level, so its messages are kept to info and above. const UPDATER_TARGET: &str = "tauri_plugin_updater"; +/// Where `--diagnose` writes its log. On Linux this is +/// `$XDG_STATE_HOME/usage-monitor/usage-monitor.log`, by default +/// `~/.local/state/usage-monitor/usage-monitor.log`; on Windows +/// `%TEMP%\usage-monitor.log`. +pub fn log_path() -> PathBuf { + #[cfg(unix)] + { + unix_log_dir(dirs::state_dir(), dirs::cache_dir(), std::env::temp_dir()).join(LOG_FILE_NAME) + } + #[cfg(not(unix))] + { + std::env::temp_dir().join(LOG_FILE_NAME) + } +} + +/// The Unix log folder: under the state directory, else the cache +/// directory, else the temp directory. +#[cfg(unix)] +fn unix_log_dir(state: Option, cache: Option, temp: PathBuf) -> PathBuf { + state.or(cache).unwrap_or(temp).join(LOG_DIR_NAME) +} + +/// Create (or truncate) the log at `path`. On Unix its folder is created +/// with mode 0700 and must be a directory owned by this user, so a folder +/// someone else made in a shared temp directory is refused; the file is +/// created with mode 0600, and an existing file is reset to 0600. +fn open_log(path: &std::path::Path) -> std::io::Result { + #[cfg(unix)] + { + use std::fs::{DirBuilder, OpenOptions, Permissions}; + use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}; + + if let Some(dir) = path.parent() { + DirBuilder::new().recursive(true).mode(0o700).create(dir)?; + let meta = std::fs::symlink_metadata(dir)?; + // SAFETY: geteuid has no preconditions and cannot fail. + let uid = unsafe { libc::geteuid() }; + if !meta.is_dir() || meta.uid() != uid { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("{} is not a folder owned by this user", dir.display()), + )); + } + } + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.set_permissions(Permissions::from_mode(0o600))?; + Ok(file) + } + #[cfg(not(unix))] + { + File::create(path) + } +} + /// Initialise file-based logging. Idempotent — second call is a no-op. /// /// Returns the resolved log-file path on success, or `Ok(None)` when /// `enabled` is false. `Err` is only returned if the file could not be -/// opened (e.g. read-only `%TEMP%`); callers may ignore the error. +/// opened (e.g. a read-only log folder); callers may ignore the error. pub fn init(enabled: bool) -> std::io::Result> { if !enabled { return Ok(None); } - let path = std::env::temp_dir().join(LOG_FILE_NAME); - let file = File::create(&path)?; + let path = log_path(); + let file = open_log(&path)?; let updater_file = file.try_clone()?; let app = ConfigBuilder::new() .add_filter_ignore_str(UPDATER_TARGET) @@ -41,3 +107,71 @@ pub fn init(enabled: bool) -> std::io::Result> { log::info!("diagnostic logging enabled at {}", path.display()); Ok(Some(path)) } + +#[cfg(all(test, unix))] +mod tests { + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; + + use super::*; + + fn mode(path: &Path) -> u32 { + std::fs::metadata(path).unwrap().permissions().mode() & 0o777 + } + + #[test] + fn log_folder_prefers_state_then_cache_then_temp() { + let state = Some(PathBuf::from("/home/me/.local/state")); + let cache = Some(PathBuf::from("/home/me/.cache")); + let temp = PathBuf::from("/tmp"); + assert_eq!( + unix_log_dir(state, cache.clone(), temp.clone()), + PathBuf::from("/home/me/.local/state/usage-monitor") + ); + assert_eq!( + unix_log_dir(None, cache, temp.clone()), + PathBuf::from("/home/me/.cache/usage-monitor") + ); + assert_eq!( + unix_log_dir(None, None, temp), + PathBuf::from("/tmp/usage-monitor") + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_log_lives_in_the_state_directory() { + let expected = dirs::state_dir() + .unwrap() + .join("usage-monitor") + .join("usage-monitor.log"); + assert_eq!(log_path(), expected); + } + + #[test] + fn log_is_private_and_truncated() { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = + std::env::temp_dir().join(format!("usage-monitor-diag-{}-{nanos}", std::process::id())); + let dir = root.join(LOG_DIR_NAME); + let path = dir.join(LOG_FILE_NAME); + + let mut file = open_log(&path).unwrap(); + file.write_all(b"first run").unwrap(); + drop(file); + assert_eq!(mode(&dir), 0o700); + assert_eq!(mode(&path), 0o600); + + // A file left readable by an older release is narrowed and emptied. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + drop(open_log(&path).unwrap()); + assert_eq!(mode(&path), 0o600); + assert_eq!(std::fs::read(&path).unwrap(), b""); + + std::fs::remove_dir_all(&root).unwrap(); + } +} From 194609732e7166b7d9400233f764be8945419f7c Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 23 Oct 2026 03:36:27 +0700 Subject: [PATCH 3/3] docs: tell AppImage users to re-enable start at login after upgrading Releases up to v0.6.0 wrote the AppImage path into the autostart entry unquoted, so an entry made by them never starts when the path contains a space, while the menu item still shows it as on. Turning the item off and on rewrites the entry with the quoted path. --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ee8fc19..d60c779 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,10 @@ pick one: - `usage-monitor_X.Y.Z_amd64.AppImage`: make it executable (`chmod +x`) and run it. Like the Windows exe it is portable and keeps `settings.json` next to the AppImage file, so give it a folder you can - write to. The AppImage self-updates in place. + write to. The AppImage self-updates in place. If you turned on "Start + at login" in v0.6.0 or earlier and the AppImage path contains a space, + turn it off and on again once: those releases wrote the path unquoted, + so the app never started at login. - `usage-monitor_X.Y.Z_amd64.deb`: install it with `sudo apt install ./usage-monitor_X.Y.Z_amd64.deb`. The app still checks for updates, but installing one is left to you: download the