diff --git a/.cargo/config.toml b/.cargo/config.toml index edc8493b54..7faa046e87 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -12,4 +12,5 @@ rustflags = ["-C", "target-cpu=x86-64-v3", "-C", "force-frame-pointers=yes"] rustflags = ["-C", "target-cpu=neoverse-n1", "-C", "force-frame-pointers=yes"] [alias] +desktop = "run --package switchyard-desktop --bin switchyard-desktop-install --" desktop-icons = "run --package switchyard-desktop --example desktop-icons --" diff --git a/INSTALLATION.md b/INSTALLATION.md index 15755f0c10..bf07cf47aa 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -36,6 +36,23 @@ See [Getting Started](docs/getting_started.md#server-path) for a complete TOML deployment and [`switchyard-server`](crates/switchyard-server/README.md) for the configuration reference. +## Desktop installer + +From a source checkout, run `cargo desktop --help` for the commands and +`cargo desktop install --dry-run` to preview installation. This checkout has +the installer helper but does not yet build the desktop app executable, so +full installation cannot complete. Uninstall keeps settings, history, and +recovery copies. + +The installer requires the repository's Rust toolchain. macOS uses LaunchAgents; +Linux requires a systemd user session. Windows requires MSVC build tools, +WebView2, and a Task Scheduler user session. + +On Windows, run install and uninstall from the source checkout with Cargo. +The installed helper rejects changes to its own installation because Windows +locks running executables. The app's Update command uses a temporary helper +copy. The installed helper still supports `uninstall --dry-run`. + ## Linux Codex Service This setup is for single-user Linux machines. It requires a systemd user diff --git a/crates/switchyard-desktop/Cargo.toml b/crates/switchyard-desktop/Cargo.toml index cc6c5af223..1c11321a26 100644 --- a/crates/switchyard-desktop/Cargo.toml +++ b/crates/switchyard-desktop/Cargo.toml @@ -34,7 +34,7 @@ dbus-secret-service = { version = "4.1", features = ["crypto-rust", "vendored"] [target.'cfg(windows)'.dependencies] mslnk = "0.1.8" self-replace = "1.5" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Globalization", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_Security_Credentials", "Win32_Security_Authorization", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Console"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_Security_Credentials", "Win32_System_Console"] } [dev-dependencies] image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs b/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs index b5f469f0a5..92e4df6781 100644 --- a/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs +++ b/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs @@ -23,8 +23,8 @@ Example: SY_PROFILE=team SY_MODEL=my-route cargo desktop install Preview the installation or removal plan with --dry-run. Installation requires a Rust toolchain and LaunchAgents (macOS) or a systemd user session (Linux). Windows requires MSVC build tools, WebView2, and a Task Scheduler user session. -The Cargo command waits for completion. On Windows, running the installed installer -starts a temporary process and returns; read SY_HOME/logs/update.log for progress and errors. +The Cargo command waits for completion. On Windows, run these commands with cargo desktop: +the installed installer cannot replace or remove itself while it runs. Use uninstall --settings FILE to select another recorded installation. Preview its removal with: cargo desktop uninstall --settings FILE --dry-run"; @@ -56,16 +56,6 @@ fn run() -> Result<(), String> { [action @ ("serve" | "desktop"), "--settings", file] => { windows::launch_managed(&Settings::load(Path::new(file))?, *action == "desktop") } - #[cfg(windows)] - [ - "apply", - "--handoff-record", - file, - "--action", - action @ ("install" | "update" | "uninstall"), - "--wait", - pid, - ] => apply(Path::new(file), action, pid).map_err(|e| e.to_string()), ["--help" | "-h"] | ["install" | "uninstall", "--help" | "-h"] => { println!("{HELP}"); Ok(()) @@ -102,9 +92,28 @@ fn perform(action: &str, dry_run: bool, file: Option<&Path>) -> Result<(), Strin None if action == "uninstall" => Settings::for_uninstall(), None => Settings::from_env(), }?; + // Windows cannot replace or delete a running executable, so the installed helper must not + // change its own installation. The app's update runs a temporary copy instead. #[cfg(windows)] - if !dry_run && handoff(&settings, action).map_err(|e| e.to_string())? { - return Ok(()); + if !dry_run { + let installed = settings.binary("switchyard-desktop-install"); + if installed.try_exists().map_err(|e| e.to_string())? + && std::env::current_exe() + .and_then(std::fs::canonicalize) + .map_err(|e| e.to_string())? + == installed.canonicalize().map_err(|e| e.to_string())? + { + return Err(format!( + "The installed installer cannot {action} its own installation, because Windows \ + locks running programs. Run cargo desktop {} from the Switchyard source \ + checkout instead.", + if action == "update" { + "install" + } else { + action + } + )); + } } execute(&settings, action, dry_run) } @@ -201,7 +210,7 @@ fn main() -> ExitCode { .nth(1) .as_deref() .and_then(std::ffi::OsStr::to_str), - Some("serve" | "desktop" | "apply" | "update" | "validate") + Some("serve" | "desktop" | "update" | "validate") ) { unsafe { windows_sys::Win32::System::Console::AttachConsole(u32::MAX) }; } @@ -226,92 +235,3 @@ fn main() -> ExitCode { } } } - -/// Handoff is the record that the installed helper passes to its temporary copy. -#[cfg(windows)] -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct Handoff { - settings: T, -} - -/// When this process runs the installed helper, this function hands `action` to a temporary -/// copy and returns true, because the installed executable cannot replace itself while Windows -/// holds it open. Any other copy returns false without doing anything. -#[cfg(windows)] -fn handoff(settings: &Settings, action: &str) -> Result> { - use std::io::Write; - use std::process::{Command, Stdio}; - let installed = settings.binary("switchyard-desktop-install"); - if !installed.try_exists()? - || std::fs::canonicalize(std::env::current_exe()?)? != std::fs::canonicalize(installed)? - { - return Ok(false); - } - windows::record_handoff(settings)?; - let helper = windows::temporary_updater(&std::env::current_exe()?)?; - let mut record = tempfile::Builder::new() - .prefix("switchyard-handoff-") - .suffix(".toml") - .tempfile()?; - record.write_all(toml::to_string(&Handoff { settings })?.as_bytes())?; - let record = record.into_temp_path(); - let log_path = settings.sy_home.join("logs/update.log"); - std::fs::create_dir_all(settings.sy_home.join("logs"))?; - let log = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path)?; - let mut child = Command::new(&helper); - windows::hide_console(&mut child); - child - .arg("apply") - .arg("--handoff-record") - .arg(&record) - .arg("--action") - .arg(action) - .arg("--wait") - .arg(std::process::id().to_string()) - .stdin(Stdio::null()) - .stderr(log.try_clone()?) - .stdout(log) - .spawn()?; - helper.keep()?; - record.keep()?; - println!( - "Started {action}. Progress and errors: {}", - log_path.display() - ); - Ok(true) -} - -/// This function checks the handoff record at `path`, waits for the installed helper with -/// process ID `pid` to exit, and runs `action`. It deletes the record when it finishes. -#[cfg(windows)] -fn apply(path: &Path, action: &str, pid: &str) -> Result<(), Box> { - const INVALID: &str = "Invalid handoff record."; - let metadata = std::fs::symlink_metadata(path)?; - let (Some(parent), Some(name)) = ( - path.parent(), - path.file_name().and_then(std::ffi::OsStr::to_str), - ) else { - return Err(INVALID.into()); - }; - if !metadata.file_type().is_file() - || !name.starts_with("switchyard-handoff-") - || !name.ends_with(".toml") - || std::fs::canonicalize(parent)? != std::fs::canonicalize(std::env::temp_dir())? - { - return Err(INVALID.into()); - } - let handoff: Handoff = toml::from_str(&std::fs::read_to_string(path)?)?; - handoff.settings.validate()?; - let pid = pid - .parse::() - .ok() - .filter(|&pid| pid != 0) - .ok_or("Invalid parent process ID.")?; - let _record = tempfile::TempPath::try_from_path(path)?; - windows::wait_parent(pid)?; - Ok(execute(&handoff.settings, action, false)?) -} diff --git a/crates/switchyard-desktop/src/install.rs b/crates/switchyard-desktop/src/install.rs index 500cf319b3..f8d4988e5b 100644 --- a/crates/switchyard-desktop/src/install.rs +++ b/crates/switchyard-desktop/src/install.rs @@ -15,18 +15,26 @@ use std::{ }; pub mod credentials; +// Linux and any other system without launchd or Task Scheduler use systemd; execute rejects +// unsupported systems before they change anything. +#[cfg(not(any(target_os = "macos", windows)))] +mod linux; +#[cfg(not(any(target_os = "macos", windows)))] +use linux as platform; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +use macos as platform; #[cfg(windows)] pub mod windows; +#[cfg(windows)] +use windows as platform; pub fn executable_name(name: &str) -> String { format!("{name}{}", std::env::consts::EXE_SUFFIX) } const SERVER: &str = "com.nvidia.switchyard.server"; -const DESKTOP: &str = "com.nvidia.switchyard.desktop"; -// Migration removes this LaunchAgent so the old app cannot restart alongside the desktop app. -const LEGACY_DESKTOP: &str = "com.nvidia.switchyard.menubar"; -const UNIT: &str = "switchyard.service"; const BUNDLED_CONFIG: &str = include_str!("../../../scripts/config/composite.toml"); const UPDATE_LOCK: &str = ".switchyard-desktop-update.lock"; const PORT_ERROR: &str = "SY_PORT must be a number from 1 to 65535."; @@ -529,20 +537,6 @@ fn removal_notes(s: &Settings) -> Vec { )); } } - for path in [ - s.codex_home.join("config.sy.toml"), - s.codex_home.join("config.toml.direct"), - s.home.join(".zshrc"), - s.home.join(".bashrc"), - ] { - if path.exists() { - notes.push(format!( - "Review legacy Switchyard cleanup: {}. Active defaults are copied before \ - restoring config.toml.direct; marked shell/profile blocks are removed.", - path.display() - )); - } - } notes.push("Custom destinations outside these known locations are not inventoried.".into()); notes.push( "Restore routing from Connect tools before uninstalling. \ @@ -695,7 +689,7 @@ fn install(s: &Settings, preserve_profile: bool) -> Result, Strin "Build and config validation passed. Replacing the installation.", )?; replace_installation(s, prepared.desktop_settings.as_deref())?; - install_services(s, prepared.missing_keys.is_empty())?; + platform::install(s, prepared.missing_keys.is_empty())?; if !preserve_profile || !present(&s.profile_file())? { save_profile(s, &prepared.profile)?; } @@ -711,11 +705,8 @@ fn prepare(s: &Settings, preserve_profile: bool) -> Result { } else { codex_profile(s, context_window) }; - if cfg!(target_os = "macos") { - run(Command::new("launchctl").arg("help"))?; - } else if cfg!(target_os = "linux") { - run(systemctl(&["show-environment"]).stdout(Stdio::null()))?; - } + #[cfg(not(windows))] + platform::preflight()?; let desktop_settings = reconcile_desktop_port(s)?; build(s)?; let missing_keys = check_server_config(s, !preserve_profile || !profile.exists())?; @@ -782,9 +773,8 @@ fn check_server_config(s: &Settings, require_route: bool) -> Result, /// before the services stop. fn replace_installation(s: &Settings, desktop_settings: Option<&str>) -> Result<(), String> { let metadata = toml::to_string(s).map_err(err)?; - let bundle = cfg!(target_os = "macos") - .then(|| stage_bundle(s, &metadata)) - .transpose()?; + #[cfg(target_os = "macos")] + let bundle = platform::stage_bundle(s, &metadata)?; fs::create_dir_all(&s.sy_home).map_err(err)?; set_update_state( s, @@ -792,7 +782,7 @@ fn replace_installation(s: &Settings, desktop_settings: Option<&str>) -> Result< "services", "Stopping services and replacing app files. Existing config and history are retained.", )?; - stop_services(s)?; + platform::stop(s)?; fs::create_dir_all(s.sy_home.join("logs")).map_err(err)?; #[cfg(windows)] windows::replace_binaries(s)?; @@ -805,32 +795,20 @@ fn replace_installation(s: &Settings, desktop_settings: Option<&str>) -> Result< let bytes = fs::read(s.release(binary)).map_err(err)?; write(&s.binary(binary), &bytes, true)?; } - migrate_settings(s, desktop_settings)?; - remove(&s.sy_home.join("bin/switchyard-menubar"))?; + write_once(&s.sy_home.join("composite.toml"), BUNDLED_CONFIG)?; + if let Some(text) = desktop_settings { + write(&s.desktop_settings(), text.as_bytes(), false)?; + } else { + write_once(&s.desktop_settings(), &desktop_config(s))?; + } for record in [s.sy_home.join("install.toml"), s.install_record()] { write(&record, metadata.as_bytes(), false)?; } - if let Some(stage) = bundle { - replace_app(s, stage)?; - } + #[cfg(target_os = "macos")] + platform::replace_app(s, bundle)?; Ok(()) } -/// Creates the server config and app settings when they are missing and moves the old menu bar -/// settings to desktop.toml. `desktop_settings` replaces the app settings when the port changed. -fn migrate_settings(s: &Settings, desktop_settings: Option<&str>) -> Result<(), String> { - write_once(&s.sy_home.join("composite.toml"), BUNDLED_CONFIG)?; - let legacy = s.sy_home.join("menubar.toml"); - if present(&legacy)? && !present(&s.desktop_settings())? { - fs::rename(&legacy, s.desktop_settings()).map_err(err)?; - } - write_once(&s.desktop_settings(), &desktop_config(s))?; - match desktop_settings { - Some(text) => write(&s.desktop_settings(), text.as_bytes(), false), - None => Ok(()), - } -} - fn save_profile(s: &Settings, text: &str) -> Result<(), String> { set_update_state( s, @@ -916,11 +894,7 @@ fn check_public_route(config: &Path, model: &str) -> Result<(), String> { } fn reconcile_desktop_port(s: &Settings) -> Result, String> { - let path = if s.desktop_settings().exists() { - s.desktop_settings() - } else { - s.sy_home.join("menubar.toml") - }; + let path = s.desktop_settings(); let Some(text) = read_optional(&path).map_err(|e| format!("Read {}: {e}", path.display()))? else { return Ok(None); @@ -1061,294 +1035,13 @@ fn desktop_config(s: &Settings) -> String { fn toml_string(value: &str) -> String { toml::Value::String(value.into()).to_string() } +#[cfg(any(target_os = "macos", windows))] fn xml(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") } -fn plist(label: &str, args: &[String], log: &Path, app: bool) -> String { - let args: String = args - .iter() - .map(|a| format!("{}", xml(a))) - .collect(); - let keep_alive = if app { - "SuccessfulExit\ - LimitLoadToSessionTypeAqua" - } else { - "EnvironmentVariables\ - RUST_LOGinfo" - }; - format!( - "\ - \ - \ - Label{label}\ - ProgramArguments{args}\ - RunAtLoad\ - KeepAlive{keep_alive}\ - StandardOutPath{}\ - StandardErrorPath{}\ - ", - xml(&log.with_extension("log").to_string_lossy()), - xml(&log.with_extension("err.log").to_string_lossy()) - ) -} - -fn domain() -> Result { - let output = Command::new("id").arg("-u").output().map_err(err)?; - if !output.status.success() { - return Err("Could not read the current user ID.".into()); - } - Ok(format!( - "gui/{}", - String::from_utf8_lossy(&output.stdout).trim() - )) -} -fn systemctl(args: &[&str]) -> Command { - let mut command = Command::new("systemctl"); - command.arg("--user").args(args); - command -} - -fn stop_services(s: &Settings) -> Result<(), String> { - if cfg!(target_os = "macos") { - let domain = domain()?; - for label in [SERVER, DESKTOP, LEGACY_DESKTOP] { - stop_launchd_job(&format!("{domain}/{label}"))?; - } - remove(&s.service_dir.join(format!("{LEGACY_DESKTOP}.plist")))?; - } else if cfg!(windows) { - #[cfg(windows)] - windows::stop(s)?; - } else if present(&s.service_dir.join(UNIT))? { - run(&mut systemctl(&["stop", UNIT]))?; - } - Ok(()) -} -fn install_services(s: &Settings, keys_saved: bool) -> Result<(), String> { - if cfg!(target_os = "macos") { - install_launch_agents(s)?; - } else if cfg!(windows) { - #[cfg(windows)] - windows::install(s)?; - } else { - install_systemd_unit(s, keys_saved)?; - } - Ok(()) -} -/// Removes the service definitions and the app. SY_HOME keeps settings, accounts, and history. -fn remove_app_and_services(s: &Settings) -> Result<(), String> { - if cfg!(target_os = "macos") { - for label in [SERVER, DESKTOP, LEGACY_DESKTOP] { - remove(&s.service_dir.join(format!("{label}.plist")))?; - } - if present(&s.app())? { - fs::remove_dir_all(s.app()).map_err(err)?; - } - } else if cfg!(windows) { - #[cfg(windows)] - windows::uninstall(s)?; - } else { - let unit = s.service_dir.join(UNIT); - if present(&unit)? { - run(&mut systemctl(&["disable", UNIT]))?; - remove(&unit)?; - } - run(&mut systemctl(&["daemon-reload"]))?; - } - Ok(()) -} - -fn launchd_job_loaded(job: &str) -> Result { - Command::new("launchctl") - .args(["print", job]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|status| status.success()) - .map_err(err) -} -fn stop_launchd_job(job: &str) -> Result<(), String> { - if !launchd_job_loaded(job)? { - return Ok(()); - } - run(Command::new("launchctl").args(["bootout", job]))?; - for _ in 0..50 { - if !launchd_job_loaded(job)? { - return Ok(()); - } - thread::sleep(Duration::from_millis(100)); - } - Err(format!( - "{job} did not stop within five seconds. Try again." - )) -} - -fn stage_bundle(s: &Settings, metadata: &str) -> Result { - let app = s.app(); - let parent = app.parent().ok_or("The app has no parent directory.")?; - fs::create_dir_all(parent).map_err(err)?; - let stage = tempfile::tempdir_in(parent).map_err(err)?; - let app = stage.path().join("Switchyard.app"); - let info = format!( - "\ - \ - CFBundleIdentifiercom.nvidia.switchyard\ - CFBundleNameSwitchyard\ - CFBundleDisplayNameSwitchyard\ - CFBundleExecutableswitchyard-desktop\ - CFBundleIconFileSwitchyard.icns\ - CFBundlePackageTypeAPPL\ - CFBundleShortVersionString{}\ - LSMinimumSystemVersion12.0\ - NSHighResolutionCapable\ - ", - env!("CARGO_PKG_VERSION") - ); - let desktop = fs::read(s.release("switchyard-desktop")).map_err(err)?; - for (file, bytes, executable) in [ - ("MacOS/switchyard-desktop", &desktop[..], true), - ("Resources/install.toml", metadata.as_bytes(), false), - ( - "Resources/Switchyard.icns", - &include_bytes!("../icons/icon.icns")[..], - false, - ), - ("Info.plist", info.as_bytes(), false), - ] { - write(&app.join("Contents").join(file), bytes, executable)?; - } - run(Command::new("codesign") - .args(["--force", "--deep", "--sign", "-"]) - .arg(&app))?; - Ok(stage) -} - -fn replace_app(s: &Settings, stage: tempfile::TempDir) -> Result<(), String> { - let previous = stage.path().join("Previous.app"); - let existed = present(&s.app())?; - if existed { - fs::rename(s.app(), &previous).map_err(err)?; - } - let Err(error) = fs::rename(stage.path().join("Switchyard.app"), s.app()) else { - return Ok(()); - }; - if existed { - fs::rename(&previous, s.app()) - .map_err(|restore| format!("Replace app: {error}. Restore previous app: {restore}"))?; - } - Err(format!("Replace app: {error}")) -} - -fn install_launch_agents(s: &Settings) -> Result<(), String> { - let server = [ - s.binary("switchyard-desktop-install").display().to_string(), - "serve".into(), - "--settings".into(), - s.sy_home.join("install.toml").display().to_string(), - ]; - let desktop = [ - s.app() - .join("Contents/MacOS/switchyard-desktop") - .display() - .to_string(), - s.desktop_settings().display().to_string(), - ]; - for (label, args, log, app) in [ - (SERVER, &server[..], "logs/server", false), - (DESKTOP, &desktop[..], "logs/desktop", true), - ] { - let job = plist(label, args, &s.sy_home.join(log), app); - write( - &s.service_dir.join(format!("{label}.plist")), - job.as_bytes(), - false, - )?; - } - let domain = domain()?; - for label in [SERVER, DESKTOP] { - run(Command::new("launchctl") - .arg("bootstrap") - .arg(&domain) - .arg(s.service_dir.join(format!("{label}.plist"))))?; - // macOS can defer RunAtLoad jobs until an explicit start is requested. - run(Command::new("launchctl") - .arg("kickstart") - .arg(format!("{domain}/{label}")))?; - } - Ok(()) -} - -fn systemd_arg(path: &Path) -> String { - // systemd expands percent specifiers and environment variables inside quoted arguments. - format!( - "\"{}\"", - path.to_string_lossy() - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('%', "%%") - .replace('$', "$$") - ) -} -fn install_systemd_unit(s: &Settings, expect_running: bool) -> Result<(), String> { - let unit = format!( - "[Unit]\n\ - Description=Switchyard local server\n\ - \n\ - [Service]\n\ - Type=simple\n\ - ExecStart={} serve --settings {}\n\ - Restart=on-failure\n\ - RestartSec=10\n\ - Environment=RUST_LOG=info\n\ - \n\ - [Install]\n\ - WantedBy=default.target\n", - systemd_arg(&s.binary("switchyard-desktop-install")), - systemd_arg(&s.sy_home.join("install.toml")), - ); - write(&s.service_dir.join(UNIT), unit.as_bytes(), false)?; - run(&mut systemctl(&["daemon-reload"]))?; - run(&mut systemctl(&["enable", UNIT]))?; - run(&mut systemctl(&["restart", UNIT]))?; - // A server waiting for a key exits and restarts until the key is saved. - if !expect_running { - return Ok(()); - } - thread::sleep(Duration::from_secs(2)); - run(&mut systemctl(&["is-active", "--quiet", UNIT])) - .map_err(|e| format!("{e}. See journalctl --user -u switchyard.")) -} - -fn strip_block(path: &Path, start: &str, end: &str) -> Result<(), String> { - let Some(text) = read_optional(path).map_err(err)? else { - return Ok(()); - }; - let mut output = String::new(); - let mut skipping = false; - for line in text.split_inclusive('\n') { - skipping |= line.contains(start); - if !skipping { - output.push_str(line); - } - if line.contains(end) { - skipping = false; - } - } - if skipping { - return Err(format!( - "{} has an unfinished Switchyard block. No edits were made to this file.", - path.display() - )); - } - if text != output { - write(path, output.as_bytes(), false)?; - } - Ok(()) -} - fn uninstall(s: &Settings) -> Result<(), String> { let _lock = lock_installation(s)?; println!("{}", installation_plan(s, "uninstall")); @@ -1356,10 +1049,9 @@ fn uninstall(s: &Settings) -> Result<(), String> { if profile.exists() { backup(&profile, "switchyard-uninstall-recovery")?; } - stop_services(s)?; - remove_app_and_services(s)?; + platform::stop(s)?; + platform::uninstall(s)?; remove(&profile)?; - remove_legacy_setup(s)?; println!( "Removed services, app, and profile. Kept settings, accounts, {}and history at {}.", if cfg!(windows) { "" } else { "binaries, " }, @@ -1371,31 +1063,3 @@ fn uninstall(s: &Settings) -> Result<(), String> { } Ok(()) } - -/// Removes legacy Switchyard edits to Codex and shell startup files. The active config.toml is -/// backed up before config.toml.direct replaces it. -fn remove_legacy_setup(s: &Settings) -> Result<(), String> { - remove(&s.codex_home.join("config.sy.toml"))?; - let config = s.codex_home.join("config.toml"); - strip_block( - &config, - "# >>> switchyard sy profile >>>", - "# <<< switchyard sy profile <<<", - )?; - let direct = s.codex_home.join("config.toml.direct"); - if present(&direct)? { - if present(&config)? { - backup(&config, "switchyard-current")?; - } - write(&config, &fs::read(&direct).map_err(err)?, false)?; - remove(&direct)?; - } - for name in [".zshrc", ".bashrc"] { - strip_block( - &s.home.join(name), - "# >>> switchyard codex alias >>>", - "# <<< switchyard codex alias <<<", - )?; - } - Ok(()) -} diff --git a/crates/switchyard-desktop/src/install/linux.rs b/crates/switchyard-desktop/src/install/linux.rs new file mode 100644 index 0000000000..5d4a784991 --- /dev/null +++ b/crates/switchyard-desktop/src/install/linux.rs @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module installs the systemd user service for the server. + +use std::{ + path::Path, + process::{Command, Stdio}, + thread, + time::Duration, +}; + +use super::{Settings, present, remove, run, write}; + +const UNIT: &str = "switchyard.service"; + +pub fn preflight() -> Result<(), String> { + run(systemctl(&["show-environment"]).stdout(Stdio::null())) +} + +pub fn stop(s: &Settings) -> Result<(), String> { + if present(&s.service_dir.join(UNIT))? { + run(&mut systemctl(&["stop", UNIT]))?; + } + Ok(()) +} + +pub fn install(s: &Settings, keys_saved: bool) -> Result<(), String> { + let unit = format!( + r#"[Unit] +Description=Switchyard local server + +[Service] +Type=simple +ExecStart={installer} serve --settings {settings} +Restart=on-failure +RestartSec=10 +Environment=RUST_LOG=info + +[Install] +WantedBy=default.target +"#, + installer = systemd_arg(&s.binary("switchyard-desktop-install")), + settings = systemd_arg(&s.sy_home.join("install.toml")), + ); + write(&s.service_dir.join(UNIT), unit.as_bytes(), false)?; + run(&mut systemctl(&["daemon-reload"]))?; + run(&mut systemctl(&["enable", UNIT]))?; + run(&mut systemctl(&["restart", UNIT]))?; + // A server waiting for a key exits and restarts until the key is saved. + if !keys_saved { + return Ok(()); + } + thread::sleep(Duration::from_secs(2)); + run(&mut systemctl(&["is-active", "--quiet", UNIT])) + .map_err(|e| format!("{e}. See journalctl --user -u switchyard.")) +} + +/// Removes the service. The binaries stay in SY_HOME. +pub fn uninstall(s: &Settings) -> Result<(), String> { + let unit = s.service_dir.join(UNIT); + if present(&unit)? { + run(&mut systemctl(&["disable", UNIT]))?; + remove(&unit)?; + } + run(&mut systemctl(&["daemon-reload"])) +} + +fn systemctl(args: &[&str]) -> Command { + let mut command = Command::new("systemctl"); + command.arg("--user").args(args); + command +} + +fn systemd_arg(path: &Path) -> String { + // systemd expands percent specifiers and environment variables inside quoted arguments. + format!( + "\"{}\"", + path.to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('%', "%%") + .replace('$', "$$") + ) +} diff --git a/crates/switchyard-desktop/src/install/macos.rs b/crates/switchyard-desktop/src/install/macos.rs new file mode 100644 index 0000000000..7fc8345221 --- /dev/null +++ b/crates/switchyard-desktop/src/install/macos.rs @@ -0,0 +1,200 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module installs the app bundle and the LaunchAgents for the server and the desktop app. + +use std::{ + fs, + path::Path, + process::{Command, Stdio}, + thread, + time::Duration, +}; + +use super::{SERVER, Settings, err, present, remove, run, write, xml}; + +const DESKTOP: &str = "com.nvidia.switchyard.desktop"; + +pub fn preflight() -> Result<(), String> { + run(Command::new("launchctl").arg("help")) +} + +pub fn stop(_s: &Settings) -> Result<(), String> { + let domain = domain()?; + for label in [SERVER, DESKTOP] { + stop_launchd_job(&format!("{domain}/{label}"))?; + } + Ok(()) +} + +pub fn install(s: &Settings, _keys_saved: bool) -> Result<(), String> { + let server = [ + s.binary("switchyard-desktop-install").display().to_string(), + "serve".into(), + "--settings".into(), + s.sy_home.join("install.toml").display().to_string(), + ]; + let desktop = [ + s.app() + .join("Contents/MacOS/switchyard-desktop") + .display() + .to_string(), + s.desktop_settings().display().to_string(), + ]; + for (label, args, log, app) in [ + (SERVER, &server[..], "logs/server", false), + (DESKTOP, &desktop[..], "logs/desktop", true), + ] { + let job = plist(label, args, &s.sy_home.join(log), app); + write( + &s.service_dir.join(format!("{label}.plist")), + job.as_bytes(), + false, + )?; + } + let domain = domain()?; + for label in [SERVER, DESKTOP] { + run(Command::new("launchctl") + .arg("bootstrap") + .arg(&domain) + .arg(s.service_dir.join(format!("{label}.plist"))))?; + // macOS can defer RunAtLoad jobs until an explicit start is requested. + run(Command::new("launchctl") + .arg("kickstart") + .arg(format!("{domain}/{label}")))?; + } + Ok(()) +} + +/// Removes the LaunchAgents and the app bundle. +pub fn uninstall(s: &Settings) -> Result<(), String> { + for label in [SERVER, DESKTOP] { + remove(&s.service_dir.join(format!("{label}.plist")))?; + } + if present(&s.app())? { + fs::remove_dir_all(s.app()).map_err(err)?; + } + Ok(()) +} + +pub fn stage_bundle(s: &Settings, metadata: &str) -> Result { + let app = s.app(); + let parent = app.parent().ok_or("The app has no parent directory.")?; + fs::create_dir_all(parent).map_err(err)?; + let stage = tempfile::tempdir_in(parent).map_err(err)?; + let app = stage.path().join("Switchyard.app"); + let info = format!( + "\ + \ + CFBundleIdentifiercom.nvidia.switchyard\ + CFBundleNameSwitchyard\ + CFBundleDisplayNameSwitchyard\ + CFBundleExecutableswitchyard-desktop\ + CFBundleIconFileSwitchyard.icns\ + CFBundlePackageTypeAPPL\ + CFBundleShortVersionString{}\ + LSMinimumSystemVersion12.0\ + NSHighResolutionCapable\ + ", + env!("CARGO_PKG_VERSION") + ); + let desktop = fs::read(s.release("switchyard-desktop")).map_err(err)?; + for (file, bytes, executable) in [ + ("MacOS/switchyard-desktop", &desktop[..], true), + ("Resources/install.toml", metadata.as_bytes(), false), + ( + "Resources/Switchyard.icns", + &include_bytes!("../../icons/icon.icns")[..], + false, + ), + ("Info.plist", info.as_bytes(), false), + ] { + write(&app.join("Contents").join(file), bytes, executable)?; + } + run(Command::new("codesign") + .args(["--force", "--deep", "--sign", "-"]) + .arg(&app))?; + Ok(stage) +} + +pub fn replace_app(s: &Settings, stage: tempfile::TempDir) -> Result<(), String> { + let previous = stage.path().join("Previous.app"); + let existed = present(&s.app())?; + if existed { + fs::rename(s.app(), &previous).map_err(err)?; + } + let Err(error) = fs::rename(stage.path().join("Switchyard.app"), s.app()) else { + return Ok(()); + }; + if existed { + fs::rename(&previous, s.app()) + .map_err(|restore| format!("Replace app: {error}. Restore previous app: {restore}"))?; + } + Err(format!("Replace app: {error}")) +} + +fn plist(label: &str, args: &[String], log: &Path, app: bool) -> String { + let args: String = args + .iter() + .map(|a| format!("{}", xml(a))) + .collect(); + let keep_alive = if app { + "SuccessfulExit\ + LimitLoadToSessionTypeAqua" + } else { + "EnvironmentVariables\ + RUST_LOGinfo" + }; + format!( + "\ + \ + \ + Label{label}\ + ProgramArguments{args}\ + RunAtLoad\ + KeepAlive{keep_alive}\ + StandardOutPath{}\ + StandardErrorPath{}\ + ", + xml(&log.with_extension("log").to_string_lossy()), + xml(&log.with_extension("err.log").to_string_lossy()) + ) +} + +fn domain() -> Result { + let output = Command::new("id").arg("-u").output().map_err(err)?; + if !output.status.success() { + return Err("Could not read the current user ID.".into()); + } + Ok(format!( + "gui/{}", + String::from_utf8_lossy(&output.stdout).trim() + )) +} + +fn launchd_job_loaded(job: &str) -> Result { + Command::new("launchctl") + .args(["print", job]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .map_err(err) +} + +fn stop_launchd_job(job: &str) -> Result<(), String> { + if !launchd_job_loaded(job)? { + return Ok(()); + } + run(Command::new("launchctl").args(["bootout", job]))?; + for _ in 0..50 { + if !launchd_job_loaded(job)? { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + Err(format!( + "{job} did not stop within five seconds. Try again." + )) +} diff --git a/crates/switchyard-desktop/src/install/windows.rs b/crates/switchyard-desktop/src/install/windows.rs index 154a8628ea..37f3677212 100644 --- a/crates/switchyard-desktop/src/install/windows.rs +++ b/crates/switchyard-desktop/src/install/windows.rs @@ -3,10 +3,15 @@ //! This module installs per-user Windows tasks and shortcuts and replaces stopped app executables. -use super::*; -use std::os::windows::{ - ffi::{OsStrExt, OsStringExt}, - process::CommandExt, +use std::{ + fs, + io::Write, + os::windows::{ + ffi::{OsStrExt, OsStringExt}, + process::CommandExt, + }, + path::{Path, PathBuf}, + process::{Command, Stdio}, }; use windows_sys::Win32::{ Foundation::{CloseHandle, ERROR_NOT_FOUND, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0}, @@ -26,6 +31,8 @@ use windows_sys::Win32::{ }, }; +use super::{Settings, credentials, executable_name, remove, run, write, xml}; + /// The managed executables, in the order they are replaced and stopped. const BINARIES: [&str; 3] = [ "switchyard-server", @@ -102,50 +109,26 @@ fn task_succeeds(s: &Settings, desktop: bool, action: &str) -> Result Result { - use windows_sys::Win32::{ - Foundation::LocalFree, - Security::{ - Authorization::ConvertSidToStringSidW, GetTokenInformation, TOKEN_QUERY, TOKEN_USER, - TokenUser, - }, - System::Threading::{GetCurrentProcess, OpenProcessToken}, - }; - unsafe { - let mut token = std::ptr::null_mut(); - if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { - return Err(error()); - } - let token = Handle(token); - let mut length = 0; - GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut length); - if length < std::mem::size_of::() as u32 { - return Err(error()); - } - let mut buffer = vec![0usize; (length as usize).div_ceil(std::mem::size_of::())]; - if GetTokenInformation( - token.0, - TokenUser, - buffer.as_mut_ptr().cast(), - length, - &mut length, - ) == 0 - { - return Err(error()); - } - let user = &*buffer.as_ptr().cast::(); - let mut sid = std::ptr::null_mut(); - if ConvertSidToStringSidW(user.User.Sid, &mut sid) == 0 { - return Err(error()); - } - let result = match (0..256).find(|&index| *sid.add(index) == 0) { - Some(length) => String::from_utf16(std::slice::from_raw_parts(sid, length)) - .map_err(|e| e.to_string()), - None => Err("Invalid Windows account SID.".to_owned()), - }; - LocalFree(sid.cast()); - result + let mut whoami = Command::new("whoami.exe"); + hide_console(&mut whoami); + let output = whoami + .args(["/user", "/fo", "csv", "/nh"]) + .output() + .map_err(|e| e.to_string())?; + // The output is one CSV row, "DOMAIN\user","S-1-5-...", so the SID is the last field. + let text = String::from_utf8_lossy(&output.stdout); + let sid = text + .trim() + .rsplit(',') + .next() + .unwrap_or("") + .trim_matches('"'); + if !output.status.success() || !sid.starts_with("S-1-") { + return Err("Could not read the Windows account SID from whoami.".into()); } + Ok(sid.to_owned()) } // Separate tasks let the server continue after the desktop app exits. fn task_xml(s: &Settings, user: &str, desktop: bool) -> String { @@ -208,7 +191,7 @@ fn shortcut_paths(s: &Settings) -> [PathBuf; 2] { s.service_dir.join("Startup/Switchyard.lnk"), ] } -pub fn install(s: &Settings) -> Result<(), String> { +pub fn install(s: &Settings, _keys_saved: bool) -> Result<(), String> { write( &s.bin_dir().join("install.toml"), toml::to_string(s).map_err(|e| e.to_string())?.as_bytes(), @@ -313,11 +296,6 @@ pub fn stop(s: &Settings) -> Result<(), String> { } stop_images(&BINARIES.map(|name| s.binary(name))) } -pub fn restart(s: &Settings) -> Result<(), String> { - stop_task(s, false)?; - stop_images(&[s.binary("switchyard-server")])?; - run(scheduler().args(["/Run", "/TN", &task_name(s, false)])) -} pub fn uninstall(s: &Settings) -> Result<(), String> { for desktop in [false, true] { if task_succeeds(s, desktop, "/Query")? { @@ -404,27 +382,6 @@ pub fn temporary_updater(source: &Path) -> Result { fs::copy(source, file.path()).map_err(|e| e.to_string())?; Ok(file.into_temp_path()) } -pub fn wait_parent(pid: u32) -> Result<(), String> { - use windows_sys::Win32::Foundation::{ERROR_INVALID_PARAMETER, GetLastError}; - if pid == 0 { - return Err("Invalid parent process ID.".into()); - } - unsafe { - let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); - if handle.is_null() { - return if GetLastError() == ERROR_INVALID_PARAMETER { - Ok(()) - } else { - Err(error()) - }; - } - let handle = Handle(handle); - if WaitForSingleObject(handle.0, 30000) != WAIT_OBJECT_0 { - return Err("The installer parent did not exit within thirty seconds.".into()); - } - } - Ok(()) -} /// This function waits for its child so Task Scheduler tracks the app or server's lifetime. pub fn launch_managed(s: &Settings, desktop: bool) -> Result<(), String> { let (program, log) = if desktop { @@ -476,31 +433,6 @@ pub fn launch_managed(s: &Settings, desktop: bool) -> Result<(), String> { Err(format!("Managed application exited with {status}.")) } } -pub fn open(value: &str) -> Result<(), String> { - use windows_sys::Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL}; - let value = wide(std::ffi::OsStr::new(value)); - let verb = wide(std::ffi::OsStr::new("open")); - let status = unsafe { - ShellExecuteW( - std::ptr::null_mut(), - verb.as_ptr(), - value.as_ptr(), - std::ptr::null(), - std::ptr::null(), - SW_SHOWNORMAL, - ) - } as isize; - if status > 32 { - Ok(()) - } else { - Err(format!( - "Windows could not open the file or URL (error {status})." - )) - } -} -pub fn saved_key(base_url: &str) -> Result, String> { - read_credential(&format!("Switchyard:model-list:{base_url}")) -} pub(crate) fn read_credential(target: &str) -> Result, String> { let target = wide(std::ffi::OsStr::new(target)); @@ -523,10 +455,6 @@ pub(crate) fn read_credential(target: &str) -> Result, String> { .map(|s| Some(s).filter(|s| !s.trim().is_empty())) .map_err(|e| e.to_string()) } -pub fn save_key(base_url: &str, key: &str) -> Result<(), String> { - write_credential(&format!("Switchyard:model-list:{base_url}"), key.trim()) -} - pub(crate) fn write_credential(target: &str, key: &str) -> Result<(), String> { let mut target = wide(std::ffi::OsStr::new(target)); let credential = CREDENTIALW { @@ -551,13 +479,3 @@ pub(crate) fn delete_credential(target: &str) -> Result<(), String> { } Ok(()) } - -pub fn record_handoff(s: &Settings) -> Result<(), String> { - let _lock = update_lock(s)?; - set_update_state( - s, - "starting", - "start", - "Starting the Windows source installer.", - ) -} diff --git a/crates/switchyard-desktop/tests/desktop.rs b/crates/switchyard-desktop/tests/desktop.rs index 2e71d8b17e..3c0cc1c84a 100644 --- a/crates/switchyard-desktop/tests/desktop.rs +++ b/crates/switchyard-desktop/tests/desktop.rs @@ -289,23 +289,19 @@ esac fn install_reinstall_update_and_uninstall_keep_user_data() { let mut f = Fixture::new(); let (sy_home, codex) = (f.s.sy_home.clone(), f.s.codex_home.clone()); - write(&sy_home.join("menubar.toml"), "baseline_model = 'custom'\n"); write(&sy_home.join("routing.jsonl"), "history"); write(&codex.join("config.toml"), "user defaults"); - let legacy_job = f.s.service_dir.join("com.nvidia.switchyard.menubar.plist"); - if cfg!(target_os = "macos") { - write(&legacy_job, "old menu bar job"); - } - - // A first install moves the old menu bar settings and adds the server URL. f.ok("install"); let settings = f.s.desktop_settings(); let url = |port| format!("server_url = \"http://127.0.0.1:{port}\"\n"); assert_eq!( - read(&settings), - "baseline_model = 'custom'\n".to_owned() + &url(f.s.port) + toml(&settings)["server_url"].as_str(), + Some(format!("http://127.0.0.1:{}", f.s.port).as_str()) + ); + write( + &settings, + &("baseline_model = 'custom'\n".to_owned() + &url(f.s.port)), ); - assert!(!sy_home.join("menubar.toml").exists()); let first = read(&f.profile()); let profile = toml(&f.profile()); assert_eq!(profile["model"].as_str(), Some(f.s.model.as_str())); @@ -330,7 +326,6 @@ esac job["ProgramArguments"].as_array().expect("args")[0].as_string(), helper.to_str() ); - assert!(!legacy_job.exists()); } else { let unit = read(&f.s.service_dir.join("switchyard.service")); assert!(unit.contains(" %% $$/install.toml\""), "{unit}"); @@ -339,12 +334,15 @@ esac assert!(!f.s.home.join("unsafe").exists()); // A reinstall on a new port with a route that sets no window backs up the profile, - // drops the old window, follows the port, and keeps history and configs. + // removes the old window, follows the port, and keeps history and configs. f.s.port = health_server(); f.s.model = "codex-auto-review".into(); f.save(); f.ok("install"); - assert!(read(&settings).ends_with(&url(f.s.port))); + assert_eq!( + read(&settings), + "baseline_model = 'custom'\n".to_owned() + &url(f.s.port) + ); let profile = toml(&f.profile()); assert_eq!(profile["model"].as_str(), Some("codex-auto-review")); assert!(profile.get("model_context_window").is_none()); @@ -375,28 +373,13 @@ esac let build = read(&f.s.source.join("target/build-process-group")); assert_ne!(build.trim(), unsafe { libc::getpgrp() }.to_string()); - // Uninstall restores the pre-Switchyard Codex defaults, keeps copies of what it - // replaces, removes the old shell alias, and keeps history. - write(&codex.join("config.toml"), "current"); - write(&codex.join("config.toml.direct"), "original"); - let alias = "# >>> switchyard codex alias >>>\nalias codex=old\n"; - let rc = f.s.home.join(".bashrc"); - let block = format!("before\n{alias}# <<< switchyard codex alias <<<\nafter\n"); - write(&rc, &block); + // Uninstall keeps the main Codex config, a copy of the profile, and usage history. f.ok("uninstall"); - assert_eq!(read(&codex.join("config.toml")), "original"); - assert_eq!(f.codex_files("switchyard-current"), ["current"]); + assert_eq!(read(&codex.join("config.toml")), "user defaults"); assert_eq!(f.codex_files("switchyard-uninstall-recovery"), [edited]); - assert_eq!(read(&rc), "before\nafter\n"); assert!(!f.profile().exists() && !f.s.app().exists()); assert_eq!(read(&sy_home.join("routing.jsonl")), "history"); f.ok("uninstall"); - - // An unfinished alias block is not edited. - let unfinished = format!("before\n{alias}after\n"); - write(&rc, &unfinished); - assert!(!f.run("uninstall").status.success()); - assert_eq!(read(&rc), unfinished); } #[test] @@ -466,6 +449,66 @@ esac } } +#[cfg(windows)] +#[test] +// The installed helper must refuse changes to its own installation but still allow previews. +fn installed_windows_helper_rejects_changes_and_allows_previews() { + use switchyard_desktop::install::Settings; + + let root = tempfile::tempdir().expect("fixture"); + let home = root.path(); + let settings = Settings { + home: home.to_path_buf(), + sy_home: home.join("state"), + codex_home: home.join("codex"), + service_dir: home.join("services"), + app_dir: Some(home.join("Programs/Switchyard")), + source: home.join("source"), + cargo: home.join("cargo.exe"), + port: 4123, + profile: "sy".into(), + model: "test-route".into(), + }; + fs::create_dir_all(settings.bin_dir()).expect("bin directory"); + let helper = settings.binary("switchyard-desktop-install"); + fs::copy(env!("CARGO_BIN_EXE_switchyard-desktop-install"), &helper).expect("helper"); + let record = home.join("install.toml"); + fs::write(&record, toml::to_string(&settings).expect("settings")).expect("record"); + + for action in ["install", "update", "uninstall"] { + let mut command = Command::new(&helper); + command.arg(action); + if action == "install" { + command + .env("HOME", home) + .env("LOCALAPPDATA", home) + .env("SY_HOME", &settings.sy_home) + .env("CODEX_HOME", &settings.codex_home) + .env("SY_PROFILE", &settings.profile) + .env("SY_MODEL", &settings.model) + .env("SY_PORT", settings.port.to_string()); + } else { + command.arg("--settings").arg(&record); + } + let output = command.output().expect("installer"); + let error = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "{action}"); + assert!(error.contains("Run cargo desktop"), "{error}"); + assert!(!settings.sy_home.exists()); + assert!(!settings.service_dir.exists()); + assert!(!settings.codex_home.exists()); + assert!(helper.is_file()); + } + let output = Command::new(&helper) + .args(["uninstall", "--dry-run", "--settings"]) + .arg(&record) + .output() + .expect("preview"); + assert!(output.status.success(), "{output:?}"); + assert!(!settings.sy_home.exists()); + assert!(helper.is_file()); +} + #[cfg(windows)] #[test] fn a_locked_windows_executable_rolls_back_every_earlier_replacement() {