From b962356d0c2ed346ffb5938189619ace209d333d Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 25 Aug 2026 02:57:03 +0000 Subject: [PATCH 1/3] feat(profile): add --profile flag and isolate dirs at appname root Mirrors aw-qt#128 / aw-server-rust#652. --testing is an alias for --profile testing, AW_PROFILE is exported for spawned modules, and named profiles get a sibling activitywatch- dir root. default and testing keep the bare activitywatch root. --- CONTRIBUTING.md | 3 +- README.md | 9 ++ src-tauri/src/dirs.rs | 60 +++++++--- src-tauri/src/lib.rs | 109 ++++++++++++++----- src-tauri/src/main.rs | 19 +++- src-tauri/src/mini.rs | 2 +- src-tauri/src/profile.rs | 230 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 388 insertions(+), 44 deletions(-) create mode 100644 src-tauri/src/profile.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0884bc7c..5957f767 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,10 +34,11 @@ All Rust source lives in `src-tauri/src/`: | File | Responsibility | |------|---------------| -| `main.rs` | Entry point (4 lines — just calls `lib::run()`) | +| `main.rs` | Entry point — CLI flags, then `lib::run()` | | `lib.rs` | Application setup: Tauri builder, embedded server, tray icon, config, window management | | `manager.rs` | Module process manager: discovery, start/stop, crash recovery, tray menu updates | | `dirs.rs` | Platform-specific paths for config, data, logs, runtime | +| `profile.rs` | `--profile` / `AW_PROFILE` resolution (same rule as aw-qt and aw-server-rust) | | `logging.rs` | Log configuration with `fern`, rotation at 32 MB | The aw-webui frontend is a git submodule at `aw-webui/`, built separately and served via WebView. diff --git a/README.md b/README.md index 4009fa9f..c814983e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,13 @@ aw-tauri reads its config from a TOML file: | macOS | `~/Library/Application Support/activitywatch/aw-tauri/config.toml` | | Windows | `%APPDATA%\activitywatch\aw-tauri\config.toml` | +Named profiles (`--profile research`) isolate config, data, logs and the +single-instance lock under a sibling appname (`activitywatch-research` instead +of `activitywatch`). `default` and `testing` keep the paths above so existing +installs are not orphaned. `--testing` is an alias for `--profile testing`. +Spawned modules inherit `AW_PROFILE`. Custom profiles take `port` from their +own config (or `--port`); two instances cannot share 5600. + A default config is generated on first run. Example: ```toml @@ -106,6 +113,7 @@ Log rotation happens automatically at 32 MB, keeping the 5 most recent rotated l **Environment variables:** - `AW_DEBUG=1` — Enable debug-level logging - `AW_TRACE=1` — Enable trace-level logging (very verbose) +- `AW_PROFILE=` — Fallback profile when `--profile` is not passed. The launcher also *exports* this so spawned modules inherit it. ## Architecture @@ -148,6 +156,7 @@ aw-tauri/ │ │ ├── manager.rs # Module manager: discovery, lifecycle, crash recovery │ │ ├── autostart.rs # Start-at-login: OS registration kept in sync with config │ │ ├── dirs.rs # Platform-specific directory resolution +│ │ ├── profile.rs # --profile / AW_PROFILE resolution │ │ └── logging.rs # Log setup with fern, rotation at 32 MB │ ├── build.rs # Build script — requires AW_WEBUI_DIR env var │ ├── Cargo.toml # Rust dependencies diff --git a/src-tauri/src/dirs.rs b/src-tauri/src/dirs.rs index 1f17798a..428f104a 100644 --- a/src-tauri/src/dirs.rs +++ b/src-tauri/src/dirs.rs @@ -1,10 +1,34 @@ //! Directory management for ActivityWatch Tauri //! //! Supported platforms: Windows, Linux, macOS, Android +//! +//! Isolation is at the platformdirs appname root. `default` and `testing` +//! keep the bare `activitywatch` name so existing installs are not orphaned; +//! any other profile (`research`, …) gets a sibling root `activitywatch-

