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/README.md b/README.md index 5f42a42..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 @@ -207,7 +210,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/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/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(); + } +} 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 259ddac..79d8f8b 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -911,8 +911,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")]