diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 28ea7a672f8..bfb9a1352a7 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2665,6 +2665,7 @@ dependencies = [ "tauri-utils", "tokio", "uuid", + "winreg 0.55.0", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 96d14b98e3a..f162e1d8411 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -38,6 +38,10 @@ tokio = { version = "=1.45.1", features = ["sync", "time"] } [target.'cfg(target_os = "linux")'.dependencies] dbus = "=0.9.12" +# Already present in the lock graph; only Windows writes its quoted Run command. +[target.'cfg(target_os = "windows")'.dependencies] +winreg = "=0.55.0" + [profile.release] codegen-units = 1 lto = "thin" diff --git a/desktop/src-tauri/src/first_run.rs b/desktop/src-tauri/src/first_run.rs index 3fc7616b93f..cf0c5b9e87c 100644 --- a/desktop/src-tauri/src/first_run.rs +++ b/desktop/src-tauri/src/first_run.rs @@ -6,8 +6,13 @@ use tauri_plugin_autostart::ManagerExt; const MARKER: &str = "start-at-login-claimed"; /// Marker file recording that the login item names the launch-origin argument. +#[cfg(not(target_os = "windows"))] const ORIGIN_MARKER: &str = "start-at-login-origin-flag"; +// Windows must revisit registrations claimed by the earlier, unquoted writer. +#[cfg(target_os = "windows")] +const ORIGIN_MARKER: &str = "start-at-login-quoted-origin-flag"; + /// What the one-time Start at Login decision did on this launch. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum StartAtLogin { @@ -63,7 +68,7 @@ pub fn apply_start_at_login_default(app: &AppHandle) -> StartAtLogin { if app.autolaunch().is_enabled().unwrap_or(false) { return StartAtLogin::AlreadyDecided; } - match app.autolaunch().enable() { + match crate::login_autostart::enable(app) { Ok(()) => StartAtLogin::Enabled, Err(_) => StartAtLogin::Unavailable, } @@ -99,7 +104,7 @@ pub fn adopt_launch_origin_argument(app: &AppHandle) { // leave a login launch showing its window forever. match app.autolaunch().is_enabled() { Ok(true) => { - if app.autolaunch().enable().is_err() { + if crate::login_autostart::enable(app).is_err() { return; } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b8e6e9fe7d7..0dcd286717d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,9 @@ mod first_run; mod formatting; mod identity; mod logging; +mod login_autostart; +#[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 // elsewhere would leave its contents unreachable, which -D warnings rejects. #[cfg(target_os = "macos")] diff --git a/desktop/src-tauri/src/login_autostart.rs b/desktop/src-tauri/src/login_autostart.rs new file mode 100644 index 00000000000..0e45bbd43ce --- /dev/null +++ b/desktop/src-tauri/src/login_autostart.rs @@ -0,0 +1,45 @@ +use tauri::AppHandle; + +/// All shell-owned enables use this entry point; the plugin still owns status and disable. +#[cfg(not(target_os = "windows"))] +pub fn enable(app: &AppHandle) -> Result<(), String> { + use tauri_plugin_autostart::ManagerExt; + app.autolaunch().enable().map_err(|error| error.to_string()) +} + +#[cfg(target_os = "windows")] +pub fn enable(app: &AppHandle) -> Result<(), String> { + use winreg::enums::{HKEY_CURRENT_USER, KEY_SET_VALUE, REG_BINARY}; + use winreg::{RegKey, RegValue}; + + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let executable = executable + .to_str() + .ok_or("autostart executable path is not Unicode")?; + let command = + crate::windows_autostart_command::command(executable, crate::startup::AUTOSTART_FLAG)?; + let name = &app.package_info().name; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + hkcu.open_subkey_with_flags( + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", + KEY_SET_VALUE, + ) + .and_then(|key| key.set_value(name, &command)) + .map_err(|error| error.to_string())?; + + // Match the plugin's explicit-enable behavior when Task Manager has an override. + if let Ok(key) = hkcu.open_subkey_with_flags( + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run", + KEY_SET_VALUE, + ) { + key.set_raw_value( + name, + &RegValue { + vtype: REG_BINARY, + bytes: vec![2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + ) + .map_err(|error| error.to_string())?; + } + Ok(()) +} diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index 479a5a90906..eb06e2dd1dc 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -208,7 +208,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { if enabled { let _ = app.autolaunch().disable(); } else { - let _ = app.autolaunch().enable(); + let _ = crate::login_autostart::enable(app); } } "stop-proxy" => { diff --git a/desktop/src-tauri/src/windows_autostart_command.rs b/desktop/src-tauri/src/windows_autostart_command.rs new file mode 100644 index 00000000000..4afa82b90fa --- /dev/null +++ b/desktop/src-tauri/src/windows_autostart_command.rs @@ -0,0 +1,41 @@ +/// Build the Run value without letting spaces split the executable path. +pub fn command(executable: &str, argument: &str) -> Result { + if executable.is_empty() || executable.contains(['"', '\0']) { + return Err("invalid executable path for Windows autostart"); + } + Ok(format!("\"{executable}\" {argument}")) +} + +#[cfg(test)] +mod tests { + use super::command; + + #[test] + fn quotes_program_files_path_and_keeps_launch_origin() { + assert_eq!( + command( + r"C:\Program Files\OpenCodex\opencodex-desktop.exe", + "--autostart" + ) + .unwrap(), + r#""C:\Program Files\OpenCodex\opencodex-desktop.exe" --autostart"# + ); + } + + #[test] + fn quotes_paths_without_spaces_and_preserves_unicode() { + for path in [r"C:\OpenCodex\app.exe", r"C:\用户\OpenCodex\app.exe"] { + assert_eq!( + command(path, "--autostart").unwrap(), + format!("\"{path}\" --autostart") + ); + } + } + + #[test] + fn rejects_paths_that_cannot_be_represented_in_a_run_command() { + for path in ["", "bad\"path.exe", "bad\0path.exe"] { + assert!(command(path, "--autostart").is_err()); + } + } +} diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index 03116c3125d..c8a6e0de34c 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -66,6 +66,11 @@ if it fails, use **Retry** to resolve the current runtime again. On macOS, closing the dashboard keeps the app running in the menu bar. Open OpenCodex again from Dock or Finder to restore the dashboard without restarting the proxy. +On Windows, **Start at Login** quotes the executable path in the current-user startup +registration, including installations under `Program Files`. Previously enabled +registrations are updated once on launch. Startup entries you disabled in the tray +or Task Manager remain disabled. + ## Startup safety on macOS Startup safety reports **Desktop app** protection when OpenCodex's recorded ownership, diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 0ba792f2195..bca41330fd3 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -349,8 +349,12 @@ that is not running has no menu bar item, so leaving autostart off by default le installed app absent after a reboot. The marker in the app config directory is written before the login item is touched and is never removed, so a user who turns the setting off keeps it off; writing it afterwards would let a failed enable retry on every launch. -The behaviour is not macOS-only — the autostart plugin implements the Linux autostart -entry and the current-user Windows Run registration too. +The behaviour is not macOS-only. The autostart plugin implements the macOS and Linux +registrations and reads or disables the current-user Windows Run entry. Shell enables +on Windows write a quoted executable path followed by `--autostart`, preserving the +plugin's registration name and Task Manager explicit-enable semantics. A Windows-specific +launch-origin migration marker revisits older registrations once, only when already +enabled; entries disabled in the tray or Task Manager stay disabled. 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 diff --git a/tests/clients/desktop-start-at-login-default.test.ts b/tests/clients/desktop-start-at-login-default.test.ts index f968fd97ae5..01e7acc54e3 100644 --- a/tests/clients/desktop-start-at-login-default.test.ts +++ b/tests/clients/desktop-start-at-login-default.test.ts @@ -28,7 +28,7 @@ describe("start at login default", () => { test("an existing marker returns before anything is changed", () => { const early = firstRun.indexOf("marker.exists()"); - const enable = firstRun.indexOf("autolaunch().enable()"); + const enable = firstRun.indexOf("crate::login_autostart::enable(app)"); expect(early).toBeGreaterThan(-1); expect(enable).toBeGreaterThan(-1); expect(early).toBeLessThan(enable); @@ -37,14 +37,14 @@ describe("start at login default", () => { test("the marker is written before the login item is registered", () => { const write = firstRun.indexOf("fs::write(&marker"); - const enable = firstRun.indexOf("autolaunch().enable()"); + const enable = firstRun.indexOf("crate::login_autostart::enable(app)"); expect(write).toBeGreaterThan(-1); expect(write).toBeLessThan(enable); }); test("enabling is guarded by the current autolaunch state", () => { const guard = firstRun.indexOf("autolaunch().is_enabled()"); - const enable = firstRun.indexOf("autolaunch().enable()"); + const enable = firstRun.indexOf("crate::login_autostart::enable(app)"); expect(guard).toBeGreaterThan(-1); expect(guard).toBeLessThan(enable); }); @@ -67,7 +67,7 @@ describe("start at login default", () => { const start = firstRun.indexOf("pub fn adopt_launch_origin_argument"); expect(start).toBeGreaterThan(-1); const body = firstRun.slice(start); - const enable = body.indexOf("autolaunch().enable()"); + const enable = body.indexOf("crate::login_autostart::enable(app)"); const claim = body.indexOf("fs::write(&claimed"); expect(enable).toBeGreaterThan(-1); expect(claim).toBeGreaterThan(enable); @@ -76,3 +76,12 @@ describe("start at login default", () => { expect(body).not.toContain("autolaunch().disable()"); }); }); + +test("Windows revisits old migration markers and every shell enable uses the quoted writer", () => { + const firstRun = code(FIRST_RUN); + expect(firstRun).toContain('const ORIGIN_MARKER: &str = "start-at-login-quoted-origin-flag"'); + const tray = code(repoPath("desktop/src-tauri/src/tray.rs")); + expect(tray).toContain("crate::login_autostart::enable(app)"); + expect(tray).not.toContain("autolaunch().enable()"); + expect(firstRun).not.toContain("autolaunch().enable()"); +});