diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml index f758ad64ff8..1ab22dd1dc9 100644 --- a/.github/actions/setup-project-bun/action.yml +++ b/.github/actions/setup-project-bun/action.yml @@ -4,8 +4,10 @@ description: >- runtime (default), or testRunnerBun for test-runner. # Runtime 1.4.2 fixes #6684's Windows Bun.fetch stream truncation. The test runner -# stays on 1.4.0: #4821 reverted #4064 after 1.4.2's multi-file --isolate preload -# segfault; #6713 run 37631558263 reproduces it on Linux 1/4, macOS 2/2, Windows 9/9. +# skipped 1.4.2: #4821 reverted #4064 after its multi-file --isolate preload segfault +# (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to +# 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after +# finalizers ran inside an earlier call and spin past its own timeout (#4956). # Rejoin only after the upstream runner crash is fixed and a green lane=all # dispatch proves the unified version; then delete testRunnerBun and this role input. inputs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5624b4b2c7..34de6c49240 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -550,8 +550,9 @@ jobs: printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA" # Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; - # test-runner 1.4.0 avoids 1.4.2's multi-file --isolate preload segfault (#4821 - # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). + # the test runner skipped 1.4.2's multi-file --isolate preload segfault (#4821 + # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and runs + # 1.4.3, whose oven-sh/bun#44581 stops Bun.spawnSync losing a child's exit (#4956). # Rejoin after the upstream runner crash is fixed and a green lane=all dispatch # proves the unified version, then delete testRunnerBun and the action's role input. # The suite, split by file across four Linux runners. @@ -797,23 +798,30 @@ jobs: run: bun run src/cli/index.ts help platform-macos: - name: macos ${{ matrix.shard }}/2 + name: macos ${{ matrix.shard }}/3 needs: changes # Native-gated: these legs only run when the changes job's `native` filter # says the macOS suite's inputs could have changed, so an ordinary source - # pull request stops paying for two macOS runners. + # pull request stops paying for three macOS runners. if: github.event_name != 'pull_request' || (needs.changes.outputs.ci == 'true' && needs.changes.outputs.native == 'true') runs-on: macos-latest - # Two shards. Unsharded, this job was the critical path on every green dev + # Three shards. Unsharded, this job was the critical path on every green dev # push (mean 14.9 min against a 4.7 min Linux maximum; devlog - # 260905_test_modularization_and_windows/003). Two halves finish in ~7.7 and - # cost 0.6 extra macOS minutes of setup per run. The full-membership control - # runs the same bounded batches without sharding on explicit dispatch. + # 260905_test_modularization_and_windows/003), so it was split in two. The + # suite then outgrew two halves: across seventeen dev dispatches from + # 2026-10-04 to 10-12 the slower half's test step went from 867s to 1155s, + # and run 38214884055 cancelled macos 2/2 at this cap with 68 files left + # while every test it had run was passing (#6978). Replaying the duration + # assignment against those runs' measured per-file times puts the slowest of + # three shards at 635-796s of tests, about 14 minutes with setup. The cap + # stays 20: the suite needs another shard, not a longer wait, and a wedged + # shard (#4956) is still cut off as early as before. The full-membership + # control runs the same bounded batches without sharding on explicit dispatch. timeout-minutes: 20 strategy: fail-fast: false matrix: - shard: [1, 2] + shard: [1, 2, 3] steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -859,7 +867,7 @@ jobs: - name: Test in fresh-process batches env: - TEST_SHARD: ${{ matrix.shard }}/2 + TEST_SHARD: ${{ matrix.shard }}/3 BUN_TEST_FILE_SCOPE: all BUN_TEST_BATCH_SIZE: '12' BUN_TEST_PARALLEL: '1' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c74e70a2d1..f6b200e609b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,8 +61,8 @@ A ready-for-review PR is the author's claim that the change is complete, underst ## Local validation and hooks -The suite uses Bun 1.4.0 (`testRunnerBun`) while the shipped runtime is 1.4.2. -`scripts/test.ts` finds a 1.4.0 binary on PATH or in `~/.bun/bin`, or uses +The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.2. +`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`; install that version locally if it is missing. Run `bun run test` before review readiness. If the full local suite is too costly diff --git a/Dockerfile b/Dockerfile index c8e587fe934..7e36f2063a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ # syntax=docker/dockerfile:1 # Keep the runtime aligned with package.json and pin the multi-platform image index. -# Runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; CI tests stay on 1.4.0 +# Runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; CI tests run on 1.4.3 # because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821; -# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). Rejoin after the +# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and 1.4.0's spawnSync +# can lose a child's exit (#4956, oven-sh/bun#44581). Rejoin after the # upstream crash fix passes a unified-version lane=all dispatch, then delete # package.json's testRunnerBun pin and setup-project-bun's role input. ARG BUN_IMAGE=oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 2bfb0de69c3..1e665e0bab7 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.83.0" +version = "2.84.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index bed8513efd5..2cdfa74f2c0 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.83.0" +version = "2.84.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/src/cli_command_record.rs b/desktop/src-tauri/src/cli_command_record.rs index 88eac9750b8..294c0d86346 100644 --- a/desktop/src-tauri/src/cli_command_record.rs +++ b/desktop/src-tauri/src/cli_command_record.rs @@ -187,7 +187,75 @@ impl Record { pub struct Store { pub root: PathBuf, pub rc_allowed: Vec, - _lock: fs::File, + _lock: LockGuard, +} +struct LockGuard { + file: fs::File, +} +impl LockGuard { + fn unlock(&self) -> i32 { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // LOCK_UN releases the shared file description even if a fork retains a duplicate. + unsafe { flock(self.file.as_raw_fd(), 8) } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + let mut v = Overlapped::zero(); + unsafe { UnlockFileEx(self.file.as_raw_handle(), 0, 1, 0, &mut v) } + } + } + #[cfg(test)] + fn release_for_test(&self) -> i32 { + self.unlock() + } +} +impl Drop for LockGuard { + fn drop(&mut self) { + let _ = self.unlock(); + } +} +#[cfg(windows)] +#[repr(C)] +struct Overlapped { + internal: usize, + high: usize, + offset: u32, + offset_high: u32, + event: *mut std::ffi::c_void, +} +#[cfg(windows)] +impl Overlapped { + fn zero() -> Self { + Self { + internal: 0, + high: 0, + offset: 0, + offset_high: 0, + event: std::ptr::null_mut(), + } + } +} +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + fn LockFileEx( + h: *mut std::ffi::c_void, + flags: u32, + reserved: u32, + low: u32, + high: u32, + overlapped: *mut Overlapped, + ) -> i32; + fn UnlockFileEx( + h: *mut std::ffi::c_void, + reserved: u32, + low: u32, + high: u32, + overlapped: *mut Overlapped, + ) -> i32; } #[cfg(unix)] unsafe extern "C" { @@ -533,7 +601,7 @@ pub fn atomic(path: &Path, before: Option<&[u8]>, after: &[u8], mode: u32) -> Re let _ = fs::remove_file(tmp); result } -fn lock(root: &Path) -> Result { +fn lock(root: &Path) -> Result { let p = root.join("cli.lock"); let existed = fs::symlink_metadata(&p).is_ok(); if fs::symlink_metadata(&p).is_ok() { @@ -561,7 +629,7 @@ fn lock(root: &Path) -> Result { #[cfg(unix)] { use std::os::fd::AsRawFd; - // LOCK_EX | LOCK_NB: OS releases the lock on close/process death; no stale PID deletion. + // LOCK_EX | LOCK_NB: immediate exclusion; the guard unlocks before closing. if unsafe { flock(f.as_raw_fd(), 2 | 4) } != 0 { return Err("lock-busy".into()); } @@ -569,37 +637,13 @@ fn lock(root: &Path) -> Result { #[cfg(windows)] { use std::os::windows::io::AsRawHandle; - #[repr(C)] - struct Overlapped { - internal: usize, - high: usize, - offset: u32, - offset_high: u32, - event: *mut std::ffi::c_void, - } - #[link(name = "kernel32")] - unsafe extern "system" { - fn LockFileEx( - h: *mut std::ffi::c_void, - flags: u32, - reserved: u32, - low: u32, - high: u32, - overlapped: *mut Overlapped, - ) -> i32; - } - let mut v = Overlapped { - internal: 0, - high: 0, - offset: 0, - offset_high: 0, - event: std::ptr::null_mut(), - }; + let mut v = Overlapped::zero(); if unsafe { LockFileEx(f.as_raw_handle(), 1 | 2, 0, 1, 0, &mut v) } == 0 { return Err("lock-busy".into()); } } - Ok(f) + // A failed acquisition must only close its own file, never unlock another owner. + Ok(LockGuard { file: f }) } impl Store { pub fn open(root: PathBuf, rc_allowed: Vec) -> Result { @@ -1807,6 +1851,120 @@ pub(crate) mod tests { assert!(s.validate(&r, false).is_err()); } #[test] + fn lock_drop_releases_and_held_store_excludes_second_open() { + let t = Temp::new(); + let s = t.store(); + let root = s.root.clone(); + assert_eq!( + Store::open(root.clone(), vec![]).err().as_deref(), + Some("lock-busy") + ); + drop(s); + let reopened = Store::open(root.clone(), vec![]).unwrap(); + assert_eq!( + Store::open(root, vec![]).err().as_deref(), + Some("lock-busy") + ); + drop(reopened); + } + #[cfg(unix)] + #[test] + fn lock_drop_releases_even_with_duplicate_file_description() { + let t = Temp::new(); + let s = t.store(); + let root = s.root.clone(); + let duplicate = s._lock.file.try_clone().unwrap(); + drop(s); + let reopened = Store::open(root.clone(), vec![]).unwrap(); + assert_eq!( + Store::open(root, vec![]).err().as_deref(), + Some("lock-busy") + ); + assert_eq!(reopened._lock.release_for_test(), 0); + drop(duplicate); + } + #[cfg(unix)] + #[test] + fn lock_drop_releases_during_concurrent_process_spawns() { + use std::{ + process::Command, + sync::{atomic::AtomicBool, atomic::Ordering, Barrier}, + thread, + }; + // Present on macOS and on Linux runners; macOS has no /bin/true. + const TRUE: &str = "/usr/bin/true"; + let t = Temp::new(); + let root = t.store().root.clone(); + let stop = AtomicBool::new(false); + let ready = Barrier::new(5); + let mut busy = 0; + let mut errors = Vec::new(); + let spawns = thread::scope(|scope| { + let workers: Vec<_> = (0..4) + .map(|_| { + scope.spawn(|| { + ready.wait(); + let mut spawns = 0; + while !stop.load(Ordering::Relaxed) { + assert!(Command::new(TRUE).status().unwrap().success()); + spawns += 1; + } + spawns + }) + }) + .collect(); + ready.wait(); + for _ in 0..2000 { + // Each cycle opens, drops and reopens, without waiting or retrying a busy lock. + for _ in 0..2 { + match Store::open(root.clone(), vec![]) { + Ok(store) => drop(store), + Err(e) if e == "lock-busy" => busy += 1, + Err(e) => errors.push(e), + } + } + } + stop.store(true, Ordering::Relaxed); + workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .sum::() + }); + eprintln!("lock stress: cycles=2000, spawners=4, spawns={spawns}, lock-busy={busy}"); + assert!(spawns >= 4, "process spawners did not run"); + assert!(errors.is_empty(), "unexpected open errors: {errors:?}"); + assert_eq!(busy, 0); + } + #[test] + fn failed_open_after_lock_acquisition_releases_lock() { + let t = Temp::new(); + let s = t.store(); + let root = s.root.clone(); + atomic(&root.join("cli.json"), None, b"{", 0o600).unwrap(); + drop(s); + assert_eq!( + Store::open(root.clone(), vec![]).err().as_deref(), + Some("record-invalid") + ); + let guard = lock(&root).unwrap(); + assert_eq!(lock(&root).err().as_deref(), Some("lock-busy")); + drop(guard); + let _guard = lock(&root).unwrap(); + } + #[cfg(windows)] + #[test] + fn lock_guard_windows_explicit_release_allows_second_handle_to_relock() { + let t = Temp::new(); + let s = t.store(); + assert_ne!(s._lock.release_for_test(), 0); + let root = s.root.clone(); + let second = lock(&root).unwrap(); + drop(s); + assert_eq!(lock(&root).err().as_deref(), Some("lock-busy")); + assert_ne!(second.release_for_test(), 0); + let _third = lock(&root).unwrap(); + } + #[test] fn lock_and_atomic_write_preserve_concurrent_user_edits() { let t = Temp::new(); let s = t.store(); 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..2461254c5eb --- /dev/null +++ b/desktop/src-tauri/src/login_registration.rs @@ -0,0 +1,1044 @@ +//! 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")); + 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; + }; + 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; + } + 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 + } +} + +/// 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}; + +#[cfg(target_os = "macos")] +mod system { + use super::*; + use std::ffi::CString; + use std::os::raw::c_char; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::MetadataExt; + use std::time::Duration; + + /// TypeScript's `run` deadline in `desktop-startup.ts`. + const TIMEOUT: Duration = Duration::from_millis(750); + 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 { + command::run(program, args, TIMEOUT) + } + } + + 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 + ); + // 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] + 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/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 80755617547..aec82800802 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.83.0", + "version": "2.84.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/devlog/_fin/261011_claude_tier_labels_and_inclusive_usage/000_plan.md b/devlog/_fin/261011_claude_tier_labels_and_inclusive_usage/000_plan.md new file mode 100644 index 00000000000..7e340205242 --- /dev/null +++ b/devlog/_fin/261011_claude_tier_labels_and_inclusive_usage/000_plan.md @@ -0,0 +1,47 @@ +# 000 — Claude Code tier labels and inclusive Anthropic usage: Plan + +## Objective + +Two independent Claude-surface defects, delivered in one bundle PR into `dev`: + +1. **#6926 — `/model` shows the OCX alias.** Claude Code 2.1.296 (binary read from + `@anthropic-ai/claude-code-darwin-arm64@2.1.296`) builds a tier row (Opus, Sonnet, Fable, + Haiku) as `label: ANTHROPIC_DEFAULT__MODEL_NAME ?? ?? ` + (function `xs(e,r,n,s)`). Gateway rows already use `display_name` + (`label: AT(s.display_name ?? "") || AT(s.id)`), so the discovery path is correct. OpenCodex + sets `ANTHROPIC_DEFAULT__MODEL=ocx-claude-native--gpt-6.1-sol` but never the paired + `_NAME`, and an `ocx-claude-*` alias is not a first-party id, so the row prints the alias. + The alias stays the submitted id (owner constraint on #6926); only the label changes. +2. **#6566 carry — inclusive Anthropic usage.** `usageFromAnthropicShape` + (`src/adapters/coding-agent/protocol.ts`) reports Anthropic's cache-exclusive + `input_tokens` as `inputTokens`. OpenCodex's contract is inclusive + (`normalizeCostTokens` returns `null` when `cacheRead + cacheWrite > input`), so a Claude + CLI / CodeBuddy / Qoder turn with 12 fresh, 150,000 cache-read and 4,000 cache-write tokens + loses its cost estimate. Carry only that fix from #6566 (Co-authored-by: Foodcel). + +#6532 needs no code: its fixes are ancestors of the base (`0455845834`, `26dd8cf657`, +`33125e62d2`); it stays open for live multi-account acceptance (NEEDS_HUMAN). + +## Loop-spec + +- Loop archetype: verifier-defined (hosted CI on the exact head + independent review). +- Write scope: `src/claude/context-windows.ts`, `src/cli/claude.ts`, `src/server/system-env.ts`, + `src/server/system-env-shell.ts`, `src/adapters/coding-agent/protocol.ts`, their tests, + `docs-site/src/content/docs/guides/claude-code.md`. +- Out of scope: Claude intercept settings / NO_PROXY (#6938), GUI manual-env block, #6566 usage + admission and stable replay, Desktop 3P hashed aliases. +- Bounds: no local test runs (lane packet); static checks only; one push. + +## Work-phase map + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| 1 | 010_phase1.md | tier labels + inclusive usage | — | + +## Accept criteria + +- An alias tier slot is paired with its `_NAME` label in the launch env, launchd and the shell + file; a user-set slot or `_NAME` always wins; a native session drops a label it strips the slot for. +- Anthropic-shaped usage is inclusive; partial folds keep fresh input counted once. +- Regression tests for both; hosted CI green on the exact head; independent review clean. + diff --git a/devlog/_fin/261011_claude_tier_labels_and_inclusive_usage/010_phase1.md b/devlog/_fin/261011_claude_tier_labels_and_inclusive_usage/010_phase1.md new file mode 100644 index 00000000000..57874032179 --- /dev/null +++ b/devlog/_fin/261011_claude_tier_labels_and_inclusive_usage/010_phase1.md @@ -0,0 +1,98 @@ +# 010 — Phase 1: tier labels and inclusive usage + +## MODIFY / NEW / DELETE map + +### MODIFY `src/claude/context-windows.ts` + +Add after `effectiveModelEnv` (import `resolveAlias` from `./alias`): + +```ts +export const CLAUDE_TIER_LABEL_SLOTS = [ + "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", +] as const; +export function claudeTierDisplayName(selector: string): string | undefined + // resolveAlias(stripOneMillionMarker(trimmed)); null -> undefined. + // native (no "/"): " (native)"; a native "claude-*" slug -> "". + // routed "/": " ()". Same strings as discovery + // (model-info.ts). "--fast" suffix -> " · Fast"; [1m] marker -> " · 1M". +export function claudeTierDisplayNameEnv(env): Record + // { "_NAME": label } for every CLAUDE_TIER_LABEL_SLOTS value that is an alias. +``` + +### MODIFY `src/cli/claude.ts` + +- `buildClaudeEnv`: after the `effectiveModelEnv` loop, + `for (const [name, label] of Object.entries(claudeTierDisplayNameEnv(env))) setDefault(name, label);` + Labels are derived from the FINAL slot values, so a user-exported slot gets a label matching + its own alias and a user-exported `_NAME` wins. +- `buildNativeClaudeEnv`: in the `MODEL_ENV_SLOT_NAMES` strip loop, when a tier slot is + deleted also delete `_NAME` if it equals `claudeTierDisplayName(value)`. + +### MODIFY `src/server/system-env.ts` + +- `MANAGED_SYSTEM_ENV_NAMES`: add the four `ANTHROPIC_DEFAULT_*_MODEL_NAME`. +- `injectSystemEnv`: after the model-slot loop, inject labels only for slots this run owns + (`injectedKeys.includes(slot)`), through `injectLever` (user-wins, tracked, unset when no + longer produced). + +### MODIFY `src/server/system-env-shell.ts` + +- `writeShellEnvFile`: after the slot loop emit, per label, + `[ -z "${NAME+x}" ] && [ "${SLOT-}" = '' ] && export NAME='