From fc0b3b3060e36d0c9c6a86ddcdecf84d6fd43df5 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 22:23:47 +0900 Subject: [PATCH 1/6] fix(desktop): tray Start at Login shows only what Startup safety verifies The macOS tray item read tauri-plugin-autostart's is_enabled, which is only "the LaunchAgent plist exists", so a launchd-disabled job, another app copy's registration or a job loaded from an old copy showed a checked item while Startup safety reported Start at Login unverified (#6972). login_registration.rs ports macLoginRegistration step for step and the tray checks the item only for a verified registration of this copy. Other states are unchecked with the reason in the label; a launchd-disabled job opens Login Items settings, and another copy's, a missing app's or unreadable registrations are never rewritten. One shared fixture (58 cases) runs against both the Rust port and the TypeScript judgment. The macOS launch-origin migration now rewrites only this copy's own entry in the exact shape the plugin writes. --- desktop/src-tauri/src/first_run.rs | 22 + desktop/src-tauri/src/lib.rs | 5 + desktop/src-tauri/src/login_registration.rs | 931 ++++++++++++++ desktop/src-tauri/src/tray.rs | 35 +- desktop/src-tauri/src/tray_login.rs | 163 +++ .../000_plan.md | 218 ++++ .../src/content/docs/guides/desktop-app.md | 11 +- .../src/content/docs/guides/macos-menu-bar.md | 2 +- .../src/content/docs/ko/guides/desktop-app.md | 10 +- scripts/test-layout/layout.json | 2 +- src/service/desktop-startup.ts | 19 +- structure/INDEX.md | 3 + structure/desktop-shell.md | 3 + structure/desktop-start-at-login.md | 32 + structure/manifest.json | 10 + .../desktop-login-registration-cases.json | 1136 +++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- ...service-desktop-login-registration.test.ts | 111 ++ 18 files changed, 2694 insertions(+), 21 deletions(-) create mode 100644 desktop/src-tauri/src/login_registration.rs create mode 100644 desktop/src-tauri/src/tray_login.rs create mode 100644 devlog/_plan/261012_6972_tray_start_at_login/000_plan.md create mode 100644 structure/desktop-start-at-login.md create mode 100644 tests/fixtures/desktop-login-registration-cases.json create mode 100644 tests/service/service-desktop-login-registration.test.ts diff --git a/desktop/src-tauri/src/first_run.rs b/desktop/src-tauri/src/first_run.rs index cf0c5b9e87c..9f1532e79c3 100644 --- a/desktop/src-tauri/src/first_run.rs +++ b/desktop/src-tauri/src/first_run.rs @@ -85,6 +85,11 @@ pub fn apply_start_at_login_default(app: &AppHandle) -> StartAtLogin { /// It runs once, behind its own marker, and only where autostart is already on. It never turns the /// setting on and never turns it off; the worst case is an entry that keeps its old arguments and a /// login launch that shows its window, which is the visible failure rather than the silent one. +/// +/// On macOS the marker lives in a config directory every copy of the app shares, while the plugin +/// rewrites whatever plist exists. The rewrite is therefore limited to this copy's own entry in the +/// exact shape the plugin writes; another or a missing copy, or any key the rewrite would drop, is +/// left untouched and unmarked (#6972). pub fn adopt_launch_origin_argument(app: &AppHandle) { let Ok(dir) = app.path().app_config_dir() else { return; @@ -102,6 +107,23 @@ pub fn adopt_launch_origin_argument(app: &AppHandle) { // that is already enabled, so retrying after a transient registry, LaunchAgent or desktop-file // error is free — and claiming the marker first would suppress the migration permanently and // leave a login launch showing its window forever. + #[cfg(target_os = "macos")] + { + use crate::login_registration::Migration; + let Ok(home) = app.path().home_dir() else { + return; + }; + match crate::login_registration::judge_migration(&home) { + Migration::Rewrite => { + if crate::login_autostart::enable(app).is_err() { + return; + } + } + Migration::Absent => {} + Migration::Leave => return, + } + } + #[cfg(not(target_os = "macos"))] match app.autolaunch().is_enabled() { Ok(true) => { if crate::login_autostart::enable(app).is_err() { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 06c1fa29540..d4e3dec7e32 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -15,6 +15,9 @@ mod formatting; mod identity; mod logging; mod login_autostart; +// The judgment is portable so Linux CI runs its fixture parity tests; only macOS reads launchd. +#[cfg(any(target_os = "macos", test))] +mod login_registration; #[cfg(any(target_os = "windows", test))] mod windows_autostart_command; // macOS only: it exists to replace one item in a menu no other platform installs. Compiling it @@ -35,6 +38,8 @@ mod popup; mod popup; #[cfg(target_os = "macos")] mod provider_icons; +#[cfg(target_os = "macos")] +mod tray_login; // The macOS build selects native_tray.rs as the popup module; compile the portable popup // module's tests on macOS too so its navigation rules run on the maintainers' platform. #[cfg(all(test, target_os = "macos"))] diff --git a/desktop/src-tauri/src/login_registration.rs b/desktop/src-tauri/src/login_registration.rs new file mode 100644 index 00000000000..e3ae3b41389 --- /dev/null +++ b/desktop/src-tauri/src/login_registration.rs @@ -0,0 +1,931 @@ +//! Start at Login on macOS, judged the way Startup safety judges it. +//! +//! The autostart plugin answers "is Start at Login on?" with whether +//! `~/Library/LaunchAgents/OpenCodex.plist` exists. Startup safety +//! (`src/service/desktop-startup.ts`, `macLoginRegistration`) asks more: the plist must have the +//! shape this app writes, launchd must not have the job disabled, the registered app must be this +//! copy, and a loaded job must have been loaded from this copy and this plist. When the two +//! disagreed the tray showed a checked item while the Startup page reported Start at Login as +//! unverified (#6972). +//! +//! [`classify`] is a step-for-step port of that judgment over an injected [`Probe`], so the tray +//! can be tested without launchd. The cases in `tests/fixtures/desktop-login-registration-cases.json` +//! are run against both implementations; a change to either side that is not mirrored in the other +//! fails one of the two suites. Only [`LoginState::On`] is shown checked: every other state is a +//! reason the registration could not be credited, and none of them may read as on. + +use serde_json::Value; +use std::io; +use std::path::{Path, PathBuf}; + +const SERVICE: &str = "OpenCodex"; +const APP_SUFFIX: &str = "/Contents/MacOS/opencodex-desktop"; +const AUTOSTART_ARGUMENT: &str = "--autostart"; +/// System Settings → General → Login Items & Extensions, where a disabled job is turned back on. +#[cfg(target_os = "macos")] +pub const LOGIN_ITEMS_SETTINGS: &str = + "x-apple.systempreferences:com.apple.LoginItems-Settings.extension"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginState { + /// This copy's registration is enabled and either loaded from this copy or valid for the next + /// login. The only state Startup safety credits, and the only one shown checked. + On, + /// No LaunchAgent plist. + Off, + /// This copy is registered but launchd has the job disabled (System Settings or + /// `launchctl disable`). Rewriting the plist does not clear that, so the item opens Login + /// Items settings instead. + DisabledBySystem, + /// The registration, or the job launchd loaded, belongs to another OpenCodex copy. The tray + /// never rewrites it. + OtherCopy, + /// The registration names an app path that does not exist. It is still not this copy's. + MissingCopy, + /// The evidence could not be read, was malformed, timed out or changed while it was read. + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClickAction { + Disable, + Enable, + OpenLoginItems, + Nothing, +} + +impl LoginState { + pub fn checked(self) -> bool { + self == Self::On + } + + pub fn label(self) -> &'static str { + match self { + Self::On | Self::Off => "Start at Login", + Self::DisabledBySystem => "Start at Login (disabled by macOS)", + Self::OtherCopy => "Start at Login (another OpenCodex copy)", + Self::MissingCopy => "Start at Login (registered app is missing)", + Self::Unknown => "Start at Login (status unknown)", + } + } + + pub fn click_action(self) -> ClickAction { + match self { + Self::On => ClickAction::Disable, + Self::Off => ClickAction::Enable, + Self::DisabledBySystem => ClickAction::OpenLoginItems, + // A missing path may be a copy being replaced or on a volume that is not mounted, so it + // is no more this copy's to take over than an existing one. + Self::OtherCopy | Self::MissingCopy | Self::Unknown => ClickAction::Nothing, + } + } + + pub fn clickable(self) -> bool { + self.click_action() != ClickAction::Nothing + } +} + +/// A failed command. `Exit` is an ordinary non-zero exit; everything else (spawn failure, +/// timeout, signal, oversized or non-UTF-8 output) is `Failed`, which never counts as absence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunError { + Exit { status: i32, stderr: String }, + Failed, +} + +/// The filesystem and process reads the judgment needs, injectable for tests. +pub trait Probe { + /// An identity of the path itself (not its target) that changes when it is replaced or edited. + fn identity(&self, path: &Path) -> io::Result; + fn realpath(&self, path: &Path) -> io::Result; + fn executable(&self, path: &Path) -> io::Result<()>; + /// Trimmed stdout of a successful run. + fn run(&self, program: &str, args: &[&str]) -> Result; +} + +/// JavaScript's `\s`, so trimming and the regex ports below match the TypeScript judgment. +fn js_space(c: char) -> bool { + matches!( + c, + '\t' | '\n' | '\u{0b}' | '\u{0c}' | '\r' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +pub fn js_trim(text: &str) -> &str { + text.trim_matches(js_space) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Enabled { + Yes, + Disabled, + Unknown, +} + +/// `/"OpenCodex"\s*=>\s*(enabled|false|disabled|true)(?=\s*(?:\r?\n|[,;}]|$))/`, first match. +fn override_value(text: &str) -> Option<&'static str> { + let needle = "\"OpenCodex\""; + let mut from = 0; + while let Some(found) = text[from..].find(needle) { + let start = from + found; + from = start + needle.len(); + let rest = text[from..].trim_start_matches(js_space); + let Some(rest) = rest.strip_prefix("=>") else { + continue; + }; + let rest = rest.trim_start_matches(js_space); + for word in ["enabled", "false", "disabled", "true"] { + let Some(after) = rest.strip_prefix(word) else { + continue; + }; + let spaces = after.len() - after.trim_start_matches(js_space).len(); + let (gap, next) = after.split_at(spaces); + if gap.contains('\n') || next.is_empty() || next.starts_with([',', ';', '}']) { + return Some(word); + } + } + } + None +} + +/// The print-disabled half of `loginEnabled` in `macLoginRegistration`. +fn enabled(disabled: &str, plist_disabled: Option) -> Enabled { + let body = js_trim(disabled); + if !(body.starts_with("disabled services = {") && body.ends_with('}')) { + return Enabled::Unknown; + } + match disabled.matches("\"OpenCodex\"").count() { + // launchd's override is absent, so the plist's own Disabled key decides. auto-launch never + // writes one; a plist that disables itself was edited by something else. + 0 if plist_disabled == Some(true) => Enabled::Unknown, + 0 => Enabled::Yes, + 1 => match override_value(disabled) { + Some("enabled" | "false") => Enabled::Yes, + Some(_) => Enabled::Disabled, + None => Enabled::Unknown, + }, + _ => Enabled::Unknown, + } +} + +/// ECMAScript line terminators: what `^`, `$` and `.` honour under the `m` flag. +fn js_line_end(c: char) -> bool { + matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') +} + +/// `/^\s*key(.+)$/m`, first match. +fn line_value<'a>(text: &'a str, key: &str) -> Option<&'a str> { + text.split(js_line_end).find_map(|line| { + line.trim_start_matches(js_space) + .strip_prefix(key) + .filter(|value| !value.is_empty()) + }) +} + +/// The registered program when the plist has the shape auto-launch writes for this app. +fn registered_app(plist: &Value) -> Option<(&str, Option)> { + let args = plist.get("ProgramArguments")?.as_array()?; + let app = args.first()?.as_str()?; + let disabled = match plist.get("Disabled") { + None => None, + Some(Value::Bool(value)) => Some(*value), + Some(_) => return None, + }; + let shaped = plist.get("Label").and_then(Value::as_str) == Some(SERVICE) + && plist.get("RunAtLoad") == Some(&Value::Bool(true)) + && args.len() == 2 + && args[1].as_str() == Some(AUTOSTART_ARGUMENT) + && app.ends_with(APP_SUFFIX) + && plist + .get("Program") + .is_none_or(|program| program.as_str() == Some(app)); + shaped.then_some((app, disabled)) +} + +#[derive(Debug, PartialEq, Eq)] +struct Capture { + app: PathBuf, + proxy: PathBuf, + registration: PathBuf, + plist_identity: String, + target_identity: String, + definition: String, + app_identity: String, + proxy_identity: String, + disabled: String, + enabled: Enabled, +} + +fn unknown(_: T) -> LoginState { + LoginState::Unknown +} + +fn capture( + probe: &dyn Probe, + plist: &Path, + domain: &str, + current_app: &Path, +) -> Result { + let plist_identity = probe.identity(plist).map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + LoginState::Off + } else { + LoginState::Unknown + } + })?; + let registration = probe.realpath(plist).map_err(unknown)?; + let target_identity = probe.identity(®istration).map_err(unknown)?; + let plist_arg = plist.to_str().ok_or(LoginState::Unknown)?; + let definition = probe + .run( + "/usr/bin/plutil", + &["-convert", "json", "-o", "-", plist_arg], + ) + .map_err(unknown)?; + let parsed: Value = serde_json::from_str(&definition).map_err(unknown)?; + let (registered, plist_disabled) = registered_app(&parsed).ok_or(LoginState::Unknown)?; + let registered = Path::new(registered); + let app = probe.realpath(registered).map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + LoginState::MissingCopy + } else { + LoginState::Unknown + } + })?; + if app != current_app { + return Err(LoginState::OtherCopy); + } + let sibling = app.parent().ok_or(LoginState::Unknown)?.join("ocx"); + let proxy = probe.realpath(&sibling).map_err(unknown)?; + probe.executable(&app).map_err(unknown)?; + probe.executable(&proxy).map_err(unknown)?; + let app_identity = probe.identity(&app).map_err(unknown)?; + let proxy_identity = probe.identity(&proxy).map_err(unknown)?; + let disabled = probe + .run("/bin/launchctl", &["print-disabled", domain]) + .map_err(unknown)?; + let enabled = enabled(&disabled, plist_disabled); + // Every path and identity is read again: a registration replaced during the probes is not the + // registration that was judged. + let stable = probe.realpath(plist).ok().as_ref() == Some(®istration) + && probe.identity(plist).ok().as_ref() == Some(&plist_identity) + && probe.identity(®istration).ok().as_ref() == Some(&target_identity) + && probe.realpath(registered).ok().as_ref() == Some(&app) + && probe.realpath(&sibling).ok().as_ref() == Some(&proxy) + && probe.identity(&app).ok().as_ref() == Some(&app_identity) + && probe.identity(&proxy).ok().as_ref() == Some(&proxy_identity); + if !stable { + return Err(LoginState::Unknown); + } + Ok(Capture { + app, + proxy, + registration, + plist_identity, + target_identity, + definition, + app_identity, + proxy_identity, + disabled, + enabled, + }) +} + +/// Judge this copy's Start at Login registration. `current_app` must already be resolved. +pub fn classify(probe: &dyn Probe, home: &Path, uid: u32, current_app: &Path) -> LoginState { + let plist = home + .join("Library") + .join("LaunchAgents") + .join(format!("{SERVICE}.plist")); + let domain = format!("gui/{uid}"); + let first = match capture(probe, &plist, &domain, current_app) { + Ok(first) => first, + Err(state) => return state, + }; + match first.enabled { + Enabled::Yes => {} + Enabled::Disabled => return LoginState::DisabledBySystem, + Enabled::Unknown => return LoginState::Unknown, + } + let target = format!("{domain}/{SERVICE}"); + match probe.run("/bin/launchctl", &["print", &target]) { + Ok(loaded) => { + let Some(program) = line_value(&loaded, "program = ") else { + return LoginState::Unknown; + }; + match probe.realpath(Path::new(program)) { + Ok(program) if program == first.app => {} + Ok(_) => return LoginState::OtherCopy, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return LoginState::OtherCopy + } + Err(_) => return LoginState::Unknown, + } + let Some(path) = line_value(&loaded, "path = ") else { + return LoginState::Unknown; + }; + match probe.realpath(Path::new(path)) { + Ok(path) if path == first.registration => LoginState::On, + _ => LoginState::Unknown, + } + } + // launchctl's own service-not-found answer is absence; any other failure is unverified. + Err(RunError::Exit { + status: 113, + stderr, + }) => { + let missing = + format!("Could not find service \"{SERVICE}\" in domain for user gui: {uid}"); + let stderr = js_trim(&stderr); + if stderr != missing && stderr != format!("Bad request.\n{missing}") { + return LoginState::Unknown; + } + // An unloaded job leaves no durable evidence, so the whole registration is read again + // and must not have changed. + match capture(probe, &plist, &domain, current_app) { + Ok(last) if last == first => LoginState::On, + _ => LoginState::Unknown, + } + } + Err(_) => LoginState::Unknown, + } +} + +/// What the once-per-installation launch-origin migration in `first_run.rs` may do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Migration { + /// No LaunchAgent: nothing to migrate. + Absent, + /// This copy's own enabled entry, in the shape auto-launch writes with or without + /// `--autostart`: rewriting it changes only the arguments. + Rewrite, + /// Anything else: another or a missing copy, a `Program` override, a definition that disables + /// itself, an edited shape or unreadable evidence. Rewriting would change more than arguments. + Leave, +} + +pub fn migration(probe: &dyn Probe, home: &Path, current_app: &Path) -> Migration { + let plist = home + .join("Library") + .join("LaunchAgents") + .join(format!("{SERVICE}.plist")); + match probe.identity(&plist) { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => return Migration::Absent, + Err(_) => return Migration::Leave, + } + let Some(plist_arg) = plist.to_str() else { + return Migration::Leave; + }; + let Ok(definition) = probe.run( + "/usr/bin/plutil", + &["-convert", "json", "-o", "-", plist_arg], + ) else { + return Migration::Leave; + }; + let Ok(parsed) = serde_json::from_str::(&definition) else { + return Migration::Leave; + }; + let Some(args) = parsed.get("ProgramArguments").and_then(Value::as_array) else { + return Migration::Leave; + }; + let Some(app) = args.first().and_then(Value::as_str) else { + return Migration::Leave; + }; + let arguments = match args.len() { + 1 => true, + 2 => args[1].as_str() == Some(AUTOSTART_ARGUMENT), + _ => false, + }; + // The plugin rewrites the whole file with exactly these three keys, so any other key — + // `Program`, `Disabled`, `KeepAlive`, environment — would be dropped by the rewrite. + let keys = parsed.as_object().is_some_and(|plist| { + plist.len() == 3 + && ["Label", "ProgramArguments", "RunAtLoad"] + .iter() + .all(|key| plist.contains_key(*key)) + }); + let own_shape = arguments + && keys + && parsed.get("Label").and_then(Value::as_str) == Some(SERVICE) + && parsed.get("RunAtLoad") == Some(&Value::Bool(true)) + && app.ends_with(APP_SUFFIX); + if !own_shape { + return Migration::Leave; + } + match probe.realpath(Path::new(app)) { + Ok(app) if app == current_app => Migration::Rewrite, + _ => Migration::Leave, + } +} + +#[cfg(target_os = "macos")] +pub use system::{judge, judge_migration}; + +#[cfg(target_os = "macos")] +mod system { + use super::*; + use std::ffi::CString; + use std::io::Read; + use std::os::raw::c_char; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::MetadataExt; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + const TIMEOUT: Duration = Duration::from_millis(750); + const MAX_OUTPUT: u64 = 128 * 1024; + const X_OK: i32 = 1; + + unsafe extern "C" { + fn getuid() -> u32; + fn access(path: *const c_char, mode: i32) -> i32; + } + + pub struct SystemProbe; + + impl Probe for SystemProbe { + fn identity(&self, path: &Path) -> io::Result { + let meta = std::fs::symlink_metadata(path)?; + Ok(format!( + "{}:{}:{}:{}:{}.{}:{}.{}", + meta.dev(), + meta.ino(), + meta.size(), + meta.mode(), + meta.mtime(), + meta.mtime_nsec(), + meta.ctime(), + meta.ctime_nsec() + )) + } + + fn realpath(&self, path: &Path) -> io::Result { + std::fs::canonicalize(path) + } + + fn executable(&self, path: &Path) -> io::Result<()> { + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + // SAFETY: `path` is a NUL-terminated string that outlives the call. + if unsafe { access(path.as_ptr(), X_OK) } == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + + fn run(&self, program: &str, args: &[&str]) -> Result { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| RunError::Failed)?; + // Output collection shares the command's deadline: a reader still blocked when it + // passes is abandoned, never joined. + let drain = |stream: Option>| { + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut bytes = Vec::new(); + if let Some(stream) = stream { + let _ = stream.take(MAX_OUTPUT + 1).read_to_end(&mut bytes); + } + let _ = sender.send(bytes); + }); + receiver + }; + let stdout = drain( + child + .stdout + .take() + .map(|s| Box::new(s) as Box), + ); + let stderr = drain( + child + .stderr + .take() + .map(|s| Box::new(s) as Box), + ); + let deadline = Instant::now() + TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)) + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + return Err(RunError::Failed); + } + } + }; + let collect = |receiver: std::sync::mpsc::Receiver>| { + let left = deadline.saturating_duration_since(Instant::now()); + receiver.recv_timeout(left).map_err(|_| RunError::Failed) + }; + let stdout = collect(stdout)?; + let stderr = collect(stderr)?; + if stdout.len() as u64 > MAX_OUTPUT || stderr.len() as u64 > MAX_OUTPUT { + return Err(RunError::Failed); + } + // Node decodes `encoding: "utf8"` output lossily; so does this. + if status.success() { + return Ok(js_trim(&String::from_utf8_lossy(&stdout)).to_string()); + } + match status.code() { + Some(status) => Err(RunError::Exit { + status, + stderr: String::from_utf8_lossy(&stderr).into_owned(), + }), + None => Err(RunError::Failed), + } + } + } + + fn current_app() -> Option { + std::fs::canonicalize(std::env::current_exe().ok()?).ok() + } + + /// Decide the launch-origin migration against the live LaunchAgent. Blocking. + pub fn judge_migration(home: &Path) -> Migration { + match current_app() { + Some(app) => migration(&SystemProbe, home, &app), + None => Migration::Leave, + } + } + + /// Read the live registration. Blocking: run off the main thread. + pub fn judge(home: &Path) -> LoginState { + let Some(app) = current_app() else { + return LoginState::Unknown; + }; + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { getuid() }; + classify(&SystemProbe, home, uid, &app) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::collections::{BTreeMap, BTreeSet}; + + const CASES: &str = + include_str!("../../../tests/fixtures/desktop-login-registration-cases.json"); + const HOME: &str = "/fixture/home"; + const APP: &str = "/fixture/OpenCodex.app/Contents/MacOS/opencodex-desktop"; + const OTHER_APP: &str = "/fixture/Other/OpenCodex.app/Contents/MacOS/opencodex-desktop"; + const MISSING_APP: &str = "/fixture/Gone/OpenCodex.app/Contents/MacOS/opencodex-desktop"; + const PLIST: &str = "/fixture/home/Library/LaunchAgents/OpenCodex.plist"; + const REAL_PLIST: &str = "/fixture/home/Real.plist"; + const OTHER_PLIST: &str = "/fixture/home/Other.plist"; + + fn sibling(app: &str) -> String { + format!("{}/ocx", app.rsplit_once('/').unwrap().0) + } + + struct Fake { + files: BTreeSet, + links: BTreeMap, + plist: Option, + disabled: Option, + disabled_after: Option, + loaded: Result, + touch: bool, + disabled_reads: Cell, + plutil_calls: Cell, + } + + impl Fake { + fn new(plist: Option) -> Self { + let mut files: BTreeSet = [APP, OTHER_APP, OTHER_PLIST] + .into_iter() + .map(String::from) + .collect(); + files.insert(sibling(APP)); + files.insert(sibling(OTHER_APP)); + if plist.is_some() { + files.insert(PLIST.into()); + } + Self { + files, + links: BTreeMap::new(), + plist, + disabled: Some("disabled services = {\n}".into()), + disabled_after: None, + loaded: Err(RunError::Failed), + touch: false, + disabled_reads: Cell::new(0), + plutil_calls: Cell::new(0), + } + } + + fn known(&self, path: &Path) -> Option { + let path = path.to_str()?.to_string(); + (self.files.contains(&path) || self.links.contains_key(&path)).then_some(path) + } + } + + fn not_found() -> io::Error { + io::Error::from(io::ErrorKind::NotFound) + } + + impl Probe for Fake { + fn identity(&self, path: &Path) -> io::Result { + let path = self.known(path).ok_or_else(not_found)?; + let generation = if self.touch && path == PLIST { + self.plutil_calls.get() + } else { + 0 + }; + Ok(format!("{path}#{generation}")) + } + + fn realpath(&self, path: &Path) -> io::Result { + let path = self.known(path).ok_or_else(not_found)?; + Ok(PathBuf::from( + self.links.get(&path).cloned().unwrap_or(path), + )) + } + + fn executable(&self, path: &Path) -> io::Result<()> { + self.known(path).map(|_| ()).ok_or_else(not_found) + } + + fn run(&self, program: &str, args: &[&str]) -> Result { + match (program, args.first().copied()) { + ("/usr/bin/plutil", _) => { + self.plutil_calls.set(self.plutil_calls.get() + 1); + self.plist + .as_ref() + .map(Value::to_string) + .ok_or(RunError::Failed) + } + ("/bin/launchctl", Some("print-disabled")) => { + let reads = self.disabled_reads.get() + 1; + self.disabled_reads.set(reads); + let after = self.disabled_after.as_ref().filter(|_| reads > 1); + after + .or(self.disabled.as_ref()) + .cloned() + .ok_or(RunError::Failed) + } + ("/bin/launchctl", Some("print")) => self.loaded.clone(), + other => panic!("unexpected command {other:?}"), + } + } + } + + fn fill(text: &str) -> String { + text.replace("{APP}", APP) + .replace("{OTHER_APP}", OTHER_APP) + .replace("{MISSING_APP}", MISSING_APP) + .replace("{PLIST}", PLIST) + .replace("{OTHER_PLIST}", OTHER_PLIST) + .replace("{UID}", "501") + } + + fn fake_for(case: &Value) -> Fake { + let case: Value = serde_json::from_str(&fill(&case.to_string())).unwrap(); + let plist = case.get("plist").filter(|plist| !plist.is_null()).cloned(); + let present = plist.is_some(); + let mut fake = Fake::new(plist); + if case.get("plutilFails") == Some(&Value::Bool(true)) { + fake.plist = None; + } + if case.get("proxy") == Some(&Value::Bool(false)) { + fake.files.remove(&sibling(APP)); + } + if present && case.get("plistLink") == Some(&Value::Bool(true)) { + fake.files.remove(PLIST); + fake.files.insert(REAL_PLIST.into()); + fake.links.insert(PLIST.into(), REAL_PLIST.into()); + } + fake.touch = case.get("touchPlistOnRead") == Some(&Value::Bool(true)); + fake.disabled = case["disabled"].as_str().map(String::from); + fake.disabled_after = case + .get("disabledAfterFirstRead") + .and_then(Value::as_str) + .map(String::from); + let loaded = &case["loaded"]; + fake.loaded = if let Some(stdout) = loaded.get("stdout").and_then(Value::as_str) { + Ok(stdout.into()) + } else if let (Some(status), None) = (loaded.get("status"), loaded.get("signal")) { + Err(RunError::Exit { + status: status.as_i64().unwrap() as i32, + stderr: loaded["stderr"].as_str().unwrap().into(), + }) + } else { + Err(RunError::Failed) + }; + fake + } + + fn name(state: LoginState) -> &'static str { + match state { + LoginState::On => "on", + LoginState::Off => "off", + LoginState::DisabledBySystem => "disabled-by-system", + LoginState::OtherCopy => "other-copy", + LoginState::MissingCopy => "missing-copy", + LoginState::Unknown => "unknown", + } + } + + #[test] + fn shared_fixture_matches_startup_safety() { + let fixture: Value = serde_json::from_str(CASES).unwrap(); + let cases = fixture["cases"].as_array().unwrap(); + assert!(cases.len() > 40); + for case in cases { + let label = case["name"].as_str().unwrap(); + let fake = fake_for(case); + let state = classify(&fake, Path::new(HOME), 501, Path::new(APP)); + assert_eq!(name(state), case["expect"]["tray"], "{label}"); + assert_eq!( + Some(state.checked()), + case["expect"]["loginEnabled"].as_bool(), + "{label}" + ); + } + } + + #[test] + fn only_a_verified_registration_is_checked_or_rewritable() { + let table = [ + (LoginState::On, true, ClickAction::Disable, "Start at Login"), + ( + LoginState::Off, + false, + ClickAction::Enable, + "Start at Login", + ), + ( + LoginState::DisabledBySystem, + false, + ClickAction::OpenLoginItems, + "Start at Login (disabled by macOS)", + ), + ( + LoginState::OtherCopy, + false, + ClickAction::Nothing, + "Start at Login (another OpenCodex copy)", + ), + ( + LoginState::MissingCopy, + false, + ClickAction::Nothing, + "Start at Login (registered app is missing)", + ), + ( + LoginState::Unknown, + false, + ClickAction::Nothing, + "Start at Login (status unknown)", + ), + ]; + for (state, checked, action, label) in table { + assert_eq!(state.checked(), checked, "{state:?}"); + assert_eq!(state.click_action(), action, "{state:?}"); + assert_eq!( + state.clickable(), + action != ClickAction::Nothing, + "{state:?}" + ); + assert_eq!(state.label(), label, "{state:?}"); + } + } + + #[test] + fn migration_rewrites_only_this_copys_own_enabled_entry() { + let entry = |extra: Value| { + let mut plist = serde_json::json!({ + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [APP], + }); + for (key, value) in extra.as_object().unwrap() { + plist[key] = value.clone(); + } + Some(plist) + }; + let cases = [ + ("absent", None, Migration::Absent), + ( + "legacy entry", + entry(serde_json::json!({})), + Migration::Rewrite, + ), + ( + "current arguments", + entry(serde_json::json!({ "ProgramArguments": [APP, "--autostart"] })), + Migration::Rewrite, + ), + ( + "Program equal", + entry(serde_json::json!({ "Program": APP })), + Migration::Leave, + ), + ( + "Disabled false", + entry(serde_json::json!({ "Disabled": false })), + Migration::Leave, + ), + ( + "a key the rewrite would drop", + entry(serde_json::json!({ "KeepAlive": true })), + Migration::Leave, + ), + ( + "another copy", + entry(serde_json::json!({ "ProgramArguments": [OTHER_APP] })), + Migration::Leave, + ), + ( + "missing copy", + entry(serde_json::json!({ "ProgramArguments": [MISSING_APP, "--autostart"] })), + Migration::Leave, + ), + ( + "Program names another copy", + entry(serde_json::json!({ "Program": OTHER_APP })), + Migration::Leave, + ), + ( + "Program names a missing copy", + entry(serde_json::json!({ "Program": MISSING_APP })), + Migration::Leave, + ), + ( + "disabled by its own definition", + entry(serde_json::json!({ "Disabled": true })), + Migration::Leave, + ), + ( + "Disabled is not a boolean", + entry(serde_json::json!({ "Disabled": "yes" })), + Migration::Leave, + ), + ( + "unknown argument", + entry(serde_json::json!({ "ProgramArguments": [APP, "--hidden"] })), + Migration::Leave, + ), + ( + "extra argument", + entry(serde_json::json!({ "ProgramArguments": [APP, "--autostart", "x"] })), + Migration::Leave, + ), + ( + "another label", + entry(serde_json::json!({ "Label": "com.example.OpenCodex" })), + Migration::Leave, + ), + ( + "RunAtLoad false", + entry(serde_json::json!({ "RunAtLoad": false })), + Migration::Leave, + ), + ]; + for (label, plist, expected) in cases { + let fake = Fake::new(plist); + assert_eq!( + migration(&fake, Path::new(HOME), Path::new(APP)), + expected, + "{label}" + ); + } + let mut unreadable = Fake::new(entry(serde_json::json!({}))); + unreadable.plist = None; + assert_eq!( + migration(&unreadable, Path::new(HOME), Path::new(APP)), + Migration::Leave + ); + } + + #[test] + fn javascript_whitespace_and_line_terminators() { + assert_eq!(js_trim("\u{feff}\u{a0} x \u{3000}"), "x"); + assert_eq!(js_trim("\u{85}x"), "\u{85}x"); + assert_eq!( + line_value("a\u{2028}program = /x\u{2029}y", "program = "), + Some("/x") + ); + assert_eq!( + line_value("program = \rprogram = /y", "program = "), + Some("/y") + ); + } +} diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index e8e76697ec9..c702bc7c240 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -14,6 +14,7 @@ use tauri::{ tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, AppHandle, Manager, Wry, }; +#[cfg(not(target_os = "macos"))] use tauri_plugin_autostart::ManagerExt; use tauri_plugin_opener::OpenerExt; @@ -28,6 +29,8 @@ pub struct TrayMenu { check_updates: MenuItem, install_update: MenuItem, stop: MenuItem, + #[cfg(target_os = "macos")] + pub(crate) login: CheckMenuItem, } impl Default for TrayState { @@ -79,6 +82,10 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { true, None::<&str>, )?; + // macOS shows only what Startup safety would verify, and probes it off the main thread. + #[cfg(target_os = "macos")] + let login = crate::tray_login::item(app)?; + #[cfg(not(target_os = "macos"))] let login = CheckMenuItem::with_id( app, "start-at-login", @@ -133,8 +140,12 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { check_updates: check_updates.clone(), install_update: install_update.clone(), stop: stop.clone(), + #[cfg(target_os = "macos")] + login: login.clone(), }); } + #[cfg(target_os = "macos")] + crate::tray_login::refresh(app); let builder = TrayIconBuilder::with_id("main") .icon(icon()) @@ -151,6 +162,11 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let builder = builder.show_menu_on_left_click(false); let tray = builder .on_tray_icon_event(|tray, event| { + #[cfg(target_os = "macos")] + if let TrayIconEvent::Enter { .. } = event { + crate::tray_login::hover(tray.app_handle()); + return; + } if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, @@ -213,11 +229,16 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { .open_url(format!("{}#/usage", endpoint.url("/")), None::); } "start-at-login" => { - let enabled = app.autolaunch().is_enabled().unwrap_or(false); - if enabled { - let _ = app.autolaunch().disable(); - } else { - let _ = crate::login_autostart::enable(app); + #[cfg(target_os = "macos")] + crate::tray_login::click(app); + #[cfg(not(target_os = "macos"))] + { + let enabled = app.autolaunch().is_enabled().unwrap_or(false); + if enabled { + let _ = app.autolaunch().disable(); + } else { + let _ = crate::login_autostart::enable(app); + } } } "stop-proxy" => { @@ -254,6 +275,8 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let mut tick = 0; loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; + #[cfg(target_os = "macos")] + crate::tray_login::refresh(&app); let Some(proxy) = app .try_state::() .and_then(|state| state.proxy()) @@ -287,7 +310,7 @@ fn refresh(app: &AppHandle, tray: &tauri::tray::TrayIcon) { /// the main thread and takes this same mutex while doing so, so calling a setter with the lock held /// is a cycle: a background update owns the mutex and waits for the main thread, and the main /// thread waits for the mutex. The app would stop answering Quit. -fn menu_handles(app: &AppHandle) -> Option { +pub(crate) fn menu_handles(app: &AppHandle) -> Option { let state = app.try_state::()?; let handles = state.menu.lock().ok()?; handles.as_ref().cloned() diff --git a/desktop/src-tauri/src/tray_login.rs b/desktop/src-tauri/src/tray_login.rs new file mode 100644 index 00000000000..5126c1f44e7 --- /dev/null +++ b/desktop/src-tauri/src/tray_login.rs @@ -0,0 +1,163 @@ +//! The macOS tray's Start at Login item (#6972). +//! +//! The item shows what [`login_registration::judge`] verifies, the judgment Startup safety uses, +//! and is checked only for [`LoginState::On`]. Reading launchd runs subprocesses, so every probe +//! runs on a blocking worker; one mutex serializes probes with the mutation a click performs. +//! +//! Presentation is applied in one closure on the main thread, the thread that also delivers +//! clicks, so a click and a refresh can never interleave their setters. muda flips a check item +//! before the click reaches the app; the click handler puts back what was shown and holds the +//! item disabled until its own job answers, and refresh results that land meanwhile are dropped. + +use crate::login_registration::{self, ClickAction, LoginState}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, PoisonError, +}; +use std::time::{Duration, Instant}; +use tauri::{menu::CheckMenuItem, AppHandle, Manager, Wry}; +use tauri_plugin_autostart::ManagerExt; +use tauri_plugin_opener::OpenerExt; + +const HOVER_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Default)] +pub struct LoginTray { + /// The state the item shows; `None` until the first probe answers. + shown: Mutex>, + /// Clicks whose job has not applied its answer yet. + pending: AtomicUsize, + /// Serializes probes and the mutation a click performs. + probe: Mutex<()>, + /// When hovering last asked for a refresh. + hovered: Mutex>, +} + +/// The item as built, before anything has been verified: unchecked and inert. +pub fn item(app: &AppHandle) -> tauri::Result> { + app.manage(LoginTray::default()); + let state = LoginState::Unknown; + CheckMenuItem::with_id( + app, + "start-at-login", + state.label(), + state.clickable(), + state.checked(), + None::<&str>, + ) +} + +fn judge(app: &AppHandle) -> LoginState { + match app.path().home_dir() { + Ok(home) => login_registration::judge(&home), + Err(_) => LoginState::Unknown, + } +} + +/// Show `state`. A refresh (`click == false`) yields to any click still waiting for its answer. +fn present(app: &AppHandle, state: LoginState, click: bool) { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + let Some(login) = handle.try_state::() else { + return; + }; + if click { + login.pending.fetch_sub(1, Ordering::AcqRel); + } else if login.pending.load(Ordering::Acquire) > 0 { + return; + } + *login.shown.lock().unwrap_or_else(PoisonError::into_inner) = Some(state); + let busy = login.pending.load(Ordering::Acquire) > 0; + if let Some(menu) = crate::tray::menu_handles(&handle) { + let _ = menu.login.set_text(state.label()); + let _ = menu.login.set_checked(state.checked()); + let _ = menu.login.set_enabled(state.clickable() && !busy); + } + }); +} + +/// Probe again unless a probe or a click is already running. +pub fn refresh(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let Some(login) = app.try_state::() else { + return; + }; + let Ok(_probe) = login.probe.try_lock() else { + return; + }; + let state = judge(&app); + present(&app, state, false); + }); +} + +/// The pointer entered the status item: the menu may be about to open. +pub fn hover(app: &AppHandle) { + let Some(login) = app.try_state::() else { + return; + }; + { + let mut hovered = login.hovered.lock().unwrap_or_else(PoisonError::into_inner); + let now = Instant::now(); + if hovered.is_some_and(|last| now.duration_since(last) < HOVER_INTERVAL) { + return; + } + *hovered = Some(now); + } + refresh(app); +} + +/// Handle a click on the item. Runs on the main thread and never waits for a probe. +pub fn click(app: &AppHandle) { + let Some(login) = app.try_state::() else { + return; + }; + let shown = login + .shown + .lock() + .unwrap_or_else(PoisonError::into_inner) + .unwrap_or(LoginState::Unknown); + login.pending.fetch_add(1, Ordering::AcqRel); + if let Some(menu) = crate::tray::menu_handles(app) { + let _ = menu.login.set_checked(shown.checked()); + let _ = menu.login.set_enabled(false); + } + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let state = match app.try_state::() { + Some(login) => { + let _probe = login.probe.lock().unwrap_or_else(PoisonError::into_inner); + let fresh = judge(&app); + // The click answered what was shown. If the registration has changed since, the + // person has not seen the new state yet: show it and do nothing else. + if fresh == shown && fresh.click_action() != ClickAction::Nothing { + perform(&app, fresh.click_action()); + judge(&app) + } else { + fresh + } + } + None => LoginState::Unknown, + }; + present(&app, state, true); + }); +} + +fn perform(app: &AppHandle, action: ClickAction) { + let result = match action { + ClickAction::Disable => app + .autolaunch() + .disable() + .map_err(|error| error.to_string()), + ClickAction::Enable => crate::login_autostart::enable(app), + // launchd keeps its override whatever the plist says; the person turns it back on. + ClickAction::OpenLoginItems => app + .opener() + .open_url(login_registration::LOGIN_ITEMS_SETTINGS, None::) + .map_err(|error| error.to_string()), + ClickAction::Nothing => Ok(()), + }; + if let Err(error) = result { + crate::logging::log_once("start at login", &error); + } +} diff --git a/devlog/_plan/261012_6972_tray_start_at_login/000_plan.md b/devlog/_plan/261012_6972_tray_start_at_login/000_plan.md new file mode 100644 index 00000000000..bf5cbaba56a --- /dev/null +++ b/devlog/_plan/261012_6972_tray_start_at_login/000_plan.md @@ -0,0 +1,218 @@ +# 6972 — Tray Start at Login uses the Startup safety judgment + +Issue: lidge-jun/opencodex#6972. Related: #6980 (Startup page label only), #6963. +Branch: `codex/6972-tray-start-at-login-launchd-261012`, target `dev`. + +## Problem + +On macOS the tray's **Start at Login** `CheckMenuItem` is built from +`app.autolaunch().is_enabled()` (tauri-plugin-autostart 2.5.0 → auto-launch 0.5.0), which +is `Path::exists(~/Library/LaunchAgents/OpenCodex.plist)`. Startup safety +(`src/service/desktop-startup.ts` `macLoginRegistration`) credits the registration only when: + +1. the plist has the auto-launch shape (`Label` OpenCodex, `RunAtLoad` true, + `ProgramArguments = [<…>/Contents/MacOS/opencodex-desktop, --autostart]`, `Program` + absent or equal, `Disabled` absent or boolean), app and sibling `ocx` resolve and are + executable; +2. `launchctl print-disabled gui/` is well formed and either does not mention + `"OpenCodex"` (then plist `Disabled !== true`) or mentions it exactly once as + `enabled`/`false`; +3. `launchctl print gui//OpenCodex` either names this app as `program` and the + registration plist as `path` (`loaded`), or fails with the exact service-not-found result + (status 113, exact stderr) and a full re-capture is unchanged (`registered-not-loaded`); +4. the registered app equals the app being judged (unowned supervision path: + `login.app === app`). + +So a launchd-disabled job, another app copy, or a loaded job from an old copy show a checked +tray item while Startup reports Start at Login unverified. Clicking writes the plist again, +which neither clears a launchd override nor respects another copy's registration. + +## Decision + +Port the judgment to Rust (`desktop/src-tauri/src/login_registration.rs`) as a pure +classifier over an injected probe, and prove parity with one shared fixture file read by both +a Rust unit test and a bun test that drives the TypeScript judgment. The tray checks the item +only for the verified state. The classifier subdivides "not verified" so the tray can explain +and choose a safe click action: + +| State | TS `loginEnabled` | Checked | Label | Clickable | Click action (after a fresh re-probe that still returns the same state) | +|---|---|---|---|---|---| +| `On` (loaded or registered-not-loaded, this copy) | true | yes | Start at Login | yes | plugin `disable()` (unchanged) | +| `Off` (plist absent) | false | no | Start at Login | yes | `login_autostart::enable` (unchanged) | +| `DisabledBySystem` (this copy; print-disabled override disabled/true) | false | no | Start at Login (off in System Settings) | yes | open `x-apple.systempreferences:com.apple.LoginItems-Settings.extension`; never rewrites the plist | +| `OtherCopy` (registered app or loaded program resolves to another existing copy) | false | no | Start at Login (another OpenCodex copy) | no | none | +| `MissingCopy` (registered app path does not exist) | false | no | Start at Login (registered app is missing) | yes | `login_autostart::enable` for this copy | +| `Unknown` (probe failure, timeout, malformed output, shape mismatch, plist Disabled=true without override, path mismatch, changed during probe, not yet probed) | false | no | Start at Login (status unknown) | no | none | + +Invariant: `checked ⇔ state == On ⇔ TS loginEnabled for this app`. Nothing but `On` is +checked; an unverified result can never become checked. + +Why the Rust port instead of asking the running proxy: the tray must work when no proxy is +attached or when the attached proxy is an older runtime, and the proxy's Startup diagnostics +judge the supervising app, which is not necessarily this copy. The shared fixture is the +drift guard. + +## Diff plan + +### `desktop/src-tauri/src/login_registration.rs` (new) + +- `pub enum LoginState { On, Off, DisabledBySystem, OtherCopy, MissingCopy, Unknown }` with + `checked()`, `label()`, `clickable()`, `click_action() -> ClickAction`. +- `pub trait Probe { identity(&Path) -> io::Result; realpath(&Path) -> io::Result; + executable(&Path) -> io::Result<()>; run(&str, &[&str]) -> Result }`, + `RunError::{Exit { status, stderr }, Failed}` (timeout, signal, spawn failure, oversize → + `Failed`). +- `pub fn classify(probe, home, uid, current_app) -> LoginState` mirroring + `macLoginRegistration` step for step: capture (realpath + identity of plist and target, + plutil JSON, shape, app/ocx realpath + X_OK + identity, print-disabled parse, re-check of + every path/identity), then loaded/not-loaded branch with full re-capture comparison. + Parsers mirror the TS regexes by hand (no new crate). +- `#[cfg(target_os = "macos")] pub struct SystemProbe`: `std::fs` realpath and + `MetadataExt` identity (dev, ino, size, mode, mtime ns, ctime ns), and + `std::process::Command` with a 750 ms deadline (try_wait poll, kill on timeout), 128 KiB + cap and trimmed UTF-8 stdout, matching TS `run`. `getuid` and `access(X_OK)` are declared + through `extern "C"` (precedent: `geteuid` in `cli_command_record.rs`), so executability + matches TS `accessSync(X_OK)` with no new dependency. +- `#[cfg(test)]` parity test reading `../../../tests/fixtures/desktop-login-registration-cases.json` + through `include_str!` with a fake probe; plus the invariant `checked == startupLoginEnabled`. + +### `desktop/src-tauri/src/tray.rs` + +- macOS: build the item unchecked with the `Unknown` label and disabled, store it in + `TrayMenu`, then spawn the first probe. Keep the plugin path on Linux/Windows unchanged. +- `"start-at-login"` on macOS: `spawn_blocking` → take the probe lock, re-probe; if the + fresh state differs from the state the menu displayed, apply it and stop; otherwise run the + state's click action, re-probe and apply. Always re-apply so muda's automatic check toggle + never survives. +- Refresh (try-lock, skip when busy): at install, `TrayIconEvent::Enter` (throttled to 2 s), + and the existing 60 s loop (before the "no proxy" `continue`). +- Setters run only on background threads and never while holding the menu mutex + (existing `menu_handles` rule). + +### `desktop/src-tauri/src/lib.rs` + +- `#[cfg(any(target_os = "macos", test))] mod login_registration;` + +### `src/service/desktop-startup.ts` + +- Extract the existing unowned-path lambda into + `export function macLoginFactsFor(app, deps)` (no behavior change) so the parity test + judges exactly what Startup safety uses for "this app". + +### Tests + +- `tests/fixtures/desktop-login-registration-cases.json` (shared cases, placeholders + `{APP}`, `{OTHER_APP}`, `{MISSING_APP}`, `{PLIST}`, `{OTHER_PLIST}`, `{UID}`). + Cases: loaded current; registered-not-loaded (both stderr forms); override enabled / + false; override disabled / true; plist absent; plist Disabled=true without override; + plist Disabled=true with enabled override; other copy registered; loaded job from other + copy; loaded path mismatch; registered app missing; ocx missing; plutil failure; shape + mismatches (no --autostart, wrong Label, wrong suffix); print-disabled failure, malformed, + duplicate mentions; print failure non-113, timeout/signal, 113 with other stderr; + registration changed during the not-loaded re-capture. +- `tests/service/service-desktop-login-registration.test.ts`: materializes the fixture in a + temp home, drives `macLoginFactsFor`, asserts `loginEnabled`/`loginRegistration` and the + tray invariant. Registered in `scripts/test-layout/layout.json` and + `tests/fixtures/test-layout-expected.json`. + +### Docs + +- `structure/desktop-shell.md`: tray Start at Login judgment, states, click rules, parity + fixture. +- `docs-site/src/content/docs/guides/macos-menu-bar.md` and `desktop-app.md` (English): + the checkbox shows only a verified registration; reason labels. + +## Out of scope + +Linux/Windows tray, `first_run.rs` (its once-per-install migration still uses the plugin), +`gui/`, `app/`, TS judgment semantics, any real launchd/LaunchAgents state. + +## Verification + +- `cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check` +- `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` +- `cargo test --manifest-path desktop/src-tauri/Cargo.toml` +- `bun test tests/service/service-desktop-login-registration.test.ts tests/service/service-desktop-startup.test.ts tests/service/service-desktop-startup-health.test.ts` +- `bun run typecheck`, `bun run structure:check`, `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts`, `bun run privacy:scan` +- No real launchd: tests use fakes and temp homes only. + + +## Audit round 1 (GPT-6.1-Sol high): FAIL → folded + +1. **MissingCopy takeover** — accepted. `MissingCopy` is non-clickable like `OtherCopy`; a + missing path may be an installation being replaced or on an unmounted volume. Table: + MissingCopy → Clickable no, action none. +2. **first_run migration rewrites another copy** — accepted. On macOS + `adopt_launch_origin_argument` only rewrites when the plist's `ProgramArguments[0]` + resolves to this running copy (legacy entries without `--autostart` included, via + `login_registration::registered_to`). Foreign, missing or unreadable registrations are left + untouched and the marker is not written, so a later launch can still migrate this copy's own + entry. Covered by Rust unit cases (this copy / other copy / missing / legacy shape / plutil + failure / absent). +3. **auto-toggle before probe** — accepted. The menu callback (main thread; Tauri runs setters + inline there, `tauri-runtime-wry` `send_user_message`) immediately restores the last + applied presentation and disables the item, then spawns the blocking job. It never waits on + the probe lock. Jobs and refreshes are serialized by one probe mutex; refreshes use + `try_lock`. The residual is AppKit's own toggle between the click and the callback. +4. **X_OK** — already folded: `access(path, X_OK)` via `extern "C"`. +5. **parser boundaries** — accepted: fixture adds delimiter/newline/EOF, invalid suffix + (`enabledx`), duplicate mentions, malformed envelope, non-boolean `Disabled`, `Program` + equal/different, symlinked plist, 113 with signal-style failure, wrong uid in stderr, + changed registration during re-capture. +6. **Linux dead code** — accepted: portable tests exercise `label`/`clickable`/ + `click_action`; macOS-only constants and helpers sit behind `cfg(target_os = "macos")`; + CI clippy runs on Linux, local run on macOS, both `--all-targets -D warnings`. +7. **label overclaims** — accepted: "Start at Login (disabled by macOS)"; click opens Login + Items settings, opener failure is logged once, and nothing is credited until a fresh probe. +8. **translations** — accepted: update `ko/guides/desktop-app.md` and any locale that + describes the checkbox as file existence; update `structure/desktop-shell.md` first-run + paragraph. + + +## Audit round 2 (GPT-6.1-Sol high): FAIL → folded + +1. **first-run guard and `Program`** — accepted. `login_registration::migration` returns + `Rewrite` only for `Label` OpenCodex, `RunAtLoad` true, `ProgramArguments` = + `[<…>/Contents/MacOS/opencodex-desktop]` or `[…, --autostart]`, `Program` absent or equal to + argument zero, and argument zero resolving to this running copy. Everything else is `Leave`. +2. **first-run re-enables a self-disabled plist** — accepted. `Disabled` must be absent or + `false`; a `true` or non-boolean value is `Leave`. A launchd override is untouched by a + plist rewrite, so it needs no special case. `Leave` does not write the origin marker; + `Absent` does (unchanged from the current `Ok(false)` branch). +3. **ECMAScript line terminators** — accepted. `line_value` splits on `\n`, `\r`, U+2028 and + U+2029. Shared fixture adds CR / U+2028 / U+2029 terminated `launchctl print` output, a + program value cut at U+2028, a program value that starts after a terminator, and a path value + cut at CR (58 cases). +4. Inline main-thread setters confirmed (`tauri-runtime-wry` 2.11.4). + +Final state table (supersedes the one above): + +| State | Checked | Label | Clickable | Action | +|---|---|---|---|---| +| On | yes | Start at Login | yes | disable | +| Off | no | Start at Login | yes | enable for this copy | +| DisabledBySystem | no | Start at Login (disabled by macOS) | yes | open Login Items settings | +| OtherCopy | no | Start at Login (another OpenCodex copy) | no | — | +| MissingCopy | no | Start at Login (registered app is missing) | no | — | +| Unknown | no | Start at Login (status unknown) | no | — | + + +## Audit round 3 (GPT-6.1-Sol high): FAIL → 4 folded, 1 rebutted + +1. **migration drops keys** — folded. `Rewrite` requires exactly the three keys auto-launch + writes (`Label`, `ProgramArguments`, `RunAtLoad`); `Program`, `Disabled`, `KeepAlive` + or any other key is `Leave`. +2. **cross-process check-then-act** — rebutted as residual. Every mutation re-probes + immediately before acting and the window is the few milliseconds between that probe and the + plugin's file write; the only actor that can interleave is another OpenCodex copy of the + same user acting in that window. The `Off` → enable write is the existing plugin path. + Cross-process locking or a custom plist writer is disproportionate to #6972 and would replace + plugin-owned code; recorded as a known limit in the PR. +3. **lossy UTF-8** — folded: `String::from_utf8_lossy` like Node's `encoding: "utf8"`. +4. **reader joins** — folded: readers send over channels and are collected with + `recv_timeout` inside the same 750 ms deadline; a blocked reader is abandoned. +5. **presentation interleaving** — folded: every presentation is applied in one + main-thread closure. A click increments a pending counter and restores/disables on the main + thread; refresh results are dropped while a click is pending; the click job's own result + decrements the counter and applies. + diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index b16551e1e5e..05ace2e004e 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -104,9 +104,14 @@ Turn on **Start at Login** in the OpenCodex menu instead of installing a backgro service. Startup safety credits the app only when its login registration is verified; if it cannot be verified, follow the Desktop guidance in status. The Startup page then labels the Desktop row **Running · Start at Login not verified**. -The menu checkbox only shows that the login item file exists. If it is already checked, -make sure macOS allows OpenCodex to open at login under System Settings → General → -Login Items & Extensions. While the app runs the proxy, the Startup page keeps the +On macOS the menu's **Start at Login** item is checked only when Startup safety would +verify the same registration. Otherwise the item is unchecked and its label says why: +**disabled by macOS** opens System Settings → General → Login Items & Extensions, where +you allow OpenCodex to open at login; **another OpenCodex copy** means the login item +belongs to a different copy of the app, so turn it off from that copy; **registered app +is missing** means the login item names an app that no longer exists, so delete +`~/Library/LaunchAgents/OpenCodex.plist` and turn the item on again; **status unknown** +means the registration could not be read. While the app runs the proxy, the Startup page keeps the background service and launcher shim installers disabled; to use them instead, quit OpenCodex and run `ocx service install` or `ocx codex-shim install` in a terminal. diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index 38af14941bb..c5da2bd6ce3 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -13,7 +13,7 @@ Download `OpenCodex--macos.dmg` from the [latest release](https://githu Release builds of `OpenCodex.app` are signed with a Developer ID, use the hardened runtime, and are notarized by Apple with the ticket stapled to the app. On first launch, macOS normally asks only for the standard confirmation for an app downloaded from the internet. If macOS still blocks it, open **System Settings → Privacy & Security** and choose **Open Anyway** for OpenCodex. Apps you build yourself are ad-hoc signed; see [Build from source](#build-from-source). -The app shows its startup progress in a window when you open it. It enables **Start at Login** once on first launch; you can turn that off from the tray menu. Later launches from the login item start with the window hidden while the tray remains available. +The app shows its startup progress in a window when you open it. It enables **Start at Login** once on first launch; you can turn that off from the tray menu. The tray item is checked only when this copy of the app is registered and macOS has not disabled the login item; otherwise its label says why (see [Desktop app](/guides/desktop-app/)). Later launches from the login item start with the window hidden while the tray remains available. ## Menu bar and usage panel diff --git a/docs-site/src/content/docs/ko/guides/desktop-app.md b/docs-site/src/content/docs/ko/guides/desktop-app.md index 6c0078f6543..7645930f3e4 100644 --- a/docs-site/src/content/docs/ko/guides/desktop-app.md +++ b/docs-site/src/content/docs/ko/guides/desktop-app.md @@ -80,9 +80,13 @@ macOS와 Linux에서 데스크톱 앱이 번들 프록시를 실행하는 것으 등록이 확인되어야 재시작 후 보호가 적용됩니다. 등록을 확인할 수 없으면 status에 표시되는 데스크톱 안내를 따르세요. 실행 관계 확인은 소유권을 생성하지 않습니다. 이때 시작 안전 페이지의 데스크톱 앱 행에는 **실행 중 · 로그인 시 시작 미확인**이 표시됩니다. -메뉴의 체크 표시는 로그인 항목 파일이 있다는 것만 나타냅니다. 이미 체크되어 있다면 -macOS의 시스템 설정 → 일반 → 로그인 항목 및 확장 프로그램에서 OpenCodex가 로그인 시 -열리도록 허용되어 있는지 확인하세요. 앱이 프록시를 실행하는 동안 시작 안전 페이지는 +macOS에서 메뉴의 **Start at Login** 항목은 시작 안전 페이지도 같은 등록을 확인할 때만 +체크됩니다. 그렇지 않으면 체크가 해제된 채 이유가 라벨에 붙습니다. **disabled by macOS**는 +시스템 설정 → 일반 → 로그인 항목 및 확장 프로그램을 열며, 거기서 OpenCodex가 로그인 시 +열리도록 허용하면 됩니다. **another OpenCodex copy**는 로그인 항목이 다른 앱 사본의 것이라는 +뜻이니 그 사본에서 끄세요. **registered app is missing**은 로그인 항목이 더 이상 없는 앱을 +가리킨다는 뜻이니 `~/Library/LaunchAgents/OpenCodex.plist`를 지우고 항목을 다시 켜세요. +**status unknown**은 등록을 읽지 못했다는 뜻입니다. 앱이 프록시를 실행하는 동안 시작 안전 페이지는 백그라운드 서비스와 런처 shim 설치를 막아 둡니다. 이를 대신 쓰려면 OpenCodex를 종료하고 터미널에서 `ocx service install` 또는 `ocx codex-shim install`을 실행하세요. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1ce21b6af2a..b1f2294e6ad 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -87,7 +87,7 @@ "codex-sidecar-credit-policy.test.ts": "codex-integration", "codex-credits-after-limit-main.test.ts": "codex-integration", "service-runtime-preflight.test.ts": "service", "service-desktop-runtime-preflight.test.ts": "service", - "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", "service-desktop-startup.test.ts": "service", + "service-desktop-login-registration.test.ts": "service", "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", "service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server", "discovered-native-models.test.ts": "codex-integration", "responses-hosted-image-delivery.test.ts": "responses", diff --git a/src/service/desktop-startup.ts b/src/service/desktop-startup.ts index a567269103d..bb25a38e880 100644 --- a/src/service/desktop-startup.ts +++ b/src/service/desktop-startup.ts @@ -121,6 +121,18 @@ function macLoginRegistration(deps: DesktopStartupDeps) { type DesktopLoginFacts = Pick; +/** + * Start at Login credit for one Desktop app copy. The tray's Start at Login item ports this + * judgment (desktop/src-tauri/src/login_registration.rs); both run the cases in + * tests/fixtures/desktop-login-registration-cases.json. Throws when evidence is unreadable. + */ +export function macLoginFactsFor(app: string, deps: DesktopStartupDeps): DesktopLoginFacts { + const login = macLoginRegistration(deps); + return login !== null && login.app === app + ? { loginEnabled: login.loginEnabled, ...(login.loginRegistration ? { loginRegistration: login.loginRegistration } : {}) } + : { loginEnabled: false }; +} + function diagnoseSupervisedStartup(deps: DesktopStartupDeps, ownerRevision: number, loginFor: (app: string) => DesktopLoginFacts): DesktopStartupDiagnostic | undefined { const evidence = inspectDesktopSupervision(deps); if (evidence.kind !== "desktop") return undefined; @@ -144,12 +156,7 @@ export function diagnoseMacDesktopStartup(deps: DesktopStartupDeps = {}): Deskto if ((deps.platform ?? process.platform) !== "darwin") return undefined; const ownership = deps.ownership ?? resolveServiceOwnership; const owner = ownership(); - if (owner.kind === "none") return diagnoseSupervisedStartup(deps, owner.revision, app => { - const login = macLoginRegistration(deps); - return login !== null && login.app === app - ? { loginEnabled: login.loginEnabled, ...(login.loginRegistration ? { loginRegistration: login.loginRegistration } : {}) } - : { loginEnabled: false }; - }); + if (owner.kind === "none") return diagnoseSupervisedStartup(deps, owner.revision, app => macLoginFactsFor(app, deps)); if (owner.kind !== "owned" || owner.ownership.owner !== "desktop") return undefined; const facts: Omit = { owned: true, loginEnabled: false, running: false }; const execute = deps.run ?? run; diff --git a/structure/INDEX.md b/structure/INDEX.md index 6d7b21f643c..75f69ce1119 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -98,6 +98,7 @@ Background service, docs, release, and design discipline. | --- | --- | | [`desktop-shell.md`](desktop-shell.md) | Tauri desktop shell, proxy attachment and sidecar lifecycle, tray controls, bootstrap navigation, and desktop companion presence. | | [`desktop-terminal-command.md`](desktop-terminal-command.md) | Desktop-owned ocx command on PATH: record and journal, POSIX shim and shell blocks, Windows user Path, private-path ACL policy, and the local Terminal command page. | +| [`desktop-start-at-login.md`](desktop-start-at-login.md) | macOS tray Start at Login judgment shared with Startup safety, its parity fixture, click rules, and the guarded launch-origin migration. | | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | Service install/repair, platform launchers, tray, and sidecar processes. | | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | Opt-in sentinel that turns a hung but listening managed service proxy into an exit its manager restarts. | | [`ops/plugins.md`](ops/plugins.md) | Plugin loading from OPENCODEX_HOME/plugins and the upstream rewrite slot plugins attach to. | @@ -121,6 +122,8 @@ A source area can be described by more than one doc, because these docs are orga | `desktop/src-tauri/src/cli_command_posix.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_record.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_windows.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | +| `desktop/src-tauri/src/login_registration.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | +| `desktop/src-tauri/src/tray_login.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | | `desktop/ui/cli.html` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/ui/cli.js` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `docs-site/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 8e9b9538b5e..085e53c5e4b 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -408,6 +408,9 @@ plugin's registration name and Task Manager explicit-enable semantics. A Windows launch-origin migration marker revisits older registrations once, only when already enabled; entries disabled in the tray or Task Manager stay disabled. +The macOS tray item and the macOS side of this migration judge the login item the way Startup +safety does; see [Desktop Start at Login](desktop-start-at-login.md). + The WidgetKit extension in `app/` needs three things that Xcode's app-extension target would supply on its own, and SwiftPM has no such target: `@main` on `OpenCodexWidgetBundle`, the `-e _NSExtensionMain` linker entry, and diff --git a/structure/desktop-start-at-login.md b/structure/desktop-start-at-login.md new file mode 100644 index 00000000000..6cff9cd0938 --- /dev/null +++ b/structure/desktop-start-at-login.md @@ -0,0 +1,32 @@ +# Desktop Start at Login + +How the macOS tray's **Start at Login** item decides what to show and what a click may change, +and how the once-per-installation launch-origin migration avoids another copy's login item. The +surrounding first-run default and tray are described in [Desktop shell](desktop-shell.md); the +plan and audit history is in `devlog/_plan/261012_6972_tray_start_at_login/`. + +On macOS the tray's Start at Login item does not read the plugin's `is_enabled`, which is +only "the LaunchAgent plist exists". `desktop/src-tauri/src/login_registration.rs` ports +`macLoginRegistration` and `macLoginFactsFor` from `src/service/desktop-startup.ts` step for +step — plist shape, `launchctl print-disabled` override, registered app equal to this running +copy, loaded `program`/`path` or the exact service-not-found answer with an unchanged +re-read — so the item is checked exactly when Startup safety would credit this copy (#6972). +The cases in `tests/fixtures/desktop-login-registration-cases.json` run against both +implementations (`login_registration::tests` and +`tests/service/service-desktop-login-registration.test.ts`); a change to either judgment +must change the fixture and pass both. Every other state is unchecked with its reason in the +label. Only two are actionable: absent registers this copy, and a launchd-disabled job of this +copy opens Login Items settings, because rewriting the plist cannot clear launchd's override. +Another copy's registration, a registration naming a missing app and unreadable evidence leave +the item disabled; the tray never rewrites them. `desktop/src-tauri/src/tray_login.rs` probes on +blocking workers under one mutex (at build, on hover at most every two seconds, every 60 +seconds, and around a click, re-probing before it acts) and applies presentation in one +main-thread closure. muda flips a check item before the click reaches the app, so the click +handler restores the shown state and disables the item until its own answer; refreshes landing +meanwhile are dropped. The probe and the action are not atomic across processes: another copy +rewriting the plist in the milliseconds between them is a known limit. + +The macOS launch-origin migration is narrower than on other platforms. The marker sits in a +config directory every copy shares, and the plugin rewrites the whole plist, so +`login_registration::migration` allows the rewrite only for this copy's entry carrying exactly +the three keys the plugin writes. Anything else is left untouched and unmarked. diff --git a/structure/manifest.json b/structure/manifest.json index bfca98fb21f..420dbd78ecd 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -553,6 +553,16 @@ "desktop/ui/cli.js" ] }, + { + "path": "desktop-start-at-login.md", + "tier": 6, + "title": "Desktop Start at Login", + "scope": "macOS tray Start at Login judgment shared with Startup safety, its parity fixture, click rules, and the guarded launch-origin migration.", + "documents": [ + "desktop/src-tauri/src/login_registration.rs", + "desktop/src-tauri/src/tray_login.rs" + ] + }, { "path": "ops/service-and-sidecars.md", "tier": 6, diff --git a/tests/fixtures/desktop-login-registration-cases.json b/tests/fixtures/desktop-login-registration-cases.json new file mode 100644 index 00000000000..9c232168a7f --- /dev/null +++ b/tests/fixtures/desktop-login-registration-cases.json @@ -0,0 +1,1136 @@ +{ + "//": "Shared by desktop/src-tauri/src/login_registration.rs and tests/service/service-desktop-login-registration.test.ts (#6972). Each case is one macOS Start at Login registration; expect.loginEnabled is what Startup safety reports for this app, expect.tray is what the tray shows. Only tray \"on\" is checked, and it must coincide with loginEnabled. Placeholders: {APP} this copy, {OTHER_APP} another existing copy, {MISSING_APP} a copy that does not exist, {PLIST} ~/Library/LaunchAgents/OpenCodex.plist, {OTHER_PLIST} another existing plist, {UID} 501. plist null means the LaunchAgent file is absent; disabled null means print-disabled fails; loaded is launchctl print: stdout, or an exit status with stderr (and optional signal), or a failure such as a timeout.", + "cases": [ + { + "name": "loaded from this copy and this plist", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "valid registration not loaded yet", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "status": 113, + "stderr": "Could not find service \"OpenCodex\" in domain for user gui: {UID}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "registered-not-loaded" + } + }, + { + "name": "valid registration not loaded yet, Bad request form", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "status": 113, + "stderr": "Bad request.\nCould not find service \"OpenCodex\" in domain for user gui: {UID}\n" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "registered-not-loaded" + } + }, + { + "name": "no override for OpenCodex", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "empty override list", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "override false", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"OpenCodex\" => false\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "single-line override list", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = { \"OpenCodex\" => enabled }", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "override followed by a comma", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = { \"OpenCodex\" => enabled, \"x\" => disabled }", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "override with CRLF line ending", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\r\n\t\"OpenCodex\" => enabled\r\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "override with trailing spaces before newline", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"OpenCodex\" => enabled \n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "plist absent", + "plist": null, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "off", + "loginEnabled": false + } + }, + { + "name": "plist absent while launchd keeps a disabled override", + "plist": null, + "disabled": "disabled services = {\n\t\"OpenCodex\" => disabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "off", + "loginEnabled": false + } + }, + { + "name": "launchd disables this copy", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"OpenCodex\" => disabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "disabled-by-system", + "loginEnabled": false + } + }, + { + "name": "launchd disables this copy with true", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"OpenCodex\" => true\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "disabled-by-system", + "loginEnabled": false + } + }, + { + "name": "plist Disabled true without override", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ], + "Disabled": true + }, + "disabled": "disabled services = {\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "plist Disabled false without override", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ], + "Disabled": false + }, + "disabled": "disabled services = {\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "plist Disabled true with enabled override", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ], + "Disabled": true + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "plist Disabled is not a boolean", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ], + "Disabled": "yes" + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "Program equal to the registered app", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ], + "Program": "{APP}" + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "Program differs from the registered app", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ], + "Program": "{OTHER_APP}" + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "another existing copy is registered", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{OTHER_APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "other-copy", + "loginEnabled": false + } + }, + { + "name": "another copy is registered and launchd disables it", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{OTHER_APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"OpenCodex\" => disabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "other-copy", + "loginEnabled": false + } + }, + { + "name": "loaded job runs another copy", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tpath = {PLIST}\n\tprogram = {OTHER_APP}\n}" + }, + "expect": { + "tray": "other-copy", + "loginEnabled": false + } + }, + { + "name": "loaded job runs a copy that no longer exists", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tpath = {PLIST}\n\tprogram = {MISSING_APP}\n}" + }, + "expect": { + "tray": "other-copy", + "loginEnabled": false + } + }, + { + "name": "loaded job came from another plist", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tpath = {OTHER_PLIST}\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "loaded job without a program line", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tpath = {PLIST}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "loaded job without a path line", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "registered app no longer exists", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{MISSING_APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "missing-copy", + "loginEnabled": false + } + }, + { + "name": "this copy has no bundled ocx", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "proxy": false, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "plist cannot be converted", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "plutilFails": true, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "registration lacks --autostart", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "registration carries an extra argument", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart", + "--x" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "registration label is not OpenCodex", + "plist": { + "Label": "com.example.OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "RunAtLoad is not true", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": false, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "registered program is not the desktop binary", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "/usr/bin/true", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "print-disabled fails", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": null, + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "print-disabled output is malformed", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "\"OpenCodex\" => enabled", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "print-disabled envelope is not closed", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"OpenCodex\" => enabled\n", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "OpenCodex mentioned twice", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"OpenCodex\" => enabled\n\t\"OpenCodex\" => disabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "override value has a suffix", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"OpenCodex\" => enabledx\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "override value is unrecognized", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"OpenCodex\" => maybe\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "override value followed by text on the same line", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"OpenCodex\" => enabled later\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "launchctl print fails for another reason", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "status": 5, + "stderr": "Operation not permitted" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "launchctl print times out", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "failure": "timeout" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "service-not-found result killed by a signal", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "status": 113, + "stderr": "Could not find service \"OpenCodex\" in domain for user gui: {UID}", + "signal": "SIGKILL" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "exit 113 with different stderr", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "status": 113, + "stderr": "Could not find service \"OpenCodex\" in domain for user gui: 502" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "exit 113 with extra stderr", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "status": 113, + "stderr": "Could not find service \"OpenCodex\" in domain for user gui: {UID}\nextra" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "override changes during the not-loaded re-read", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "status": 113, + "stderr": "Could not find service \"OpenCodex\" in domain for user gui: {UID}" + }, + "disabledAfterFirstRead": "disabled services = {\n\t\"OpenCodex\" => disabled\n}", + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "override text changes during the not-loaded re-read", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "status": 113, + "stderr": "Could not find service \"OpenCodex\" in domain for user gui: {UID}" + }, + "disabledAfterFirstRead": "disabled services = {\n\t\"OpenCodex\" => enabled\n\t\"com.apple.new\" => disabled\n}", + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "plist is rewritten while it is read", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "touchPlistOnRead": true, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "plist is a symlink and the job was loaded through it", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tactive count = 1\n\tpath = {PLIST}\n\tstate = running\n\tprogram = {APP}\n}" + }, + "plistLink": true, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "plist is a symlink and not loaded yet", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "status": 113, + "stderr": "Could not find service \"OpenCodex\" in domain for user gui: {UID}" + }, + "plistLink": true, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "registered-not-loaded" + } + }, + { + "name": "launchctl print lines end in CR", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\r\tpath = {PLIST}\r\tprogram = {APP}\r}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "launchctl print lines end in U+2028", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {
\tpath = {PLIST}
\tprogram = {APP}
}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "launchctl print lines end in U+2029", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {
\tpath = {PLIST}
\tprogram = {APP}
}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "loaded program value stops at U+2028", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tpath = {PLIST}\n\tprogram = {APP}
tail\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + }, + { + "name": "loaded program value starts after a line terminator", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tpath = {PLIST}\n\tprogram = 
{APP}\n}" + }, + "expect": { + "tray": "unknown", + "loginEnabled": false + } + }, + { + "name": "loaded path value stops at CR", + "plist": { + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [ + "{APP}", + "--autostart" + ] + }, + "disabled": "disabled services = {\n\t\"com.apple.example\" => disabled\n\t\"OpenCodex\" => enabled\n}", + "loaded": { + "stdout": "gui/{UID}/OpenCodex = {\n\tpath = {PLIST}\rtail\n\tprogram = {APP}\n}" + }, + "expect": { + "tray": "on", + "loginEnabled": true, + "loginRegistration": "loaded" + } + } + ] +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b1647d7ace1..2be84bf2954 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -111,7 +111,7 @@ "codex-pool-credit-policy.test.ts": "codex-integration", "codex-sidecar-credit-policy.test.ts": "codex-integration", "service-runtime-preflight.test.ts": "service", "service-desktop-runtime-preflight.test.ts": "service", - "codex-credits-after-limit-main.test.ts": "codex-integration", "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", + "codex-credits-after-limit-main.test.ts": "codex-integration", "service-desktop-login-registration.test.ts": "service", "service-desktop-startup-health.test.ts": "service", "service-desktop-startup-linux.test.ts": "service", "service-desktop-startup.test.ts": "service", "startup-health-packaged-probe.test.ts": "server", "discovered-native-models.test.ts": "codex-integration", "responses-hosted-image-delivery.test.ts": "responses", "responses-hosted-image-display.test.ts": "responses", "responses-hosted-image-limits.test.ts": "responses", diff --git a/tests/service/service-desktop-login-registration.test.ts b/tests/service/service-desktop-login-registration.test.ts new file mode 100644 index 00000000000..a0b54f87afe --- /dev/null +++ b/tests/service/service-desktop-login-registration.test.ts @@ -0,0 +1,111 @@ +import { afterEach, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { macLoginFactsFor } from "../../src/service/desktop-startup"; +import { fixturePath } from "../helpers/repo-root"; + +// One registration judgment, two implementations: Startup safety here and the Desktop tray's +// Start at Login item in desktop/src-tauri/src/login_registration.rs. Both suites run every case +// in this fixture, so neither side can drift without failing (#6972). +interface LoginCase { + name: string; + plist: Record | null; + plutilFails?: boolean; + proxy?: boolean; + plistLink?: boolean; + touchPlistOnRead?: boolean; + disabled: string | null; + disabledAfterFirstRead?: string; + loaded: { stdout: string } | { status: number; stderr: string; signal?: string } | { failure: string }; + expect: { tray: string; loginEnabled: boolean; loginRegistration?: "loaded" | "registered-not-loaded" }; +} + +const { cases } = JSON.parse(readFileSync(fixturePath("desktop-login-registration-cases.json"), "utf8")) as { cases: LoginCase[] }; +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +function fill(value: T, paths: Record): T { + if (typeof value === "string") return value.replace(/\{[A-Z_]+\}/g, key => paths[key.slice(1, -1)] ?? key) as T; + if (Array.isArray(value)) return value.map(item => fill(item, paths)) as T; + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, fill(item, paths)])) as T; + } + return value; +} + +function judge(raw: LoginCase) { + const root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-login-parity-"))); + roots.push(root); + const home = join(root, "home"); + const paths = { + APP: join(root, "OpenCodex.app", "Contents", "MacOS", "opencodex-desktop"), + OTHER_APP: join(root, "Other", "OpenCodex.app", "Contents", "MacOS", "opencodex-desktop"), + MISSING_APP: join(root, "Gone", "OpenCodex.app", "Contents", "MacOS", "opencodex-desktop"), + PLIST: join(home, "Library", "LaunchAgents", "OpenCodex.plist"), + OTHER_PLIST: join(home, "Other.plist"), + UID: "501", + }; + const c = fill(raw, paths); + const files = [paths.APP, paths.OTHER_APP, join(dirname(paths.OTHER_APP), "ocx"), paths.OTHER_PLIST]; + if (c.proxy !== false) files.push(join(dirname(paths.APP), "ocx")); + const realPlist = join(home, "Real.plist"); + if (c.plist !== null) files.push(c.plistLink ? realPlist : paths.PLIST); + for (const file of files) { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, "fixture"); + chmodSync(file, 0o700); + } + if (c.plist !== null && c.plistLink) { + mkdirSync(dirname(paths.PLIST), { recursive: true }); + symlinkSync(realPlist, paths.PLIST); + } + let disabledReads = 0; + const run = (command: string, args: string[]) => { + if (command === "/usr/bin/plutil") { + if (c.touchPlistOnRead) writeFileSync(paths.PLIST, "rewritten while it was read"); + if (c.plutilFails || c.plist === null) throw new Error("plutil failed"); + return JSON.stringify(c.plist); + } + if (command === "/bin/launchctl" && args[0] === "print-disabled") { + disabledReads++; + if (c.disabled === null) throw new Error("print-disabled failed"); + return disabledReads > 1 && c.disabledAfterFirstRead !== undefined ? c.disabledAfterFirstRead : c.disabled; + } + if (command === "/bin/launchctl" && args[0] === "print") { + if ("stdout" in c.loaded) return c.loaded.stdout; + if ("failure" in c.loaded) { + throw Object.assign(new Error(`launchctl ${c.loaded.failure}`), { code: "ETIMEDOUT", signal: "SIGTERM", status: null }); + } + throw Object.assign(new Error("launchctl print failed"), { + status: c.loaded.status, stderr: Buffer.from(c.loaded.stderr), signal: c.loaded.signal ?? null, + }); + } + throw new Error(`unexpected command: ${command}`); + }; + // diagnoseSupervisedStartup turns a thrown login probe into an uncredited registration. + try { + return macLoginFactsFor(realpathSync(paths.APP), { platform: "darwin", home, uid: 501, run }); + } catch { + return { loginEnabled: false }; + } +} + +// The judgment needs POSIX paths (the registered program must end in /Contents/MacOS/...) and +// symlinks, which Windows runners do not provide without privileges. +const parity = process.platform === "win32" ? test.skip : test; + +test("the shared fixture keeps the tray's checked state equal to Startup safety's credit", () => { + expect(cases.length).toBeGreaterThan(40); + for (const c of cases) { + expect({ name: c.name, checked: c.expect.tray === "on" }).toEqual({ name: c.name, checked: c.expect.loginEnabled }); + expect({ name: c.name, registration: c.expect.loginRegistration !== undefined }).toEqual({ name: c.name, registration: c.expect.loginEnabled }); + } +}); + +for (const c of cases) { + parity(`Startup safety login credit: ${c.name}`, () => { + const { loginEnabled, loginRegistration } = c.expect; + expect(judge(c)).toEqual({ loginEnabled, ...(loginRegistration ? { loginRegistration } : {}) }); + }); +} From cbe8fe44ebb11b6f56b2b2f17b2192b3a2ea22a1 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 22:30:44 +0900 Subject: [PATCH 2/6] fix(desktop): leave a LaunchAgent replaced during the migration read untouched Code review: migration() judged the plist from one plutil read without re-reading its identity, so another copy replacing it mid-probe could be rewritten. Re-read the plist, target and executable identities as classify() does. Also correct the actionable-state count in the structure doc. --- desktop/src-tauri/src/login_registration.rs | 34 +++++++++++++++---- .../000_plan.md | 10 ++++++ structure/desktop-start-at-login.md | 5 +-- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/login_registration.rs b/desktop/src-tauri/src/login_registration.rs index e3ae3b41389..f5fdea436b9 100644 --- a/desktop/src-tauri/src/login_registration.rs +++ b/desktop/src-tauri/src/login_registration.rs @@ -376,11 +376,17 @@ pub fn migration(probe: &dyn Probe, home: &Path, current_app: &Path) -> Migratio .join("Library") .join("LaunchAgents") .join(format!("{SERVICE}.plist")); - match probe.identity(&plist) { - Ok(_) => {} + let plist_identity = match probe.identity(&plist) { + Ok(identity) => identity, Err(error) if error.kind() == io::ErrorKind::NotFound => return Migration::Absent, Err(_) => return Migration::Leave, - } + }; + let Ok(registration) = probe.realpath(&plist) else { + return Migration::Leave; + }; + let Ok(target_identity) = probe.identity(®istration) else { + return Migration::Leave; + }; let Some(plist_arg) = plist.to_str() else { return Migration::Leave; }; @@ -420,9 +426,18 @@ pub fn migration(probe: &dyn Probe, home: &Path, current_app: &Path) -> Migratio if !own_shape { return Migration::Leave; } - match probe.realpath(Path::new(app)) { - Ok(app) if app == current_app => Migration::Rewrite, - _ => Migration::Leave, + let Ok(resolved) = probe.realpath(Path::new(app)) else { + return Migration::Leave; + }; + // As in [`classify`]: a plist replaced while it was read is not the plist that was judged. + let stable = probe.identity(&plist).ok().as_ref() == Some(&plist_identity) + && probe.realpath(&plist).ok().as_ref() == Some(®istration) + && probe.identity(®istration).ok().as_ref() == Some(&target_identity) + && probe.realpath(Path::new(app)).ok().as_ref() == Some(&resolved); + if stable && resolved == current_app { + Migration::Rewrite + } else { + Migration::Leave } } @@ -913,6 +928,13 @@ mod tests { migration(&unreadable, Path::new(HOME), Path::new(APP)), Migration::Leave ); + // Another copy replacing the plist while it is read leaves it alone. + let mut replaced = Fake::new(entry(serde_json::json!({}))); + replaced.touch = true; + assert_eq!( + migration(&replaced, Path::new(HOME), Path::new(APP)), + Migration::Leave + ); } #[test] diff --git a/devlog/_plan/261012_6972_tray_start_at_login/000_plan.md b/devlog/_plan/261012_6972_tray_start_at_login/000_plan.md index bf5cbaba56a..a0f617280a0 100644 --- a/devlog/_plan/261012_6972_tray_start_at_login/000_plan.md +++ b/devlog/_plan/261012_6972_tray_start_at_login/000_plan.md @@ -216,3 +216,13 @@ Final state table (supersedes the one above): thread; refresh results are dropped while a click is pending; the click job's own result decrements the counter and applies. +## Code review round 1 (GPT-6.1-Sol high, commit 4c5fb7734d): FAIL → folded + +1. **migration accepted a plist replaced during its own read** — folded: `migration` now + captures the plist identity, its resolved target and target identity before `plutil`, and + re-reads them plus the registered executable's resolution afterwards; any change is + `Leave`. Regression: `migration_rewrites_only_this_copys_own_enabled_entry` replacement + case. The reviewer accepted the cross-process rebuttal for the window after a stable final + probe. +2. **structure doc said two actionable states** — folded: three (verified disables, absent + enables, launchd-disabled opens Login Items settings). diff --git a/structure/desktop-start-at-login.md b/structure/desktop-start-at-login.md index 6cff9cd0938..0c048d46198 100644 --- a/structure/desktop-start-at-login.md +++ b/structure/desktop-start-at-login.md @@ -15,8 +15,9 @@ The cases in `tests/fixtures/desktop-login-registration-cases.json` run against implementations (`login_registration::tests` and `tests/service/service-desktop-login-registration.test.ts`); a change to either judgment must change the fixture and pass both. Every other state is unchecked with its reason in the -label. Only two are actionable: absent registers this copy, and a launchd-disabled job of this -copy opens Login Items settings, because rewriting the plist cannot clear launchd's override. +label. Three states are actionable: verified turns Start at Login off, absent registers this +copy, and a launchd-disabled job of this copy opens Login Items settings, because rewriting the +plist cannot clear launchd's override. Another copy's registration, a registration naming a missing app and unreadable evidence leave the item disabled; the tray never rewrites them. `desktop/src-tauri/src/tray_login.rs` probes on blocking workers under one mutex (at build, on hover at most every two seconds, every 60 From 23e344a77a6c1e0d310f022f4be5f313b73687ee Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 22:36:22 +0900 Subject: [PATCH 3/6] docs(devlog): publish plan for #6972 --- .../010_publish.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 devlog/_plan/261012_6972_tray_start_at_login/010_publish.md diff --git a/devlog/_plan/261012_6972_tray_start_at_login/010_publish.md b/devlog/_plan/261012_6972_tray_start_at_login/010_publish.md new file mode 100644 index 00000000000..dbe1a98a6a3 --- /dev/null +++ b/devlog/_plan/261012_6972_tray_start_at_login/010_publish.md @@ -0,0 +1,18 @@ +# 010 — Publish #6972 + +Previous D (wp1): implementation at a0b181577f passed the focused gates (receipt +`.codexclaw/evidence/.../test-receipt.json`) and the GPT-6.1-Sol code review round 2 PASS. +Direction unchanged: publish for the merge-management thread; never merge here. + +Steps: + +1. `git fetch origin dev`; rebase onto `origin/dev`; if it moved, rerun `.tmp/check.sh`. +2. Push `codex/6972-tray-start-at-login-launchd-261012` to `origin` (maintainer branch). +3. Open a PR to `dev` from `.github/PULL_REQUEST_TEMPLATE.md` (Summary, Verification with the + independent review results, Checklist), `Closes #6972` (close manually after landing on + `dev`). No `gui/` change, so no screenshot. +4. `link_pull_request`; `goat_thread_send` to mcp:284c76b4-6e51-464d-acf4-ff040b9dc299 with PR + number, head SHA and verification summary. +5. Observe exact-head CI; fix author-owned failures; report status. + +Out of scope: merging, closing the issue before `dev` lands, touching live launchd/service state. From 27a79b949cc15f52de0e56e2ea269a0a85db42f6 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 22:54:29 +0900 Subject: [PATCH 4/6] docs(structure): map the Startup safety judgment and first-run migration to the Start at Login doc --- structure/INDEX.md | 2 ++ structure/desktop-start-at-login.md | 9 +++++---- structure/manifest.json | 4 +++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/structure/INDEX.md b/structure/INDEX.md index 75f69ce1119..c61d1496a76 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -122,6 +122,7 @@ A source area can be described by more than one doc, because these docs are orga | `desktop/src-tauri/src/cli_command_posix.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_record.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | | `desktop/src-tauri/src/cli_command_windows.rs` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | +| `desktop/src-tauri/src/first_run.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | | `desktop/src-tauri/src/login_registration.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | | `desktop/src-tauri/src/tray_login.rs` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | | `desktop/ui/cli.html` | [`desktop-terminal-command.md`](desktop-terminal-command.md) | @@ -184,6 +185,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/service-manager-probe.ts` | [`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/service.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/service/` | [`runtime.md`](runtime.md) | +| `src/service/desktop-startup.ts` | [`desktop-start-at-login.md`](desktop-start-at-login.md) | | `src/service/health-sentinel-launch.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | | `src/service/health-sentinel-main.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | | `src/service/health-sentinel-policy.ts` | [`ops/service-health-recovery.md`](ops/service-health-recovery.md) | diff --git a/structure/desktop-start-at-login.md b/structure/desktop-start-at-login.md index 0c048d46198..8fc9e7d8032 100644 --- a/structure/desktop-start-at-login.md +++ b/structure/desktop-start-at-login.md @@ -27,7 +27,8 @@ handler restores the shown state and disables the item until its own answer; ref meanwhile are dropped. The probe and the action are not atomic across processes: another copy rewriting the plist in the milliseconds between them is a known limit. -The macOS launch-origin migration is narrower than on other platforms. The marker sits in a -config directory every copy shares, and the plugin rewrites the whole plist, so -`login_registration::migration` allows the rewrite only for this copy's entry carrying exactly -the three keys the plugin writes. Anything else is left untouched and unmarked. +The macOS launch-origin migration in `desktop/src-tauri/src/first_run.rs` is narrower than on +other platforms. The marker sits in a config directory every copy shares, and the plugin +rewrites the whole plist, so `login_registration::migration` allows the rewrite only for this +copy's entry carrying exactly the three keys the plugin writes and not replaced while it was +read. Anything else is left untouched and unmarked. diff --git a/structure/manifest.json b/structure/manifest.json index 420dbd78ecd..abf84adaf95 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -559,8 +559,10 @@ "title": "Desktop Start at Login", "scope": "macOS tray Start at Login judgment shared with Startup safety, its parity fixture, click rules, and the guarded launch-origin migration.", "documents": [ + "desktop/src-tauri/src/first_run.rs", "desktop/src-tauri/src/login_registration.rs", - "desktop/src-tauri/src/tray_login.rs" + "desktop/src-tauri/src/tray_login.rs", + "src/service/desktop-startup.ts" ] }, { From c583a37142618c63ed2f3ae8d8d644cc11e165ae Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 23:17:53 +0900 Subject: [PATCH 5/6] fix(desktop): abandon a timed-out login probe instead of killing it tests/clients/desktop-exit-ownership guards that no shell source calls .kill(). A launchctl or plutil that outlives the 750 ms deadline is now left to finish and reaped on a detached thread; the probe still answers unverified at the deadline. --- desktop/src-tauri/src/login_registration.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/login_registration.rs b/desktop/src-tauri/src/login_registration.rs index f5fdea436b9..da86b1a82bb 100644 --- a/desktop/src-tauri/src/login_registration.rs +++ b/desktop/src-tauri/src/login_registration.rs @@ -538,8 +538,12 @@ mod system { std::thread::sleep(Duration::from_millis(5)) } _ => { - let _ = child.kill(); - let _ = child.wait(); + // The shell never kills a process (tests/clients/desktop-exit-ownership). + // A late launchctl or plutil is abandoned and reaped when it ends; the + // probe has already answered unverified. + std::thread::spawn(move || { + let _ = child.wait(); + }); return Err(RunError::Failed); } } From 2f75923c18fad152abb3939a2b158fda538edaf4 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 12 Oct 2026 23:22:26 +0900 Subject: [PATCH 6/6] fix(desktop): bound abandoned login probes to one process-wide slot Code review: abandoning a timed-out launchctl or plutil without a bound let a hung launchd accumulate children and reader threads on every tray refresh. One slot is now held until the child and both readers finish, and no command starts while it is held, so a hung launchd costs at most one abandoned command. Covered by a real-process regression. --- desktop/src-tauri/src/login_registration.rs | 235 ++++++++++++++------ 1 file changed, 161 insertions(+), 74 deletions(-) diff --git a/desktop/src-tauri/src/login_registration.rs b/desktop/src-tauri/src/login_registration.rs index da86b1a82bb..2461254c5eb 100644 --- a/desktop/src-tauri/src/login_registration.rs +++ b/desktop/src-tauri/src/login_registration.rs @@ -441,6 +441,164 @@ pub fn migration(probe: &dyn Probe, home: &Path, current_app: &Path) -> Migratio } } +/// Running launchctl and plutil with a deadline, without ever killing a process. +/// +/// The shell never kills a process (`tests/clients/desktop-exit-ownership.test.ts`), so a +/// command that outlives its deadline is abandoned: the caller gets [`RunError::Failed`] at the +/// deadline and a detached thread reaps the child and its output readers later. One process-wide +/// slot is held until all three have finished, and no command starts while it is held, so a +/// launchd that never answers costs at most one abandoned command however often the tray asks. +#[cfg(any(target_os = "macos", all(test, unix)))] +mod command { + use super::{js_trim, RunError}; + use std::io::Read; + use std::process::{Child, Command, Stdio}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc::{channel, Receiver}; + use std::thread::JoinHandle; + use std::time::{Duration, Instant}; + + const MAX_OUTPUT: u64 = 128 * 1024; + static BUSY: AtomicBool = AtomicBool::new(false); + + /// Occupancy of the one command slot; released when dropped. + struct Slot; + + impl Slot { + fn take() -> Option { + BUSY.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .ok() + .map(|_| Self) + } + } + + impl Drop for Slot { + fn drop(&mut self) { + BUSY.store(false, Ordering::Release); + } + } + + type Reader = (Receiver>, JoinHandle<()>); + + fn drain(stream: Option) -> Reader { + let (sender, receiver) = channel(); + let handle = std::thread::spawn(move || { + let mut bytes = Vec::new(); + if let Some(stream) = stream { + let _ = stream.take(MAX_OUTPUT + 1).read_to_end(&mut bytes); + } + let _ = sender.send(bytes); + }); + (receiver, handle) + } + + /// Hand the slot to a thread that waits for the child and both readers, then frees it. + fn abandon(slot: Slot, mut child: Child, readers: [Reader; 2]) -> RunError { + std::thread::spawn(move || { + let _ = child.wait(); + for (_, handle) in readers { + let _ = handle.join(); + } + drop(slot); + }); + RunError::Failed + } + + pub(super) fn run(program: &str, args: &[&str], timeout: Duration) -> Result { + let slot = Slot::take().ok_or(RunError::Failed)?; + let deadline = Instant::now() + timeout; + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| RunError::Failed)?; + let readers = [drain(child.stdout.take()), drain(child.stderr.take())]; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)) + } + _ => return Err(abandon(slot, child, readers)), + } + }; + let mut output = Vec::new(); + for (receiver, _) in &readers { + let left = deadline.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(left) { + Ok(bytes) => output.push(bytes), + Err(_) => return Err(abandon(slot, child, readers)), + } + } + for (_, handle) in readers { + let _ = handle.join(); + } + drop(slot); + let [stdout, stderr] = [&output[0], &output[1]]; + if stdout.len() as u64 > MAX_OUTPUT || stderr.len() as u64 > MAX_OUTPUT { + return Err(RunError::Failed); + } + // Node decodes `encoding: "utf8"` output lossily; so does this. + if status.success() { + return Ok(js_trim(&String::from_utf8_lossy(stdout)).to_string()); + } + match status.code() { + Some(status) => Err(RunError::Exit { + status, + stderr: String::from_utf8_lossy(stderr).into_owned(), + }), + None => Err(RunError::Failed), + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn a_command_past_its_deadline_holds_the_only_slot_until_it_ends() { + let started = Instant::now(); + assert_eq!( + run("/bin/sleep", &["1"], Duration::from_millis(50)), + Err(RunError::Failed) + ); + assert!(started.elapsed() < Duration::from_millis(500)); + // Repeated timeouts cannot add work: nothing runs while the abandoned command lives. + for _ in 0..20 { + assert_eq!( + run("/bin/echo", &["ready"], Duration::from_secs(5)), + Err(RunError::Failed) + ); + } + let freed = Instant::now() + Duration::from_secs(10); + while BUSY.load(Ordering::Acquire) { + assert!( + Instant::now() < freed, + "the abandoned command was never reaped" + ); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + run("/bin/echo", &["ready"], Duration::from_secs(5)), + Ok("ready".into()) + ); + assert_eq!( + run( + "/bin/sh", + &["-c", "echo nope >&2; exit 113"], + Duration::from_secs(5) + ), + Err(RunError::Exit { + status: 113, + stderr: "nope\n".into() + }) + ); + } + } +} + #[cfg(target_os = "macos")] pub use system::{judge, judge_migration}; @@ -448,15 +606,13 @@ pub use system::{judge, judge_migration}; mod system { use super::*; use std::ffi::CString; - use std::io::Read; use std::os::raw::c_char; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::MetadataExt; - use std::process::{Command, Stdio}; - use std::time::{Duration, Instant}; + use std::time::Duration; + /// TypeScript's `run` deadline in `desktop-startup.ts`. const TIMEOUT: Duration = Duration::from_millis(750); - const MAX_OUTPUT: u64 = 128 * 1024; const X_OK: i32 = 1; unsafe extern "C" { @@ -498,76 +654,7 @@ mod system { } fn run(&self, program: &str, args: &[&str]) -> Result { - let mut child = Command::new(program) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|_| RunError::Failed)?; - // Output collection shares the command's deadline: a reader still blocked when it - // passes is abandoned, never joined. - let drain = |stream: Option>| { - let (sender, receiver) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let mut bytes = Vec::new(); - if let Some(stream) = stream { - let _ = stream.take(MAX_OUTPUT + 1).read_to_end(&mut bytes); - } - let _ = sender.send(bytes); - }); - receiver - }; - let stdout = drain( - child - .stdout - .take() - .map(|s| Box::new(s) as Box), - ); - let stderr = drain( - child - .stderr - .take() - .map(|s| Box::new(s) as Box), - ); - let deadline = Instant::now() + TIMEOUT; - let status = loop { - match child.try_wait() { - Ok(Some(status)) => break status, - Ok(None) if Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(5)) - } - _ => { - // The shell never kills a process (tests/clients/desktop-exit-ownership). - // A late launchctl or plutil is abandoned and reaped when it ends; the - // probe has already answered unverified. - std::thread::spawn(move || { - let _ = child.wait(); - }); - return Err(RunError::Failed); - } - } - }; - let collect = |receiver: std::sync::mpsc::Receiver>| { - let left = deadline.saturating_duration_since(Instant::now()); - receiver.recv_timeout(left).map_err(|_| RunError::Failed) - }; - let stdout = collect(stdout)?; - let stderr = collect(stderr)?; - if stdout.len() as u64 > MAX_OUTPUT || stderr.len() as u64 > MAX_OUTPUT { - return Err(RunError::Failed); - } - // Node decodes `encoding: "utf8"` output lossily; so does this. - if status.success() { - return Ok(js_trim(&String::from_utf8_lossy(&stdout)).to_string()); - } - match status.code() { - Some(status) => Err(RunError::Exit { - status, - stderr: String::from_utf8_lossy(&stderr).into_owned(), - }), - None => Err(RunError::Failed), - } + command::run(program, args, TIMEOUT) } }