From fa52aef6dfbf009f13a4ef3194b8564ea307d1e2 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Thu, 8 Oct 2026 11:39:24 -0700 Subject: [PATCH] feat(macos): add app installation and session usage Signed-off-by: Elyas Mehtabuddin --- Cargo.lock | 2 + crates/switchyard-menubar/Cargo.toml | 2 + crates/switchyard-menubar/README.md | 96 ++- crates/switchyard-menubar/src/accounts.rs | 204 ++++++ crates/switchyard-menubar/src/dashboard.rs | 690 ++++++++++++++++++++ crates/switchyard-menubar/src/harness.rs | 659 +++++++++++++++++++ crates/switchyard-menubar/src/history.rs | 192 ++++++ crates/switchyard-menubar/src/main.rs | 19 +- crates/switchyard-menubar/src/sessions.rs | 392 +++++++++++ crates/switchyard-menubar/src/tray.rs | 57 +- crates/switchyard-server/src/lib.rs | 15 +- crates/switchyard-server/src/routing_log.rs | 5 + crates/switchyard-server/tests/server.rs | 5 + scripts/macos/common.sh | 1 + scripts/macos/install.sh | 57 +- scripts/macos/uninstall.sh | 2 + tests/test_macos_install.py | 68 +- 17 files changed, 2422 insertions(+), 44 deletions(-) create mode 100644 crates/switchyard-menubar/src/accounts.rs create mode 100644 crates/switchyard-menubar/src/dashboard.rs create mode 100644 crates/switchyard-menubar/src/harness.rs create mode 100644 crates/switchyard-menubar/src/history.rs create mode 100644 crates/switchyard-menubar/src/sessions.rs diff --git a/Cargo.lock b/Cargo.lock index 22f77a8718..73115a436c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3256,6 +3256,8 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", "toml_edit 0.25.13+spec-1.1.0", "tray-icon", + "url", + "uuid", ] [[package]] diff --git a/crates/switchyard-menubar/Cargo.toml b/crates/switchyard-menubar/Cargo.toml index e4ede3faa9..15973929e1 100644 --- a/crates/switchyard-menubar/Cargo.toml +++ b/crates/switchyard-menubar/Cargo.toml @@ -20,6 +20,8 @@ serde_json.workspace = true tempfile = "3" toml = "0.9" toml_edit = "0.25" +url = "2" +uuid.workspace = true [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" diff --git a/crates/switchyard-menubar/README.md b/crates/switchyard-menubar/README.md index f0b5027115..fa96d3d8c9 100644 --- a/crates/switchyard-menubar/README.md +++ b/crates/switchyard-menubar/README.md @@ -1,6 +1,7 @@ # switchyard-menubar -A macOS menu bar companion for a Switchyard server running in the background. +The macOS app shows usage, installs coding-tool settings, and opens local +worktree sessions. Its menu bar shows server status and estimated savings. The server does the routing. This process reads what the server wrote and edits the server config only when you ask it to, so quitting it never affects @@ -22,10 +23,11 @@ make install-macos # or install-macos-dry-run to see the steps first make uninstall-macos ``` -That builds `switchyard-server` and `switchyard-menubar` into -`~/.switchyard/bin`, writes a server config and menu bar settings, loads two -LaunchAgents, and writes a `sy` Codex profile. Use the CLI profile with -`codex -p sy`. +`make install-macos` builds `switchyard-server` and `switchyard-menubar` into +`~/.switchyard/bin` and installs `~/Applications/Switchyard.app`. It writes a +server config and menu bar settings, loads two LaunchAgents, and writes a `sy` +Codex profile. Use the CLI profile with `codex -p sy`. Open Switchyard from +Finder or Spotlight to see Usage, or choose **Install…** to set up a coding tool. Your server config and menu bar settings are never overwritten once they exist, so edits survive a reinstall. Logs are in `~/.switchyard/logs/`. @@ -318,6 +320,88 @@ Reinstalling backs up the profile before replacing it if its contents change. The profile sets `model`, `model_provider`, and the Switchyard provider details. It leaves `approval_policy` and `sandbox_mode` to your existing Codex settings. -Codex.app cannot use the profile. The installer leaves its config unchanged. +Codex.app cannot use the profile. `make install-macos` leaves its +`config.toml` unchanged. To change the defaults used by Codex.app and Codex CLI, +choose **Codex app and CLI defaults** in the app’s **Install…** view. For older installs that replaced `config.toml`, `make uninstall-macos` restores `config.toml.direct` and keeps a copy of the active config before replacing it. + +## Update the app from source + +**Update from source…** opens Terminal and runs `scripts/macos/install.sh` from +the checkout used for the current install. Keep that checkout and a Rust +toolchain available. Update the checkout first to choose the code you want to +build. Routing settings and prices stay saved. The installer signs the app ad +hoc for local use; the app has no Developer ID signature or notarization. + +## Install coding-tool settings + +**Install…** shows where Codex, Claude Code, and Pi are installed, their user +settings, and their configured model. Pick a public route on the local HTTP +server and click **Install / refresh**. Codex CLI gets `sy.config.toml` and uses +`codex -p sy`. The Codex app option changes the defaults in `config.toml` for +both the app and CLI. Claude Code gets `settings.json` environment settings. Pi gets a custom provider in `models.json` and defaults in +`settings.json`. Unrelated settings remain. The original files are backed up +beside each file as `.switchyard-original`. If a file did not exist, +`.switchyard-original-missing` records its absence. **Restore original** +keeps a copy of the current file, then restores the backup or removes the file +that was originally absent. + +The app checks all selected settings files before replacing any of them. Each +replacement is atomic. If a later replacement fails, the app tries to restore +earlier files. A crash can leave Pi’s two files out of sync, and rollback can +fail. Another process running as the same user can also change a file after the +app checks it. The original backups remain available for manual recovery. + +Restart the coding tool after changing its defaults. Project settings, shell +variables, and command-line options can override them. + +Subscription routes forward the caller's saved login to its own provider. +Choose Codex for a ChatGPT route or Claude Code for an Anthropic route. Pi's +custom provider uses routes whose API credentials the server owns. Configure +those credentials on the server before installing its route. The app never +reads or copies OAuth tokens, switches accounts in running sessions, or combines +subscription quotas. Claude settings with an explicit API key or bearer token +must be cleared before installing a subscription route. Shell overrides still +need to be checked with Claude's `/status`. + +## View session usage + +Usage lists the actual upstream model, public route, timestamp, input, cached, +and output tokens for each completed model call. Select a recorded session ID +to filter the view. A recorded turn ID groups related calls; a turn may contain +several model calls. Older records and tools that send no IDs say **Not recorded**. +The viewer reads the last 8 MiB of the log plus one byte to check whether the +first record is complete. It keeps at most 5,000 complete records and excludes +partial records. It labels a limited view and counts unreadable records. +Classifier calls appear as routing overhead. It displays neither prompts nor responses. +Observed tokens do not report provider quota, reset windows, or authoritative +billing. Refresh reads the log again. + +## Start sessions with separate logins and worktrees + +Codex CLI and Claude Code support named accounts. Enter a name and click +**Add account…** to open the tool’s own login command in Terminal. Complete the +login, click **Refresh**, and select the account. Switchyard keeps these account +directories under `~/.switchyard/accounts`; the coding tool owns their login +files. The selected account determines where **Install / refresh** saves settings +and which login a new session uses. Existing sessions keep their login. + +To run several tasks with one coding tool, enter a Git project path under +Install and click **New worktree session**. Each session starts from that +checkout's HEAD on a new branch in `~/.switchyard/worktrees` and opens in Terminal. +Uncommitted files stay in the original checkout. Each launched session has its +own model settings; launching another session does not change user defaults. +Codex and Claude launches send a session ID for usage correlation. Pi stores +its session under the private agent directory beside the worktree; its requests +need an integration that sends session and turn IDs to appear as a correlated +session. Otherwise their usage is still listed under **All sessions**. +Use `git worktree list` and `git worktree remove` to manage the checkouts after +saving or discarding changes. Successful launches keep their Git branches and +the private settings directories beside the worktrees until you remove them. Named +account directories also remain until you remove them. If launch setup fails, +the app tries to remove the worktree, branch, and private settings it created; +cleanup can fail. + +The app does not manage task dependencies, resume history, remote workspaces, +or review comments. diff --git a/crates/switchyard-menubar/src/accounts.rs b/crates/switchyard-menubar/src/accounts.rs new file mode 100644 index 0000000000..a8c7fd4092 --- /dev/null +++ b/crates/switchyard-menubar/src/accounts.rs @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Named login directories use the coding tool's own authentication commands. + +use crate::harness::{self, Harness}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +// PendingAccount removes only an empty directory if login setup fails. +// The coding tool may already have saved login files, which must stay. +struct PendingAccount(Option); + +impl Drop for PendingAccount { + fn drop(&mut self) { + if let Some(path) = &self.0 { + let _ = std::fs::remove_dir(path); + } + } +} + +fn root(tool: Harness) -> Result { + let name = match tool { + Harness::CodexCli => "codex", + Harness::Claude => "claude", + _ => return Err("Named accounts are available for Codex CLI and Claude Code.".into()), + }; + let home = crate::config::expand_home(Path::new("~")); + let mut path = home; + for component in [".switchyard", "accounts", name] { + path.push(component); + if std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink()) { + return Err(format!( + "{} is a symlink. Edit its target yourself.", + path.display() + )); + } + } + Ok(path) +} + +pub fn validate(tool: Harness, path: &Path) -> Result<(), String> { + let root = root(tool)?; + if path.parent() != Some(root.as_path()) + || !std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir() && !m.file_type().is_symlink()) + { + return Err("Choose a supported coding tool.".into()); + } + Ok(()) +} + +pub fn list(tool: Harness) -> Vec<(String, PathBuf)> { + let Ok(root) = root(tool) else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + let mut accounts: Vec<_> = entries + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter_map(|e| { + e.file_name() + .into_string() + .ok() + .map(|name| (name, e.path())) + }) + .collect(); + accounts.sort_by(|a, b| a.0.cmp(&b.0)); + accounts +} + +pub fn config_paths(tool: Harness, account: Option<&Path>) -> Vec { + let Some(account) = account else { + return harness::paths(tool); + }; + match tool { + Harness::CodexCli => vec![account.join("sy.config.toml")], + Harness::Claude => vec![account.join("settings.json")], + _ => harness::paths(tool), + } +} + +pub fn add(tool: Harness, name: &str) -> Result { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err("Account names need 1–64 letters, numbers, hyphens, or underscores.".into()); + } + let root = root(tool)?; + let binary = harness::binary(tool).ok_or("Install the selected coding tool first.")?; + std::fs::create_dir_all(&root).map_err(|e| e.to_string())?; + let path = root.join(name); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new().mode(0o700).create(&path).map_err(|e|format!("Create account: {e}. Pick an existing account after Refresh, or choose a new name."))?; + } + #[cfg(not(unix))] + std::fs::create_dir(&path).map_err(|e| e.to_string())?; + let mut pending = PendingAccount(Some(path.clone())); + let variable = match tool { + Harness::CodexCli => "CODEX_HOME", + Harness::Claude => "CLAUDE_CONFIG_DIR", + _ => return Err("Choose a supported coding tool.".into()), + }; + let login = match tool { + Harness::CodexCli => "login", + _ => "auth login", + }; + let mut script = tempfile::Builder::new() + .prefix("switchyard-login-") + .suffix(".command") + .tempfile() + .map_err(|e| e.to_string())?; + writeln!(script,"#!/bin/bash\nrm -- \"$0\"\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\nexport {variable}={}\n{} {login}",crate::sessions::quote(&path.display().to_string()),crate::sessions::quote(&binary.display().to_string())).map_err(|e|e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + script + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + } + crate::server::command("open", &[&script.path().display().to_string()])?; + pending.0 = None; + let _ = script.keep().map_err(|e| e.to_string())?; + Ok(format!( + "Opened the coding tool's login in Terminal for account {name}. Complete login, click Refresh, and select this account. Switchyard does not read the login tokens. The selected account applies to new sessions only. Account directory: {}", + path.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn unsafe_names_fail_before_any_side_effect() { + for name in ["", "..", "a/b", "$(touch bad)", "a\nb"] { + assert!(add(Harness::CodexCli, name).is_err()); + } + assert!(add(Harness::Pi, "valid").is_err()); + } + #[test] + fn selected_account_changes_only_the_owned_config_destination() { + let account = Path::new("/tmp/named-account"); + assert_eq!( + config_paths(Harness::CodexCli, Some(account)), + vec![account.join("sy.config.toml")] + ); + assert_eq!( + config_paths(Harness::Claude, Some(account)), + vec![account.join("settings.json")] + ); + } + #[test] + fn a_login_setup_failure_removes_only_its_empty_directory() { + let root = tempfile::tempdir().expect("directory"); + let path = root.path().join("account"); + std::fs::create_dir(&path).expect("account"); + drop(PendingAccount(Some(path.clone()))); + assert!(!path.exists()); + std::fs::create_dir(&path).expect("account"); + std::fs::write(path.join("auth.json"), "private").expect("auth"); + drop(PendingAccount(Some(path.clone()))); + assert!(path.join("auth.json").is_file()); + } + // The subprocess isolates HOME so parallel tests keep their own settings. + #[cfg(unix)] + #[test] + fn symlinked_account_roots_are_not_listed_or_used_for_login() { + if std::env::var_os("SWITCHYARD_ACCOUNT_FIXTURE").is_some() { + assert!(list(Harness::CodexCli).is_empty()); + assert!(add(Harness::CodexCli, "new").is_err()); + let path = crate::config::expand_home(Path::new("~/.switchyard/accounts/codex/named")); + assert!(validate(Harness::CodexCli, &path).is_err()); + return; + } + let dir = tempfile::tempdir().expect("fixture"); + let home = dir.path().join("home"); + let target = dir.path().join("target"); + std::fs::create_dir(&home).expect("home"); + std::fs::create_dir_all(target.join("accounts/codex/named")).expect("accounts"); + std::os::unix::fs::symlink(&target, home.join(".switchyard")).expect("symlink"); + let output = std::process::Command::new(std::env::current_exe().expect("runner")) + .args([ + "--exact", + "accounts::tests::symlinked_account_roots_are_not_listed_or_used_for_login", + ]) + .env("SWITCHYARD_ACCOUNT_FIXTURE", "1") + .env("HOME", &home) + .output() + .expect("child"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + assert!(!target.join("accounts/codex/new").exists()); + } +} diff --git a/crates/switchyard-menubar/src/dashboard.rs b/crates/switchyard-menubar/src/dashboard.rs new file mode 100644 index 0000000000..791afe5a86 --- /dev/null +++ b/crates/switchyard-menubar/src/dashboard.rs @@ -0,0 +1,690 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The app window shares the tray event loop and loads routes and usage on workers. + +use crate::accounts; +use crate::config::Config; +use crate::harness::{self, HARNESSES, Harness}; +use crate::history::{self, History}; +use crate::picker::rect; +use crate::server_config::ServerConfig; +use objc2::rc::Retained; +use objc2::runtime::ProtocolObject; +use objc2::{DefinedClass, MainThreadOnly, define_class, msg_send, sel}; +use objc2_app_kit::{ + NSApplication, NSApplicationDelegate, NSBackingStoreType, NSButton, NSControl, NSFont, + NSPopUpButton, NSScrollView, NSTextField, NSTextView, NSView, NSWindow, NSWindowStyleMask, +}; +use objc2_foundation::{MainThreadMarker, NSObject, NSObjectProtocol, NSSize, NSString}; +use std::cell::RefCell; +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender, channel}; + +const USAGE: isize = 1; +const INSTALL: isize = 2; +const REFRESH: isize = 3; +const SAVE: isize = 4; +const RESTORE: isize = 5; +const ROUTE: isize = 6; +const TOOL: isize = 7; +const SESSION: isize = 8; +const LAUNCH: isize = 9; +const UPDATE: isize = 10; +const ACCOUNT: isize = 11; +const ADD_ACCOUNT: isize = 12; +const REOPEN: isize = 13; + +define_class!( + // SAFETY: NSObject has no subclassing requirements. The target only queues tags. + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "SwitchyardAppTarget"] + #[ivars = RefCell>] + struct Target; + impl Target { + // SAFETY: AppKit sends the control that invoked this action. + #[unsafe(method(changed:))] + fn changed(&self, sender: &NSControl) { self.ivars().borrow_mut().push(sender.tag()); } + } + unsafe impl NSObjectProtocol for Target {} + unsafe impl NSApplicationDelegate for Target { + #[unsafe(method(applicationShouldHandleReopen:hasVisibleWindows:))] + fn reopen(&self, _application: &NSApplication, _visible: bool) -> bool { + self.ivars().borrow_mut().push(REOPEN); + true + } + } +); + +enum Finished { + Refresh(Snapshot), + Status(Result), +} + +struct Snapshot { + routes: Result, String>, + history: Result, +} + +fn load(settings: &Config) -> Snapshot { + Snapshot { + routes: std::fs::read_to_string(&settings.config_file) + .map_err(|e| e.to_string()) + .and_then(|text| ServerConfig::parse(&text)) + .map(|config| config.routes()), + history: history::load(&settings.routing_log), + } +} + +pub struct Dashboard { + mtm: MainThreadMarker, + window: Retained, + // Dashboard retains the delegate because NSApplication holds a weak reference. + target: Retained, + text: Retained, + tool: Retained, + route: Retained, + session: Retained, + project: Retained, + account: Retained, + account_name: Retained, + add_account: Retained, + accounts: Vec<(String, PathBuf)>, + save: Retained, + restore: Retained, + launch: Retained, + settings: Config, + history: History, + sessions: Vec, + routes: Vec, + install: bool, + busy: bool, + refresh_pending: bool, + route_error: Option, + usage_error: Option, + status: Option, + sender: Sender, + receiver: Receiver, +} + +impl Dashboard { + pub fn new(mtm: MainThreadMarker, settings: &Config) -> Self { + // SAFETY: The app owns this window and keeps it after a close action. + let window = unsafe { + let w = NSWindow::initWithContentRect_styleMask_backing_defer( + NSWindow::alloc(mtm), + rect(0., 0., 940., 720.), + NSWindowStyleMask::Titled + | NSWindowStyleMask::Closable + | NSWindowStyleMask::Miniaturizable, + NSBackingStoreType::Buffered, + false, + ); + w.setReleasedWhenClosed(false); + w + }; + window.setTitle(&NSString::from_str("Switchyard")); + window.center(); + let allocated = Target::alloc(mtm).set_ivars(RefCell::new(Vec::new())); + // SAFETY: NSObject init has this signature. + let target: Retained = unsafe { msg_send![super(allocated), init] }; + NSApplication::sharedApplication(mtm).setDelegate(Some(ProtocolObject::from_ref(&*target))); + let content = NSView::initWithFrame(NSView::alloc(mtm), rect(0., 0., 940., 720.)); + for (title, tag, x, width) in [ + ("Usage", USAGE, 20., 100.), + ("Install…", INSTALL, 130., 110.), + ("Refresh", REFRESH, 250., 100.), + ("Update from source…", UPDATE, 690., 230.), + ] { + content.addSubview(&button(mtm, &target, title, tag, rect(x, 666., width, 32.))); + } + let tool = popup(mtm, &target, TOOL, rect(20., 619., 240., 30.)); + for (_, name) in HARNESSES { + tool.addItemWithTitle(&NSString::from_str(name)); + } + let route = popup(mtm, &target, ROUTE, rect(280., 619., 310., 30.)); + let session = popup(mtm, &target, SESSION, rect(20., 619., 900., 30.)); + let save = button( + mtm, + &target, + "Install / refresh", + SAVE, + rect(600., 619., 150., 30.), + ); + let restore = button( + mtm, + &target, + "Restore original", + RESTORE, + rect(760., 619., 160., 30.), + ); + let project = + NSTextField::initWithFrame(NSTextField::alloc(mtm), rect(20., 573., 680., 30.)); + project.setPlaceholderString(Some(&NSString::from_str( + "Git project path for a separate worktree session", + ))); + let launch = button( + mtm, + &target, + "New worktree session", + LAUNCH, + rect(710., 573., 210., 30.), + ); + let account = popup(mtm, &target, ACCOUNT, rect(20., 530., 280., 30.)); + let account_name = + NSTextField::initWithFrame(NSTextField::alloc(mtm), rect(320., 530., 370., 30.)); + account_name.setPlaceholderString(Some(&NSString::from_str("Name for a new account"))); + let add_account = button( + mtm, + &target, + "Add account…", + ADD_ACCOUNT, + rect(710., 530., 210., 30.), + ); + for view in [ + &*account as &NSView, + &*account_name, + &*add_account, + &*tool as &NSView, + &*route, + &*session, + &*save, + &*restore, + &*project, + &*launch, + ] { + content.addSubview(view); + } + let scroll = + NSScrollView::initWithFrame(NSScrollView::alloc(mtm), rect(20., 20., 900., 495.)); + scroll.setHasVerticalScroller(true); + scroll.setHasHorizontalScroller(true); + let text = NSTextView::initWithFrame(NSTextView::alloc(mtm), rect(0., 0., 880., 495.)); + text.setEditable(false); + text.setSelectable(true); + text.setFont(Some(&NSFont::monospacedSystemFontOfSize_weight(13., 0.))); + text.setTextContainerInset(NSSize::new(12., 12.)); + scroll.setDocumentView(Some(&text)); + content.addSubview(&scroll); + window.setContentView(Some(&content)); + let (sender, receiver) = channel(); + let mut app = Self { + mtm, + window, + target, + text, + tool, + route, + session, + project, + account, + account_name, + add_account, + accounts: Vec::new(), + save, + restore, + launch, + settings: settings.clone(), + history: History::default(), + sessions: Vec::new(), + routes: Vec::new(), + install: false, + busy: false, + refresh_pending: false, + route_error: None, + usage_error: None, + status: None, + sender, + receiver, + }; + app.mode(false); + app + } + + pub fn is_busy(&self) -> bool { + self.busy + } + pub fn set_settings(&mut self, settings: &Config) { + self.settings = settings.clone(); + self.refresh(); + } + pub fn show(&mut self, install: bool) { + self.mode(install); + #[allow(deprecated)] + NSApplication::sharedApplication(self.mtm).activateIgnoringOtherApps(true); + self.window.makeKeyAndOrderFront(None); + self.refresh(); + } + + fn mode(&mut self, install: bool) { + self.install = install; + for view in [ + &*self.tool as &NSView, + &*self.route, + &*self.save, + &*self.restore, + &*self.project, + &*self.launch, + &*self.account, + &*self.account_name, + &*self.add_account, + ] { + view.setHidden(!install); + } + self.session.setHidden(install); + if install { + self.refresh_accounts(); + self.inspect(); + } else { + self.render_history(); + } + } + + fn tool(&self) -> Harness { + HARNESSES + .get(self.tool.indexOfSelectedItem() as usize) + .map(|(tool, _)| *tool) + .unwrap_or(Harness::CodexCli) + } + + fn selected_account(&self) -> Option<&std::path::Path> { + let index = self.account.indexOfSelectedItem(); + if index > 0 { + self.accounts + .get(index as usize - 1) + .map(|(_, p)| p.as_path()) + } else { + None + } + } + fn refresh_accounts(&mut self) { + let selected = self.selected_account().map(std::path::Path::to_path_buf); + self.accounts = accounts::list(self.tool()); + self.account.removeAllItems(); + self.account + .addItemWithTitle(&NSString::from_str("Default coding-tool login")); + for (name, _) in &self.accounts { + self.account.addItemWithTitle(&NSString::from_str(name)); + } + if let Some(index) = self + .accounts + .iter() + .position(|(_, p)| Some(p) == selected.as_ref()) + { + self.account.selectItemAtIndex(index as isize + 1); + } + let supported = matches!(self.tool(), Harness::CodexCli | Harness::Claude); + self.account.setEnabled(supported && !self.busy); + self.add_account.setEnabled(supported && !self.busy); + self.account_name.setEnabled(supported && !self.busy); + } + fn inspect(&self) { + if let Some(error) = &self.route_error { + self.text.setString(&NSString::from_str(error)); + return; + } + if let Some(status) = &self.status { + self.text.setString(&NSString::from_str(status)); + return; + } + let tool = self.tool(); + let found = harness::binary(tool).map(|p| format!("Installed: {}",p.display())).unwrap_or_else(|| "Executable not found in the usual install directories. Install the coding tool first if needed.".into()); + let info = harness::inspect(tool, &accounts::config_paths(tool, self.selected_account())) + .unwrap_or_else(|e| format!("Could not read settings: {e}")); + self.text.setString(&NSString::from_str(&format!("{found}\n{info}\n\nSelect a public Switchyard route, then click Install / refresh.\nCodex CLI gets a standalone sy profile. Codex app changes the base user config.\nClaude and Pi get user settings. Existing settings are backed up.\nRestart the coding tool to apply defaults. Existing sessions keep their login. Select a named account to change where settings are saved and which login new sessions use. Add account… opens the coding tool’s own login in Terminal.\n\nA subscription route uses the coding tool's own saved login.\nA route with server API credentials works across tools.\nSwitchyard records observed token usage; provider quota and billing remain with the provider.\n\nNew worktree session creates an independent checkout from the project's HEAD\nand opens the selected tool in Terminal. Uncommitted changes stay in the original checkout.\nUse git worktree list and git worktree remove to manage these checkouts.\nProject settings and shell variables can override user defaults."))); + } + + fn refresh(&mut self) { + if self.busy { + self.refresh_pending = true; + return; + } + self.status = None; + let settings = self.settings.clone(); + let tx = self.sender.clone(); + std::thread::spawn(move || { + let _ = tx.send(Finished::Refresh(load(&settings))); + }); + self.set_busy(true); + } + + fn apply_refresh(&mut self, snapshot: Snapshot) { + let previous = self + .routes + .get(self.route.indexOfSelectedItem() as usize) + .map(|r| r.id.clone()); + self.route_error = None; + match snapshot.routes { + Ok(routes) => { + self.routes = routes; + self.route.removeAllItems(); + for route in &self.routes { + self.route.addItemWithTitle(&NSString::from_str(&route.id)); + } + if let Some(index) = self + .routes + .iter() + .position(|r| Some(&r.id) == previous.as_ref()) + { + self.route.selectItemAtIndex(index as isize); + } + } + Err(e) => { + self.routes.clear(); + self.route.removeAllItems(); + self.route_error = Some(format!("Could not read routes: {e}")); + } + } + self.usage_error = None; + match snapshot.history { + Ok(history) => { + let selected = self + .sessions + .get(self.session.indexOfSelectedItem().saturating_sub(1) as usize) + .cloned(); + self.history = history; + self.sessions = self.history.sessions(); + self.session.removeAllItems(); + self.session.addItemWithTitle(&NSString::from_str( + "All sessions (includes calls without a session ID)", + )); + for session in &self.sessions { + self.session.addItemWithTitle(&NSString::from_str(session)); + } + if let Some(index) = self + .sessions + .iter() + .position(|s| Some(s) == selected.as_ref()) + { + self.session.selectItemAtIndex(index as isize + 1); + } + } + Err(error) => self.usage_error = Some(format!("Could not read usage: {error}")), + } + self.mode(self.install); + } + + fn render_history(&self) { + if let Some(error) = &self.usage_error { + self.text.setString(&NSString::from_str(error)); + return; + } + let index = self.session.indexOfSelectedItem(); + let selected = if index > 0 { + self.sessions.get(index as usize - 1).map(String::as_str) + } else { + None + }; + self.text + .setString(&NSString::from_str(&self.history.display(selected))); + } + + fn set_busy(&mut self, busy: bool) { + self.busy = busy; + for button in [&self.save, &self.restore, &self.launch, &self.add_account] { + button.setEnabled(!busy); + } + self.tool.setEnabled(!busy); + self.route.setEnabled(!busy); + self.refresh_accounts(); + } + + pub fn poll(&mut self) { + let tags = std::mem::take(&mut *self.target.ivars().borrow_mut()); + for tag in tags { + if self.busy && !matches!(tag, USAGE | INSTALL | SESSION | REOPEN) { + continue; + } + match tag { + USAGE => self.mode(false), + INSTALL => { + self.mode(true); + } + REFRESH => self.refresh(), + TOOL | ROUTE | ACCOUNT => { + if tag == TOOL { + self.refresh_accounts(); + } + self.status = None; + self.inspect(); + } + SESSION => self.render_history(), + REOPEN => self.show(self.install), + SAVE | RESTORE | LAUNCH | ADD_ACCOUNT => self.action(tag), + UPDATE => { + let result = update(); + self.text + .setString(&NSString::from_str(&result.unwrap_or_else(|e| e))); + } + _ => {} + } + } + while let Ok(result) = self.receiver.try_recv() { + self.set_busy(false); + match result { + Finished::Refresh(snapshot) => self.apply_refresh(snapshot), + Finished::Status(result) => { + self.status = Some(result.unwrap_or_else(|e| e)); + self.mode(self.install); + } + } + if self.refresh_pending { + self.refresh_pending = false; + self.refresh(); + } + } + } + + fn action(&mut self, tag: isize) { + let tool = self.tool(); + let account = self.selected_account().map(std::path::Path::to_path_buf); + let files = accounts::config_paths(tool, account.as_deref()); + let account_name = self.account_name.stringValue().to_string(); + let tx = self.sender.clone(); + let settings = self.settings.clone(); + let route = self + .routes + .get(self.route.indexOfSelectedItem() as usize) + .cloned(); + let project = PathBuf::from(self.project.stringValue().to_string()); + self.set_busy(true); + self.text.setString(&NSString::from_str("Working…")); + std::thread::spawn(move || { + let result = (|| { + if tag == ADD_ACCOUNT { + return accounts::add(tool, account_name.trim()); + } + if let Some(account) = account.as_deref() { + accounts::validate(tool, account)?; + } + if tag == RESTORE { + return harness::restore(&files); + } + let route = route.ok_or_else(|| "Pick a route first.".to_string())?; + let login = login_mode(tool, &settings, &route)?; + if tag == LAUNCH { + crate::sessions::launch( + tool, + &project, + &route.id, + &settings.server_url, + login, + account.as_deref(), + ) + } else { + harness::install(tool, &files, &settings.server_url, &route.id, login) + } + })(); + let _ = tx.send(Finished::Status(result)); + }); + } +} + +fn login_mode( + tool: Harness, + settings: &Config, + route: &crate::server_config::Route, +) -> Result { + let text = std::fs::read_to_string(&settings.config_file).map_err(|e| e.to_string())?; + let config = ServerConfig::parse(&text)?; + let clients = config.clients(); + let choices = config.choices(&route.key); + if choices.is_empty() + || !config + .routes() + .iter() + .any(|current| current.key == route.key && current.id == route.id) + { + return Err( + "This route cannot be installed by the app. Choose a route with models.".into(), + ); + } + let mut login = false; + for choice in choices { + let client = clients + .iter() + .find(|c| c.name == choice.client) + .ok_or("The route has an unknown endpoint.")?; + if client.forward_auth { + let url = url::Url::parse(&client.base_url) + .map_err(|_| "The route has an invalid endpoint URL.")?; + let host = url + .host_str() + .ok_or("The route has an invalid endpoint URL.")?; + let supported = url.scheme() == "https" + && url.port().is_none_or(|port| port == 443) + && match tool { + Harness::CodexCli | Harness::CodexApp => host == "chatgpt.com", + Harness::Claude => host == "api.anthropic.com", + Harness::Pi => false, + }; + if !supported { + return Err(format!( + "This route forwards the caller's login to {host}. Pick a matching coding tool or a route with server API credentials." + )); + } + login = true; + } + } + Ok(login) +} + +fn button( + mtm: MainThreadMarker, + target: &Target, + title: &str, + tag: isize, + frame: objc2_foundation::NSRect, +) -> Retained { + // SAFETY: Target implements changed: and lives as long as the controls. + let b = unsafe { + NSButton::buttonWithTitle_target_action( + &NSString::from_str(title), + Some(target), + Some(sel!(changed:)), + mtm, + ) + }; + b.setTag(tag); + b.setFrame(frame); + b +} +fn popup( + mtm: MainThreadMarker, + target: &Target, + tag: isize, + frame: objc2_foundation::NSRect, +) -> Retained { + let p = NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), frame, false); + p.setTag(tag); + // SAFETY: Target implements changed: and lives as long as the controls. + unsafe { + p.setTarget(Some(target)); + p.setAction(Some(sel!(changed:))); + } + p +} +fn update() -> Result { + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + let resource = executable + .parent() + .and_then(|p| p.parent()) + .ok_or("Could not locate the app bundle.")? + .join("Resources/Update.command"); + if !resource.is_file() { + return Err( + "Install Switchyard.app with scripts/macos/install.sh before updating from the app." + .into(), + ); + } + crate::server::command("open", &[&resource.display().to_string()])?; + Ok("Terminal is rebuilding and reinstalling from the source checkout used for this install. The app restarts after a successful build. Your routing and prices stay saved.".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn usage_load_does_not_depend_on_a_valid_server_config() { + let dir = tempfile::tempdir().expect("directory"); + let log = dir.path().join("log"); + std::fs::write( + &log, + "{\"ts\":\"2026-10-08T12:00:00Z\",\"model\":\"actual\",\"session_id\":\"session\"}\n", + ) + .expect("log"); + let settings = Config { + config_file: dir.path().join("config"), + routing_log: log, + ..Config::default() + }; + let snapshot = load(&settings); + assert!(snapshot.routes.is_err()); + assert_eq!( + snapshot.history.expect("history").sessions(), + vec!["session"] + ); + std::fs::write(&settings.config_file, "not valid TOML").expect("config"); + let snapshot = load(&settings); + assert!(snapshot.routes.is_err()); + assert!( + snapshot + .history + .expect("history") + .display(None) + .contains("actual") + ); + } + #[test] + fn installing_rejects_stale_ids_and_insecure_subscription_endpoints() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("server.toml"); + let settings = Config { + config_file: path.clone(), + ..Config::default() + }; + for endpoint in [ + "https://chatgpt.com/backend-api/codex", + "http://chatgpt.com/backend-api/codex", + ] { + let text = format!( + "[llm_clients.upstream]\nbase_url = '{endpoint}'\nformat = 'openai_responses'\nforward_auth = true\n[targets.model]\nid = 'actual'\nllm_client = 'upstream'\n[routes.public]\nid = 'route'\ntype = 'passthrough'\ntarget = 'model'\n" + ); + std::fs::write(&path, &text).expect("config"); + let route = ServerConfig::parse(&text) + .expect("parse") + .routes() + .remove(0); + assert_eq!( + login_mode(Harness::CodexCli, &settings, &route).is_ok(), + endpoint.starts_with("https:") + ); + let mut stale = route; + stale.id = "old-public-id".into(); + assert!(login_mode(Harness::CodexCli, &settings, &stale).is_err()); + assert_eq!(std::fs::read_to_string(&path).expect("unchanged"), text); + } + } +} diff --git a/crates/switchyard-menubar/src/harness.rs b/crates/switchyard-menubar/src/harness.rs new file mode 100644 index 0000000000..9c8a76a6bf --- /dev/null +++ b/crates/switchyard-menubar/src/harness.rs @@ -0,0 +1,659 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module installs and restores user settings for the supported coding tools. + +use serde_json::{Value, json}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use toml_edit::{DocumentMut, value}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Harness { + CodexCli, + CodexApp, + Claude, + Pi, +} + +pub const HARNESSES: &[(Harness, &str)] = &[ + (Harness::CodexCli, "Codex CLI profile"), + (Harness::CodexApp, "Codex app and CLI defaults"), + (Harness::Claude, "Claude Code"), + (Harness::Pi, "Pi"), +]; + +pub fn paths(tool: Harness) -> Vec { + let home = crate::config::expand_home(Path::new("~")); + let codex = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".codex")); + let claude = std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".claude")); + let pi = std::env::var_os("PI_CODING_AGENT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".pi/agent")); + match tool { + Harness::CodexCli => vec![codex.join("sy.config.toml")], + Harness::CodexApp => vec![codex.join("config.toml")], + Harness::Claude => vec![claude.join("settings.json")], + Harness::Pi => vec![pi.join("models.json"), pi.join("settings.json")], + } +} + +pub fn inspect(tool: Harness, files: &[PathBuf]) -> Result { + let text = read(&files[0])?; + let model; + let endpoint; + match tool { + Harness::CodexCli | Harness::CodexApp => { + let doc = text + .parse::() + .map_err(|e| e.message().to_owned())?; + model = doc + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("Default") + .to_string(); + let provider = doc + .get("model_provider") + .and_then(|v| v.as_str()) + .unwrap_or("openai"); + endpoint = doc + .get("model_providers") + .and_then(|v| v.get(provider)) + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()) + .unwrap_or("Provider default") + .to_string(); + } + Harness::Claude => { + let doc = object(&text)?; + model = doc + .pointer("/env/ANTHROPIC_MODEL") + .and_then(Value::as_str) + .or_else(|| doc.get("model").and_then(Value::as_str)) + .unwrap_or("Default") + .to_string(); + endpoint = doc + .pointer("/env/ANTHROPIC_BASE_URL") + .and_then(Value::as_str) + .unwrap_or("Anthropic default") + .to_string(); + } + Harness::Pi => { + let doc = object(&read(&files[1])?)?; + model = doc + .get("defaultModel") + .and_then(Value::as_str) + .unwrap_or("Default") + .to_string(); + let provider = doc + .get("defaultProvider") + .and_then(Value::as_str) + .unwrap_or("Default"); + let models = object(&text)?; + endpoint = models + .get("providers") + .and_then(|v| v.get(provider)) + .and_then(|v| v.get("baseUrl")) + .and_then(Value::as_str) + .unwrap_or("Provider default") + .to_string(); + } + } + // The display keeps the URL scheme, host, and port to omit credentials and queries. + let endpoint = url::Url::parse(&endpoint) + .ok() + .map(|u| { + format!( + "{}://{}{}", + u.scheme(), + u.host_str().unwrap_or("?"), + u.port().map(|p| format!(":{p}")).unwrap_or_default() + ) + }) + .unwrap_or_else(|| "Provider default".into()); + Ok(format!( + "Model: {model}\nEndpoint: {endpoint}\nSettings: {}", + files[0].display() + )) +} + +fn read_file(path: &Path) -> Result, String> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(format!( + "{} is a symlink. Edit its target yourself.", + path.display() + )); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.to_string()), + _ => {} + } + std::fs::read_to_string(path) + .map(Some) + .map_err(|e| format!("Read {}: {e}", path.display())) +} + +fn read(path: &Path) -> Result { + Ok(read_file(path)?.unwrap_or_default()) +} + +fn object(text: &str) -> Result { + let doc: Value = if text.is_empty() { + json!({}) + } else { + serde_json::from_str(text).map_err(|e| e.to_string())? + }; + if !doc.is_object() { + return Err("Settings must be a JSON object.".into()); + } + Ok(doc) +} + +fn map<'a>(doc: &'a mut Value, key: &str) -> Result<&'a mut Value, String> { + if doc.get(key).is_none() { + doc[key] = json!({}); + } + if !doc[key].is_object() { + return Err(format!("{key} must be a JSON object.")); + } + Ok(&mut doc[key]) +} + +pub fn validate(tool: Harness, root: &str, model: &str, login: bool) -> Result { + let url = url::Url::parse(root).map_err(|e| e.to_string())?; + if url.scheme() != "http" + || !matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + return Err( + "Install requires a local HTTP server URL, such as http://127.0.0.1:4123.".into(), + ); + } + if model.trim().is_empty() { + return Err("Pick a route first.".into()); + } + if tool == Harness::Pi && login { + return Err("Pi's custom provider needs a route with server API credentials. Use Codex or Claude for subscription login forwarding.".into()); + } + // validate returns the parsed URL without a trailing slash so callers can append API paths. + Ok(url.to_string().trim_end_matches('/').to_owned()) +} + +fn codex_settings(text: &str, root: &str, model: &str, login: bool) -> Result { + let mut doc = text + .parse::() + .map_err(|e| e.message().to_owned())?; + if doc + .get("model_providers") + .is_some_and(|item| item.as_table_like().is_none()) + { + return Err("model_providers must be a TOML table.".into()); + } + if doc + .get("model_providers") + .and_then(|v| v.get("sy")) + .is_some_and(|item| item.as_table_like().is_none()) + { + return Err("sy must be a TOML provider table.".into()); + } + doc["model"] = value(model); + doc["model_provider"] = value("sy"); + doc["model_providers"]["sy"]["name"] = value("Switchyard"); + doc["model_providers"]["sy"]["base_url"] = value(format!("{root}/v1")); + doc["model_providers"]["sy"]["wire_api"] = value("responses"); + doc["model_providers"]["sy"]["requires_openai_auth"] = value(login); + // A stale env_key would make Codex require a local API key even when the + // server owns the credentials or forwards the coding tool’s subscription login. + doc["model_providers"]["sy"] + .as_table_like_mut() + .ok_or("sy must be a provider table")? + .remove("env_key"); + Ok(doc.to_string()) +} + +fn claude_settings(text: &str, root: &str, model: &str, login: bool) -> Result { + let mut doc = object(text)?; + let env = map(&mut doc, "env")?; + env["ANTHROPIC_BASE_URL"] = json!(root); + env["ANTHROPIC_MODEL"] = json!(model); + // All model roles use the selected public route, including subagents. + for key in [ + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + ] { + env[key] = json!(model); + } + if login { + for key in ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] { + if env + .get(key) + .and_then(Value::as_str) + .is_some_and(|s| !s.is_empty()) + { + return Err(format!( + "{key} overrides subscription login. Remove it from Claude settings before installing this route." + )); + } + } + } else { + env["ANTHROPIC_AUTH_TOKEN"] = json!("switchyard-local"); + env["ANTHROPIC_API_KEY"] = json!(""); + } + Ok(serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n") +} + +pub fn install( + tool: Harness, + files: &[PathBuf], + root: &str, + model: &str, + login: bool, +) -> Result { + let root = validate(tool, root, model, login)?; + let originals = files + .iter() + .map(|p| read_file(p)) + .collect::, _>>()?; + let mut incoming = Vec::new(); + match tool { + Harness::CodexCli | Harness::CodexApp => incoming.push(codex_settings( + originals[0].as_deref().unwrap_or_default(), + &root, + model, + login, + )?), + Harness::Claude => incoming.push(claude_settings( + originals[0].as_deref().unwrap_or_default(), + &root, + model, + login, + )?), + Harness::Pi => { + let mut doc = object(originals[0].as_deref().unwrap_or_default())?; + let providers = map(&mut doc, "providers")?; + providers["switchyard"] = json!({"baseUrl":format!("{root}/v1"), "api":"openai-completions", "apiKey":"switchyard-local", "headers":{"x-switchyard-origin":"pi"}, "models":[{"id":model,"name":model,"reasoning":true,"input":["text"],"contextWindow":128000,"maxTokens":16384,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}); + incoming.push(serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n"); + let mut settings = object(originals[1].as_deref().unwrap_or_default())?; + settings["defaultProvider"] = json!("switchyard"); + settings["defaultModel"] = json!(model); + incoming + .push(serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())? + "\n"); + } + } + let incoming: Vec<_> = incoming.into_iter().map(Some).collect(); + for (path, before) in files.iter().zip(&originals) { + let backup = backup_path(path); + let absent = absent_path(path); + if read_file(&backup)?.is_some() || read_file(&absent)?.is_some() { + continue; + } + match before { + Some(text) => { + prepare(&backup, text)? + .persist_noclobber(&backup) + .map_err(|e| e.to_string())?; + } + None => { + prepare(&absent, "")? + .persist_noclobber(&absent) + .map_err(|e| e.to_string())?; + } + } + } + replace(files, &originals, &incoming)?; + Ok("Saved the Switchyard settings and kept the original backup. Restart the coding tool. For Codex CLI, use codex -p sy. Pi can reload models with /model. Project settings and shell variables can override these user defaults.".into()) +} + +fn backup_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + "{}.switchyard-original", + path.file_name().unwrap_or_default().to_string_lossy() + )) +} + +// The marker distinguishes an originally missing file from an empty file. +// Restore removes installed settings only when the original file was missing. +fn absent_path(path: &Path) -> PathBuf { + backup_path(path).with_extension("switchyard-original-missing") +} + +pub fn restore(files: &[PathBuf]) -> Result { + let mut restored = Vec::new(); + for path in files { + let original = read_file(&backup_path(path))?; + let absent = read_file(&absent_path(path))?.is_some(); + if original.is_none() && !absent { + return Err("No original backup is available for this installation.".into()); + } + restored.push(original); + } + let current = files + .iter() + .map(|p| read_file(p)) + .collect::, _>>()?; + for (path, before) in files.iter().zip(¤t) { + let Some(before) = before else { + continue; + }; + let mut recovery = tempfile::Builder::new() + .prefix("switchyard-before-restore-") + .tempfile_in(path.parent().ok_or("Settings need a parent directory")?) + .map_err(|e| e.to_string())?; + recovery + .write_all(before.as_bytes()) + .map_err(|e| e.to_string())?; + let (_, kept) = recovery.keep().map_err(|e| e.to_string())?; + eprintln!("Saved the previous settings at {}", kept.display()); + } + replace(files, ¤t, &restored)?; + Ok("Restored original settings. Restart the coding tool.".into()) +} + +fn prepare(path: &Path, text: &str) -> Result { + let parent = path.parent().ok_or("Settings need a parent directory")?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + temp.write_all(text.as_bytes()) + .and_then(|_| temp.as_file().sync_all()) + .map_err(|e| e.to_string())?; + Ok(temp) +} + +// Each file replacement is atomic, but the set of replacements is not. +// A later failure triggers an attempt to restore files already replaced. +fn replace( + files: &[PathBuf], + before: &[Option], + after: &[Option], +) -> Result<(), String> { + let staged = files + .iter() + .zip(after) + .map(|(path, text)| text.as_deref().map(|text| prepare(path, text)).transpose()) + .collect::, _>>()?; + for (path, expected) in files.iter().zip(before) { + if read_file(path)? != *expected { + return Err(format!( + "{} changed while installing. Refresh and try again.", + path.display() + )); + } + } + for (index, (path, temp)) in files.iter().zip(staged).enumerate() { + let result = match temp { + Some(temp) => temp.persist(path).map(|_| ()).map_err(|e| e.to_string()), + None if before[index].is_some() => { + std::fs::remove_file(path).map_err(|e| e.to_string()) + } + None => Ok(()), + }; + if let Err(error) = result { + for (path, original) in files[..index].iter().zip(before) { + match original { + Some(text) => { + prepare(path, text)? + .persist(path) + .map_err(|e| e.to_string())?; + } + None => std::fs::remove_file(path).map_err(|e| e.to_string())?, + } + } + return Err(error); + } + } + Ok(()) +} + +pub fn binary(tool: Harness) -> Option { + if tool == Harness::CodexApp { + return [ + PathBuf::from("/Applications/Codex.app"), + crate::config::expand_home(Path::new("~/Applications/Codex.app")), + ] + .into_iter() + .find(|p| p.exists()); + } + let name = match tool { + Harness::CodexCli => "codex", + Harness::Claude => "claude", + Harness::Pi => "pi", + Harness::CodexApp => return None, + }; + let mut dirs: Vec<_> = std::env::var_os("PATH") + .map(|s| std::env::split_paths(&s).collect()) + .unwrap_or_default(); + dirs.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + crate::config::expand_home(Path::new("~/.local/bin")), + crate::config::expand_home(Path::new("~/.npm-global/bin")), + ]); + dirs.into_iter().map(|p| p.join(name)).find(|p| p.is_file()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn preserves_user_fields_refreshes_and_restores() { + let dir = tempfile::tempdir().expect("directory"); + for (tool, _) in HARNESSES { + let files = if *tool == Harness::Pi { + vec![ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ] + } else { + vec![dir.path().join(format!("{tool:?}.config"))] + }; + let original = if matches!(tool, Harness::CodexCli | Harness::CodexApp) { + "# user comment\nmodel = 'old'\nsandbox_mode = 'workspace-write'\n" + } else { + "{\"custom\":true}" + }; + std::fs::write(&files[0], original).expect("settings"); + install(*tool, &files, "http://127.0.0.1:4123", "route-a", false).expect("install"); + install(*tool, &files, "http://127.0.0.1:4123", "route-b", false).expect("refresh"); + let current = read(&files[0]).expect("read"); + assert!(current.contains("route-b")); + assert!( + current.contains(if matches!(tool, Harness::CodexCli | Harness::CodexApp) { + "sandbox_mode" + } else { + "custom" + }) + ); + assert_eq!(read(&backup_path(&files[0])).expect("backup"), original); + restore(&files).expect("restore"); + assert_eq!(read(&files[0]).expect("restored"), original); + } + } + #[test] + fn invalid_inputs_change_no_files() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("settings.json"); + for invalid in ["[]", "null", "{", "{\"env\":false}"] { + std::fs::write(&path, invalid).expect("settings"); + assert!( + install( + Harness::Claude, + std::slice::from_ref(&path), + "http://localhost:4123", + "route", + false + ) + .is_err() + ); + assert_eq!(read(&path).expect("read"), invalid); + assert!(!backup_path(&path).exists()); + } + std::fs::write(&path, "{}").expect("settings"); + for root in [ + "https://example.com", + "http://localhost.evil", + "http://user:secret@localhost", + "http://127.0.0.1/path", + "http://localhost?q=a", + ] { + assert!( + install( + Harness::Claude, + std::slice::from_ref(&path), + root, + "route", + false + ) + .is_err() + ); + assert_eq!(read(&path).expect("read"), "{}"); + } + assert!(!backup_path(&path).exists()); + } + #[test] + fn pi_preflights_both_files() { + let dir = tempfile::tempdir().expect("directory"); + let files = vec![ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ]; + std::fs::write(&files[0], "{}").expect("models"); + std::fs::write(&files[1], "[]").expect("settings"); + assert!(install(Harness::Pi, &files, "http://localhost:4123", "route", false).is_err()); + assert_eq!(read(&files[0]).expect("read"), "{}"); + assert!(!backup_path(&files[0]).exists()); + } + #[test] + fn malformed_codex_provider_tables_do_not_panic_or_write() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("config.toml"); + for text in [ + "model_providers = 1", + "model_providers = []", + "[model_providers]\nsy = false", + "[[model_providers.sy]]\nname = 'old'", + ] { + std::fs::write(&path, text).expect("settings"); + assert!( + install( + Harness::CodexCli, + std::slice::from_ref(&path), + "http://localhost:4123", + "route", + false + ) + .is_err() + ); + assert_eq!(read(&path).expect("read"), text); + assert!(!backup_path(&path).exists()); + } + std::fs::write( + &path, + "model_providers = { sy = { name = 'old', env_key = 'OLD_KEY' } }\n", + ) + .expect("settings"); + install( + Harness::CodexCli, + std::slice::from_ref(&path), + "http://localhost:4123", + "route", + false, + ) + .expect("inline table"); + let doc = read(&path) + .expect("read") + .parse::() + .expect("parse"); + assert_eq!( + doc["model_providers"]["sy"]["base_url"].as_str(), + Some("http://localhost:4123/v1") + ); + assert!(doc["model_providers"]["sy"].get("env_key").is_none()); + } + + #[test] + fn restoring_an_absent_original_removes_the_installed_settings() { + let dir = tempfile::tempdir().expect("directory"); + let files = vec![ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ]; + install(Harness::Pi, &files, "http://localhost:4123", "route", false).expect("install"); + install(Harness::Pi, &files, "http://localhost:4123", "other", false).expect("refresh"); + restore(&files).expect("restore"); + assert!(files.iter().all(|path| !path.exists())); + assert!(files.iter().all(|path| absent_path(path).is_file())); + } + + #[test] + fn inspection_omits_url_secrets_and_invalid_toml_source() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "model_provider = 'p'\n[model_providers.p]\nbase_url = 'https://user:SECRET@example.com:8443/v1?key=SECRET'\n").expect("settings"); + let info = inspect(Harness::CodexCli, std::slice::from_ref(&path)).expect("inspect"); + assert!(info.contains("https://example.com:8443")); + assert!(!info.contains("SECRET")); + std::fs::write(&path, "api_key = \"SECRET\n").expect("invalid"); + let error = inspect(Harness::CodexCli, std::slice::from_ref(&path)).expect_err("invalid"); + assert!(!error.contains("SECRET")); + } + + #[test] + fn replacement_prepares_every_file_and_rejects_changed_inputs() { + let dir = tempfile::tempdir().expect("directory"); + let first = dir.path().join("first"); + let blocked = dir.path().join("blocked"); + std::fs::write(&first, "original").expect("first"); + std::fs::write(&blocked, "not a directory").expect("blocked"); + let files = vec![first.clone(), blocked.join("second")]; + assert!( + replace( + &files, + &[Some("original".into()), None], + &[Some("new".into()), Some("new".into())] + ) + .is_err() + ); + assert_eq!(read(&first).expect("read"), "original"); + assert!( + replace( + std::slice::from_ref(&first), + &[Some("stale".into())], + &[Some("new".into())] + ) + .is_err() + ); + assert_eq!(read(&first).expect("read"), "original"); + } + + #[cfg(unix)] + #[test] + fn restore_preflights_symlinks_before_changing_any_settings() { + let dir = tempfile::tempdir().expect("directory"); + let files = vec![ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ]; + for path in &files { + std::fs::write(path, "{}").expect("original"); + } + install(Harness::Pi, &files, "http://localhost:4123", "route", false).expect("install"); + let before = read(&files[0]).expect("read"); + std::fs::remove_file(&files[1]).expect("remove"); + let target = dir.path().join("target"); + std::fs::write(&target, "private").expect("target"); + std::os::unix::fs::symlink(&target, &files[1]).expect("symlink"); + assert!(restore(&files).is_err()); + assert_eq!(read(&files[0]).expect("read"), before); + assert_eq!(std::fs::read_to_string(target).expect("target"), "private"); + } +} diff --git a/crates/switchyard-menubar/src/history.rs b/crates/switchyard-menubar/src/history.rs new file mode 100644 index 0000000000..8e0abf7a59 --- /dev/null +++ b/crates/switchyard-menubar/src/history.rs @@ -0,0 +1,192 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module reads recent completed model calls and filters them by recorded IDs. + +use serde::Deserialize; +use std::collections::{BTreeMap, VecDeque}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +const MAX_BYTES: u64 = 8 * 1024 * 1024; +const MAX_RECORDS: usize = 5000; + +#[derive(Debug, Deserialize)] +pub struct Entry { + pub ts: String, + pub model: String, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub turn_id: Option, + #[serde(default)] + pub route_id: String, + #[serde(default)] + pub tier: String, + #[serde(default)] + pub prompt_tokens: u64, + #[serde(default)] + pub cached_tokens: u64, + #[serde(default)] + pub completion_tokens: u64, +} + +#[derive(Default)] +pub struct History { + pub entries: Vec, + pub limited: bool, + pub skipped: usize, +} + +pub fn load(path: &Path) -> Result { + let mut file = match std::fs::File::open(path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(History::default()), + Err(e) => return Err(e.to_string()), + }; + let size = file.metadata().map_err(|e| e.to_string())?.len(); + let offset = size.saturating_sub(MAX_BYTES); + // The preceding byte distinguishes a partial record from a complete first + // record when the byte limit starts immediately after a newline. + let mut previous = *b"\n"; + if offset > 0 { + file.seek(SeekFrom::Start(offset - 1)) + .map_err(|e| e.to_string())?; + file.read_exact(&mut previous).map_err(|e| e.to_string())?; + } + file.seek(SeekFrom::Start(offset)) + .map_err(|e| e.to_string())?; + let mut bytes = Vec::new(); + file.take(MAX_BYTES) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + let mut history = parse(&bytes, offset > 0 && previous[0] != b'\n'); + history.limited |= offset > 0; + Ok(history) +} + +fn parse(bytes: &[u8], truncated: bool) -> History { + let mut result = History { + limited: truncated, + ..History::default() + }; + let mut lines = bytes.split_inclusive(|b| *b == b'\n'); + if truncated { + lines.next(); + } + let mut entries = VecDeque::new(); + for line in lines { + if !line.ends_with(b"\n") { + break; + } + match serde_json::from_slice::(line) { + Ok(entry) + if !entry.model.trim().is_empty() + && chrono::DateTime::parse_from_rfc3339(&entry.ts).is_ok() => + { + if entries.len() == MAX_RECORDS { + entries.pop_front(); + result.limited = true; + } + entries.push_back(entry); + } + _ => result.skipped += 1, + } + } + result.entries = entries.into(); + result +} + +impl History { + pub fn sessions(&self) -> Vec { + self.entries + .iter() + .filter_map(|e| e.session_id.as_deref()) + .filter(|s| !s.trim().is_empty()) + .map(str::to_string) + .collect::>() + .into_iter() + .collect() + } + + pub fn display(&self, session: Option<&str>) -> String { + let mut lines = vec![format!("{} completed requests{} · {} unreadable records skipped", self.entries.len(), if self.limited { " in the recent log window" } else { "" }, self.skipped), "Input includes cached reads. Classifier calls are routing overhead. Each line is one model call; a user turn can contain several calls.".into()]; + let entries: Vec<_> = self + .entries + .iter() + .filter(|e| session.is_none_or(|s| e.session_id.as_deref() == Some(s))) + .collect(); + let mut models: BTreeMap<&str, (u64, u64, u64)> = BTreeMap::new(); + for entry in &entries { + let totals = models.entry(&entry.model).or_default(); + totals.0 = totals.0.saturating_add(entry.prompt_tokens); + totals.1 = totals.1.saturating_add(entry.cached_tokens); + totals.2 = totals.2.saturating_add(entry.completion_tokens); + } + lines.push("\nModels in this view".into()); + for (model, (input, cached, output)) in models { + lines.push(format!( + "{model} Input {input} Cached {cached} Output {output}" + )); + } + lines.push("\nRecent calls (newest first)".into()); + for entry in entries.into_iter().rev() { + lines.push(format!("\n{} {}{}\nSession: {} Turn: {}\nRoute: {} Input: {} Cached: {} Output: {}", entry.ts, entry.model, if entry.tier == "classifier" { " (routing overhead)" } else { "" }, entry.session_id.as_deref().filter(|s| !s.trim().is_empty()).unwrap_or("Not recorded"), entry.turn_id.as_deref().filter(|s| !s.trim().is_empty()).unwrap_or("Not recorded"), entry.route_id, entry.prompt_tokens, entry.cached_tokens, entry.completion_tokens)); + } + if self.entries.is_empty() { + lines.push("No completed calls recorded. Run a coding session through Switchyard, then click Refresh.".into()); + } + lines.join("\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn groups_recorded_ids_and_never_displays_content() { + let bytes = b"{\"ts\":\"2026-10-08T12:00:00Z\",\"model\":\"actual\",\"session_id\":\"s\",\"turn_id\":\"t\",\"prompt_tokens\":12,\"completion_tokens\":3,\"messages\":\"SECRET\"}\n{\"ts\":\"2026-10-08T12:00:01Z\",\"model\":\"judge\",\"tier\":\"classifier\"}\ninvalid\n{\"model\":\"unfinished\"}"; + let history = parse(bytes, false); + assert_eq!(history.sessions(), vec!["s"]); + assert_eq!(history.entries.len(), 2); + assert_eq!(history.skipped, 1); + let text = history.display(Some("s")); + assert!(text.contains("Turn: t")); + assert!(text.contains("Input: 12")); + assert!(!text.contains("judge")); + assert!(!text.contains("SECRET")); + assert!(history.display(None).contains("Not recorded")); + } + #[test] + fn reads_a_bounded_tail_and_keeps_only_recent_records() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("log"); + let line = "{\"ts\":\"2026-10-08T12:00:00Z\",\"model\":\"m\"}\n"; + std::fs::write(&path, line.repeat(MAX_RECORDS + 1)).expect("log"); + let history = load(&path).expect("load"); + assert_eq!(history.entries.len(), MAX_RECORDS); + assert!(history.limited); + let mut contents = vec![b'x'; MAX_BYTES as usize]; + contents.extend_from_slice(b"\n"); + contents.extend_from_slice(line.as_bytes()); + std::fs::write(&path, contents).expect("large log"); + let history = load(&path).expect("load tail"); + assert_eq!(history.entries.len(), 1); + assert!(history.limited); + } + // The cutoff falls after a newline, so the first complete record must stay. + #[test] + fn a_byte_limit_aligned_to_a_record_keeps_that_record() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("log"); + let line = b"{\"ts\":\"2026-10-08T12:00:00Z\",\"model\":\"first\"}\n"; + let mut contents = b"discarded prefix\n".to_vec(); + contents.extend_from_slice(line); + contents.resize(contents.len() + MAX_BYTES as usize - line.len(), b' '); + std::fs::write(&path, contents).expect("log"); + let history = load(&path).expect("load"); + assert_eq!(history.entries.len(), 1); + assert_eq!(history.entries[0].model, "first"); + assert!(history.limited); + } +} diff --git a/crates/switchyard-menubar/src/main.rs b/crates/switchyard-menubar/src/main.rs index 732c6d02ce..9f31fababc 100644 --- a/crates/switchyard-menubar/src/main.rs +++ b/crates/switchyard-menubar/src/main.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Menu bar companion for a locally running Switchyard server. +//! The macOS app shows usage and manages settings for a local Switchyard server. //! //! The server owns routing; this process reads what the server wrote. It //! shows today's and this week's traffic, and what that traffic would have @@ -9,22 +9,31 @@ //! routes…" window edits a route in the server config and restarts the //! server. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod accounts; mod app; mod config; +#[cfg(target_os = "macos")] +mod dashboard; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod harness; mod health; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod history; mod pricing; mod rollup; +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod sessions; mod summary; -// Only the macOS picker window calls these. They build on every target so -// that their tests also run in CI, which does not run on macOS. +// The macOS windows use these modules. They build on every target so their +// tests also run in CI, which does not run on macOS. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] mod models; #[cfg_attr(not(target_os = "macos"), allow(dead_code))] mod server; #[cfg_attr(not(target_os = "macos"), allow(dead_code))] mod server_config; -// The status item, the glyph it draws, and the routes window with its sidebar -// are the only platform-specific code. +// The status item, its glyph, and the app windows use macOS APIs. #[cfg(target_os = "macos")] mod icon; #[cfg(target_os = "macos")] diff --git a/crates/switchyard-menubar/src/sessions.rs b/crates/switchyard-menubar/src/sessions.rs new file mode 100644 index 0000000000..3032f40cbc --- /dev/null +++ b/crates/switchyard-menubar/src/sessions.rs @@ -0,0 +1,392 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module starts CLI sessions with separate worktrees and model settings. + +use crate::harness::{self, Harness}; +use std::io::Write; +use std::path::Path; + +// PendingSession attempts cleanup until open accepts the launch request. +// A successful launch keeps the worktree and settings for the coding session. +struct PendingSession { + project: std::path::PathBuf, + worktree: std::path::PathBuf, + private: std::path::PathBuf, + branch: String, + opened: bool, +} + +impl Drop for PendingSession { + fn drop(&mut self) { + if self.opened { + return; + } + let _ = std::process::Command::new("git") + .arg("-C") + .arg(&self.project) + .args(["worktree", "remove"]) + .arg(&self.worktree) + .output(); + let _ = std::process::Command::new("git") + .arg("-C") + .arg(&self.project) + .args(["branch", "-d", &self.branch]) + .output(); + let _ = std::fs::remove_dir_all(&self.private); + } +} + +pub(crate) fn quote(text: &str) -> String { + format!("'{}'", text.replace('\'', "'\\''")) +} + +pub fn launch( + tool: Harness, + project: &Path, + model: &str, + root: &str, + login: bool, + account: Option<&Path>, +) -> Result { + let root = harness::validate(tool, root, model, login)?; + if let Some(account) = account { + crate::accounts::validate(tool, account)?; + } + if tool == Harness::CodexApp { + return Err("Choose Codex CLI to launch a worktree session. Codex app uses its own workspace controls.".into()); + } + let binary = harness::binary(tool).ok_or("Install the selected coding tool first.")?; + let project = std::fs::canonicalize(project).map_err(|e| format!("Open project: {e}"))?; + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(["rev-parse", "--show-toplevel"]) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err("Choose a Git checkout directory.".into()); + } + let id = format!("sy-{}", uuid::Uuid::now_v7()); + let worktree = crate::config::expand_home(Path::new("~/.switchyard/worktrees")).join(&id); + std::fs::create_dir_all( + worktree + .parent() + .ok_or("Worktree needs a parent directory")?, + ) + .map_err(|e| e.to_string())?; + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(["worktree", "add", "-b"]) + .arg(&id) + .arg(&worktree) + .arg("HEAD") + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(format!( + "Could not create worktree: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let private = worktree + .parent() + .ok_or("Worktree needs a parent directory")? + .join(format!("{id}-settings")); + let mut pending = PendingSession { + project, + worktree: worktree.clone(), + private: private.clone(), + branch: id.clone(), + opened: false, + }; + let files = match tool { + Harness::Pi => vec![private.join("models.json"), private.join("settings.json")], + _ => vec![private.join("settings.json")], + }; + if matches!(tool, Harness::Claude | Harness::Pi) { + std::fs::create_dir_all(&private).map_err(|e| e.to_string())?; + harness::install(tool, &files, &root, model, login)?; + } + let program = quote(&binary.display().to_string()); + let command = match tool { + Harness::CodexCli => format!( + "{program} -c {} -c {} -c {}", + quote(&format!("model={}", toml_edit::Value::from(model))), + quote("model_provider=\"sy\""), + quote(&format!( + "model_providers.sy={{name=\"Switchyard\",base_url={},wire_api=\"responses\",requires_openai_auth={login},http_headers={{\"x-switchyard-session-id\"={},\"x-switchyard-origin\"=\"codex\"}}}}", + toml_edit::Value::from(format!("{}/v1", root.trim_end_matches('/'))), + toml_edit::Value::from(id.as_str()) + )) + ), + Harness::Claude => format!( + "env ANTHROPIC_CUSTOM_HEADERS={} {program} --settings {} --model {}", + quote(&format!( + "x-switchyard-session-id: {id}\nx-switchyard-origin: claude" + )), + quote(&files[0].display().to_string()), + quote(model) + ), + Harness::Pi => format!( + "env PI_CODING_AGENT_DIR={} {program} --provider switchyard --model {}", + quote(&private.display().to_string()), + quote(model) + ), + Harness::CodexApp => return Err("Choose a CLI tool.".into()), + }; + let default_files = harness::paths(tool); + let account = account.or_else(|| default_files[0].parent()); + let account_env = match (tool, account) { + (Harness::CodexCli, Some(path)) => { + format!("export CODEX_HOME={}\n", quote(&path.display().to_string())) + } + (Harness::Claude, Some(path)) => format!( + "export CLAUDE_CONFIG_DIR={}\n", + quote(&path.display().to_string()) + ), + _ => String::new(), + }; + let subscription_env = if tool == Harness::Claude && login { + "unset ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN\n" + } else { + "" + }; + let mut script = tempfile::Builder::new() + .prefix("switchyard-session-") + .suffix(".command") + .tempfile() + .map_err(|e| e.to_string())?; + writeln!(script,"#!/bin/bash\nexport PATH=\"$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"\nrm -- \"$0\"\ncd {} || exit 1\n{account_env}{subscription_env}{command}",quote(&worktree.display().to_string())).map_err(|e|e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + script + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + } + crate::server::command("open", &[&script.path().display().to_string()])?; + pending.opened = true; + let _ = script.keep().map_err(|e| e.to_string())?; + Ok(format!( + "Opened a new session in Terminal.\nWorktree: {}\nBranch: {id}\nModel route: {model}\nRemove the worktree with git worktree remove after saving or discarding its changes.\nPi supplies session/turn IDs only when its integration sends them; unidentified calls remain visible in All sessions.", + worktree.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn quotes_shell_metacharacters_literally() { + assert_eq!(quote("a'b $(touch /tmp/no)"), "'a'\\''b $(touch /tmp/no)'"); + } + #[test] + fn rejected_launch_inputs_create_no_worktree_or_settings() { + let dir = tempfile::tempdir().expect("directory"); + assert!( + launch( + Harness::CodexCli, + dir.path(), + "route", + "https://remote.example", + true, + None + ) + .is_err() + ); + assert!( + launch( + Harness::Pi, + dir.path(), + "route", + "http://localhost:4123", + true, + None + ) + .is_err() + ); + assert!( + std::fs::read_dir(dir.path()) + .expect("contents") + .next() + .is_none() + ); + } + + #[test] + fn failed_session_setup_removes_its_worktree_branch_and_private_settings() { + let dir = tempfile::tempdir().expect("directory"); + let project = dir.path().join("project"); + std::fs::create_dir(&project).expect("project"); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output + }; + git(&["init"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ]); + let worktree = dir.path().join("worktree"); + let private = dir.path().join("settings"); + git(&[ + "worktree", + "add", + "-b", + "session-fixture", + worktree.to_str().expect("path"), + ]); + std::fs::create_dir(&private).expect("private"); + std::fs::write(private.join("settings.json"), "{}").expect("settings"); + drop(PendingSession { + project: project.clone(), + worktree: worktree.clone(), + private: private.clone(), + branch: "session-fixture".into(), + opened: false, + }); + assert!(!worktree.exists()); + assert!(!private.exists()); + assert!( + !String::from_utf8_lossy(&git(&["branch", "--list", "session-fixture"]).stdout) + .contains("session-fixture") + ); + } + // The subprocess isolates HOME and PATH. Its Terminal stub supplies a + // conflicting login to check that the generated script selects the account. + #[cfg(unix)] + #[test] + fn generated_sessions_use_explicit_accounts_without_changing_user_defaults() { + use std::os::unix::fs::PermissionsExt; + if let Ok(fixture) = std::env::var("SWITCHYARD_SESSION_FIXTURE") { + let fixture = std::path::PathBuf::from(fixture); + let tool = if std::env::var("SWITCHYARD_SESSION_TOOL").expect("tool") == "codex" { + Harness::CodexCli + } else { + Harness::Claude + }; + let account = + std::env::var_os("SWITCHYARD_SESSION_ACCOUNT").map(std::path::PathBuf::from); + launch( + tool, + &fixture.join("project"), + "route'$(literal)", + "http://localhost:4123", + tool == Harness::Claude, + account.as_deref(), + ) + .expect("launch"); + return; + } + for (name, named) in [ + ("codex", false), + ("codex", true), + ("claude", false), + ("claude", true), + ] { + let fixture = tempfile::tempdir().expect("fixture"); + let home = fixture.path().join("home"); + let bin = fixture.path().join("bin"); + let project = fixture.path().join("project"); + for path in [&home, &bin, &project] { + std::fs::create_dir(path).expect("directory"); + } + for args in [ + vec!["init"], + vec![ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ], + ] { + assert!( + std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git") + .status + .success() + ); + } + let default = home.join("default-login"); + std::fs::create_dir(&default).expect("default"); + std::fs::write(default.join("settings.json"), "{\"user\":true}").expect("settings"); + let account = home.join(".switchyard/accounts").join(name).join("named"); + if named { + std::fs::create_dir_all(&account).expect("account"); + } + let output = fixture.path().join("arguments"); + let captured = fixture.path().join("session.command"); + let open = bin.join("open"); + std::fs::write(&open, format!("#!/bin/bash\ncp -- \"$1\" {}\nexport CODEX_HOME=wrong-terminal-login CLAUDE_CONFIG_DIR=wrong-terminal-login ANTHROPIC_AUTH_TOKEN=wrong-token\nbash \"$1\"\n",quote(&captured.display().to_string()))).expect("open"); + let tool = bin.join(name); + std::fs::write(&tool, format!("#!/bin/bash\nprintf '%s\\n' \"${{CODEX_HOME:-}}\" \"${{CLAUDE_CONFIG_DIR:-}}\" \"${{ANTHROPIC_AUTH_TOKEN:-}}\" \"$@\" > {}\n", quote(&output.display().to_string()))).expect("tool"); + for path in [&open, &tool] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .expect("executable"); + } + let mut child = std::process::Command::new(std::env::current_exe().expect("runner")); + child.args(["--exact", "sessions::tests::generated_sessions_use_explicit_accounts_without_changing_user_defaults"]) + .env("SWITCHYARD_SESSION_FIXTURE", fixture.path()).env("SWITCHYARD_SESSION_TOOL", name) + .env("HOME", &home).env("CODEX_HOME", &default).env("CLAUDE_CONFIG_DIR", &default) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())); + if named { + child.env("SWITCHYARD_SESSION_ACCOUNT", &account); + } + let result = child.output().expect("child"); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + let arguments = std::fs::read_to_string(output).expect("arguments"); + let lines: Vec<_> = arguments.lines().collect(); + let expected = if named { &account } else { &default }; + assert_eq!( + lines[usize::from(name == "claude")], + expected.to_str().expect("path") + ); + if name == "claude" { + assert_eq!(lines[2], ""); + } + assert!(arguments.contains("route'$(literal)")); + let script = std::fs::read_to_string(captured).expect("script"); + assert!(script.contains("x-switchyard-session-id")); + assert_eq!( + std::fs::read_to_string(default.join("settings.json")).expect("settings"), + "{\"user\":true}" + ); + } + } +} diff --git a/crates/switchyard-menubar/src/tray.rs b/crates/switchyard-menubar/src/tray.rs index 26cc92d8cc..5c6f7859b7 100644 --- a/crates/switchyard-menubar/src/tray.rs +++ b/crates/switchyard-menubar/src/tray.rs @@ -1,10 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The macOS status item: event loop, menu, and the menu's actions. -//! -//! Everything platform-specific lives here and in the routes window, so the -//! rest of the crate builds and is tested on any target. +//! This module runs the macOS status item, its menu, and the app event loop. use objc2::rc::autoreleasepool; use std::path::Path; @@ -18,6 +15,7 @@ use tray_icon::{Icon, TrayIconBuilder}; use crate::app::refresh; use crate::config::Config; +use crate::dashboard::Dashboard; use crate::health::ServerStatus; use crate::icon; use crate::models::CACHE_FILE; @@ -25,13 +23,15 @@ use crate::picker::Picker; use crate::server::{command, restart}; use crate::summary::Row; +const OPEN_APP: &str = "open-app"; +const INSTALL: &str = "install"; const EDIT_ROUTES: &str = "edit-routes"; const RESTART: &str = "restart-server"; const OPEN_CONFIG: &str = "open-config"; const OPEN_SETTINGS: &str = "open-settings"; const QUIT: &str = "quit"; -/// How long the loop blocks waiting for a UI event before checking the clock. +/// EVENT_POLL sets how long the loop waits for a UI event before checking the clock. const EVENT_POLL: f64 = 0.1; /// Runs the status item until the user quits. `settings` is the settings @@ -40,11 +40,10 @@ const EVENT_POLL: f64 = 0.1; pub fn run(mut config: Config, settings: &Path) -> Result<(), String> { let mtm = MainThreadMarker::new().ok_or("the menu bar must run on the main thread")?; let ns_app = NSApplication::sharedApplication(mtm); - // Accessory keeps the process out of the Dock and the app switcher. - ns_app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); - // An accessory app shows no menu bar, but AppKit still finds keyboard - // shortcuts, such as Cmd-C in a text field and Cmd-W in the routes - // window, through the app's main menu. + // The Regular policy shows the app in the Dock and the app switcher. + ns_app.setActivationPolicy(NSApplicationActivationPolicy::Regular); + // AppKit uses the main menu for shortcuts such as Cmd-C in a text field, + // Cmd-W in a window, and Cmd-Q to request quitting. let main_menu = main_menu()?; main_menu.init_for_nsapp(); @@ -62,12 +61,15 @@ pub fn run(mut config: Config, settings: &Path) -> Result<(), String> { ns_app.finishLaunching(); + let mut dashboard = Dashboard::new(mtm, &config); + dashboard.show(false); let mut picker: Option = None; let mut next = Instant::now() + interval(&config); let mut actions: Vec<(&str, thread::JoinHandle>)> = Vec::new(); loop { let quit = autoreleasepool(|_| -> Result { pump_events(&ns_app); + dashboard.poll(); if let Some(picker) = picker.as_mut() { picker.poll(); } @@ -78,18 +80,20 @@ pub fn run(mut config: Config, settings: &Path) -> Result<(), String> { QUIT => { // Quitting between the save and the restart would leave a // saved config that the server has not loaded. - if picker.as_ref().is_some_and(Picker::is_busy) { + if picker.as_ref().is_some_and(Picker::is_busy) || dashboard.is_busy() { alert( mtm, - "Apply is still running", - "Quitting now could leave the new config saved while the server \ - still runs the old one. Wait for the result in the routes window, \ - then quit.", + "Switchyard is still working", + "Wait for the current operation to finish in the Switchyard or \ + routes window, then quit. Quitting during a settings change could \ + interrupt the save or server restart.", ); } else { return Ok(true); } } + OPEN_APP => dashboard.show(false), + INSTALL => dashboard.show(true), EDIT_ROUTES => picker .get_or_insert_with(|| { Picker::new(mtm, &config, settings.with_file_name(CACHE_FILE)) @@ -140,6 +144,7 @@ pub fn run(mut config: Config, settings: &Path) -> Result<(), String> { Ok(loaded) => { eprintln!("switchyard-menubar: reloaded {}", settings.display()); config = loaded; + dashboard.set_settings(&config); if let Some(picker) = picker.as_mut() { picker.set_settings(&config); } @@ -248,6 +253,8 @@ fn menu(rows: &[Row]) -> Result { } append(&PredefinedMenuItem::separator())?; for (id, text) in [ + (OPEN_APP, "Open Switchyard…"), + (INSTALL, "Install…"), (EDIT_ROUTES, "Edit routes…"), (RESTART, "Restart server"), (OPEN_CONFIG, "Open server config…"), @@ -259,9 +266,22 @@ fn menu(rows: &[Row]) -> Result { Ok(menu) } -/// Builds the app's hidden main menu, which gives text fields their Edit -/// shortcuts and windows their Cmd-W shortcut. +/// The main menu provides Edit shortcuts, Cmd-W for windows, and Cmd-Q to quit. fn main_menu() -> Result { + let application = + Submenu::with_items( + "Switchyard", + true, + &[&MenuItem::with_id( + QUIT, + "Quit", + true, + Some("Cmd+Q".parse().map_err( + |e: tray_icon::menu::accelerator::AcceleratorParseError| e.to_string(), + )?), + )], + ) + .map_err(|error| error.to_string())?; let edit = Submenu::with_items( "Edit", true, @@ -278,7 +298,8 @@ fn main_menu() -> Result { .map_err(|error| format!("build the Edit menu: {error}"))?; let window = Submenu::with_items("Window", true, &[&PredefinedMenuItem::close_window(None)]) .map_err(|error| format!("build the Window menu: {error}"))?; - Menu::with_items(&[&edit, &window]).map_err(|error| format!("build the main menu: {error}")) + Menu::with_items(&[&application, &edit, &window]) + .map_err(|error| format!("build the main menu: {error}")) } /// Drains pending AppKit events, blocking briefly when there are none. diff --git a/crates/switchyard-server/src/lib.rs b/crates/switchyard-server/src/lib.rs index 9ee6700712..fd68d13833 100644 --- a/crates/switchyard-server/src/lib.rs +++ b/crates/switchyard-server/src/lib.rs @@ -1693,16 +1693,17 @@ mod tests { use super::*; - /// A successful judge call lands in the per-session routing snapshot under its - /// model id with the classifier tier, while routed calls stay off the observer's - /// log path — they are logged with terminal usage when the served response is - /// observed, so an append here would double count them. + /// The observer logs judge calls with their model IDs, classifier tier, and + /// recorded session and turn IDs. The served response logs routed calls with + /// their final usage, so logging them here would count each routed call twice. #[test] - fn stats_observer_logs_judge_calls_to_the_routing_log() { + fn stats_observer_logs_judge_calls_to_the_routing_log() -> Result<(), Box> + { let dir = tempfile::tempdir().expect("temp dir"); let log = SharedRoutingLog::new(dir.path().join("routing.jsonl")).expect("routing log"); let mut headers = HeaderMap::new(); headers.insert("proxy_x_session_id", "session-1".parse().expect("header")); + headers.insert("x-switchyard-turn-id", "turn-1".parse()?); let metadata = metadata_from_headers(headers); let context = routing_log::RoutingLogContext::from_metadata(&metadata); let observer = stats_observer(StatsAccumulator::default(), Some((log.clone(), context))); @@ -1736,6 +1737,10 @@ mod tests { assert_eq!(snapshot["models"]["judge-model"]["prompt_tokens"], 100); assert_eq!(snapshot["models"]["judge-model"]["completion_tokens"], 7); assert!(snapshot["models"].get("routed-model").is_none()); + let record: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&log.path)?)?; + assert_eq!(record["session_id"], "session-1"); + assert_eq!(record["turn_id"], "turn-1"); + Ok(()) } #[derive(Clone)] diff --git a/crates/switchyard-server/src/routing_log.rs b/crates/switchyard-server/src/routing_log.rs index cd1a21f737..674db1b6c5 100644 --- a/crates/switchyard-server/src/routing_log.rs +++ b/crates/switchyard-server/src/routing_log.rs @@ -58,6 +58,7 @@ impl RoutingLog { task: context.task.map(Cow::Owned), trial_id: context.trial_id.map(Cow::Owned), session_id: context.session_id.map(Cow::Owned), + turn_id: context.turn_id.map(Cow::Owned), model: model.into(), tier: tier.unwrap_or("").into(), prompt_tokens: usage.prompt_tokens, @@ -108,6 +109,7 @@ pub(crate) struct RoutingLogContext { task: Option, trial_id: Option, session_id: Option, + turn_id: Option, } impl RoutingLogContext { @@ -126,6 +128,7 @@ impl RoutingLogContext { trial_id: headers .and_then(|headers| nonempty_header(headers, TRIAL_ID_HEADER)) .map(str::to_string), + turn_id: metadata.turn_id.clone(), session_id: metadata.session_id.clone().or_else(|| { headers .and_then(|headers| nonempty_header(headers, LEGACY_SESSION_ID_HEADER)) @@ -159,6 +162,8 @@ struct RoutingRecord<'a> { trial_id: Option>, #[serde(borrow)] session_id: Option>, + #[serde(borrow)] + turn_id: Option>, model: Cow<'a, str>, tier: Cow<'a, str>, prompt_tokens: u64, diff --git a/crates/switchyard-server/tests/server.rs b/crates/switchyard-server/tests/server.rs index b8f5bffe4e..a000f8f735 100644 --- a/crates/switchyard-server/tests/server.rs +++ b/crates/switchyard-server/tests/server.rs @@ -4040,6 +4040,7 @@ async fn routing_log_prefers_canonical_and_preserves_legacy_fallback() -> TestRe .uri("/v1/chat/completions") .header("content-type", "application/json") .header("x-switchyard-session-id", "canonical-session") + .header("x-switchyard-turn-id", "canonical-turn") .header("proxy_x_session_id", "legacy-session") .header("x-switchyard-origin", r#"custom-agent/"quoted"\path"#) .body(Body::from(serde_json::to_vec(&json!({ @@ -4099,8 +4100,10 @@ async fn routing_log_prefers_canonical_and_preserves_legacy_fallback() -> TestRe let first: Value = serde_json::from_str(records.lines().next().ok_or("routing log was empty")?)?; assert_eq!(first["session_id"], "canonical-session"); + assert_eq!(first["turn_id"], "canonical-turn"); assert_eq!(first["origin"], r#"custom-agent/"quoted"\path"#); let second: Value = serde_json::from_str(records.lines().nth(1).ok_or("missing record")?)?; + assert_eq!(second["turn_id"], Value::Null); assert_eq!(second.get("origin"), Some(&Value::Null)); assert!( first["ts"] @@ -4208,6 +4211,7 @@ async fn routing_log_keeps_the_canonical_session_id_until_a_stream_drains() -> T })), &[ ("x-switchyard-session-id", "streaming-session"), + ("x-switchyard-turn-id", "streaming-turn"), ("x-switchyard-origin", "codex-cli"), ], ) @@ -4232,6 +4236,7 @@ async fn routing_log_keeps_the_canonical_session_id_until_a_stream_drains() -> T let record: Value = serde_json::from_str(&std::fs::read_to_string(log_path)?)?; assert_eq!(record["route_id"], ROUTE_MODEL); + assert_eq!(record["turn_id"], "streaming-turn"); assert_eq!(record["algorithm"], "random"); assert_eq!(record["origin"], "codex-cli"); Ok(()) diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh index 9978601f41..bf67ceff63 100644 --- a/scripts/macos/common.sh +++ b/scripts/macos/common.sh @@ -10,6 +10,7 @@ source "$(dirname "${BASH_SOURCE[0]}")/../common.sh" SERVER_LABEL="com.nvidia.switchyard.server" MENUBAR_LABEL="com.nvidia.switchyard.menubar" LAUNCH_AGENTS="$HOME/Library/LaunchAgents" +APP_PATH="$HOME/Applications/Switchyard.app" CODEX_CONFIG="$CODEX_DIR/config.toml" # Codex reads `--profile sy` from its own file next to config.toml. A # [profiles.sy] table in config.toml is rejected outright as legacy config. diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index 43c97c9c07..bfd2490aed 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -2,12 +2,11 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Installs the Switchyard background server and its menu bar companion as -# per-user LaunchAgents and sets up a `sy` Codex profile. +# This script installs Switchyard.app, loads the server and app as per-user +# LaunchAgents, and sets up a `sy` Codex profile. # -# Keeps existing server and menu bar settings and backs up sy.config.toml -# before replacing it. -# Run with --dry-run to print what would happen. +# It keeps existing server and menu bar settings and backs up sy.config.toml +# before replacing it. The --dry-run option prints the installation commands. set -euo pipefail @@ -41,8 +40,11 @@ if [[ "$(uname -s)" != "Darwin" ]]; then fi step "Building release binaries" -run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" \ - -p switchyard-server -p switchyard-menubar +( + cd "$REPO_ROOT" + run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" \ + --target-dir "$REPO_ROOT/target" -p switchyard-server -p switchyard-menubar +) step "Installing binaries into $SY_HOME/bin" run mkdir -p "$SY_HOME/bin" "$SY_HOME/logs" @@ -50,6 +52,41 @@ for binary in switchyard-server switchyard-menubar; do run install -m 755 "$REPO_ROOT/target/release/$binary" "$SY_HOME/bin/$binary" done +step "Installing $APP_PATH" +run mkdir -p "$APP_PATH/Contents/MacOS" "$APP_PATH/Contents/Resources" +run install -m 755 "$REPO_ROOT/target/release/switchyard-menubar" "$APP_PATH/Contents/MacOS/switchyard-menubar" +write_always "$APP_PATH/Contents/Info.plist" < + + +CFBundleIdentifiercom.nvidia.switchyard +CFBundleNameSwitchyard +CFBundleDisplayNameSwitchyard +CFBundleExecutableSwitchyard +CFBundlePackageTypeAPPL +CFBundleShortVersionString$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$REPO_ROOT/Cargo.toml" | head -1) +LSMinimumSystemVersion12.0 +NSHighResolutionCapable + +EOF +# Bash's printf %q keeps source and settings paths literal in generated scripts. +shell_quote() { printf '%q' "$1"; } +write_always "$APP_PATH/Contents/MacOS/Switchyard" < @@ -154,7 +192,7 @@ write_always "$LAUNCH_AGENTS/$MENUBAR_LABEL.plist" <$MENUBAR_LABEL ProgramArguments - $XML_SY_HOME/bin/switchyard-menubar + $XML_APP_PATH/Contents/MacOS/Switchyard $XML_SY_HOME/menubar.toml RunAtLoad @@ -202,4 +240,5 @@ step "Done" say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" say "Use it with: codex -p sy (requires Codex CLI 0.134.0 or newer)" say "Settings: $SY_HOME/menubar.toml" -say "Look for the Switchyard glyph in the menu bar." +say "App: $APP_PATH (open it from Finder or Spotlight)" +say "Use Update from source… in the app to rebuild from this checkout." diff --git a/scripts/macos/uninstall.sh b/scripts/macos/uninstall.sh index ad4314229f..ae0dc6531f 100755 --- a/scripts/macos/uninstall.sh +++ b/scripts/macos/uninstall.sh @@ -33,6 +33,8 @@ for label in "$SERVER_LABEL" "$MENUBAR_LABEL"; do fi done +run rm -rf "$APP_PATH" + step "Removing the sy Codex profile" remove_file "$CODEX_PROFILE_CONFIG" remove_file "$CODEX_SWITCHYARD_CONFIG" diff --git a/tests/test_macos_install.py b/tests/test_macos_install.py index 6a2ce3a792..eaba334e98 100644 --- a/tests/test_macos_install.py +++ b/tests/test_macos_install.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import os +import shlex import shutil import subprocess @@ -22,6 +23,7 @@ def setup(tmp_path): scripts = tmp_path / "repo" / "scripts" / "macos" shutil.copytree(REPO / "scripts" / "macos", scripts) shutil.copy(REPO / "scripts" / "common.sh", scripts.parent / "common.sh") + shutil.copy(REPO / "Cargo.toml", scripts.parents[1] / "Cargo.toml") shutil.copytree(REPO / "scripts" / "config", scripts.parent / "config") home = tmp_path / "home" home.mkdir() @@ -38,6 +40,7 @@ def setup(tmp_path): } stubs = { "cargo": "exit 0\n", + "codesign": "exit 0\n", "uname": "echo Darwin\n", "install": 'printf "#!/bin/sh\\nexit 0\\n" > "$4"\nchmod +x "$4"\n', "launchctl": '[[ "$1" != print ]]\n', @@ -88,7 +91,7 @@ def test_install_escapes_switchyard_path_in_launch_agent(setup): } menubar_arguments = [element.text for element in menubar_values["ProgramArguments"]] assert menubar_arguments == [ - str(switchyard_home / "bin" / "switchyard-menubar"), + str(home / "Applications" / "Switchyard.app" / "Contents" / "MacOS" / "Switchyard"), str(switchyard_home / "menubar.toml"), ] @@ -274,3 +277,66 @@ def test_scripts_reject_unknown_arguments_before_any_work(setup, script, args): path.relative_to(home): path.read_bytes() for path in home.rglob("*") if path.is_file() } assert after == before + + +def test_app_bundle_has_a_launcher_and_source_update_command(setup): + _, home, switchyard_home, _ = setup + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + contents = home / "Applications" / "Switchyard.app" / "Contents" + root = ET.parse(contents / "Info.plist").getroot() + entries = list(root.find("dict")) + values = {entries[i].text: entries[i+1] for i in range(0, len(entries), 2)} + assert values["CFBundleIdentifier"].text == "com.nvidia.switchyard" + assert values["CFBundleExecutable"].text == "Switchyard" + assert os.access(contents / "MacOS" / "Switchyard", os.X_OK) + update = contents / "Resources" / "Update.command" + assert os.access(update, os.X_OK) + home_line = next(line for line in update.read_text().splitlines() if line.startswith("export SY_HOME=")) + assert shlex.split(home_line.split("=", 1)[1])[0] == str(switchyard_home) + for path in [update, contents / "MacOS" / "Switchyard"]: + assert subprocess.run(["bash", "-n", str(path)]).returncode == 0 + + +def test_app_launcher_preserves_quotes_and_shell_metacharacters(setup): + """The launcher must pass shell syntax in the settings path as literal text.""" + _, home, _, env = setup + switchyard_home = home / "Switchyard ' $(touch unsafe)" + env["SY_HOME"] = str(switchyard_home) + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + executable = home / "Applications" / "Switchyard.app" / "Contents" / "MacOS" / "switchyard-menubar" + output = home / "arguments" + executable.write_text('#!/bin/bash\nprintf "%s\\n" "$@" > ' + shlex.quote(str(output)) + '\n') + executable.chmod(0o755) + launcher = executable.with_name("Switchyard") + result = subprocess.run(["bash", str(launcher)], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert output.read_text().strip() == str(switchyard_home / "menubar.toml") + assert not (home / "unsafe").exists() + + +def test_uninstall_removes_the_installed_app_bundle(setup): + _, home, _, _ = setup + assert run(setup, "install.sh").returncode == 0 + app = home / "Applications" / "Switchyard.app" + assert app.is_dir() + result = run(setup, "uninstall.sh") + assert result.returncode == 0, result.stderr + assert not app.exists() + + +def test_installer_builds_in_source_checkout_with_an_explicit_target_directory(setup): + """The build must use the checkout target directory even if CARGO_TARGET_DIR differs.""" + scripts, home, _, env = setup + recorded = home / "build.txt" + cargo = Path(env["PATH"].split(":")[0]) / "cargo" + cargo.write_text('#!/bin/bash\nprintf "%s\\n" "$PWD" "$@" > ' + shlex.quote(str(recorded)) + '\n') + cargo.chmod(0o755) + env["CARGO_TARGET_DIR"] = str(home / "unrelated-target") + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + lines = recorded.read_text().splitlines() + assert lines[0] == str(scripts.parents[1]) + target = lines.index("--target-dir") + assert lines[target + 1] == str(scripts.parents[1] / "target")