mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-24 00:16:11 +00:00
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:
1 parent
452ba60ea5
commit
ab81dfd7e2
6 files changed
+200
-6
No files matched your search
+2
-1
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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
@@ -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")]
|
||||
|
||||
Reference in new issue
Block a user