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.
This commit is contained in:
tiennm99 committed 2026-10-23 03:24:27 +07:00
1 parent 452ba60ea5
commit ab81dfd7e2
6 files changed
+200 -6

No files matched your search

+2 -1
View File
@@ -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]
+124
View File
@@ -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(&quoted)
}
/// `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"
);
}
}
+1
View File
@@ -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;
+22 -3
View File
@@ -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<bool, String> {
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<bool, String> {
if app.try_state::<AutoLaunchManager>().is_none() {
return Ok(false);
@@ -123,7 +142,7 @@ fn autostart_state(app: &AppHandle) -> Result<bool, String> {
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::<AutoLaunchManager>().is_none() {
return Err("autostart plugin is not available".into());
+49
View File
@@ -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<u32> {
}
}
/// The start-at-login entry, `~/.config/autostart/<name>.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<PathBuf> {
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<bool> {
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<OsString>, 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::*;
+2 -2
View File
@@ -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")]