`. +//! Module segments (`aw-tauri`, …) do not change. use std::fs; use std::path::PathBuf; +use crate::profile::{current_profile, DEFAULT_PROFILE, TESTING_PROFILE}; + +/// Platform "appname" root for the current profile. +#[cfg(not(target_os = "android"))] +fn appname() -> String { + appname_for(¤t_profile()) +} + +/// `default` and `testing` keep the legacy bare root — existing installs must +/// not be orphaned. Any other profile gets its own sibling root. +#[cfg(not(target_os = "android"))] +fn appname_for(profile: &str) -> String { + if profile == DEFAULT_PROFILE || profile == TESTING_PROFILE { + "activitywatch".to_string() + } else { + format!("activitywatch-{profile}") + } +} + #[cfg(target_os = "android")] use std::sync::Mutex; @@ -21,7 +45,7 @@ lazy_static! { pub fn get_config_dir() -> Result { let dir = dirs::config_dir() .ok_or(())? - .join("activitywatch") + .join(appname()) .join("aw-tauri"); fs::create_dir_all(&dir).expect("Unable to create config dir"); Ok(dir) @@ -35,10 +59,7 @@ pub fn get_config_dir() -> Result { #[cfg(not(target_os = "android"))] #[allow(dead_code)] pub fn get_data_dir() -> Result { - let dir = dirs::data_dir() - .ok_or(())? - .join("activitywatch") - .join("aw-tauri"); + let dir = dirs::data_dir().ok_or(())?.join(appname()).join("aw-tauri"); fs::create_dir_all(&dir).expect("Unable to create data dir"); Ok(dir) } @@ -56,7 +77,7 @@ pub fn get_log_dir() -> Result { // Linux uses cache dir for logs let dir = dirs::cache_dir() .ok_or(())? - .join("activitywatch") + .join(appname()) .join("aw-tauri") .join("log"); fs::create_dir_all(&dir).expect("Unable to create log dir"); @@ -65,10 +86,10 @@ pub fn get_log_dir() -> Result { #[cfg(target_os = "windows")] pub fn get_log_dir() -> Result { - // Windows: %LOCALAPPDATA%\activitywatch\Logs\aw-tauri + // Windows: %LOCALAPPDATA%\\Logs\aw-tauri let dir = dirs::data_local_dir() .ok_or(())? - .join("activitywatch") + .join(appname()) .join("Logs") .join("aw-tauri"); fs::create_dir_all(&dir).expect("Unable to create log dir"); @@ -81,12 +102,12 @@ pub fn get_log_dir() -> Result { not(target_os = "windows") ))] pub fn get_log_dir() -> Result { - // macOS: ~/Library/Logs/activitywatch/aw-tauri + // macOS: ~/Library/Logs//aw-tauri let dir = dirs::home_dir() .ok_or(())? .join("Library") .join("Logs") - .join("activitywatch") + .join(appname()) .join("aw-tauri"); fs::create_dir_all(&dir).expect("Unable to create log dir"); Ok(dir) @@ -113,9 +134,7 @@ pub fn get_log_path() -> PathBuf { pub fn get_runtime_dir() -> PathBuf { // Linux: use XDG_RUNTIME_DIR or fallback to cache dir if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") { - let dir = PathBuf::from(runtime_dir) - .join("activitywatch") - .join("aw-tauri"); + let dir = PathBuf::from(runtime_dir).join(appname()).join("aw-tauri"); if fs::create_dir_all(&dir).is_ok() { return dir; } @@ -123,7 +142,7 @@ pub fn get_runtime_dir() -> PathBuf { // Fallback to cache dir let dir = dirs::cache_dir() .unwrap_or_else(|| PathBuf::from("/tmp")) - .join("activitywatch") + .join(appname()) .join("aw-tauri"); let _ = fs::create_dir_all(&dir); dir @@ -265,4 +284,17 @@ mod tests { assert!(log_path.parent().unwrap().exists()); } } + + #[test] + #[cfg(not(target_os = "android"))] + fn test_appname_root_isolation() { + // default and testing keep the legacy bare root — existing installs + // must not be orphaned by this change. + assert_eq!(appname_for("default"), "activitywatch"); + assert_eq!(appname_for("testing"), "activitywatch"); + + // any other profile gets its own sibling root + assert_eq!(appname_for("research"), "activitywatch-research"); + assert_eq!(appname_for("my-profile"), "activitywatch-my-profile"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a51b1f0f..1785b2b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,16 +25,33 @@ mod logging; mod manager; mod mini; mod module_alert_ui; +mod profile; mod updater_ui; +pub use profile::{export_profile, is_testing, resolve_profile, DEFAULT_PROFILE, TESTING_PROFILE}; + /// CLI arguments passed from main() -#[derive(Debug, Default)] +#[derive(Debug)] pub struct CliArgs { pub testing: bool, pub verbose: bool, pub port: Option, pub daemon: bool, pub mini: bool, + pub profile: String, +} + +impl Default for CliArgs { + fn default() -> Self { + Self { + testing: false, + verbose: false, + port: None, + daemon: false, + mini: false, + profile: DEFAULT_PROFILE.to_string(), + } + } } static CLI_ARGS: OnceLock = OnceLock::new(); @@ -60,6 +77,59 @@ fn get_cli_args() -> &'static CliArgs { CLI_ARGS.get_or_init(CliArgs::default) } +fn warn_if_custom_profile_shares_default_port(profile: &str, port: u16) { + if !profile::is_default(profile) && !profile::is_testing(profile) && port == 5600 { + warn!( + "profile '{profile}' is using port 5600; set `port` in its config or pass --port so it can run alongside the default instance" + ); + } +} + +fn log_profile_startup(profile: &str, port: u16) { + if profile::is_testing(profile) { + info!("Running in testing mode (port {port})"); + } else if !profile::is_default(profile) { + info!("Running with profile '{profile}' (port {port})"); + } + warn_if_custom_profile_shares_default_port(profile, port); +} + +/// Single-instance plugin keyed per profile so named instances can run next to +/// the default one. On Linux this is a D-Bus name suffix; on Windows/macOS the +/// plugin still keys off the bundle identifier, so named profiles share that +/// slot until the plugin grows an identifier override. +fn single_instance_plugin(profile: &str) -> tauri::plugin::TauriPlugin { + let lock_name = profile::lockfile_name(profile); + let callback = move |_app: &tauri::AppHandle, _args: Vec, _cwd: String| { + let lock_path = get_runtime_path().join(&lock_name); + if let Some(parent) = lock_path.parent() { + if !parent.exists() { + create_dir_all(parent).expect("Failed to create runtime dir"); + } + } + let _lock_file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&lock_path) + .expect("Failed to open lock file"); + info!("Another instance is running, quitting!"); + }; + + #[cfg(target_os = "linux")] + { + tauri_plugin_single_instance::Builder::new() + .callback(callback) + .dbus_id(profile::single_instance_dbus_id(profile)) + .build() + } + + #[cfg(not(target_os = "linux"))] + { + tauri_plugin_single_instance::init(callback) + } +} + use log::{error, info, trace, warn}; use tauri::{ menu::{Menu, MenuItem}, @@ -255,10 +325,11 @@ fn build_dashboard_url(port: u16, api_key: Option<&str>) -> Url { } pub fn listen_for_lockfile() { - thread::spawn(|| { + let lock_name = profile::lockfile_name(&get_cli_args().profile); + thread::spawn(move || { let runtime_path = get_runtime_path(); loop { - let watcher = match SpecificFileWatcher::new(&runtime_path, "single_instance.lock") { + let watcher = match SpecificFileWatcher::new(&runtime_path, &lock_name) { Ok(w) => w, Err(e) => { warn!("Failed to create file watcher: {}. Retrying in 2s...", e); @@ -271,7 +342,7 @@ pub fn listen_for_lockfile() { match watcher.wait_for_file() { Ok(()) => { log::info!("Lock file detected"); - remove_file(get_runtime_path().join("single_instance.lock")) + remove_file(get_runtime_path().join(&lock_name)) .expect("Failed to remove lock file"); let app = &*get_app_handle().lock().expect("Failed to get app handle"); if let Some(window) = app.webview_windows().get("main") { @@ -552,6 +623,7 @@ fn run_daemon() { device_id, }; + log_profile_startup(&cli_args.profile, port); info!("Starting aw-tauri in daemon mode on port {port}"); // Build Tokio runtime first so we can spawn Rocket before starting modules @@ -649,9 +721,7 @@ pub(crate) fn prepare_aw_server( asset_resolver: aw_server::endpoints::AssetResolver::new(asset_path_opt), device_id, }; - if testing { - info!("Running in testing mode (port {})", port); - } + log_profile_startup(&cli_args.profile, port); let dashboard_api_key = aw_config .auth .api_key @@ -869,19 +939,7 @@ pub fn run() { MacosLauncher::AppleScript, Some(vec![]), )) - .plugin(tauri_plugin_single_instance::init(|_app, _args, _cwd| { - let lock_path = get_runtime_path().join("single_instance.lock"); - if !lock_path.parent().unwrap().exists() { - create_dir_all(lock_path.parent().unwrap()).expect("Failed to create runtime dir"); - } - let _lock_file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(lock_path) - .expect("Failed to open lock file"); - info!("Another instance is running, quitting!"); - })) + .plugin(single_instance_plugin(&cli_args.profile)) // Serve the module-alert HTML with a valid Origin for the webview. .register_uri_scheme_protocol(module_alert_ui::URI_SCHEME, |_ctx, _request| { tauri::http::Response::builder() @@ -956,9 +1014,7 @@ pub fn run() { .show(|_| {}); panic!("Port {} is already in use", port); } - if testing { - info!("Running in testing mode (port {})", port); - } + log_profile_startup(&cli_args.profile, port); let dashboard_api_key = aw_config .auth .api_key @@ -979,7 +1035,7 @@ pub fn run() { "main", tauri::WebviewUrl::External(dashboard_url), ) - .title("aw-tauri") + .title(profile::window_title(&cli_args.profile)) .inner_size(800.0, 600.0) .visible(false) .initialization_script( @@ -1019,7 +1075,8 @@ pub fn run() { .clone(), ) .menu(&menu) - .show_menu_on_left_click(true); + .show_menu_on_left_click(true) + .tooltip(profile::tray_tooltip(&cli_args.profile)); #[cfg(target_os = "windows")] let tray_builder = TrayIconBuilder::new() @@ -1030,7 +1087,7 @@ pub fn run() { ) .menu(&menu) .show_menu_on_left_click(true) - .tooltip("ActivityWatch"); + .tooltip(profile::tray_tooltip(&cli_args.profile)); let tray = tray_builder.build(app).expect("Failed to create tray"); init_tray_id(tray.id().clone()); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e383e336..18fdf2d2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -7,10 +7,15 @@ use clap::Parser; #[derive(Parser, Debug)] #[command(name = "aw-tauri", version, about)] struct Cli { - /// Run in testing mode (port 5666, separate database) + /// Run in testing mode (port 5666, separate database). Alias for --profile testing. #[arg(long)] testing: bool, + /// Run an isolated instance under this profile name (data, config, logs + /// and lockfile are separate). --testing is an alias for --profile testing. + #[arg(long)] + profile: Option, + /// Enable verbose/debug logging #[arg(short, long)] verbose: bool, @@ -30,12 +35,22 @@ struct Cli { fn main() { let cli = Cli::parse(); + let profile = match aw_tauri_lib::resolve_profile(cli.profile.as_deref(), cli.testing) { + Ok(p) => p, + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(2); + } + }; + // Modules are spawned as subprocesses and inherit the profile from here. + aw_tauri_lib::export_profile(&profile); aw_tauri_lib::set_cli_args(aw_tauri_lib::CliArgs { - testing: cli.testing, + testing: aw_tauri_lib::is_testing(&profile), verbose: cli.verbose, port: cli.port, daemon: cli.daemon, mini: cli.mini, + profile, }); aw_tauri_lib::run(); } diff --git a/src-tauri/src/mini.rs b/src-tauri/src/mini.rs index f6d6ac3a..20ef265d 100644 --- a/src-tauri/src/mini.rs +++ b/src-tauri/src/mini.rs @@ -154,7 +154,7 @@ fn create_tray_icon(modules: &manager::ModulesSnapshot) -> TrayIcon { let mut builder = TrayIconBuilder::new() .with_menu(Box::new(menu)) .with_icon(icon) - .with_tooltip("ActivityWatch") + .with_tooltip(crate::profile::tray_tooltip(&crate::get_cli_args().profile)) .with_menu_on_left_click(true); #[cfg(target_os = "linux")] diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs new file mode 100644 index 00000000..9ae77b14 --- /dev/null +++ b/src-tauri/src/profile.rs @@ -0,0 +1,230 @@ +//! Named instance profiles for isolated ActivityWatch runs. +//! +//! A *profile* names an isolated instance (data, config, logs, lockfile). +//! `default` is the ordinary install, `testing` is what `--testing` has always +//! meant, and any other name (for example `research`) is a sibling instance +//! that can run at the same time as the others. +//! +//! aw-tauri is the launcher, so its job is small: resolve the profile, export +//! it as `AW_PROFILE` for the modules it spawns, and use it for the things +//! aw-tauri itself owns (dirs via [`crate::dirs`], single-instance id, tray +//! label). Spawned modules inherit the env var without every CLI growing a flag. +//! +//! Same validation rule as aw-server-rust and aw-qt: lowercase alphanumeric +//! plus `-`/`_`, at most 32 chars, so a profile is always a safe path segment. + +pub const DEFAULT_PROFILE: &str = "default"; +pub const TESTING_PROFILE: &str = "testing"; +pub const ENV_VAR: &str = "AW_PROFILE"; + +/// Return `Ok(())` if `name` is a usable profile, or `Err(message)` if not. +pub fn validate_profile(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("profile name must not be empty".into()); + } + if name.len() > 32 { + return Err(format!( + "profile name too long ({} chars, max 32)", + name.len() + )); + } + let first = name.chars().next().unwrap(); + if !first.is_ascii_alphanumeric() { + return Err(format!( + "profile name must start with a letter or digit, got '{first}'" + )); + } + for c in name.chars() { + if !c.is_ascii_alphanumeric() && c != '-' && c != '_' { + return Err(format!("invalid character '{c}' in profile name")); + } + } + if name != name.to_lowercase() { + return Err("profile name must be lowercase".into()); + } + Ok(()) +} + +/// Resolve the effective profile from CLI flags and an optional env value. +/// +/// `--testing` is an alias for `--profile testing`; passing both is only an +/// error if they disagree. `--profile` wins over `AW_PROFILE`. Invalid names +/// are rejected rather than silently ignored. +pub fn resolve_profile(cli_profile: Option<&str>, testing: bool) -> Result { + resolve_profile_from( + cli_profile, + testing, + std::env::var(ENV_VAR).ok().filter(|s| !s.is_empty()), + ) +} + +fn resolve_profile_from( + cli_profile: Option<&str>, + testing: bool, + env_profile: Option, +) -> Result { + if let Some(name) = cli_profile { + validate_profile(name)?; + if testing && name != TESTING_PROFILE { + return Err(format!( + "--testing conflicts with --profile {name}: --testing is an alias for --profile {TESTING_PROFILE}" + )); + } + return Ok(name.to_string()); + } + if testing { + return Ok(TESTING_PROFILE.to_string()); + } + match env_profile { + Some(name) => { + validate_profile(&name)?; + Ok(name) + } + None => Ok(DEFAULT_PROFILE.to_string()), + } +} + +pub fn is_testing(profile: &str) -> bool { + profile == TESTING_PROFILE +} + +pub fn is_default(profile: &str) -> bool { + profile == DEFAULT_PROFILE +} + +/// Publish the profile to this process and its children. +pub fn export_profile(profile: &str) { + std::env::set_var(ENV_VAR, profile); +} + +/// Profile currently exported (or `"default"` if unset/invalid). +pub fn current_profile() -> String { + match std::env::var(ENV_VAR) { + Ok(name) if validate_profile(&name).is_ok() => name, + _ => DEFAULT_PROFILE.to_string(), + } +} + +/// Single-instance lock filename. `default` keeps the legacy name so existing +/// watchers still fire; other profiles get a suffix so they don't steal the +/// default instance's focus signal (they may share a runtime dir with it). +pub fn lockfile_name(profile: &str) -> String { + if is_default(profile) { + "single_instance.lock".to_string() + } else { + format!("single_instance-{profile}.lock") + } +} + +pub fn tray_tooltip(profile: &str) -> String { + if is_default(profile) { + "ActivityWatch".to_string() + } else { + format!("ActivityWatch ({profile})") + } +} + +pub fn window_title(profile: &str) -> String { + if is_default(profile) { + "aw-tauri".to_string() + } else { + format!("aw-tauri ({profile})") + } +} + +/// Linux D-Bus well-known name base for the single-instance plugin. +/// `default` uses the bundle identifier; other profiles get a suffix so they +/// can run at the same time as the default instance. +pub fn single_instance_dbus_id(profile: &str) -> String { + if is_default(profile) { + "net.activitywatch.app".to_string() + } else { + format!("net.activitywatch.app.{profile}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_profile() { + assert!(validate_profile("default").is_ok()); + assert!(validate_profile("testing").is_ok()); + assert!(validate_profile("research").is_ok()); + assert!(validate_profile("my-profile").is_ok()); + assert!(validate_profile("profile_1").is_ok()); + + assert!(validate_profile("").is_err()); + assert!( + validate_profile("Research").is_err(), + "uppercase should be rejected" + ); + assert!(validate_profile("-bad").is_err(), "must start with alnum"); + assert!(validate_profile("bad name").is_err(), "spaces not allowed"); + assert!( + validate_profile("a/b").is_err(), + "path separator not allowed" + ); + assert!(validate_profile(&"a".repeat(33)).is_err(), "too long"); + } + + #[test] + fn test_resolve_profile() { + assert_eq!( + resolve_profile_from(None, false, None).unwrap(), + DEFAULT_PROFILE + ); + assert_eq!( + resolve_profile_from(None, true, None).unwrap(), + TESTING_PROFILE + ); + assert_eq!( + resolve_profile_from(Some("research"), false, None).unwrap(), + "research" + ); + assert_eq!( + resolve_profile_from(Some("testing"), true, None).unwrap(), + TESTING_PROFILE + ); + assert!(resolve_profile_from(Some("research"), true, None).is_err()); + assert_eq!( + resolve_profile_from(None, false, Some("research".into())).unwrap(), + "research" + ); + // --profile wins over AW_PROFILE + assert_eq!( + resolve_profile_from(Some("research"), false, Some("other".into())).unwrap(), + "research" + ); + // --testing wins over AW_PROFILE (it's an explicit CLI alias) + assert_eq!( + resolve_profile_from(None, true, Some("research".into())).unwrap(), + TESTING_PROFILE + ); + assert!(resolve_profile_from(Some("Research"), false, None).is_err()); + assert!(resolve_profile_from(None, false, Some("Not Valid".into())).is_err()); + } + + #[test] + fn test_lockfile_and_labels() { + assert_eq!(lockfile_name(DEFAULT_PROFILE), "single_instance.lock"); + assert_eq!( + lockfile_name(TESTING_PROFILE), + "single_instance-testing.lock" + ); + assert_eq!(lockfile_name("research"), "single_instance-research.lock"); + assert_eq!(tray_tooltip(DEFAULT_PROFILE), "ActivityWatch"); + assert_eq!(tray_tooltip("research"), "ActivityWatch (research)"); + assert_eq!(window_title(DEFAULT_PROFILE), "aw-tauri"); + assert_eq!(window_title("research"), "aw-tauri (research)"); + assert_eq!( + single_instance_dbus_id(DEFAULT_PROFILE), + "net.activitywatch.app" + ); + assert_eq!( + single_instance_dbus_id("research"), + "net.activitywatch.app.research" + ); + } +} From afc205af05a82e2676d55e684eb3dff28293fc86 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 25 Aug 2026 04:18:57 +0000 Subject: [PATCH 2/3] fix(profile): reject digit-leading names; pass --profile to autostart Digit-leading profiles (e.g. '1work') pass the alphanumeric check but produce invalid D-Bus well-known-name elements on Linux, preventing single-instance registration and startup. Fix: require the first char to be a letter (is_ascii_alphabetic). Autostart did not preserve the selected profile: the OS login item was registered without arguments, so every relaunch resolved to 'default'. Fix: pass ['--profile', name] to tauri_plugin_autostart::init when the active profile is not 'default'. Addresses Greptile P1 findings on PR #241. --- src-tauri/src/lib.rs | 8 +++++++- src-tauri/src/profile.rs | 13 ++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1785b2b0..b3808265 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -937,7 +937,13 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_autostart::init( MacosLauncher::AppleScript, - Some(vec![]), + // Pass --profile so the OS login item relaunches the selected + // profile, not always `default`. + if profile::is_default(&cli_args.profile) { + Some(vec![]) + } else { + Some(vec!["--profile", cli_args.profile.as_str()]) + }, )) .plugin(single_instance_plugin(&cli_args.profile)) // Serve the module-alert HTML with a valid Origin for the webview. diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 9ae77b14..c4bfcb42 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -29,9 +29,9 @@ pub fn validate_profile(name: &str) -> Result<(), String> { )); } let first = name.chars().next().unwrap(); - if !first.is_ascii_alphanumeric() { + if !first.is_ascii_alphabetic() { return Err(format!( - "profile name must start with a letter or digit, got '{first}'" + "profile name must start with a letter, got '{first}'" )); } for c in name.chars() { @@ -160,7 +160,14 @@ mod tests { validate_profile("Research").is_err(), "uppercase should be rejected" ); - assert!(validate_profile("-bad").is_err(), "must start with alnum"); + assert!( + validate_profile("-bad").is_err(), + "must start with a letter" + ); + assert!( + validate_profile("1work").is_err(), + "digit-leading profiles produce invalid D-Bus names" + ); assert!(validate_profile("bad name").is_err(), "spaces not allowed"); assert!( validate_profile("a/b").is_err(), From f06ea22c66d47ab1d07805a52d9d0a020e7d2d21 Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 25 Aug 2026 04:33:29 +0000 Subject: [PATCH 3/3] fix(profile): use LaunchAgent for named profiles on macOS autostart AppleScript login items silently discard extra arguments, so --profile was dropped on relogin. LaunchAgent creates a plist with ProgramArguments that preserves --profile, ensuring the correct instance starts on boot. Default profile keeps AppleScript (visible in System Settings login items). Named profiles use LaunchAgent (correct args in ~/Library/LaunchAgents/). Fixes Greptile P1: macOS autostart drops profiles. --- src-tauri/src/lib.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b3808265..7e784482 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -936,9 +936,13 @@ pub fn run() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_autostart::init( - MacosLauncher::AppleScript, - // Pass --profile so the OS login item relaunches the selected - // profile, not always `default`. + // AppleScript login items silently drop extra arguments; LaunchAgent + // writes a plist with ProgramArguments so --profile survives relogin. + if profile::is_default(&cli_args.profile) { + MacosLauncher::AppleScript + } else { + MacosLauncher::LaunchAgent + }, if profile::is_default(&cli_args.profile) { Some(vec![]) } else {