Merge branch 'worktree-wf_093aad93-ad0-5' into review-fixes

This commit is contained in:
tiennm99 committed 2026-10-23 03:41:26 +07:00
commit 8766e656cb
8 files changed
+344 -13

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]
+5 -2
View File
@@ -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:
+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;
+139 -5
View File
@@ -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<PathBuf>, cache: Option<PathBuf>, 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<File> {
#[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<Option<PathBuf>> {
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<Option<PathBuf>> {
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();
}
}
+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
@@ -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")]