From afd20741b52167396171ad6b0a7c0a63b9bd521c Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:59 -0700 Subject: [PATCH] feat(desktop): add the desktop app pages and route workspace Signed-off-by: Elyas Mehtabuddin --- Cargo.lock | 27 + INSTALLATION.md | 13 +- crates/switchyard-desktop/Cargo.toml | 10 +- crates/switchyard-desktop/SETUP.md | 38 + .../capabilities/main-chrome.json | 9 + .../src/bin/switchyard-desktop-install.rs | 12 +- crates/switchyard-desktop/src/config.rs | 233 +++++- crates/switchyard-desktop/src/controller.rs | 177 ++++- crates/switchyard-desktop/src/gui.rs | 625 ++++++++++++++--- crates/switchyard-desktop/src/install.rs | 163 +++-- .../switchyard-desktop/src/install/windows.rs | 12 +- crates/switchyard-desktop/src/main.rs | 52 +- crates/switchyard-desktop/src/server.rs | 23 +- crates/switchyard-desktop/src/tui.rs | 165 ++++- crates/switchyard-desktop/tauri.conf.json | 14 +- crates/switchyard-desktop/tests/desktop.rs | 93 ++- .../switchyard-desktop/tests/setup.test.cjs | 63 ++ crates/switchyard-desktop/ui/app.js | 661 ++++++++++++++++++ crates/switchyard-desktop/ui/basic.css | 11 - crates/switchyard-desktop/ui/basic.js | 53 -- crates/switchyard-desktop/ui/index.html | 59 +- 21 files changed, 2189 insertions(+), 324 deletions(-) create mode 100644 crates/switchyard-desktop/SETUP.md create mode 100644 crates/switchyard-desktop/capabilities/main-chrome.json create mode 100644 crates/switchyard-desktop/tests/setup.test.cjs create mode 100644 crates/switchyard-desktop/ui/app.js delete mode 100644 crates/switchyard-desktop/ui/basic.css delete mode 100644 crates/switchyard-desktop/ui/basic.js diff --git a/Cargo.lock b/Cargo.lock index d5e6aed600..a26bf1d4ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4673,6 +4673,29 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "percent-encoding", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -5398,13 +5421,17 @@ version = "0.3.0" dependencies = [ "chrono", "dbus-secret-service", + "dispatch2", "image", "libc", "mslnk", + "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-quartz-core", "plist", "ratatui", + "rfd", "security-framework", "self-replace", "serde", diff --git a/INSTALLATION.md b/INSTALLATION.md index fed288a958..613493c19d 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -39,10 +39,15 @@ configuration reference. ## Desktop installer From a source checkout, run `cargo desktop --help` for the commands and -`cargo desktop install --dry-run` to preview installation. This checkout has -the installer helper but does not yet build the desktop app executable, so -full installation cannot complete. Uninstall keeps settings, history, and -recovery copies. +`cargo desktop install --dry-run` to preview installation. Run +`cargo desktop install` to build and install the app and server. + +The app opens setup on its first launch before creating app and server config files. +Existing app settings or a default server config skip setup, including after +reinstall. Settings lets you change the selected files or remove app preferences. +On Linux, open the installed app with `~/.switchyard/bin/switchyard-desktop --tui`. +See [Desktop setup](crates/switchyard-desktop/SETUP.md) for these steps. +Uninstall keeps settings, history, and recovery copies. The installer requires the repository's Rust toolchain. macOS uses LaunchAgents; Linux requires a systemd user session. Windows requires MSVC build tools, diff --git a/crates/switchyard-desktop/Cargo.toml b/crates/switchyard-desktop/Cargo.toml index 6368d8c3e1..63df21c467 100644 --- a/crates/switchyard-desktop/Cargo.toml +++ b/crates/switchyard-desktop/Cargo.toml @@ -32,8 +32,11 @@ shlex = "2" strum = { version = "0.28", features = ["derive"] } [target.'cfg(target_os = "macos")'.dependencies] -objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSWorkspace"] } -objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSString", "NSURL"] } +objc2-quartz-core = { version = "0.3.2", default-features = false, features = ["CALayer"] } +dispatch2 = "0.3" +objc2 = "0.6" +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSWorkspace", "NSAccessibility", "NSApplication", "NSPanel", "NSColor", "NSWindow", "NSView", "NSScreen", "NSEvent", "NSStatusItem", "NSStatusBarButton", "NSButton", "NSControl", "NSResponder", "objc2-quartz-core"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSString", "NSURL", "NSProcessInfo"] } security-framework = "3" [target.'cfg(unix)'.dependencies] @@ -50,7 +53,8 @@ windows = { version = "0.62.2", features = ["Management_Deployment", "Applicatio windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_Security_Credentials", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Console", "Win32_Storage_FileSystem"] } [target.'cfg(any(target_os = "macos", windows))'.dependencies] -tauri = { version = "2", features = ["custom-protocol"] } +rfd = { version = "0.17", default-features = false } +tauri = { version = "2", features = ["tray-icon", "custom-protocol", "macos-private-api"] } [target.'cfg(any(target_os = "macos", windows))'.build-dependencies] tauri-build = "2" diff --git a/crates/switchyard-desktop/SETUP.md b/crates/switchyard-desktop/SETUP.md new file mode 100644 index 0000000000..f8c577adc6 --- /dev/null +++ b/crates/switchyard-desktop/SETUP.md @@ -0,0 +1,38 @@ +# Desktop setup + +Run `cargo desktop install` from the source checkout to build and install the +desktop app and server. On Linux, open the installed app with +`~/.switchyard/bin/switchyard-desktop --tui`. If you set `SY_HOME`, use its `bin` +directory instead. + +Setup opens when neither the app settings file nor its default server config +exists. Existing files skip setup, including after reinstall. Invalid existing +files show an error so you can repair them in Settings. Routing logs and price +files alone do not skip setup. + +Choose an existing server config or create one from bundled defaults. Select +the server address, routing log, optional price file, baseline model, and refresh +interval, then save. Use absolute file paths and an `http://` server address. +Creating a server config never replaces an existing file. +The server waits for saved app settings before starting. Save any required API +keys in **Settings → Server keys**. Use **Check config** in Settings, then restart +the server. The managed server listens on `127.0.0.1`; changing the address does +not expose it to the network. + +**Settings → Configuration** uses the same form. Saving preserves comments in +the app settings file. Restart the server after changing its selected files or +port. The installed server reads those selections when it starts. + +**Run setup again** reopens the form. **Remove app preferences** shows the exact +settings file before removal and checks that its contents have not changed +since review. Removal keeps a recovery copy and preserves server configs, saved +keys, history, accounts, and coding-tool settings. Use **Connect tools** to +restore a coding tool's settings. + +In the terminal app, open Settings with `7`. Press `f` to edit configuration or +`d` to review removal of app preferences. + +A fresh installation leaves the Codex profile unchanged. After setup saves a +server config, reinstalling creates or updates the Codex profile, which defaults +to `~/.codex/sy.config.toml`. The installer keeps a recovery copy of a changed +profile. diff --git a/crates/switchyard-desktop/capabilities/main-chrome.json b/crates/switchyard-desktop/capabilities/main-chrome.json new file mode 100644 index 0000000000..fa51ccd02d --- /dev/null +++ b/crates/switchyard-desktop/capabilities/main-chrome.json @@ -0,0 +1,9 @@ +{ + "identifier": "main-chrome", + "description": "The desktop titlebar supports dragging and double-click maximization.", + "windows": ["main"], + "permissions": [ + "core:window:allow-start-dragging", + "core:window:allow-internal-toggle-maximize" + ] +} diff --git a/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs b/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs index 42ed3b7a56..4c7ed5ad5d 100644 --- a/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs +++ b/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs @@ -149,8 +149,16 @@ fn key_remove(name: &str) -> Result<(), String> { fn serve(file: &Path) -> Result<(), String> { use std::os::unix::process::CommandExt; let s = Settings::load(file)?; - let config = s.sy_home.join("composite.toml"); - let mut server = s.server_command(); + // The installed helper waits until setup atomically saves the app settings. + while !s + .desktop_settings() + .try_exists() + .map_err(|error| error.to_string())? + { + std::thread::sleep(std::time::Duration::from_millis(250)); + } + let config = switchyard_desktop::config::Config::load(&s.desktop_settings())?.config_file; + let mut server = s.server_command()?; #[cfg(target_os = "linux")] credentials::wait_for_keyring()?; credentials::configure(&mut server, &config)?; diff --git a/crates/switchyard-desktop/src/config.rs b/crates/switchyard-desktop/src/config.rs index 5b558b1edc..44afc94c3c 100644 --- a/crates/switchyard-desktop/src/config.rs +++ b/crates/switchyard-desktop/src/config.rs @@ -84,13 +84,17 @@ impl Config { pub fn load(path: &Path) -> Result { let defaults = Self::defaults_for(path)?; let mut values = toml::Table::try_from(defaults).map_err(|e| e.to_string())?; + // A dangling settings link needs repair instead of silently using defaults. match std::fs::read_to_string(path) { Ok(text) => { let overrides: toml::Table = toml::from_str(&text) .map_err(|error| format!("parse {}: {error}", path.display()))?; values.extend(overrides); } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && std::fs::symlink_metadata(path) + .is_err_and(|lookup| lookup.kind() == std::io::ErrorKind::NotFound) => {} Err(error) => return Err(format!("read {}: {error}", path.display())), } let moved = if values.contains_key("prices") { @@ -127,14 +131,126 @@ impl Config { } } -pub fn save_preferences(path: &Path, baseline: &str, refresh: u64) -> Result<(), String> { - use std::io::Write; +/// Setup is required only when neither the app settings nor its default server config exists. +/// Existing files, including broken symlinks, skip setup and remain available for repair in Settings. +pub fn needs_setup(path: &Path) -> Result { + fn present(path: &Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("Read {}: {error}", path.display())), + } + } + Ok(!present(path)? && !present(&Config::defaults_for(path)?.config_file)?) +} + +/// This function saves the complete form while retaining comments and unrelated settings. +pub fn save_configuration(path: &Path, config: &Config, create_server: bool) -> Result<(), String> { + crate::health::validate_url(&config.server_url)?; + for file in [&config.config_file, &config.routing_log] + .into_iter() + .chain(config.prices_file.iter()) + { + if !file.is_absolute() + || file + .as_os_str() + .to_string_lossy() + .chars() + .any(char::is_control) + { + return Err("Choose absolute file paths without control characters.".into()); + } + } + if config.launchd_label.is_empty() || config.launchd_label.chars().any(char::is_control) { + return Err("Choose a server service label without control characters.".into()); + } + let paths = [ + Some(path), + Some(config.config_file.as_path()), + Some(config.routing_log.as_path()), + config.prices_file.as_deref(), + ]; + let resolved = paths + .into_iter() + .flatten() + .map(|path| resolved_destination(path, 0)) + .collect::, _>>()?; + let distinct: std::collections::HashSet<_> = resolved.iter().collect(); + if distinct.len() != resolved.len() { + return Err("App settings, server config, routing log, and price override must use different files.".into()); + } + validate_preferences(&config.baseline_model, config.refresh_seconds)?; + let mut values = toml::Table::try_from(config).map_err(|error| error.to_string())?; + // An absent price file removes a previous override and restores bundled prices. + if config.prices_file.is_none() { + values.insert("pricing_file".into(), toml::Value::String(String::new())); + } + if let Some(prices) = &config.prices_file { + switchyard_costs::Pricing::load(prices).map_err(|error| error.to_string())?; + } + if !create_server { + let text = std::fs::read_to_string(&config.config_file) + .map_err(|error| format!("Read server config: {error}"))?; + switchyard_runner::api_key_variables(&text).map_err(|error| error.to_string())?; + } + edit_settings( + path, + values, + create_server.then_some(config.config_file.as_path()), + ) +} + +// Canonical paths also catch existing symlinks and aliases to a file that setup will create. +fn resolved_destination(path: &Path, depth: usize) -> Result { + if depth > 40 { + return Err("A selected file has too many symbolic links or parent folders.".into()); + } + match std::fs::canonicalize(path) { + Ok(path) => return Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + if let Ok(target) = std::fs::read_link(path) { + return resolved_destination( + &path.parent().ok_or("Missing file folder.")?.join(target), + depth + 1, + ); + } + let name = path + .file_name() + .ok_or("Choose a file path without unresolved parent components.")?; + Ok(resolved_destination(path.parent().ok_or("Missing file folder.")?, depth + 1)?.join(name)) +} + +fn validate_preferences(baseline: &str, refresh: u64) -> Result<(), String> { if baseline.trim().is_empty() || baseline.contains(['\n', '\r']) { return Err("Choose a nonempty baseline model ID without line breaks.".into()); } if refresh == 0 { return Err("Refresh interval must be at least one second.".into()); } + Ok(()) +} + +pub fn save_preferences(path: &Path, baseline: &str, refresh: u64) -> Result<(), String> { + validate_preferences(baseline, refresh)?; + let mut edits = toml::Table::new(); + edits.insert("baseline_model".into(), baseline.trim().into()); + edits.insert( + "refresh_seconds".into(), + i64::try_from(refresh) + .map_err(|error| error.to_string())? + .into(), + ); + edit_settings(path, edits, None) +} + +fn edit_settings( + path: &Path, + edits: toml::Table, + create_server: Option<&Path>, +) -> Result<(), String> { + use std::io::Write; let original = match std::fs::read_to_string(path) { Ok(text) => Some(text), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, @@ -145,9 +261,17 @@ pub fn save_preferences(path: &Path, baseline: &str, refresh: u64) -> Result<(), .unwrap_or("") .parse::() .map_err(|error| error.to_string())?; - doc["baseline_model"] = toml_edit::value(baseline.trim()); - doc["refresh_seconds"] = - toml_edit::value(i64::try_from(refresh).map_err(|error| error.to_string())?); + for (key, value) in edits { + if key == "pricing_file" && value.as_str() == Some("") { + doc.remove(&key); + } else { + doc[&key] = match value { + toml::Value::String(value) => toml_edit::value(value), + toml::Value::Integer(value) => toml_edit::value(value), + _ => return Err(format!("Unsupported app setting: {key}")), + }; + } + } let text = doc.to_string(); // Validation fills missing fields with installation defaults, just as Config::load does. // The saved text retains comments and changes only the requested preferences. @@ -182,8 +306,36 @@ pub fn save_preferences(path: &Path, baseline: &str, refresh: u64) -> Result<(), if current != original { return Err("App settings changed while saving. Reload them and try again.".into()); } - temp.persist(destination) - .map_err(|error| error.to_string())?; + let created = if let Some(server) = create_server { + let parent = server + .parent() + .ok_or("Server config needs a parent folder.")?; + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let mut staged = + tempfile::NamedTempFile::new_in(parent).map_err(|error| error.to_string())?; + staged + .write_all(crate::install::BUNDLED_CONFIG.as_bytes()) + .and_then(|()| staged.as_file().sync_all()) + .map_err(|error| error.to_string())?; + staged.persist_noclobber(server).map_err(|error| { + format!( + "Create {}: {error}. Choose an existing config without creating a new file.", + server.display() + ) + })?; + Some(server) + } else { + None + }; + if let Err(error) = temp.persist(destination) { + if let Some(server) = created + && std::fs::read(server).ok().as_deref() + == Some(crate::install::BUNDLED_CONFIG.as_bytes()) + { + let _ = std::fs::remove_file(server); + } + return Err(error.to_string()); + } Ok(()) } @@ -203,9 +355,68 @@ pub fn expand_home(path: &Path) -> Result { } } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests { - use super::*; + use super::{Config, needs_setup, save_configuration, save_preferences}; + + #[test] + fn existing_primary_files_skip_setup_even_when_they_need_repair() { + let dir = tempfile::tempdir().expect("fixture"); + let path = dir.path().join("desktop.toml"); + assert!(needs_setup(&path).expect("fresh")); + std::fs::write(dir.path().join("routing.jsonl"), "").expect("log"); + std::fs::write(dir.path().join("prices.json"), "{}").expect("prices"); + assert!(needs_setup(&path).expect("optional files")); + std::fs::write(dir.path().join("composite.toml"), "invalid").expect("server config"); + assert!(!needs_setup(&path).expect("server file")); + std::fs::remove_file(dir.path().join("composite.toml")).expect("remove server"); + std::fs::write(&path, "invalid [").expect("broken settings"); + assert!(!needs_setup(&path).expect("existing settings")); + } + + #[test] + fn setup_saves_selected_files_and_rejected_edits_have_no_file_side_effects() { + let dir = tempfile::tempdir().expect("fixture"); + let path = dir.path().join("desktop.toml"); + let mut config = Config::defaults_for(&path).expect("defaults"); + config.config_file = dir.path().join("chosen.toml"); + config.routing_log = dir.path().join("chosen.jsonl"); + save_configuration(&path, &config, true).expect("setup"); + assert!(!needs_setup(&path).expect("completed")); + assert_eq!( + Config::load(&path).expect("load").config_file, + config.config_file + ); + assert_eq!( + std::fs::read_to_string(&config.config_file).expect("template"), + crate::install::BUNDLED_CONFIG + ); + let original = std::fs::read(&path).expect("settings"); + assert!( + save_configuration(&path, &config, true).is_err(), + "creating must not replace a file" + ); + assert_eq!(std::fs::read(&path).expect("unchanged"), original); + for (url, file) in [ + ("https://invalid.test", dir.path().join("absent.toml")), + ("http://localhost", path.clone()), + ("http://localhost", config.routing_log.clone()), + ] { + let mut invalid = config.clone(); + invalid.server_url = url.into(); + invalid.config_file = file.clone(); + assert!(save_configuration(&path, &invalid, true).is_err()); + assert_eq!(std::fs::read(&path).expect("unchanged"), original); + assert!(!dir.path().join("absent.toml").exists()); + assert!(!config.routing_log.exists()); + } + std::fs::write(&path, "# keep my comment\nunknown_key=true\n").expect("unknown field"); + let before = std::fs::read(&path).expect("raw settings"); + config.config_file = dir.path().join("absent.toml"); + assert!(save_configuration(&path, &config, true).is_err()); + assert_eq!(std::fs::read(&path).expect("raw unchanged"), before); + assert!(!config.config_file.exists()); + } #[cfg(unix)] #[test] @@ -214,6 +425,8 @@ mod tests { let path = dir.path().join("desktop.toml"); let target = dir.path().join("missing.toml"); std::os::unix::fs::symlink(&target, &path).expect("symlink"); + assert!(!needs_setup(&path).expect("existing settings link")); + assert!(Config::load(&path).is_err()); assert!(save_preferences(&path, "baseline", 30).is_err()); assert_eq!( std::fs::read_link(&path).expect("preserved symlink"), diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 2233ffcec7..e93d95ddd4 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -165,6 +165,14 @@ pub enum Action { RemoveServerKey { name: String, }, + SaveConfiguration { + config: Config, + create_server: bool, + }, + PreviewResetSettings {}, + ResetSettings { + preview_token: Option, + }, SavePreferences { baseline_model: String, refresh_seconds: u64, @@ -175,6 +183,8 @@ pub enum Action { /// `..`, so adding a field breaks the build until the terminal app shows it. #[derive(Clone, Default, Serialize)] pub struct Snapshot { + pub needs_setup: bool, + pub configuration_error: Option, pub update_status: Value, /// Each value is the `session.json` file that `sessions::launch` wrote for a managed /// worktree session, plus `process_status`. The snapshot passes these files through as JSON. @@ -446,10 +456,10 @@ struct ReviewedLaunch { } pub struct Controller { - health_probe: crate::health::Probe, settings: PathBuf, config: Config, reader: rollup::Reader, + health: crate::health::Probe, // The generation ties a draft to the server config file selected by the settings. generation: u64, route_reviews: std::collections::HashMap, @@ -459,14 +469,15 @@ pub struct Controller { route_change: Option, update_review: Option<(String, Value)>, launch_review: Option, + settings_review: Option, } impl Controller { pub fn new(config: Config, settings: PathBuf) -> Self { Self { config, settings, - health_probe: crate::health::Probe::default(), reader: rollup::Reader::default(), + health: crate::health::Probe::default(), generation: 0, route_reviews: Default::default(), tool_review: None, @@ -475,6 +486,7 @@ impl Controller { route_change: None, update_review: None, launch_review: None, + settings_review: None, } } fn reload(&mut self) -> Result<(), String> { @@ -526,8 +538,9 @@ impl Controller { revision } pub fn snapshot(&mut self) -> Result { - self.reload()?; - let mut errors = Vec::new(); + let configuration_error = self.reload().err(); + let mut errors: Vec = configuration_error.iter().cloned().collect(); + let needs_setup = crate::config::needs_setup(&self.settings)?; // One local date keeps aggregate totals and frontend filters aligned across midnight. let today = chrono::Local::now().date_naive(); let mut usage_state = Availability::Available; @@ -557,7 +570,7 @@ impl Controller { }) .collect(); let checked_at = chrono::Utc::now().to_rfc3339(); - let mut health = self.health_probe.details(&self.config.server_url); + let mut health = self.health.details(&self.config.server_url); health["checked_at"] = json!(checked_at); health["url"] = json!(self.config.server_url); let server_status = if health["state"] == "running" { @@ -701,6 +714,8 @@ impl Controller { prices_file, } = self.config.clone(); Ok(Snapshot { + needs_setup, + configuration_error, update_status, session_inventory, session_inventory_error, @@ -805,8 +820,59 @@ impl Controller { server::command("open", &[&self.settings.display().to_string()])?; return Ok(Reply::message("Opened app settings.".into())); } + // Configuration actions remain available while a saved file needs repair. + match &action { + Action::SaveConfiguration { + config, + create_server, + } => { + crate::config::save_configuration(&self.settings, config, *create_server)?; + self.reload()?; + return Ok(Reply::message("Saved configuration. The installed server loads these files when it starts. Check Server keys before restarting.".into())); + } + Action::PreviewResetSettings {} => { + let review = Review::capture(json!({}), vec![self.settings.clone()])?; + let data = json!({"preview_token":review.token,"file":self.settings,"message":"This removes only the app preferences. A recovery copy is kept. Server configs, keys, history, accounts, and tool settings are preserved."}); + self.settings_review = Some(review); + return Ok(Reply { + message: String::new(), + data, + }); + } + Action::ResetSettings { preview_token } => { + let review = self + .settings_review + .as_ref() + .ok_or("Review app preference removal first.")?; + review.check(preview_token.as_deref(), &json!({}))?; + let backup = self + .settings + .with_extension(format!("removed-{}.toml", uuid::Uuid::now_v7())); + match self.settings.symlink_metadata() { + Ok(_) => std::fs::rename(&self.settings, &backup) + .map_err(|error| error.to_string())?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + self.settings_review = None; + self.config = Config::defaults_for(&self.settings)?; + self.generation += 1; + self.route_reviews.clear(); + self.route_change = None; + self.tool_review = None; + self.launch_review = None; + return Ok(Reply::message(format!( + "Removed app preferences. Recovery copy: {}. Server files are preserved.", + backup.display() + ))); + } + _ => {} + } self.reload()?; match action { + Action::SaveConfiguration { .. } + | Action::PreviewResetSettings {} + | Action::ResetSettings { .. } => unreachable!(), Action::OpenLogs {} => { server::command("open", &[&logs_dir()?.display().to_string()])?; Ok(Reply::message("Opened Switchyard logs.".into())) @@ -856,6 +922,7 @@ impl Controller { .check(revision.as_deref(), &json!({"route":route}))?; server::apply( &self.config, + &self.health, &route, algorithm, &choices, @@ -877,6 +944,7 @@ impl Controller { .check(revision.as_deref(), &json!({"route":route}))?; server::remove( &self.config, + &self.health, &route, &id, self.route_reviews[&route].original()?, @@ -936,7 +1004,7 @@ impl Controller { algorithm.as_deref().map(algorithm_by_id).transpose()?, &choices, )?; - server::save_route_change(&self.config, review.original()?, edited) + server::save_route_change(&self.config, &self.health, review.original()?, edited) .map(Reply::saved_edit) } Action::Models { @@ -1288,7 +1356,9 @@ impl Controller { } sessions::cleanup(&id, data).map(Reply::message) } - Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), + Action::Restart {} => { + server::restart_checked(&self.config, &self.health).map(Reply::message) + } Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), Action::ServerKeys {} => Ok(Reply { @@ -1553,6 +1623,99 @@ fn update(settings: &std::path::Path) -> Result { #[cfg(test)] mod tests { use super::*; + + #[test] + fn setup_and_settings_share_saves_and_removal_requires_the_reviewed_bytes() { + let dir = tempfile::tempdir().expect("fixture"); + let path = dir.path().join("desktop.toml"); + let mut config = Config::defaults_for(&path).expect("defaults"); + config.server_url = "http://127.0.0.1:1".into(); + let mut controller = Controller::new(config.clone(), path.clone()); + assert!(controller.snapshot().expect("initial snapshot").needs_setup); + let mut unknown = serde_json::to_value(&config).expect("config"); + unknown["unknown_file"] = json!("ignored.toml"); + assert!( + serde_json::from_value::( + json!({"kind":"save_configuration","config":unknown,"create_server":true}) + ) + .is_err() + ); + assert!(!path.exists()); + controller + .dispatch(Action::SaveConfiguration { + config: config.clone(), + create_server: true, + }) + .expect("setup save"); + assert!(!controller.snapshot().expect("saved snapshot").needs_setup); + let server_bytes = std::fs::read(&config.config_file).expect("server"); + let selected = dir.path().join("selected.toml"); + std::fs::write(&selected, &server_bytes).expect("another server config"); + config.config_file = selected; + config.routing_log = dir.path().join("selected.jsonl"); + controller + .dispatch(Action::SaveConfiguration { + config: config.clone(), + create_server: false, + }) + .expect("settings save"); + let snapshot = controller.snapshot().expect("changed snapshot"); + assert_eq!( + snapshot.settings.expect("settings").config_file, + config.config_file + ); + assert_eq!( + snapshot.generation, 1, + "selecting a different config invalidates route drafts" + ); + let preview = controller + .dispatch(Action::PreviewResetSettings {}) + .expect("preview"); + let original = std::fs::read_to_string(&path).expect("preferences"); + std::fs::write(&path, format!("{original}# external change\n")).expect("external edit"); + assert!( + controller + .dispatch(Action::ResetSettings { + preview_token: preview.data["preview_token"].as_str().map(str::to_owned) + }) + .is_err() + ); + assert!(path.exists()); + let preview = controller + .dispatch(Action::PreviewResetSettings {}) + .expect("new preview"); + controller + .dispatch(Action::ResetSettings { + preview_token: preview.data["preview_token"].as_str().map(str::to_owned), + }) + .expect("remove preferences"); + assert!(!path.exists()); + assert_eq!( + std::fs::read(&config.config_file).expect("server preserved"), + server_bytes + ); + assert!( + std::fs::read_dir(dir.path()) + .expect("files") + .flatten() + .any(|entry| entry.file_name().to_string_lossy().contains("removed-")) + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(dir.path().join("missing-settings.toml"), &path) + .expect("broken settings link"); + let snapshot = controller.snapshot().expect("repair snapshot"); + assert!(!snapshot.needs_setup); + assert!(snapshot.configuration_error.is_some()); + assert!(controller.dispatch(Action::Restart {}).is_err()); + assert!( + path.symlink_metadata() + .expect("preserved link") + .is_symlink() + ); + } + } + // The subprocess isolates harness discovery and every launch destination. #[cfg(unix)] #[test] diff --git a/crates/switchyard-desktop/src/gui.rs b/crates/switchyard-desktop/src/gui.rs index 42ffef8d16..c866521aee 100644 --- a/crates/switchyard-desktop/src/gui.rs +++ b/crates/switchyard-desktop/src/gui.rs @@ -1,149 +1,556 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The desktop window shows server health and recorded request and token counts. +//! Tauri manages the window and tray, and runs blocking operations on worker threads. +use crate::controller::{Action, Controller, Reply, Snapshot}; +use crate::summary::Overview; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use switchyard_desktop::{config::Config, health::Probe, rollup}; -use tauri::State; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicU8, Ordering}, +}; +use std::time::Duration; +use tauri::{ + Manager, State, + menu::{Menu, MenuItem, PredefinedMenuItem}, + tray::TrayIconBuilder, +}; -/// The window retains the probe and reader so refreshes reuse the health worker and log offset. -struct Desktop { - settings: PathBuf, - health: Probe, - reader: rollup::Reader, +struct Shared { + controller: Arc>, + // The state is idle (0), busy (1), closing (2), or busy with Quit queued (3). + // The idle state accepts new operations. + operation: Arc, + // This lock serializes saved appearance changes from the main window and popup. + appearance: Mutex<()>, } +/// TRAY_ID names the tray icon so refreshes can replace its menu. +const TRAY_ID: &str = "main"; -#[derive(serde::Serialize)] -struct Counts { - requests: u64, - tokens: u64, +/// TrayUsage reads the routing log for the tray menu and popup. It loads the settings +/// and keeps its own log reader, so the tray never waits for an app operation and the +/// app never waits for the tray. +struct TrayUsage { + settings: PathBuf, + reader: Mutex, + health: switchyard_desktop::health::Probe, + // The menu is rebuilt only when these rows change, so an open menu does not flicker. + rows: Mutex>, } +/// TrayView holds what the tray shows: the usage overview, or the errors that hide it. #[derive(serde::Serialize)] -struct Snapshot { - server_url: String, - health: serde_json::Value, - today: Option, - week: Option, +struct TrayView { + overview: Option, errors: Vec, refresh_seconds: u64, } -type Shared = Arc>; +impl TrayUsage { + /// This function reads new log entries, probes the server, and builds the overview. + fn read(&self) -> TrayView { + let config = match switchyard_desktop::config::Config::load(&self.settings) { + Ok(config) => config, + Err(error) => { + return TrayView { + overview: None, + errors: vec![format!("Cannot read app settings: {error}")], + refresh_seconds: MIN_TRAY_REFRESH, + }; + } + }; + let refresh_seconds = config.refresh_seconds.max(MIN_TRAY_REFRESH); + let today = chrono::Local::now().date_naive(); + let usage = match self.reader.lock() { + Ok(mut reader) => reader.read(&config.routing_log, today), + Err(_) => Err(std::io::Error::other( + "the log reader stopped after an earlier failure", + )), + }; + let usage = match usage { + Ok(usage) => usage, + Err(error) => { + return TrayView { + overview: None, + errors: vec![format!("Cannot read routing log: {error}")], + refresh_seconds, + }; + } + }; + let mut errors = Vec::new(); + // An invalid price file hides savings but keeps traffic and spending visible. + let rates = crate::pricing::load(config.prices_file.as_deref()).unwrap_or_else(|error| { + errors.push(format!("Could not read model prices: {error}")); + Default::default() + }); + let status = self.health.probe(&config.server_url); + TrayView { + overview: Some(Overview::new(status, &usage, &config, &rates)), + errors, + refresh_seconds, + } + } +} + +/// MIN_TRAY_REFRESH keeps the tray from probing the server more than once every 5 seconds. +const MIN_TRAY_REFRESH: u64 = 5; + +/// This function reads the tray usage and replaces the tray menu when its rows changed. +fn refresh_tray(app: &tauri::AppHandle) -> TrayView { + let usage = app.state::(); + let view = usage.read(); + let mut rows = view + .overview + .as_ref() + .map(Overview::rows) + .unwrap_or_default(); + if !view.errors.is_empty() && !rows.is_empty() { + rows.push(String::new()); + } + rows.extend(view.errors.iter().cloned()); + let Ok(mut shown) = usage.rows.lock() else { + return view; + }; + if *shown != rows { + let result = tray_menu(app, &rows).and_then(|menu| match app.tray_by_id(TRAY_ID) { + Some(tray) => tray.set_menu(Some(menu)), + None => Ok(()), + }); + match result { + Ok(()) => *shown = rows, + Err(error) => eprintln!("switchyard-desktop: could not update the tray menu: {error}"), + } + } + view +} + +/// This function builds the tray menu: the usage rows, then the server and app actions. +fn tray_menu(app: &tauri::AppHandle, rows: &[String]) -> tauri::Result> { + let menu = Menu::new(app)?; + for row in rows { + if row.is_empty() { + menu.append(&PredefinedMenuItem::separator(app)?)?; + } else { + // Rows are labels, not commands, so they are disabled. + menu.append(&MenuItem::new(app, row, false, None::<&str>)?)?; + } + } + let groups: [&[(&str, &str)]; 3] = [ + &[ + ("restart", "Restart server"), + ("open-config", "Open server config…"), + ("settings", "Settings…"), + ], + &[("open", "Open Switchyard"), ("install", "Connect tools…")], + &[("quit", "Quit Switchyard")], + ]; + for group in groups { + menu.append(&PredefinedMenuItem::separator(app)?)?; + for (id, text) in group { + menu.append(&MenuItem::with_id(app, *id, *text, true, None::<&str>)?)?; + } + } + Ok(menu) +} + +/// This function runs a tray menu action and reports a failure in the app window. +fn run_tray_action(app: &tauri::AppHandle, action: Action) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let result = dispatch(app.clone(), action).await; + let refresh = app.clone(); + // The refresh probes the server, so it runs on a worker thread. + let _ = tauri::async_runtime::spawn_blocking(move || refresh_tray(&refresh)).await; + if let Err(error) = result { + eprintln!("switchyard-desktop: {error}"); + notice(&app, &error); + } + }); +} + +/// This function shows a message in the app window and brings the window forward. +fn notice(app: &tauri::AppHandle, message: &str) { + if let Some(window) = app.get_webview_window("main") { + let detail = serde_json::json!(message); + let _ = window.eval(format!( + "window.dispatchEvent(new CustomEvent('switchyard-notice',{{detail:{detail}}}))" + )); + } + show(app); +} + +/// This function quits now, or after the running operation when one is running. +fn quit(app: &tauri::AppHandle) { + if app.state::().quit() { + app.exit(0); + } else { + notice( + app, + "Switchyard will quit when the current operation finishes.", + ); + } +} +// Operation keeps the app busy from queueing through worker completion. +// Drop returns a busy app to idle or closes it when Quit is queued. +struct Operation(Arc, Option); +impl Drop for Operation { + fn drop(&mut self) { + if self + .0 + .compare_exchange(1, 0, Ordering::AcqRel, Ordering::Acquire) + == Err(3) + { + self.0.store(2, Ordering::Release); + if let Some(app) = &self.1 { + app.exit(0); + } + } + } +} +impl Shared { + fn begin(&self) -> Result { + self.operation + .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| "An operation is running. Try again when it finishes.".to_string())?; + Ok(Operation(self.operation.clone(), None)) + } + fn quit(&self) -> bool { + if self + .operation + .compare_exchange(1, 3, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return false; + } + self.operation + .compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + || self.operation.load(Ordering::Acquire) == 2 + } +} #[tauri::command] -async fn snapshot(state: State<'_, Shared>) -> Result { - let shared = state.inner().clone(); +async fn snapshot(app: tauri::AppHandle, state: State<'_, Shared>) -> Result { + let mut operation = state.begin()?; + operation.1 = Some(app.clone()); + let shared = state.controller.clone(); tauri::async_runtime::spawn_blocking(move || { - let mut desktop = shared + let _operation = operation; + shared .try_lock() - .map_err(|_| "A refresh is running. Try again when it finishes.".to_string())?; - let config = Config::load(&desktop.settings)?; - let health = desktop.health.details(&config.server_url); - let mut errors = Vec::new(); - // A failed read leaves counts absent so the window cannot show incomplete usage as zero. - let usage = desktop - .reader - .read(&config.routing_log, chrono::Local::now().date_naive()) - .map_err(|error| errors.push(format!("Could not read usage: {error}"))) - .ok(); - let counts = |totals: &rollup::Totals| Counts { - requests: totals.requests(), - tokens: totals.tokens(), - }; - Ok(Snapshot { - server_url: config.server_url, - health, - today: usage.as_ref().map(|usage| counts(&usage.today)), - week: usage.as_ref().map(|usage| counts(&usage.week)), - errors, - refresh_seconds: config.refresh_seconds.max(1), - }) + .map_err(|_| "An operation is running. Try again when it finishes.".to_string())? + .snapshot() }) .await - .map_err(|error| error.to_string())? + .map_err(|e| e.to_string())? } - -#[derive(serde::Deserialize)] -#[serde(rename_all = "snake_case")] -enum FileKind { - Config, - Logs, +async fn dispatch(app: tauri::AppHandle, action: Action) -> Result { + let (operation, shared) = { + let state = app.state::(); + let mut operation = state.begin()?; + operation.1 = Some(app.clone()); + (operation, state.controller.clone()) + }; + tauri::async_runtime::spawn_blocking(move || { + let _operation = operation; + shared + .try_lock() + .map_err(|_| "An operation is running. Try again when it finishes.".to_string())? + .dispatch(action) + }) + .await + .map_err(|e| e.to_string())? } - #[tauri::command] -async fn open_file(state: State<'_, Shared>, kind: FileKind) -> Result<(), String> { - let shared = state.inner().clone(); +async fn action(app: tauri::AppHandle, action: Action) -> Result { + dispatch(app, action).await +} +#[tauri::command] +fn quit_app(app: tauri::AppHandle) { + quit(&app); +} +#[tauri::command] +async fn choose_path(directory: bool) -> Result, String> { + // The blocking worker waits for the picker; rfd opens macOS panels on the main thread. tauri::async_runtime::spawn_blocking(move || { - let config = { - let desktop = shared - .try_lock() - .map_err(|_| "A refresh is running. Try again when it finishes.".to_string())?; - Config::load(&desktop.settings)? - }; - let path = match kind { - FileKind::Config => config.config_file, - FileKind::Logs => config.routing_log, + let dialog = rfd::FileDialog::new().set_title(if directory { + "Choose a Git project folder" + } else { + "Choose harness settings" + }); + let path = if directory { + dialog.pick_folder() + } else { + dialog.pick_file() }; - #[cfg(target_os = "macos")] + #[cfg(windows)] { - let output = std::process::Command::new("open") - .arg("--") - .arg(&path) - .output() - .map_err(|error| format!("Could not open {}: {error}", path.display()))?; - if !output.status.success() { - return Err(format!( - "Could not open {}: {}", - path.display(), - String::from_utf8_lossy(&output.stderr).trim() - )); - } - Ok(()) + Ok(path.map(|path| path.display().to_string())) } - #[cfg(windows)] + #[cfg(target_os = "macos")] { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::UI::{ - Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL, - }; - let path: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); - let status = unsafe { - ShellExecuteW( - std::ptr::null_mut(), - std::ptr::null(), - path.as_ptr(), - std::ptr::null(), - std::ptr::null(), - SW_SHOWNORMAL, - ) - } as isize; - if status > 32 { - Ok(()) - } else { - Err(format!("Windows could not open the file (error {status}).")) - } + path.map(|path| { + path.into_os_string().into_string().map_err(|_| { + "The selected path cannot be represented as UTF-8. \ + Enter a different path in the field." + .to_string() + }) + }) + .transpose() } }) .await .map_err(|error| error.to_string())? } +#[derive(Clone, Copy, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +enum AppearanceMode { + #[default] + System, + // Dark is saved as "canary". + Canary, + Light, +} + #[tauri::command] -fn quit_app(app: tauri::AppHandle) { - app.exit(0); +fn appearance( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + state: State<'_, Shared>, + mode: Option, +) -> Result { + let _lock = state.appearance.lock().map_err(|e| e.to_string())?; + let system = window.theme().map_err(|e| e.to_string())?; + let path = app + .path() + .app_config_dir() + .map_err(|e| e.to_string())? + .join("appearance.json"); + let selected = if let Some(mode) = mode { + let directory = path + .parent() + .ok_or("Could not find the appearance settings folder.")?; + std::fs::create_dir_all(directory).map_err(|e| e.to_string())?; + use std::io::Write; + let mut temporary = + tempfile::NamedTempFile::new_in(directory).map_err(|e| e.to_string())?; + temporary + .write_all(&serde_json::to_vec(&mode).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + temporary.as_file().sync_all().map_err(|e| e.to_string())?; + temporary.persist(&path).map_err(|e| e.to_string())?; + mode + } else { + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|e| format!("Could not read appearance settings: {e}"))?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => AppearanceMode::default(), + Err(e) => return Err(e.to_string()), + } + }; + let result = serde_json::json!({"mode": selected, "system": system}); + if mode.is_some() { + for window in app.webview_windows().values() { + window + .eval(format!( + "window.dispatchEvent(new CustomEvent('switchyard-appearance', \ + {{detail:{result}}}))" + )) + .map_err(|e| e.to_string())?; + } + } + Ok(result) +} + +#[cfg(target_os = "macos")] +fn material( + fallback: tauri::window::Effect, + radius: Option, + state: tauri::window::EffectState, +) -> tauri::utils::config::WindowEffectsConfig { + tauri::utils::config::WindowEffectsConfig { + effects: vec![tauri::window::Effect::LiquidGlassRegular, fallback], + state: Some(state), + radius, + color: None, + interactive: false, + } +} + +#[tauri::command] +fn accessibility() -> serde_json::Value { + #[cfg(target_os = "macos")] + { + let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace(); + serde_json::json!({ + "native_material": true, + "reduce_transparency": workspace.accessibilityDisplayShouldReduceTransparency(), + "reduce_motion": workspace.accessibilityDisplayShouldReduceMotion(), + }) + } + #[cfg(not(target_os = "macos"))] + serde_json::json!({"native_material": false, "reduce_transparency": true, "reduce_motion": false}) } -pub fn run(settings: PathBuf) -> Result<(), String> { - tauri::Builder::default() - .manage(Arc::new(Mutex::new(Desktop { +fn open_page(app: &tauri::AppHandle, page: &str) { + if let Some(window) = app.get_webview_window("main") { + // Only the fixed page names from the tray menu reach this script. + let script = format!( + "window.dispatchEvent(new CustomEvent('switchyard-page', {{detail:{page:?}}}))" + ); + if let Err(error) = window.eval(&script) { + eprintln!("switchyard-desktop: could not select the page: {error}"); + } + } + show(app); +} +fn activation(app: &tauri::AppHandle, visible: bool) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + app.set_activation_policy(if visible { + tauri::ActivationPolicy::Regular + } else { + tauri::ActivationPolicy::Accessory + }) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (app, visible); + Ok(()) + } +} +fn show(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("main") + && let Err(e) = activation(app, true) + .and_then(|()| window.show()) + .and_then(|()| window.set_focus()) + { + eprintln!("switchyard-desktop: could not open the window: {e}"); + } +} +pub fn run(controller: Controller, settings: PathBuf) -> Result<(), String> { + let app = tauri::Builder::default() + .manage(Shared { + controller: Arc::new(Mutex::new(controller)), + operation: Arc::new(AtomicU8::new(0)), + appearance: Mutex::new(()), + }) + .manage(TrayUsage { settings, - health: Probe::default(), - reader: rollup::Reader::default(), - }))) - .invoke_handler(tauri::generate_handler![snapshot, open_file, quit_app]) - .run(tauri::generate_context!()) - .map_err(|error| error.to_string()) + reader: Mutex::new(crate::rollup::Reader::default()), + health: switchyard_desktop::health::Probe::default(), + rows: Mutex::new(Vec::new()), + }) + .invoke_handler(tauri::generate_handler![ + snapshot, + action, + choose_path, + appearance, + accessibility, + quit_app + ]) + .setup(|app| { + #[cfg(target_os = "macos")] + if let Some(window) = app.get_webview_window("main") { + window.set_effects(material( + tauri::window::Effect::Sidebar, + None, + tauri::window::EffectState::FollowsWindowActiveState, + ))?; + } + + let tray_pixels = include_bytes!("../icons/tray.rgba"); + TrayIconBuilder::with_id(TRAY_ID) + .icon(tauri::image::Image::new_owned(tray_pixels.to_vec(), 22, 22)) + .icon_as_template(false) + .tooltip("Switchyard — routes and usage") + .menu(&tray_menu(app.handle(), &["Reading usage…".into()])?) + .on_menu_event(|app, event| match event.id.as_ref() { + "restart" => run_tray_action(app, Action::Restart {}), + "open-config" => run_tray_action(app, Action::OpenConfig {}), + "settings" => open_page(app, "settings"), + "open" => open_page(app, "overview"), + "install" => open_page(app, "install"), + "quit" => quit(app), + _ => {} + }) + .build(app)?; + // Like the menu bar app, the tray rereads usage on the settings interval. + let handle = app.handle().clone(); + std::thread::spawn(move || { + loop { + let view = refresh_tray(&handle); + std::thread::sleep(Duration::from_secs(view.refresh_seconds)); + } + }); + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::ThemeChanged(theme) = event { + let value = serde_json::json!(theme); + for target in window.app_handle().webview_windows().values() { + if let Err(error) = target.eval(format!( + "window.dispatchEvent(new CustomEvent('switchyard-system-theme', \ + {{detail:{value}}}))" + )) { + eprintln!("switchyard-desktop: could not update the appearance: {error}"); + } + } + } + + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + + // Accessory removes the running Dock entry while keeping the tray menu. + if let Err(e) = window.hide().and_then(|()| { + if window.label() == "main" { + activation(window.app_handle(), false) + } else { + Ok(()) + } + }) { + eprintln!("switchyard-desktop: could not hide the window: {e}"); + } + } + }) + .build(tauri::generate_context!()) + .map_err(|e| e.to_string())?; + app.run(|app, event| match event { + #[cfg(target_os = "macos")] + tauri::RunEvent::Reopen { .. } => show(app), + tauri::RunEvent::ExitRequested { api, .. } if !app.state::().quit() => { + api.prevent_exit() + } + _ => {} + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + // A queued operation must prevent Quit before its worker starts. + fn quit_blocks_pending_operations_and_stays_closed() { + let directory = tempfile::tempdir().expect("directory"); + let settings = directory.path().join("app.toml"); + let shared = Shared { + controller: Arc::new(Mutex::new(Controller::new( + crate::config::Config::defaults_for(&settings).expect("defaults"), + settings, + ))), + operation: Arc::new(AtomicU8::new(0)), + appearance: Mutex::new(()), + }; + let operation = shared.begin().expect("operation"); + assert!(shared.begin().is_err()); + assert!(!shared.quit()); + drop(operation); + assert!(shared.quit()); + assert!(shared.quit()); + assert!(shared.begin().is_err()); + } } diff --git a/crates/switchyard-desktop/src/install.rs b/crates/switchyard-desktop/src/install.rs index 4e4bd7837d..380f3d359f 100644 --- a/crates/switchyard-desktop/src/install.rs +++ b/crates/switchyard-desktop/src/install.rs @@ -40,7 +40,7 @@ pub fn executable_name(name: &str) -> String { format!("{name}{}", std::env::consts::EXE_SUFFIX) } -const BUNDLED_CONFIG: &str = include_str!("../../../scripts/config/composite.toml"); +pub const BUNDLED_CONFIG: &str = include_str!("../../../scripts/config/composite.toml"); const UPDATE_LOCK: &str = ".switchyard-desktop-update.lock"; const PORT_ERROR: &str = "SY_PORT must be a number from 1 to 65535."; @@ -210,23 +210,24 @@ impl Settings { } /// This method builds the installed server command with any existing price override. /// The caller configures and starts the process. - pub fn server_command(&self) -> Command { + pub fn server_command(&self) -> Result { + let config = Config::load(&self.desktop_settings())?; let mut command = Command::new(self.binary("switchyard-server")); - command - .arg("--config") - .arg(self.sy_home.join(SERVER_CONFIG)); + command.arg("--config").arg(&config.config_file); + let url = crate::health::validate_url(&config.server_url)?; command.args(["--host", "127.0.0.1"]); - command.arg("--port").arg(self.port.to_string()); - command - .arg("--routing-log-file") - .arg(self.sy_home.join(ROUTING_LOG)); - - let prices = self.prices_file(); - if prices.exists() { + command.arg("--port").arg( + url.port_or_known_default() + .ok_or("Missing server port.")? + .to_string(), + ); + command.arg("--routing-log-file").arg(&config.routing_log); + if let Some(prices) = &config.prices_file { command.arg("--pricing-file").arg(prices); } - command + Ok(command) } + fn profile_file(&self) -> PathBuf { self.codex_home .join(format!("{}.config.toml", self.profile)) @@ -450,6 +451,7 @@ pub fn preview_update(settings_file: &Path) -> Result } fn installation_plan(s: &Settings, action: &str) -> String { let removing = action == "uninstall"; + let needs_setup = !removing && matches!(config::needs_setup(&s.desktop_settings()), Ok(true)); let pick = |remove, install| if removing { remove } else { install }; let app = if cfg!(target_os = "macos") { "macOS desktop app" @@ -461,6 +463,8 @@ fn installation_plan(s: &Settings, action: &str) -> String { let profile = s.profile_file(); let profile_action = if removing { "Preserve recovery copy, then remove" + } else if needs_setup { + "Defer until after setup:" } else if !profile.exists() { "Create" } else if action == "update" { @@ -486,7 +490,11 @@ fn installation_plan(s: &Settings, action: &str) -> String { "{} service definitions in {}. The server {}.", pick("Remove", "Replace"), s.service_dir.display(), - pick("will stop", "will restart after build and validation") + if needs_setup { + "will wait for setup before starting" + } else { + pick("will stop", "will restart after build and validation") + } ), ]; for (path, label) in [ @@ -494,7 +502,9 @@ fn installation_plan(s: &Settings, action: &str) -> String { (s.sy_home.join(SERVER_CONFIG), "server config"), (s.sy_home.join(ROUTING_LOG), "usage history"), ] { - let verb = if removing || path.exists() { + let verb = if needs_setup { + "Defer until setup" + } else if removing || path.exists() { "Preserve" } else { "Create" @@ -590,8 +600,32 @@ fn wait_for_health(port: u16) -> bool { ) .is_ok() { - let mut response = String::new(); - if stream.take(8192).read_to_string(&mut response).is_ok() + let mut bytes = [0; 8192]; + let mut used = 0; + let mut complete = false; + while used < bytes.len() { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + break; + }; + if remaining.is_zero() + || stream + .set_read_timeout(Some(remaining.min(Duration::from_millis(300)))) + .is_err() + { + break; + } + match stream.read(&mut bytes[used..]) { + Ok(0) => { + complete = true; + break; + } + Ok(count) => used += count, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + if (complete || used == bytes.len()) + && let Ok(response) = std::str::from_utf8(&bytes[..used]) && let Some((headers, body)) = response.split_once("\r\n\r\n") && headers .lines() @@ -700,6 +734,7 @@ fn remove(path: &Path) -> Result<(), String> { /// What an install gathers and checks before it changes the installation. struct Prepared { + needs_setup: bool, /// The Codex profile to save. profile: String, /// App settings rewritten for a new port, when the port changes. @@ -719,8 +754,17 @@ fn install(s: &Settings, preserve_profile: bool) -> Result, Strin "install", "Build and config validation passed. Replacing the installation.", )?; - replace_installation(s, prepared.desktop_settings.as_deref())?; - platform::install(s, prepared.missing_keys.is_empty())?; + replace_installation( + s, + prepared.desktop_settings.as_deref(), + prepared.needs_setup, + )?; + platform::install(s, !prepared.needs_setup && prepared.missing_keys.is_empty())?; + if prepared.needs_setup { + let message = "Installed. Open Switchyard to choose your configuration. Existing files will skip setup on later installs.".to_string(); + println!("{message}"); + return Ok(Some(message)); + } if !preserve_profile || !present(&s.profile_file())? { save_profile(s, &prepared.profile)?; } @@ -728,24 +772,49 @@ fn install(s: &Settings, preserve_profile: bool) -> Result, Strin } fn prepare(s: &Settings, preserve_profile: bool) -> Result { + let needs_setup = config::needs_setup(&s.desktop_settings())?; let profile = s.profile_file(); - let context_window = route_context_window(s)?; - let profile_text = if profile.exists() && !preserve_profile { - let current = fs::read_to_string(&profile).map_err(err)?; - merge_codex_profile(s, ¤t, context_window)? + let desktop_settings = reconcile_desktop_port(s)?; + let server_url = if desktop_settings.is_some() || !present(&s.desktop_settings())? { + config::server_url(s.port) } else { - codex_profile(s, context_window) + Config::load(&s.desktop_settings())?.server_url + }; + let profile_text = if needs_setup { + String::new() + } else { + let context_window = route_context_window(s)?; + if profile.exists() && !preserve_profile { + let current = fs::read_to_string(&profile).map_err(err)?; + merge_codex_profile(s, ¤t, context_window, &server_url)? + } else { + codex_profile(s, context_window, &server_url) + } }; #[cfg(not(windows))] platform::preflight()?; - let desktop_settings = reconcile_desktop_port(s)?; - let prices = s.prices_file(); - if present(&prices)? { + let prices = if needs_setup { + None + } else { + Config::load(&s.desktop_settings())? + .prices_file + .or_else(|| { + // An installation with only a server config still imports its adjacent price override. + (!s.desktop_settings().exists() && s.prices_file().exists()) + .then(|| s.prices_file()) + }) + }; + if let Some(prices) = prices { Pricing::load(&prices).map_err(|e| format!("{e}. Fix this file before installing."))?; } build(s)?; - let missing_keys = check_server_config(s, !preserve_profile || !profile.exists())?; + let missing_keys = if needs_setup { + Vec::new() + } else { + check_server_config(s, !preserve_profile || !profile.exists())? + }; Ok(Prepared { + needs_setup, profile: profile_text, desktop_settings, missing_keys, @@ -776,7 +845,7 @@ fn build(s: &Settings) -> Result<(), String> { /// Checks the server config that the install keeps, or the bundled one on a first install, and /// returns the API key variables that have no saved key. fn check_server_config(s: &Settings, require_route: bool) -> Result, String> { - let config = s.sy_home.join(SERVER_CONFIG); + let config = Config::load(&s.desktop_settings())?.config_file; let existing = present(&config)?; let staged = tempfile::NamedTempFile::new().map_err(err)?; let checked = if existing { @@ -806,7 +875,11 @@ fn check_server_config(s: &Settings, require_route: bool) -> Result, /// Stops the services and replaces the app files. The macOS app bundle is staged and signed /// before the services stop. -fn replace_installation(s: &Settings, desktop_settings: Option<&str>) -> Result<(), String> { +fn replace_installation( + s: &Settings, + desktop_settings: Option<&str>, + needs_setup: bool, +) -> Result<(), String> { let metadata = toml::to_string(s).map_err(err)?; #[cfg(target_os = "macos")] let bundle = platform::stage_bundle(s, &metadata)?; @@ -830,11 +903,12 @@ fn replace_installation(s: &Settings, desktop_settings: Option<&str>) -> Result< let bytes = fs::read(s.release(binary)).map_err(err)?; write(&s.binary(binary), &bytes, true)?; } - write_once(&s.sy_home.join(SERVER_CONFIG), BUNDLED_CONFIG)?; - if let Some(text) = desktop_settings { - write(&s.desktop_settings(), text.as_bytes(), false)?; - } else { - write_once(&s.desktop_settings(), &desktop_config(s)?)?; + if !needs_setup { + if let Some(text) = desktop_settings { + write(&s.desktop_settings(), text.as_bytes(), false)?; + } else { + write_once(&s.desktop_settings(), &desktop_config(s)?)?; + } } for record in [s.sy_home.join("install.toml"), s.install_record()] { write(&record, metadata.as_bytes(), false)?; @@ -860,9 +934,10 @@ fn save_profile(s: &Settings, text: &str) -> Result<(), String> { /// Returns the warning to record when the server cannot start yet or does not answer /health. fn report_readiness(s: &Settings, missing_keys: &[String]) -> Result, String> { + let config = Config::load(&s.desktop_settings())?; println!( - "Installed. Server: http://127.0.0.1:{}\nSettings: {}\nUse: codex -p {}", - s.port, + "Installed. Server: {}\nSettings: {}\nUse: codex -p {}", + config.server_url, s.desktop_settings().display(), s.profile ); @@ -891,7 +966,10 @@ fn report_readiness(s: &Settings, missing_keys: &[String]) -> Result Result, String> { // config or, on a new install, the bundled one. A missing route returns `None` here; // check_public_route rejects it later with the list of valid route IDs. fn route_context_window(s: &Settings) -> Result, String> { - let path = s.sy_home.join(SERVER_CONFIG); + let path = Config::load(&s.desktop_settings())?.config_file; let text = read_optional(&path).map_err(|e| format!("Read {}: {e}", path.display()))?; let document: toml::Value = toml::from_str(text.as_deref().unwrap_or(BUNDLED_CONFIG)) .map_err(|e| format!("Parse {}: {e}", path.display()))?; @@ -992,7 +1070,7 @@ fn route_context_window(s: &Settings) -> Result, String> { // Codex compacts a thread when its usage nears the model's context window. Codex does not // know a Switchyard route ID, so the profile names the route's window. Without it, Codex // never compacts on the route, and a long thread fails when the upstream rejects it. -fn codex_profile(s: &Settings, context_window: Option) -> String { +fn codex_profile(s: &Settings, context_window: Option, server_url: &str) -> String { let mut value: toml::Value = toml::from_str( include_str!("../../../scripts/config/codex.sy.toml") .replace("@SY_PORT@", &s.port.to_string()) @@ -1000,6 +1078,8 @@ fn codex_profile(s: &Settings, context_window: Option) -> String { ) .expect("bundled profile is valid TOML"); value["model"] = s.model.clone().into(); + value["model_providers"]["sy"]["base_url"] = + format!("{}/v1", server_url.trim_end_matches('/')).into(); if let (Some(window), Some(table)) = (context_window, value.as_table_mut()) { table.insert("model_context_window".into(), window.into()); } @@ -1009,11 +1089,12 @@ fn merge_codex_profile( s: &Settings, current: &str, context_window: Option, + server_url: &str, ) -> Result { let mut document: toml_edit::DocumentMut = current .parse() .map_err(|e| format!("Read Codex profile: {e}"))?; - let template: toml_edit::DocumentMut = codex_profile(s, context_window) + let template: toml_edit::DocumentMut = codex_profile(s, context_window, server_url) .parse() .expect("generated profile is valid TOML"); for key in ["model", "model_provider"] { diff --git a/crates/switchyard-desktop/src/install/windows.rs b/crates/switchyard-desktop/src/install/windows.rs index 9cad57c992..648e8845b4 100644 --- a/crates/switchyard-desktop/src/install/windows.rs +++ b/crates/switchyard-desktop/src/install/windows.rs @@ -384,13 +384,21 @@ pub fn temporary_updater(source: &Path) -> Result { } /// This function waits for its child so Task Scheduler tracks the app or server's lifetime. pub fn launch_managed(s: &Settings, desktop: bool) -> Result<(), String> { + while !desktop + && !s + .desktop_settings() + .try_exists() + .map_err(|error| error.to_string())? + { + std::thread::sleep(std::time::Duration::from_millis(250)); + } let (mut command, log) = if desktop { ( Command::new(s.binary("switchyard-desktop")), "logs/desktop.log", ) } else { - (s.server_command(), "logs/server.log") + (s.server_command()?, "logs/server.log") }; fs::create_dir_all(s.sy_home.join("logs")).map_err(|e| e.to_string())?; let log = fs::OpenOptions::new() @@ -408,7 +416,7 @@ pub fn launch_managed(s: &Settings, desktop: bool) -> Result<(), String> { if desktop { command.arg(s.desktop_settings()); } else { - let config = s.sy_home.join("composite.toml"); + let config = crate::config::Config::load(&s.desktop_settings())?.config_file; // The helper has no console, so a missing key is reported in the server log. credentials::configure(&mut command, &config).inspect_err(|error| { let _ = writeln!(&log, "switchyard-desktop-install: {error}"); diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index bb85797100..8286dcbe5d 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The terminal app manages a local Switchyard server. +//! The desktop and terminal apps manage a local Switchyard server. //! -//! It shows recorded traffic and compares list-price estimates with the -//! configured baseline model. Its Routes view edits the server config and -//! restarts the server. +//! Both apps show recorded traffic and the cost that the server recorded for each +//! call, and compare it with the configured baseline model. Their Routes views edit +//! the server config and restart the server. #![cfg_attr(windows, windows_subsystem = "windows")] @@ -112,27 +112,15 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - if !terminal && !print_only { - #[cfg(any(target_os = "macos", windows))] - return match gui::run(settings) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("switchyard-desktop: {error}"); - ExitCode::FAILURE - } - }; - #[cfg(not(any(target_os = "macos", windows)))] - { - eprintln!("The desktop window supports macOS and Windows. Use --print or --tui."); - return ExitCode::FAILURE; - } + if !terminal && !print_only && !cfg!(any(target_os = "macos", windows)) { + eprintln!("The desktop window supports macOS and Windows. Use --print or --tui."); + return ExitCode::FAILURE; } - let config = match Config::load(&settings) { Ok(config) => config, Err(error) => { eprintln!("switchyard-desktop: {error}"); - if !terminal { + if print_only || (!terminal && !cfg!(any(target_os = "macos", windows))) { return ExitCode::FAILURE; } match Config::defaults_for(&settings) { @@ -171,12 +159,26 @@ fn main() -> ExitCode { return ExitCode::SUCCESS; } - let controller = controller::Controller::new(config, settings); - #[cfg(windows)] - windows::allocate_console(); - if let Err(error) = tui::run(controller) { + if !settings.try_exists().unwrap_or(true) + && matches!(config::needs_setup(&settings), Ok(false)) + && let Err(error) = config::save_configuration(&settings, &config, false) + { eprintln!("switchyard-desktop: {error}"); - return ExitCode::FAILURE; + } + let controller = controller::Controller::new(config, settings.clone()); + if terminal { + #[cfg(windows)] + windows::allocate_console(); + if let Err(error) = tui::run(controller) { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } + } else { + #[cfg(any(target_os = "macos", windows))] + if let Err(error) = gui::run(controller, settings) { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } } ExitCode::SUCCESS } diff --git a/crates/switchyard-desktop/src/server.rs b/crates/switchyard-desktop/src/server.rs index 9c30c0ce29..6a74262008 100644 --- a/crates/switchyard-desktop/src/server.rs +++ b/crates/switchyard-desktop/src/server.rs @@ -36,6 +36,7 @@ pub struct SaveResult { /// The details contain the backup path and edit notes. An error means the file was not changed. pub fn apply( settings: &Config, + health: &Probe, route: &str, algorithm: &Algorithm, choices: &[Choice], @@ -52,11 +53,12 @@ pub fn apply( check_original(&text, expected)?; let config = ServerConfig::parse(&text)?; let edited = config.edit(route, algorithm, choices)?; - save_edit(settings, &text, edited, "Updated the route.") + save_edit(settings, health, &text, edited, "Updated the route.") } pub fn remove( settings: &Config, + health: &Probe, route: &str, id: &str, expected: &str, @@ -68,17 +70,18 @@ pub fn remove( return Err("The route changed or was removed. Refresh and choose it again.".into()); } let edited = config.remove(route)?; - save_edit(settings, &text, edited, "Removed the route.") + save_edit(settings, health, &text, edited, "Removed the route.") } pub fn save_route_change( settings: &Config, + health: &Probe, expected: &str, edited: crate::server_config::Edited, ) -> Result { let text = std::fs::read_to_string(&settings.config_file).map_err(|error| error.to_string())?; check_original(&text, expected)?; - save_edit(settings, &text, edited, "Changed the routes.") + save_edit(settings, health, &text, edited, "Changed the routes.") } fn check_original(text: &str, expected: &str) -> Result<(), String> { @@ -92,6 +95,7 @@ fn check_original(text: &str, expected: &str) -> Result<(), String> { fn save_edit( settings: &Config, + health: &Probe, text: &str, edited: crate::server_config::Edited, summary: &str, @@ -109,7 +113,7 @@ fn save_edit( let binary = server_binary()?; let backup = save_checked(&binary, path, text, &edited.text)?; let saved = format!("{summary} Changed {}.", path.display()); - let health = format!("{}/health", settings.server_url); + let health_url = format!("{}/health", settings.server_url); let (message, warning) = match restart(&settings.launchd_label) { Err(error) => ( format!( @@ -118,12 +122,12 @@ fn save_edit( ), true, ), - Ok(()) if wait_for_health(&settings.server_url) => { + Ok(()) if wait_for_health(health, &settings.server_url) => { (format!("{saved} Restarted Switchyard."), false) } Ok(()) => ( format!( - "{saved} Switchyard restarted but did not answer {health} within {} seconds. \ + "{saved} Switchyard restarted but did not answer {health_url} within {} seconds. \ Check the server log in ~/.switchyard/logs/.", RESTART_WAIT.as_secs() ), @@ -382,10 +386,10 @@ pub fn check_config(settings: &Config) -> Result<(), String> { check(&server_binary()?, &settings.config_file) } -pub fn restart_checked(settings: &Config) -> Result { +pub fn restart_checked(settings: &Config, health: &Probe) -> Result { check_config(settings)?; restart(&settings.launchd_label)?; - if !wait_for_health(&settings.server_url) { + if !wait_for_health(health, &settings.server_url) { return Err(format!( "Switchyard did not answer {}/health within {} seconds. Open the server log and retry.", settings.server_url.trim_end_matches('/'), @@ -417,9 +421,8 @@ pub fn restart(launchd_label: &str) -> Result<(), String> { } } -pub(crate) fn wait_for_health(server_url: &str) -> bool { +pub(crate) fn wait_for_health(probe: &Probe, server_url: &str) -> bool { let deadline = Instant::now() + RESTART_WAIT; - let probe = Probe::default(); loop { if probe.probe(server_url) == ServerStatus::Running { return true; diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index f7738df662..995e295135 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -375,6 +375,20 @@ const ACTIONS: &[ActionRow] = &[ "Changes the baseline model that prices savings and the refresh interval.", &[ActionKind::SavePreferences], ), + ( + Tab::Settings, + 'f', + "Change configuration", + "Selects the server config, address, log, and price file. Existing files are preserved.", + &[ActionKind::SaveConfiguration], + ), + ( + Tab::Settings, + 'd', + "Remove app preferences", + "Reviews removal of app preferences and keeps a recovery copy. Server configs and history are preserved.", + &[ActionKind::PreviewResetSettings, ActionKind::ResetSettings], + ), ( Tab::Settings, 'p', @@ -602,6 +616,7 @@ impl Period { } enum FormKind { + Configuration, Preferences, SaveKey, ImportKey, @@ -809,6 +824,32 @@ impl Form { } }; Ok(Submit::Run(match &self.kind { + FormKind::Configuration => { + let saved = data + .settings + .as_ref() + .ok_or("App settings are unavailable.")?; + let create_server = match self.value(6).as_str() { + "yes" => true, + "no" => false, + _ => return Err("Enter yes or no for creating a server config.".into()), + }; + Action::SaveConfiguration { + config: crate::config::Config { + server_url: self.value(0), + config_file: self.value(1).into(), + routing_log: self.value(2).into(), + prices_file: (!self.value(3).is_empty()).then(|| self.value(3).into()), + launchd_label: saved.launchd_label.clone(), + baseline_model: self.value(4), + refresh_seconds: self + .value(5) + .parse() + .map_err(|_| "Enter a whole refresh interval in seconds.")?, + }, + create_server, + } + } FormKind::Preferences => Action::SavePreferences { baseline_model: self.value(0), refresh_seconds: self.value(1).parse().map_err(|_| { @@ -964,6 +1005,7 @@ impl Form { } fn label(&self) -> &'static str { match &self.kind { + FormKind::Configuration => "Saving the configuration", FormKind::Preferences => "Saving the preferences", FormKind::SaveKey => "Saving the server key", FormKind::ImportKey => "Importing the server key", @@ -1063,6 +1105,7 @@ fn route_models_form( fn reviewed(previewed: Action, preview: &Value) -> Option { let preview_token = preview["preview_token"].as_str().map(str::to_owned); Some(match previewed { + Action::PreviewResetSettings {} => Action::ResetSettings { preview_token }, Action::PreviewRoute { generation, route, @@ -1713,6 +1756,25 @@ impl App { )); } } + (Some(ActionKind::PreviewResetSettings), Ok(reply)) => { + if let Some(remove) = action.and_then(|action| reviewed(action, &reply.data)) { + self.popup = Some(Popup::review( + vec![ + Line::from( + reply.data["message"] + .as_str() + .unwrap_or_default() + .to_string(), + ), + Line::from(reply.data["file"].as_str().unwrap_or_default().to_string()), + ], + "Remove", + FormKind::Confirm("REMOVE".into(), Box::new(remove)), + "Remove app preferences?", + "Type REMOVE to keep a recovery copy and remove preferences", + )); + } + } (Some(ActionKind::PreviewSessionCleanup), Ok(reply)) => { if let Some(remove) = action.and_then(|action| reviewed(action, &reply.data)) { self.popup = Some(Popup::review( @@ -2285,6 +2347,46 @@ impl App { ActionKind::OpenSettings => { request(Action::OpenSettings {}, "Opening the app settings") } + ActionKind::SaveConfiguration => { + let saved = self + .data + .settings + .as_ref() + .ok_or("App settings are unavailable.")?; + self.open(Form::new( + FormKind::Configuration, + "Switchyard configuration", + vec![ + ("Server address", saved.server_url.clone()), + ( + "Server config file", + saved.config_file.display().to_string(), + ), + ("Routing log file", saved.routing_log.display().to_string()), + ( + "Price override (optional)", + saved + .prices_file + .as_ref() + .map(|file| file.display().to_string()) + .unwrap_or_default(), + ), + ("Baseline model ID", saved.baseline_model.clone()), + ( + "Refresh interval in seconds", + saved.refresh_seconds.to_string(), + ), + ( + "Create server config? yes/no", + if self.data.needs_setup { "yes" } else { "no" }.into(), + ), + ], + )) + } + ActionKind::PreviewResetSettings | ActionKind::ResetSettings => request( + Action::PreviewResetSettings {}, + "Reviewing app preference removal", + ), ActionKind::SavePreferences => { let (baseline, refresh) = self .data @@ -2581,27 +2683,29 @@ fn draw(frame: &mut Frame, app: &mut App) { // This destructuring has no `..`, so adding a snapshot field breaks the build until a page // shows it. Each comment names the place that shows the field. let Snapshot { - update_status: _, // draw_settings - settings: _, // draw_settings - data_state: _, // every page checks availability; draw_overview shows health - generation: _, // every route form sends it back with its action - routes: _, // draw_routes and the route details - clients: _, // draw_endpoints, the endpoint fields, and the endpoint forms - tools: _, // draw_harnesses and harness_lines - algorithms: _, // draw_routes, the route details, and the algorithm fields - summary: _, // --print shows these rows; draw_top_models builds the same notes - metrics: _, // draw_header and the Overview cards - today: _, // the Overview "Today" card title - analytics: _, // draw_top_models and draw_models - estimates: _, // the Overview cards and draw_models - baseline_model: _, // draw_top_models; draw_settings shows the saved setting - activity: _, // draw_activity - sessions: _, // the calls table title and the `s` key - entries: _, // draw_call_table and the call details - limited: _, // calls_block and draw_activity - skipped: _, // calls_block - errors: _, // draw_errors and draw_settings - refresh_seconds: _, // App::refresh_due + needs_setup: _, // draw_settings and the initial form show setup. + configuration_error: _, // draw_settings shows the error through errors + update_status: _, // draw_settings + settings: _, // draw_settings + data_state: _, // every page checks availability; draw_overview shows health + generation: _, // every route form sends it back with its action + routes: _, // draw_routes and the route details + clients: _, // draw_endpoints, the endpoint fields, and the endpoint forms + tools: _, // draw_harnesses and harness_lines + algorithms: _, // draw_routes, the route details, and the algorithm fields + summary: _, // --print shows these rows; draw_top_models builds the same notes + metrics: _, // draw_header and the Overview cards + today: _, // the Overview "Today" card title + analytics: _, // draw_top_models and draw_models + estimates: _, // the Overview cards and draw_models + baseline_model: _, // draw_top_models; draw_settings shows the saved setting + activity: _, // draw_activity + sessions: _, // the calls table title and the `s` key + entries: _, // draw_call_table and the call details + limited: _, // calls_block and draw_activity + skipped: _, // calls_block + errors: _, // draw_errors and draw_settings + refresh_seconds: _, // App::refresh_due // draw_session_table lists the inventory or shows the error that stopped reading it, // and session_lines shows the selected session. session_inventory: _, @@ -4116,6 +4220,10 @@ fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { let heading = |text: &'static str| Line::styled(text, theme.muted.bold()); let (_, _, name, about, _) = rows(Tab::Settings).nth(app.action).unwrap_or(&ACTIONS[0]); let mut lines = vec![Line::from(*about), Line::default()]; + if app.data.needs_setup { + lines.push(Line::from("Choose Change configuration to save setup. Existing config files skip setup on later installs.")); + } + if !app.data.errors.is_empty() { lines.extend( app.data @@ -4545,7 +4653,8 @@ fn form_notes(app: &App, form: &Form) -> Vec> { ), Line::default(), ], - FormKind::Preferences + FormKind::Configuration + | FormKind::Preferences | FormKind::Update(_) | FormKind::Confirm(..) | FormKind::Remove { .. } => Vec::new(), @@ -4954,7 +5063,14 @@ fn edit_in_foreground( pub fn run(mut controller: Controller) -> Result<(), String> { let (snapshot, tab) = match controller.snapshot() { - Ok(snapshot) => (snapshot, Tab::Overview), + Ok(snapshot) => { + let tab = if snapshot.needs_setup || snapshot.configuration_error.is_some() { + Tab::Settings + } else { + Tab::Overview + }; + (snapshot, tab) + } // Settings shows the error and the actions that can fix the app settings. Err(error) => ( Snapshot { @@ -4966,6 +5082,9 @@ pub fn run(mut controller: Controller) -> Result<(), String> { }; let mut app = App::new(Theme::new(color_enabled()), snapshot); app.tab = tab; + if app.data.needs_setup { + let _ = app.start_action(ActionKind::SaveConfiguration, 'f'); + } ratatui::crossterm::execute!(std::io::stdout(), event::EnableBracketedPaste) .map_err(|e| e.to_string())?; let _paste_mode = PasteMode; diff --git a/crates/switchyard-desktop/tauri.conf.json b/crates/switchyard-desktop/tauri.conf.json index 3ca080f16f..b6ce1dc040 100644 --- a/crates/switchyard-desktop/tauri.conf.json +++ b/crates/switchyard-desktop/tauri.conf.json @@ -11,15 +11,19 @@ { "label": "main", "title": "Switchyard", - "width": 680, - "height": 500, - "minWidth": 440, - "minHeight": 400 + "width": 1080, + "height": 760, + "minWidth": 760, + "minHeight": 540, + "transparent": true, + "titleBarStyle": "Overlay", + "hiddenTitle": true } ], "security": { "csp": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src ipc: http://ipc.localhost; img-src 'self' data:; object-src 'none'; frame-src 'none'; base-uri 'none'" - } + }, + "macOSPrivateApi": true }, "bundle": { "active": false, diff --git a/crates/switchyard-desktop/tests/desktop.rs b/crates/switchyard-desktop/tests/desktop.rs index 71e560b5b3..f54c36b20c 100644 --- a/crates/switchyard-desktop/tests/desktop.rs +++ b/crates/switchyard-desktop/tests/desktop.rs @@ -169,6 +169,94 @@ esac port } + #[test] + fn fresh_install_defers_setup_and_reinstalls_reuse_saved_configuration() { + let f = Fixture::new(); + let server = f.s.sy_home.join("composite.toml"); + fs::remove_file(&server).expect("fresh state"); + write(&f.profile(), "invalid ["); + write(&f.s.prices_file(), "invalid prices"); + f.ok("install"); + assert!(!server.exists()); + assert!(!f.s.desktop_settings().exists()); + assert_eq!(read(&f.profile()), "invalid ["); + assert_eq!(read(&f.s.prices_file()), "invalid prices"); + fs::remove_file(f.profile()).expect("unrelated profile"); + assert!( + switchyard_desktop::config::needs_setup(&f.s.desktop_settings()).expect("fresh setup") + ); + let config = + switchyard_desktop::config::Config::load(&f.s.desktop_settings()).expect("defaults"); + switchyard_desktop::config::save_configuration(&f.s.desktop_settings(), &config, true) + .expect("save setup"); + let mut selected = config.clone(); + selected.config_file = f.s.sy_home.join("selected-server.toml"); + selected.routing_log = f.s.sy_home.join("selected-log.jsonl"); + selected.prices_file = Some(f.s.sy_home.join("selected-prices.toml")); + fs::copy(&server, &selected.config_file).expect("selected config"); + write( + selected.prices_file.as_ref().expect("price path"), + "[model]\ninput=1\noutput=2\ncache_read=0\ncache_write=0\n", + ); + switchyard_desktop::config::save_configuration(&f.s.desktop_settings(), &selected, false) + .expect("change selected files"); + let command = f.s.server_command().expect("selected server command"); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert!( + args.windows(2) + .any(|pair| pair == ["--config", selected.config_file.to_str().expect("path")]) + ); + assert!(args.windows(2).any(|pair| pair + == [ + "--routing-log-file", + selected.routing_log.to_str().expect("path") + ])); + assert!(args.windows(2).any(|pair| { + pair == [ + "--pricing-file", + selected + .prices_file + .as_ref() + .expect("price") + .to_str() + .expect("path"), + ] + })); + for host in ["localhost", "0.0.0.0"] { + selected.server_url = format!("http://{host}:{}", f.s.port); + switchyard_desktop::config::save_configuration( + &f.s.desktop_settings(), + &selected, + false, + ) + .expect("selected server address"); + let command = f.s.server_command().expect("loopback command"); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect(); + assert!(args.windows(2).any(|pair| pair == ["--host", "127.0.0.1"])); + } + selected.server_url = format!("http://localhost:{}", health_server()); + switchyard_desktop::config::save_configuration(&f.s.desktop_settings(), &selected, false) + .expect("restore server address"); + let saved = fs::read(f.s.desktop_settings()).expect("saved settings"); + f.ok("install"); + f.ok("install"); + assert!( + !switchyard_desktop::config::needs_setup(&f.s.desktop_settings()).expect("reinstall") + ); + assert_eq!(fs::read(f.s.desktop_settings()).expect("preserved"), saved); + assert_eq!(read(&server), install::BUNDLED_CONFIG); + assert_eq!( + toml(&f.profile())["model_providers"]["sy"]["base_url"].as_str(), + Some(format!("{}/v1", selected.server_url).as_str()) + ); + } + struct Fixture { dir: tempfile::TempDir, s: Settings, @@ -202,6 +290,8 @@ esac }; let file = dir.path().join("install.toml"); let f = Self { dir, s, file }; + // Most cases exercise an installation with an existing server configuration. + write(&f.s.sy_home.join("composite.toml"), install::BUNDLED_CONFIG); f.save(); f } @@ -423,6 +513,7 @@ esac // The helper rejects malformed commands and settings before it writes anything. let f = Fixture::new(); + let unchanged = f.files(); let bad = "key;key set;key status --config;install --dryrun;install --dry-run extra;\ uninstall extra;validate --server relative --config /config;\ validate --server /server --config /config\n"; @@ -449,7 +540,7 @@ esac let names_profile = String::from_utf8_lossy(&output.stdout).contains("team-dev"); assert_eq!(names_profile, args.contains("FILE"), "{args}"); } - assert!(f.files().is_empty()); + assert_eq!(f.files(), unchanged); write(&f.file, &format!("{}\nunknown = true\n", read(&f.file))); assert!(Settings::load(&f.file).is_err()); } diff --git a/crates/switchyard-desktop/tests/setup.test.cjs b/crates/switchyard-desktop/tests/setup.test.cjs new file mode 100644 index 0000000000..56f97b2453 --- /dev/null +++ b/crates/switchyard-desktop/tests/setup.test.cjs @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +'use strict'; +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +function editor(invoke = () => new Promise(() => {})) { + const elements = new Map(); + const element = selector => { + if(!elements.has(selector))elements.set(selector,{dataset:{},append(){},querySelectorAll(){return [];},setAttribute(){},focus(){this.focused=true;}}); + return elements.get(selector); + }; + const context = vm.createContext({ + window: {__TAURI__: {core: {invoke}}}, + document: {createElement:()=>({append(){}}),querySelector: element, querySelectorAll: () => []}, + setTimeout: () => {},structuredClone, + }); + const source=fs.readFileSync(path.join(__dirname, '../ui/app.js'), 'utf8'); + // The fixture loads the real functions without starting the app's initial refresh. + vm.runInContext(source.slice(0,source.indexOf("\ncontent.append(node('p','Loading Switchyard…'")), context); + return context; +} +// Only the first snapshot selects setup; a later refresh must preserve the user's navigation. +test('first launch opens setup and retained files skip it after reinstall', async () => { + for(const needsSetup of [true,false]){ + const context=editor(()=>Promise.resolve({needs_setup:needsSetup,errors:[]})); + vm.runInContext(`render=()=>{};showPageErrors=()=>{};showFreshness=()=>{};capturePageState=()=>({});restorePageState=()=>{};`,context); + await vm.runInContext('refresh()',context); + assert.equal(vm.runInContext('page',context),needsSetup?'setup':'overview'); + vm.runInContext("page='settings'",context); + await vm.runInContext('refresh()',context); + assert.equal(vm.runInContext('page',context),'settings'); + } +}); + +// The real form handlers submit the selected files through the shared controller action. +test('setup submits selected files and Settings reuses the same form', async () => { + const context=editor(); + const result=await vm.runInContext(`(async()=>{ + const controls=new Map(),handlers=new Map(),saved=[]; + node=()=>({append(){}});card=()=>({append(){}});actions=()=>({}); + input=value=>({value});combobox=(options,value)=>({value}); + field=(parent,label,control)=>{controls.set(label,control);return control;}; + button=()=>({});mutation=(parent,label,callback)=>{handlers.set(label,callback);return {};}; + fieldError=()=>true;confirmAction=(title,message,label,callback)=>callback();navigate=()=>{}; + run=async action=>{saved.push(action);return {};}; + snapshot={needs_setup:true,settings:{path:'/tmp/desktop.toml',server_url:'http://127.0.0.1:4123',config_file:'/tmp/default.toml',routing_log:'/tmp/routing.jsonl',launchd_label:'com.nvidia.switchyard.server',baseline_model:'baseline',refresh_seconds:30}}; + renderConfiguration(true); + controls.get('Server config file').value='/tmp/chosen.toml';controls.get('Server config file').oninput(); + controls.get('Server configuration').value='existing';controls.get('Server configuration').onchange(); + await handlers.get('Save setup')();await Promise.resolve(); + renderConfiguration(false); + return {saved,settingsHasSave:handlers.has('Save configuration')}; + })()`,context); + const plain=JSON.parse(JSON.stringify(result)); + assert.equal(plain.saved.length,1); + assert.equal(plain.saved[0].kind,'save_configuration'); + assert.equal(plain.saved[0].config.config_file,'/tmp/chosen.toml'); + assert.equal(plain.saved[0].create_server,false); + assert.equal(plain.settingsHasSave,true); +}); diff --git a/crates/switchyard-desktop/ui/app.js b/crates/switchyard-desktop/ui/app.js new file mode 100644 index 0000000000..ba2c3ed762 --- /dev/null +++ b/crates/switchyard-desktop/ui/app.js @@ -0,0 +1,661 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +'use strict'; +const invoke = window.__TAURI__.core.invoke; +const content = document.querySelector('#content'); +// The page panel scrolls; the window does not. +const scroller = document.querySelector('main'); +const status = document.querySelector('#status'); +const notification = document.querySelector('#notification'); +const pageErrors = document.querySelector('#page-errors'); +// Each action replaces the previous notification so dismissed results stay dismissed. +let notificationReturn; +function notify(message, tone = 'success', details = []) { + if(notification.hidden&&document.activeElement!==document.body)notificationReturn=document.activeElement; + notification.dataset.tone = tone; + const label = {success:'✓',progress:'In progress:',warning:'Attention:',error:'Error:'}[tone]; + status.textContent = message ? `${label} ${message}` : ''; + const disclosure = document.querySelector('#notification-details'); + disclosure.open = false; + disclosure.hidden = !details.length; + document.querySelector('#notification-detail-text').textContent = details.join('\n'); + notification.hidden = !message; +} +// Background failures remain on the page without replacing the last action result. +function showPageErrors(message) { + pageErrors.textContent = message; + pageErrors.hidden = !message; +} +// Dismissal returns keyboard focus to an available element, even during an operation. +function dismissNotification(event) { + notification.hidden = true; + status.textContent = ''; + const refreshButton = document.querySelector('#refresh'); + if(!event || event.detail===0) (notificationReturn?.isConnected && !notificationReturn.disabled ? notificationReturn : refreshButton.disabled ? document.querySelector('#title') : refreshButton).focus(); +} + +let snapshot, page = 'overview', busy = false, reading = false, lastRefresh = null, stale = false; +const sessionView = {tool:'',account:'',route:'',project:''}; +const accountView = {tool:'codex_cli',name:''}; +const activity = []; +const connectedTools=new Set(); +let sessionOpened=false; +let configurationDraft; +const pageNames = {overview:'Overview',routes:'Routes',install:'Connect tools',usage:'Usage',sessions:'Sessions',settings:'Settings',setup:'Set up Switchyard'}; +let operationQueue = Promise.resolve(),ipcQueue=Promise.resolve(); +// The native controller accepts one operation at a time; queued reads leave the page usable. +function request(command,args){const captured=args===undefined?undefined:structuredClone(args);const pending=ipcQueue.then(()=>invoke(command,captured));ipcQueue=pending.catch(()=>{});return pending;} +const drafts = new Map(), lists = new Map(); +const usagePageSize = 100; +const usageView = {page:0,session:'',search:'',exact:null,period:'all',role:'all',turn:'',sort:'newest'}; +const routeView = {selected:'',search:'',pane:'editor'}; +const installView = {tool:'',account:'',route:'',file:'',mode:'default'}; +function node(tag, text, cls) { const n = document.createElement(tag); if (text !== undefined) n.textContent = text; if (cls) n.className = cls; return n; } +function card(title) { const c = node('div', undefined, 'card'); c.append(node('h3', title)); content.append(c); return c; } +function button(parent, text, fn, primary = false) { const b = node('button', text, primary ? 'primary' : ''); b.onclick = fn; parent.append(b); return b; } +// Wry's macOS delegate has no JavaScript confirm handler, so this dialog handles confirmation. +function confirmAction(title, message, label, action, onCancel) { + const previous=document.activeElement,overlay=node('div',undefined,'modal-backdrop'),dialog=node('div',undefined,'confirm-dialog'); + dialog.setAttribute('role','dialog');dialog.setAttribute('aria-modal','true');dialog.setAttribute('aria-labelledby','confirmation-title');dialog.setAttribute('aria-describedby','confirmation-description'); + const heading=node('h3',title);heading.id='confirmation-title';heading.tabIndex=-1; + const description=node('p',message,'confirmation-description');description.id='confirmation-description'; + dialog.append(heading,description);overlay.append(dialog);document.body.append(overlay); + const background=[...document.body.children].filter(element=>element!==overlay).map(element=>[element,element.inert]); + background.forEach(([element])=>{element.inert=true;}); + const close=(cancelled=true)=>{overlay.remove();background.forEach(([element,inert])=>{element.inert=inert;});if(previous?.isConnected)previous.focus();if(cancelled)onCancel?.();}; + const controls=actions(dialog),cancel=button(controls,'Cancel',close),accept=button(controls,label,()=>{close(false);action();});accept.classList.add('danger'); + dialog.onkeydown=event=>{if(event.key==='Escape'){event.preventDefault();close();}else if(event.key==='Tab'){const items=[cancel,accept],index=items.indexOf(document.activeElement);event.preventDefault();items[index<0?(event.shiftKey?items.length-1:0):(index+(event.shiftKey?-1:1)+items.length)%items.length].focus();}}; + if(message.length>300)heading.focus();else cancel.focus(); +} +// A combobox lives in a div because a label would forward clicks on its list to the input. +let helpCount=0; +function field(parent, title, control, help) { + control.setAttribute('aria-label',title); + const box=node(control.host?'div':'label',title,'field');box.append(control.host||control); + if(help){const note=node('small',help,'field-help');note.id=`field-help-${++helpCount}`;box.append(note);control.dataset.help=note.id;control.setAttribute('aria-describedby',note.id);} + parent.append(box);return control; +} +function input(value = '', type = 'text') { const i = node('input'); i.type = type; i.value = value; return i; } +function text(parent, value) { parent.append(node('pre', value)); } +function actions(parent) { const a = node('div', undefined, 'actions'); parent.append(a); return a; } +let lockedFields=[]; +function setBusy(value) { + busy=value; + if(value){lockedFields=[...document.activeElement?.closest?.('.card')?.querySelectorAll('input,select')||[]].map(control=>[control,control.disabled]);lockedFields.forEach(([control])=>{control.disabled=true;});} + else{for(const [control,disabled] of lockedFields)control.disabled=disabled||control.dataset.blocked==='true';lockedFields=[];} + document.querySelectorAll('button[data-mutation]').forEach(n=>{n.disabled=value || n.dataset.blocked==='true';}); + document.querySelector('#content').setAttribute?.('aria-busy',String(value)); +} +// Reads and writes use the controller queue; only writes lock mutation controls. +function enqueue(operation) { + operationQueue=operationQueue.then(operation).catch(error=>notify(String(error),'error')); + return operationQueue; +} +async function readAction(action) { + return request('action',{action}); +} +async function run(action, refreshAfter = true) { + if(['editor','preview_install','preview_restore','preview_launch','preview_update','preview_route','preview_account_archive','preview_session_cleanup'].includes(action.kind)) { + try { return await readAction(action); } catch(error) { notify(String(error),'error'); return; } + } + if(busy)return; + const progress={apply:'Saving the route and restarting Switchyard…',remove:'Removing the route and restarting Switchyard…',restart:'Restarting Switchyard…',install:'Applying routing settings…',restore:'Restoring settings…',launch:'Opening Terminal…',models:'Loading models…',save_server_key:'Saving the key…',import_server_key:'Reading the variable from your login shell…',remove_server_key:'Removing the key…'}; + const previous=document.activeElement; + setBusy(true);notificationReturn=previous?.tagName==='BODY'?document.querySelector('#title'):previous;notify(progress[action.kind]||'Working…','progress'); + try { + const result=await request('action',{action}); + notify(result.message,result.data?.warning?'warning':'success',result.data?.details||[]); + if(action.kind==='install')connectedTools.add(action.tool);if(action.kind==='launch')sessionOpened=true; + activity.unshift({message:result.message,time:new Date().toLocaleTimeString()});activity.splice(8); + if(refreshAfter)await refresh(true); + return result; + } catch(error) {notify(String(error),'error');} + finally {setBusy(false);if(previous?.isConnected&&!previous.disabled)previous.focus({preventScroll:true});else document.querySelector('#title').focus();} +} +function capturePageState() { + const focused=document.activeElement; + return {label:focused?.getAttribute?.('aria-label'),id:focused?.id,start:focused?.selectionStart,end:focused?.selectionEnd, + open:[...content.querySelectorAll('details[open]')].map(n=>n.querySelector('summary')?.textContent),scroll:scroller.scrollTop||0, + regions:[...content.querySelectorAll('.scroll,.usage-table-scroll,.route-picker')].map(n=>[n.scrollTop,n.scrollLeft])}; +} +function restorePageState(saved) { + content.querySelectorAll('details').forEach(n=>{n.open=saved.open.includes(n.querySelector('summary')?.textContent);}); + const control=[...content.querySelectorAll('input,select,button')].find(n=>saved.id?n.id===saved.id:saved.label&&n.getAttribute('aria-label')===saved.label); + if(control && !control.disabled){control.focus({preventScroll:true});if(saved.start!==null && saved.start!==undefined && control.setSelectionRange && !['number','date'].includes(control.type))control.setSelectionRange(saved.start,saved.end);} + content.querySelectorAll('.scroll,.usage-table-scroll,.route-picker').forEach((n,i)=>{if(saved.regions[i])[n.scrollTop,n.scrollLeft]=saved.regions[i];}); + scroller.scrollTo?.(0,saved.scroll); +} +async function refresh(force = true) { + if(reading)return; + reading=true; + document.querySelector('#refresh').setAttribute?.('aria-busy','true'); + try { + const latest=await request('snapshot'); + // A menu, dialog, or action may start while IPC waits; the next poll can apply a fresh snapshot. + if(!force&&(busy||document.querySelector('[role="dialog"]')||choosing()))return; + // The user may navigate during the read, so capture the current page after the reply. + const saved=snapshot?capturePageState():null; + if(!snapshot && latest.needs_setup)page='setup'; + snapshot=latest;lastRefresh=new Date();stale=false; + if(force || ['overview','usage','settings','setup'].includes(page)) {render();if(saved)restorePageState(saved);} + showPageErrors((snapshot.errors||[]).join('\n'));showFreshness(); + } catch(error) {stale=true;showPageErrors(`Could not refresh the page: ${error}`);showFreshness();if(!snapshot)renderRecovery(String(error));} + finally {reading=false;document.querySelector('#refresh').setAttribute?.('aria-busy','false');} +} +function showFreshness() { + const health=document.querySelector('#server-state');if(health&&snapshot?.metrics){health.replaceChildren();const state=snapshot.data_state?.health?.state||(snapshot.metrics.running?'running':'unreachable');health.hidden=state==='running'&&!stale;if(!health.hidden){health.append(node('span',stale?'Server status is stale. Check again before relying on it.':`Server ${state}. ${snapshot.data_state?.health?.reason||snapshot.metrics.server_url}`));button(health,'Check again',()=>refresh(false));mutation(health,'Retry restart',()=>run({kind:'restart'}));button(health,'Open recovery',()=>navigate('settings'));}} + const label=document.querySelector('#freshness'); + if(label)label.textContent=lastRefresh?`${stale?'Stale · last successful check':'Checked'} ${lastRefresh.toLocaleTimeString()}`:'Loading…'; + const connection=document.querySelector('.connection-state');if(stale&&connection){connection.textContent='Unavailable · last check failed';connection.classList.remove('connected');} +} +function navigate(next) {page=next;if(next==='settings')serverKeys.loaded=false;render();document.querySelector('#title').focus();scroller.scrollTo?.(0,0);} +function renderRecovery(error) { + content.replaceChildren();const c=card('App settings need attention');c.append(node('p',error)); + button(actions(c),'Open app settings',()=>run({kind:'open_settings'},false));button(actions(c),'Retry',()=>refresh()); +} +function blocked(control, value) {control.dataset.blocked=String(value);control.disabled=(control.dataset.mutation==='true'&&busy)||value;} +function mutation(parent,label,fn,primary=false) {const b=button(parent,label,fn,primary);b.dataset.mutation='true';b.disabled=busy;return b;} +function fieldError(control,message) { + const id=`error-${control.getAttribute('aria-label').replace(/[^a-z0-9]/gi,'-')}`,box=control.closest('.field'); + let note=box.querySelector('.field-error');if(!note){note=node('small',undefined,'field-error');note.id=id;box.append(note);} + note.textContent=message;note.hidden=!message;control.setAttribute('aria-describedby',[control.dataset.help,id].filter(Boolean).join(' '));control.setAttribute('aria-invalid',String(Boolean(message))); + return !message; +} +// A refresh would redraw an open option list and discard the user's search text. +function choosing() {return document.activeElement?.getAttribute?.('aria-expanded')==='true';} +function routeMatches(route, query) { return [route.id,route.label,snapshot.algorithms.find(a=>a.kind===route.kind)?.title,...route.choices.flatMap(c=>[c.model,c.client,snapshot.clients.find(e=>e.name===c.client)?.host])].join(' ').toLowerCase().includes(query.toLowerCase()); } +function routeField(c,chosen='',tool='') { + const options=snapshot.routes.map(r=>[r.key,r.id,[snapshot.algorithms.find(a=>a.kind===r.kind)?.title||r.kind,routeSummary(r),tool&&r.compatibility?.[tool]?'Unavailable for this harness':''].filter(Boolean).join(' · '),r.choices.map(c=>snapshot.clients.find(e=>e.name===c.client)?.host).join(' ')]); + return field(c,'Route',combobox(options,chosen||undefined,{mono:true,placeholder:'Search by route, model, or endpoint'}),'The harness requests this route’s public model ID.'); +} +function routeArgs(s) { const r=snapshot.routes.find(r=>r.key===s.value); if (!r) throw new Error('Choose a route first.'); return {route:r.key,id:r.id}; } +let generation; +function render() { + disposeCharts(); + content.replaceChildren(); + document.body.dataset.page=page; + document.querySelector('#title').textContent=pageNames[page]; + document.querySelectorAll('[data-page]').forEach(b=>{b.classList.toggle('active',b.dataset.page===page);if(b.dataset.page===page)b.setAttribute('aria-current','page');else b.removeAttribute('aria-current');}); + if(!snapshot){content.append(node('p','Loading Switchyard…','empty-state'));return;} + generation=snapshot.generation; + if (page==='overview') { + renderOverview(); + } else if (page==='routes') { + renderRouteBrowser(); + + } else if (page==='install') { + const intro=card('Connect tools'); + intro.append(node('p','Choose a Switchyard route for each harness, review the settings below, then apply routing settings.')); + const help=node('details');help.append(node('summary','How accounts and settings work'));help.append(node('p','Subscription routes use the harness’s login. API routes use credentials configured on the Switchyard server. Keys entered in Routes only load model lists.'));help.append(node('p','Installation backs up the original settings. Shell variables and project settings may override these defaults.'));intro.append(help); + const tool=field(intro,'Harness',combobox(snapshot.tools.map(t=>[t.tool,t.label,t.available?'Detected':'Not detected']),installView.tool||snapshot.tools[0].tool),'Switchyard changes this harness’s settings so it sends requests through the selected route.'); + installView.tool=tool.value; + const panel=node('div');content.append(panel); + const show=()=>{installView.tool=tool.value;installView.account='';installView.file='';installView.mode='default';panel.replaceChildren();renderInstall(snapshot.tools.find(t=>t.tool===tool.value),panel);}; + tool.onchange=show;renderInstall(snapshot.tools.find(t=>t.tool===tool.value),panel); + renderAccounts(); + } else if(page==='usage')renderUsage(); + else if(page==='sessions'){renderSessions();renderAccounts();} + else if(page==='settings')renderSettings(); + else if(page==='setup')renderConfiguration(true); + showFreshness(); +} +function usageUnavailable() {return snapshot.data_state.usage.state==='unavailable'||snapshot.data_state.history.state==='unavailable';} +// The final tie-breaker keeps calls with later log positions first across refreshes. +function sortedCalls(entries, order) { + const compareText=(a,b)=>ab?1:0; + return entries.map((entry,index)=>({entry,index,time:Date.parse(entry.ts)})).sort((a,b)=>{ + const validA=Number.isFinite(a.time),validB=Number.isFinite(b.time); + let time=0; + if(validA!==validB)time=validA?-1:1; + else if(validA)time=order==='oldest'?a.time-b.time:b.time-a.time; + const model=compareText(a.entry.model||'',b.entry.model||''); + const tokens=(b.entry.prompt_tokens+b.entry.completion_tokens)-(a.entry.prompt_tokens+a.entry.completion_tokens); + let primary=0; + if(order==='model')primary=model; + else if(order==='tokens')primary=tokens; + return primary||time||model||b.index-a.index; + }).map(row=>row.entry); +} +function renderUsage() { + const c=card('Model calls');c.append(node('p','Each row shows a completed model call. A turn may include several calls. Choose the order below.')); + const entries=snapshot.entries,dates=entries.map(e=>new Date(e.ts)).filter(d=>!Number.isNaN(d.getTime())).sort((a,b)=>a-b); + c.append(node('p',`${entries.length} retained calls${snapshot.limited?' · limited to 8 MiB / 5,000 records':''} · ${snapshot.skipped} unreadable recent records. ${dates.length?`${dates[0].toLocaleString()} – ${dates.at(-1).toLocaleString()}`:'No retained timestamps.'}`,'muted')); + if(usageView.exact){const pill=node('div',undefined,'usage-filter');pill.append(node('span',`${usageView.exact.group}: ${usageView.exact.id||'Not recorded'}`));button(pill,'Clear comparison',()=>{usageView.exact=null;usageView.page=0;render();});c.append(pill);} + const filters=node('div',undefined,'analytics-controls');c.append(filters); + const period=field(filters,'Usage period',combobox([['all','All recent history'],['week','Seven local calendar days'],['today','Today']],usageView.period)); + const session=field(filters,'Session',combobox([['','All sessions'],['__missing','Session not recorded'],...snapshot.sessions.map(s=>[s,s])],usageView.session)); + const search=field(filters,'Search turn, model, route, or session',input(usageView.search)); + const role=field(filters,'Call role',combobox([['all','All calls'],['answer','Answer calls'],['overhead','Routing overhead']],usageView.role)); + const sort=field(filters,'Sort calls',combobox([['newest','Time: newest first'],['oldest','Time: oldest first'],['model','Model: A to Z'],['tokens','Total tokens: highest first']],usageView.sort)); + const tally=node('p',undefined,'muted');c.append(tally); + const a=actions(c);button(a,'Clear all filters',()=>{Object.assign(usageView,{page:0,session:'',search:'',exact:null,role:'all',turn:'',period:'all'});render();}); + button(a,'Copy filtered calls',()=>copyText(JSON.stringify(filteredCalls(),null,2))); + const paging=actions(c),previous=button(paging,'Previous calls',()=>{usageView.page--;draw(false);container.scrollTop=0;}),range=node('span'),next=button(paging,'Next calls',()=>{usageView.page++;draw(false);container.scrollTop=0;});paging.insertBefore(range,next);range.setAttribute('aria-live','polite'); + const container=node('div',undefined,'scroll');container.tabIndex=0;container.setAttribute('role','region');container.setAttribute('aria-label','Model calls table');c.append(container); + function filteredCalls(){return sortedCalls(snapshot.entries,usageView.sort).filter(e=>{ + const exact=usageView.exact;if(exact&&(e[{model:'model',route:'route_id',session:'session_id'}[exact.group]]||'')!==exact.id)return false; + if(!inPeriod(e.ts,usageView.period,snapshot.today))return false; + if(usageView.session==='__missing'?Boolean(e.session_id):usageView.session&&e.session_id!==usageView.session)return false; + if(usageView.turn&&e.turn_id!==usageView.turn)return false; + if(usageView.role!=='all'&&(usageView.role==='overhead')!==(e.tier==='classifier'))return false; + return [e.model,e.turn_id,e.route_id,e.session_id].join(' ').toLowerCase().includes(usageView.search.toLowerCase()); + });} + const draw=(reset=true)=>{ + if(reset)usageView.page=0; + usageView.session=session.value;usageView.search=search.value;usageView.period=period.value;usageView.role=role.value;usageView.sort=sort.value; + container.replaceChildren();const rows=filteredCalls();tally.textContent=`${rows.length} matching of ${entries.length} retained calls${usageView.turn?` · Turn: ${usageView.turn}`:''}`; + usageView.page=Math.max(0,Math.min(usageView.page,Math.ceil(rows.length/usagePageSize)-1)); + const start=usageView.page*usagePageSize,shown=rows.slice(start,start+usagePageSize); + range.textContent=rows.length?`Showing calls ${start+1}–${start+shown.length} of ${rows.length}`:'No matching calls';previous.disabled=usageView.page===0;next.disabled=start+shown.length>=rows.length; + if(usageUnavailable()){paging.hidden=true;container.append(node('p','Usage unavailable. Check the routing log in Settings and retry.','empty-state'));button(container,'Retry',()=>refresh());return;} + const table=node('table'),thead=node('thead'),head=node('tr'),tbody=node('tbody'); + for(const label of ['Time','Model / role','Session / turn','Route','Uncached input','Cached input','Cache writes','Output']){const th=node('th',label);th.scope='col';head.append(th);}thead.append(head);table.append(thead,tbody); + for(const e of shown){const row=node('tr'),time=node('td'),date=new Date(e.ts);time.title=e.ts;time.textContent=Number.isNaN(date.getTime())?e.ts:date.toLocaleString(); + const model=node('td');model.append(node('span',e.model,'table-primary'),node('small',e.tier==='classifier'?'Routing overhead':'Answer call','table-secondary')); + const ids=node('td');if(e.session_id)button(ids,e.session_id,()=>{usageView.session=e.session_id;usageView.page=0;render();}).title=e.session_id;else ids.append(node('span','Session not recorded'));if(e.turn_id){button(ids,e.turn_id,()=>{usageView.turn=e.turn_id;draw();}).title=e.turn_id;button(ids,'Copy turn ID',()=>copyText(e.turn_id));}else ids.append(node('small','Turn not recorded')); + row.append(time,model,ids,node('td',e.route_id||'Route not recorded'));for(const value of [Math.max(0,e.prompt_tokens-e.cached_tokens-(e.cache_creation_tokens||0)),e.cached_tokens,e.cache_creation_tokens||0,e.completion_tokens])row.append(node('td',value.toLocaleString()));tbody.append(row); + }container.append(table); + if(!rows.length)container.append(node('p',entries.length?'No calls match these filters. A selected session may be outside recent history.':'No calls recorded yet. Start a session, then return here.','empty-state')); + if(usageView.exact&&snapshot.limited)container.prepend(node('p','Comparison totals cover the full log; this table only includes the retained range shown above.','coverage-note')); + };for(const control of [period,session,role,sort])control.onchange=draw;search.oninput=draw;draw(false); +} +async function choosePath(control,directory,changed){try{const result=await request('choose_path',{directory});if(result&&control.isConnected){control.value=result;changed();}}catch(error){notify(`Could not choose a ${directory?'folder':'file'}: ${error}`,'error');}} +async function copyText(value) {try{await navigator.clipboard.writeText(value);notify('Copied.');}catch{notify('Copy failed. Select and copy the text manually.','error');}} +function ignoredText(paths=[]){if(!paths.length)return '';const shown=paths.slice(0,20);return `\nCleanup also deletes these Git-ignored files:\n${shown.join('\n')}${paths.length>shown.length?`\n…and ${paths.length-shown.length} more`:''}`;} +function sessionPreviewText(data){return [`Repository: ${data.repository}`,`Starting folder: ${data.relative_directory?`${data.relative_directory} within the new worktree`:'New worktree root'}`,`Branch: ${data.branch||'Detached HEAD'}`,`Revision: ${data.revision}`,`Route: ${data.route}`,`Account: ${data.account_directory||'Current login'}`,data.dirty?'Your checkout has uncommitted changes.':'',data.notice,data.settings_notice].filter(Boolean).join('\n\n');} +function renderSessions() { + const c=card('New session');c.append(node('p','Create a Git worktree from committed HEAD and open the harness in Terminal. Uncommitted changes stay in the original checkout.')); + const tools=snapshot.tools.filter(t=>['codex_cli','claude','pi'].includes(t.tool)); + const tool=field(c,'Session harness',combobox(tools.map(t=>[t.tool,t.label,t.available?'Detected':'Not detected']),sessionView.tool||tools[0]?.tool));sessionView.tool=tool.value; + const account=field(c,'Session account',combobox([['','Current login','The harness’s normal login'],...(tools.find(t=>t.tool===tool.value)?.accounts||[]).map(n=>[n,n,'Saved account'])],sessionView.account)); + const route=routeField(c,sessionView.route,tool.value); + const project=field(c,'Project folder',input(sessionView.project));project.placeholder='Choose a Git checkout folder'; + const info=node('p',undefined,'muted');c.append(info);const preview=node('div');preview.setAttribute('role','status');preview.setAttribute('aria-live','polite');c.append(preview);let plan; + const a=actions(c),review=button(a,'Review session',async()=>{const reviewed=args();preview.setAttribute('aria-busy','true');preview.textContent='Checking the project and session settings…';try{const r=await readAction({kind:'preview_launch',...reviewed});if(JSON.stringify(reviewed)!==JSON.stringify(args())){preview.textContent='Session inputs changed. Review them again.';return;}plan={reply:r,args:structuredClone(reviewed)};preview.replaceChildren(node('pre',sessionPreviewText(r.data)));r.message=sessionPreviewText(r.data);blocked(launch,false);}catch(error){fieldError(project,String(error));preview.textContent='Session preview unavailable. Correct the project or selected settings and retry.';}finally{preview.setAttribute('aria-busy','false');}}),launch=mutation(a,'Open Terminal…',()=>{if(!plan)return;const reviewed=structuredClone(plan.args),reply=plan.reply;confirmAction('Start this session?',reply.message,'Open Terminal',()=>run({kind:'launch',...reviewed,preview_token:reply.data.preview_token}));},true); + const args=()=>({tool:tool.value,account:account.value||null,...routeArgs(route),project:project.value}); + const changed=()=>{Object.assign(sessionView,{tool:tool.value,account:account.value,route:route.value,project:project.value});plan=null;preview.replaceChildren();blocked(launch,true);const t=tools.find(t=>t.tool===tool.value);const incompatible=snapshot.routes.find(r=>r.key===route.value)?.compatibility?.[tool.value];fieldError(project,'');blocked(review,!t?.available||!route.value||!project.value||Boolean(incompatible));if(incompatible)preview.append(node('p',incompatible,'field-error'));info.textContent=account.value?`Saved account: ${account.value}. ${t?.account_details?.find(a=>a.name===account.value)?.path||''}. Login status is unknown until the harness confirms it.`:t?.status||'Select a harness.';if(tool.value==='pi')info.append(node('p','Pi uses isolated session settings. Personal settings and extensions are not copied.'));}; + button(a,'Choose project folder…',()=>choosePath(project,true,changed)); + tool.onchange=()=>{sessionView.account='';sessionView.tool=tool.value;render();};account.onchange=changed;route.onchange=changed;project.oninput=changed;changed(); + if(!snapshot.routes.length){c.append(node('p','Add a route before starting a session.'));button(a,'Choose a route',()=>navigate('routes'));} + c.append(node('p','Codex app workspaces are managed in the Codex app. Supported command line harnesses appear above.','muted')); + const inventory=card('Recorded sessions');inventory.append(node('p','Recorded calls do not establish that a process is running.')); + if(snapshot.session_inventory_error)inventory.append(node('p',snapshot.session_inventory_error,'field-error')); + for(const item of snapshot.session_inventory||[]){const row=node('div',undefined,'route-row'),last=snapshot.entries.filter(e=>e.session_id===item.id).at(-1);row.append(node('h4',item.id),node('p',[item.repository,item.branch,item.route,item.worktree,item.created_at,last?`Last recorded call: ${last.ts}`:'No recent recorded call'].filter(Boolean).join(' · ')));button(actions(row),'Copy path',()=>copyText(item.worktree));button(actions(row),'Open folder',()=>run({kind:'open_session',id:item.id},false));mutation(actions(row),'Review cleanup…',async()=>{const result=await run({kind:'preview_session_cleanup',id:item.id},false);if(!result)return;const data=result.data;confirmAction('Remove session worktree?',`${data.cleanup_notice}\n\nDirectory: ${data.worktree}\nBranch: ${data.current_branch}\nRevision: ${data.current_revision}\n${data.dirty?'This worktree has uncommitted changes. Cleanup will refuse to remove it.':'The worktree has no uncommitted changes.'}${ignoredText(data.ignored)}`,'Remove session',()=>run({kind:'remove_session',id:item.id,preview_token:data.preview_token}));});inventory.append(row);} + if(!(snapshot.session_inventory||[]).length){inventory.append(node('p','Switchyard has not recorded any managed sessions.'));const ids=actions(inventory);ids.classList.add('session-ids');for(const id of snapshot.sessions){button(ids,id,()=>{usageView.session=id;navigate('usage');});}} +} +function updatePreviewText(data){return [`Source checkout: ${data.source}`,`Branch: ${data.branch||'Detached HEAD'}`,`Revision: ${data.revision}`,`Local changes: ${data.changes||'None'}`,Array.isArray(data.plan)?data.plan.join('\n'):data.plan].filter(Boolean).join('\n\n');} +// Key status loads on demand because each check runs the helper and queries the credential store. +// Typed keys stay in drafts so a periodic re-render does not clear a half-typed key. +const serverKeys={loaded:false,loading:false,keys:[],store:'',error:'',changed:false,drafts:new Map(),shown:new Set()}; +async function loadServerKeys(){ + if(serverKeys.loading)return;serverKeys.loading=true; + try{const result=await readAction({kind:'server_keys'});serverKeys.keys=result.data.keys;serverKeys.store=result.data.store;serverKeys.error='';} + catch(error){serverKeys.error=String(error);} + finally{serverKeys.loading=false;serverKeys.loaded=true;} + if(page==='settings'){const saved=capturePageState();render();restorePageState(saved);} +} +function renderServerKeys() { + const c=card('Server keys'); + c.append(node('p',`The server that this app starts reads each api_key_env key from ${serverKeys.store||'the system credential store'}, not from your shell. A server you start from a terminal still reads your shell's variables. Import from shell reads the variable once from your login shell.`)); + if(!serverKeys.loaded){c.append(node('p','Loading server keys…'));loadServerKeys();return;} + if(serverKeys.error){c.append(node('p',`Could not check server keys: ${serverKeys.error}`,'field-error'));} + else if(!serverKeys.keys.length)c.append(node('p','The server config names no api_key_env variables.')); + for(const key of serverKeys.keys){ + const row=node('div',undefined,'server-key');row.append(node('h4',key.name),node('p',key.saved?'Saved.':'Missing. The server does not start until this key is saved.',key.saved?'':'warning-note')); + const value=field(row,`New key for ${key.name}`,input(serverKeys.drafts.get(key.name)||'',serverKeys.shown.has(key.name)?'text':'password')); + value.autocomplete='off';value.spellcheck=false;value.oninput=()=>{fieldError(value,'');serverKeys.drafts.set(key.name,value.value);}; + const show=field(row,`Show key for ${key.name}`,input('','checkbox'));show.checked=serverKeys.shown.has(key.name); + show.onchange=()=>{if(show.checked)serverKeys.shown.add(key.name);else serverKeys.shown.delete(key.name);value.type=show.checked?'text':'password';}; + const saved=async()=>{serverKeys.drafts.delete(key.name);serverKeys.shown.delete(key.name);serverKeys.changed=true;await loadServerKeys();}; + const save=async()=>{if(!value.value.trim()){fieldError(value,'Enter a key first.');return;}if(await run({kind:'save_server_key',name:key.name,key:value.value},false))await saved();}; + value.onkeydown=event=>{if(event.key==='Enter'){event.preventDefault();save();}}; + const a=actions(row);mutation(a,'Save key',save,true); + mutation(a,'Import from shell',async()=>{if(await run({kind:'import_server_key',name:key.name},false))await saved();}); + if(key.saved)mutation(a,'Remove…',()=>confirmAction(`Remove the saved key for ${key.name}?`,'The server does not start until this key is saved again.','Remove key',async()=>{if(await run({kind:'remove_server_key',name:key.name},false))await saved();})); + c.append(row); + } + const a=actions(c); + if(serverKeys.changed){c.insertBefore(node('p','Restart the server to use the changed keys.','warning-note'),a);mutation(a,'Restart server now',async()=>{if(await run({kind:'restart'},false)){serverKeys.changed=false;await refresh(true);}},true);} + button(a,'Check keys again',()=>{serverKeys.loaded=false;render();}); +} +function renderSettings() { + const appearanceCard=card('Appearance'); + const appearance=field(appearanceCard,'Theme',combobox(window.switchyardAppearance.options,window.switchyardAppearance.mode)); + appearance.id='appearance-select'; + appearance.onchange=async()=>{await window.switchyardAppearance.choose(appearance.value);appearance.value=window.switchyardAppearance.mode;}; + appearanceCard.append(node('p','System follows your device’s light or dark appearance. This setting applies to the desktop app and menu-bar popup.')); + const appearanceError=node('p',window.switchyardAppearance.error);appearanceError.id='appearance-error';appearanceError.setAttribute('role','status');appearanceCard.append(appearanceError); + + const c=card('Server and app');const paths=snapshot.settings||{}; + const facts=node('dl',undefined,'facts');c.append(facts); + for(const [label,value] of Object.entries(paths))if(typeof value==='string')facts.append(node('dt',label.replaceAll('_',' ')),node('dd',value)); + if(snapshot.version)facts.append(node('dt','installed version'),node('dd',snapshot.version)); + const a=actions(c);button(a,'Copy diagnostics',()=>copyText(JSON.stringify({app:'Switchyard desktop',version:snapshot.version||'Not reported',last_check:lastRefresh?.toISOString(),stale,server:snapshot.metrics,paths:Object.fromEntries(Object.entries(snapshot.settings||{}).filter(([key,value])=>typeof value==='string'&&['path','config_file','routing_log','prices_file'].includes(key))),routes:snapshot.routes.map(r=>r.id),tools:snapshot.tools.map(t=>({label:t.label,available:t.available})),update:snapshot.update_status?.state||'Unknown'},null,2)));for(const [label,kind] of [['Open server config','open_config'],[paths.prices_file?'Open model price override':'About model prices','open_prices'],['Open app settings','open_settings'],['Check config','check_config'],['Restart and check health','restart'],['Open server logs','open_logs']])mutation(a,label,()=>run({kind})); + c.append(node('p','Set the baseline model, server address, and refresh interval in app settings. The server and app use bundled model prices unless you configure a custom price file.')); + renderServerKeys(); + const update=card('Update from source');update.append(node('p','Review the source checkout and service restarts before building.')); + if(snapshot.update_status){const state=snapshot.update_status;update.append(node('p',`${state.state||'No update running'}${state.stage?` · ${state.stage}`:''}: ${state.message||''}`));} + button(actions(update),'Review update…',async()=>{const result=await run({kind:'preview_update'},false);if(!result)return;const unsaved=[...drafts.values()].some(d=>d.dirty);confirmAction('Build and reinstall?',`${updatePreviewText(result.data)}${unsaved?'\n\nYou have unsaved route edits. Cancel and save or discard them before updating.':''}`,'Build and reinstall',()=>{if(unsaved){notify('Save or discard route drafts before updating.','warning');return;}run({kind:'update',preview_token:result.data.preview_token});});}); + button(actions(update),'Open update log',()=>run({kind:'open_update_log'},false)); + renderConfiguration(false); + button(actions(c),'Run setup again',()=>{configurationDraft=undefined;navigate('setup');}); + mutation(actions(c),'Remove app preferences…',async()=>{ + const result=await run({kind:'preview_reset_settings'},false);if(!result)return; + confirmAction('Remove app preferences?',`${result.data.message}\n\nFile: ${result.data.file}`,'Remove preferences',async()=>{ + const removed=await run({kind:'reset_settings',preview_token:result.data.preview_token}); + if(removed){configurationDraft=undefined;navigate('settings');} + }); + }); + const display=card('Display');const zoom=field(display,'Text size',combobox([['100','100%'],['125','125%'],['150','150%'],['200','200%']],String(Math.round(Number(document.documentElement.style.zoom||1)*100))));zoom.onchange=()=>{document.documentElement.style.zoom=String(Number(zoom.value)/100);document.documentElement.style.setProperty('--ui-scale',String(Number(zoom.value)/100));document.body.classList.toggle('enlarged',Number(zoom.value)>=150);}; + const recent=card('Recent actions');for(const item of activity)recent.append(node('p',`${item.time} · ${item.message}`));if(!activity.length)recent.append(node('p','Actions from this app session appear here.')); + c.append(node('p','Closing the window keeps the app in the menu bar and the server running. Quit Switchyard exits the app; Restart controls the server.'));text(c,'Terminal: switchyard-desktop --tui'); +} + +// Setup and Settings submit the same configuration fields and preserve drafts during refreshes. +function renderConfiguration(setup) { + const c=card(setup?'Choose your Switchyard configuration':'Configuration'); + c.append(node('p',setup?'Use an existing server config or create one from the bundled defaults. Reinstalling keeps these files and skips this screen.':'Changes are saved in app settings. Restart the installed server to load a different config, log, or price file.')); + if(snapshot.configuration_error)c.append(node('p',`The saved settings need repair: ${snapshot.configuration_error}. Open app settings to fix syntax errors or unknown keys.`,'warning-note')); + if(!configurationDraft){ + const saved=snapshot.settings||{}; + configurationDraft={server_url:saved.server_url||'',config_file:saved.config_file||'',routing_log:saved.routing_log||'',pricing_file:saved.prices_file||'',launchd_label:saved.launchd_label||'',baseline_model:saved.baseline_model||snapshot.baseline_model||'',refresh_seconds:String(saved.refresh_seconds||snapshot.refresh_seconds||30),create_server:Boolean(snapshot.needs_setup)}; + } + const draft=configurationDraft; + const mode=field(c,'Server configuration',combobox([['existing','Use an existing file'],['create','Create a file from bundled defaults']],draft.create_server?'create':'existing')); + mode.onchange=()=>{draft.create_server=mode.value==='create';}; + for(const [key,label,file] of [['config_file','Server config file',true],['server_url','Server address',false],['routing_log','Routing log file',true],['pricing_file','Model price override (optional)',true],['baseline_model','Baseline model ID',false],['refresh_seconds','Refresh interval in seconds',false]]){ + const control=field(c,label,input(draft[key],key==='refresh_seconds'?'number':'text')); + control.oninput=()=>{draft[key]=control.value;fieldError(control,'');}; + if(file)button(actions(c),'Choose '+label.toLowerCase()+'…',()=>choosePath(control,false,()=>{draft[key]=control.value;})); + } + c.append(node('p','Leave the model price override empty to use bundled rates. Creating a server config never replaces an existing file.')); + c.append(node('p','Savings compare each call’s recorded cost with the same answer tokens at the baseline model’s rates. Rates are in dollars per million tokens. The app and installed server use the selected price file.')); + mutation(actions(c),setup?'Save setup':'Save configuration',async()=>{ + const config={server_url:draft.server_url,config_file:draft.config_file,routing_log:draft.routing_log,pricing_file:draft.pricing_file||undefined,launchd_label:draft.launchd_label,baseline_model:draft.baseline_model,refresh_seconds:Number(draft.refresh_seconds)}; + if(!Number.isSafeInteger(config.refresh_seconds)||config.refresh_seconds<1){notify('Enter a whole refresh interval of at least one second.','error');return;} + confirmAction('Save configuration?',`${draft.create_server?'Create':'Use'} server config: ${config.config_file}\nServer: ${config.server_url}\nRouting log: ${config.routing_log}\nModel prices: ${config.pricing_file||'Bundled rates'}\n\nSave app settings in ${snapshot.settings?.path}. Existing server files, credentials, and history are preserved.`,'Save',async()=>{ + const result=await run({kind:'save_configuration',config,create_server:draft.create_server}); + if(result){configurationDraft=undefined;navigate('settings');} + }); + },true); + button(actions(c),'Open app settings',()=>run({kind:'open_settings'},false)); +} + + +function draftKey(route) {return `${snapshot.settings?.config_file||generation}:${route.key}`;} +function routeSummary(route){return route.choices.map(c=>`${c.client} / ${c.model}`).join(' → ');} +function renderRouteBrowser() { + const c=card('Route library');c.classList.add('route-browser'); + const layout=node('div',undefined,'route-workspace'),library=node('div',undefined,'route-library'),list=node('div',undefined,'route-picker'),detail=node('div',undefined,'route-detail'); + const libraryTitle=c.querySelector('h3');library.append(libraryTitle);c.append(layout);layout.append(library,detail); + button(actions(library),'Create route…',()=>routeIdentity('create_route')); + if([...drafts.keys()].some(k=>!k.startsWith(`${snapshot.settings?.config_file||generation}:`)))library.append(node('p','Drafts from another config file are retained in this app session. Return to that config to finish or discard them.','warning-note')); + if(!snapshot.routes.length){library.append(node('p',(snapshot.errors||[]).length?'Routes could not be loaded. Open the server config to fix the error.':'No routes configured. Create a route or open the server config.'));button(actions(library),'Open server config',()=>run({kind:'open_config'},false));return;} + const search=field(library,'Search routes',input(routeView.search));search.placeholder='Name, method, model…'; + const count=node('p',undefined,'muted');library.append(count,list); + const switcher=node('div',undefined,'route-pane-switch');c.prepend(switcher); + const choosePane=pane=>{routeView.pane=pane;layout.dataset.pane=pane;libraryButton.setAttribute('aria-pressed',String(pane==='library'));editorButton.setAttribute('aria-pressed',String(pane==='editor'));}; + const libraryButton=button(switcher,'Route library',()=>choosePane('library')),editorButton=button(switcher,'Selected route',()=>choosePane('editor'));choosePane(routeView.pane); + const draw=()=>{ + routeView.search=search.value;const routes=snapshot.routes.filter(r=>routeMatches(r,search.value));count.textContent=`${routes.length} of ${snapshot.routes.length} routes`;list.replaceChildren();detail.replaceChildren(); + if(!snapshot.routes.some(r=>r.key===routeView.selected))routeView.selected=routes[0]?.key||''; + for(const route of routes){const b=button(list,'',()=>{routeView.selected=route.key;draw();choosePane('editor');detail.querySelector('h3')?.focus();});b.dataset.route=route.key;b.classList.toggle('selected',route.key===routeView.selected);b.setAttribute('aria-pressed',String(route.key===routeView.selected));b.append(node('span',route.id,'route-name'),node('small',snapshot.algorithms.find(a=>a.kind===route.kind)?.title||route.kind),node('small',routeSummary(route)));const badge=node('small','Unsaved edits','draft-badge');badge.hidden=!drafts.get(draftKey(route))?.dirty;b.append(badge);} + if(!routes.length){list.append(node('p','No routes match your search.','empty-state'));button(list,'Clear search',()=>{search.value='';draw();});return;} + const route=routes.find(r=>r.key===routeView.selected); + if(route)renderRoute(route,detail);else detail.append(node('p','Selected route is outside this filter. Clear the search to return to it.','empty-state')); + };search.oninput=draw;draw(); +} + +function routeIdentity(kind,route) { + const creating=kind==='create_route',title=creating?'Create route':kind==='rename_route'?'Rename route':'Duplicate route'; + const overlay=node('div',undefined,'modal-backdrop'),form=node('form',undefined,`confirm-dialog route-dialog${creating?' create-route':''}`);form.setAttribute('role','dialog');form.setAttribute('aria-modal','true');form.setAttribute('aria-label',title);overlay.append(form);document.body.append(overlay); + const siblings=[...document.body.children].filter(n=>n!==overlay).map(n=>[n,n.inert]);siblings.forEach(([n])=>{n.inert=true;});const previous=document.activeElement; + const close=()=>{overlay.remove();siblings.forEach(([n,inert])=>{n.inert=inert;});if(previous?.isConnected)previous.focus();else document.querySelector('#title').focus();}; + form.append(node('h3',title)); + if(creating)form.append(node('p','A route is a model name that harnesses can request. Switchyard sends each request for that name to the models you choose below.','dialog-intro')); + const id=field(form,'Public model ID',input(kind==='rename_route'?route.id:''),'Harnesses send this name as the model, for example in Codex or Claude Code settings. Each route needs its own name.');id.placeholder='team-router'; + const name=kind==='rename_route'?null:field(form,'Config name',input(),'The route’s table name in the server config file, as in [routes.team-router]. Switchyard fills it in from the public model ID. Use 1–64 letters, digits, hyphens, or underscores.'); + if(name){name.placeholder='team-router';let typed=false;name.addEventListener('input',()=>{typed=Boolean(name.value);});id.addEventListener('input',()=>{if(!typed)name.value=id.value.replace(/[^A-Za-z0-9_-]+/g,'-').replace(/^-+|-+$/g,'').slice(0,64);});} + const models=creating?routeModels(form):null; + const values=()=>({name:name?.value||'',id:id.value,...(models?models.value():{algorithm:null,choices:[]})}); + const failure=node('p',undefined,'field-error');failure.setAttribute('role','alert');failure.hidden=true;form.append(failure); + const a=actions(form);a.classList.add('dialog-actions');button(a,'Cancel',close).type='button';const save=button(a,'Review changes…',()=>{},true);save.type='submit'; + form.onsubmit=async event=>{ + event.preventDefault();failure.hidden=true;let valid=fieldError(id,!id.value.trim()||/[\n\r]/.test(id.value)?'Enter a public model ID without line breaks.':snapshot.routes.some(r=>r.id===id.value&&!(kind==='rename_route'&&r.key===route?.key))?'That public model ID already exists.':''); + if(name)valid=fieldError(name,!/^[A-Za-z0-9_-]{1,64}$/.test(name.value)?'Use 1–64 letters, digits, hyphens, or underscores.':snapshot.routes.some(r=>r.key===name.value)?'That config name already exists.':'')&&valid; + if(models)valid=models.validate()&&valid;if(!valid){form.querySelector('[aria-invalid="true"]')?.focus();return;} + const reviewed=values(),args={generation,operation:kind.replace('_route',''),route:route?.key||'',...reviewed}; + // The dialog covers the page notification, so a failed review shows its error inside the dialog. + save.disabled=true;let preview;try{preview=await readAction({kind:'preview_route_change',...args});}catch(error){failure.textContent=String(error);failure.hidden=false;}save.disabled=false;if(!preview)return; + if(JSON.stringify(values())!==JSON.stringify(reviewed)){notify('The form changed during review. Review the current values again.','warning');return;} + confirmAction('Save this route change?', [`Changed file: ${preview.data.file}`,...preview.data.notes,'Switchyard validates the config, saves a backup, and restarts the server.'].join('\n\n'),'Save and restart',async()=>{form.querySelectorAll('input,button').forEach(control=>{control.disabled=true;});const result=await run({kind:'route_change',...args,preview_token:preview.data.preview_token});if(result)close();else form.querySelectorAll('input,button').forEach(control=>{control.disabled=false;});}); + }; + + form.onkeydown=event=>{if(event.key==='Escape'){event.preventDefault();close();}if(event.key==='Tab'){const controls=[...form.querySelectorAll('input,button')].filter(control=>!control.disabled&&control.tabIndex>=0&&!control.closest('[hidden]')),i=controls.indexOf(document.activeElement);if(event.shiftKey&&i===0){event.preventDefault();controls.at(-1).focus();}else if(!event.shiftKey&&i===controls.length-1){event.preventDefault();controls[0].focus();}}};id.focus(); +} +const fieldHelp={ + endpoint:'The server connection, from [llm_clients] in the server config, that sends requests to this model.', + model:'Choose a suggested model ID or type any ID that the endpoint accepts.', +}; +function clientOptions(){return snapshot.clients.map(c=>[c.name,c.name,c.host]);} +// Model fields accept any ID; the list only suggests IDs that the endpoint is known to serve. +function modelOptions(client){return (lists.get(modelListKey(client))?.models||snapshot.clients.find(c=>c.name===client)?.models||[]).map(model=>[model]);} +function modelField(parent,label,client,value){const model=field(parent,label,combobox(modelOptions(client),value,{free:true,mono:true,placeholder:'Type or choose a model ID'}),fieldHelp.model);model.classList.add('model-id');return model;} +function rememberModels(client,data){lists.set(modelListKey(client),{models:data.models,time:new Date().toLocaleTimeString(),warning:Boolean(data.warning&&['cache','config'].includes(data.source)),source:data.source||'model-list result',fetchedAt:data.fetched_at});} +// The create dialog asks for every model the chosen algorithm needs. Switching algorithms keeps +// each model whose role (judge, capable, or efficient) the new algorithm also has. +function routeModels(form) { + const fallback=()=>({client:snapshot.clients[0]?.name||'',model:''}),spec=kind=>snapshot.algorithms.find(a=>a.kind===kind); + if(!snapshot.clients.length)form.append(node('p','The server config has no endpoints. Add an [llm_clients] entry to the server config, then create the route.','warning-note')); + const algorithm=field(form,'Routing algorithm',combobox(snapshot.algorithms.map(a=>[a.kind,a.title,a.summary]),snapshot.algorithms[0]?.kind),'The algorithm decides which of the models below answers each request.'); + const summary=node('p',undefined,'algorithm-summary'),rows=node('div',undefined,'role-fields dialog-roles');form.append(summary,rows); + const memory=new Map();let kind,choices=[],random=[]; + const roles=()=>kind==='random'?random.map((_,i)=>({label:`Model ${i+1}`,hint:'This model gets a share of the requests.',tier:spec(kind).roles[i]?.tier})):spec(kind).roles; + const render=()=>{ + rows.replaceChildren(); + roles().forEach((role,index)=>{ + const choice=choices[index],row=node('div');row.dataset.role=index;row.append(node('h4',role.label,'role-heading'),node('p',role.hint,'role-hint'));rows.append(row); + const single=role.label==='Model',client=field(row,single?'Endpoint':`${role.label} endpoint`,combobox(clientOptions(),choice.client,{mono:true}),fieldHelp.endpoint); + const model=modelField(row,single?'Model':`${role.label} model`,choice.client,choice.model),status=node('small',undefined,'model-list-status');row.append(status); + client.onchange=()=>{choice.client=client.value;model.setOptions(modelOptions(client.value));status.textContent='';};model.oninput=()=>{choice.model=model.value;fieldError(model,'');}; + const tools=actions(row); + button(tools,'Load models from endpoint',async()=>{ + const endpoint=client.value; + status.textContent='Loading models…'; + try{ + const result=await readAction({kind:'models',client:endpoint,refresh:true,key:null}); + rememberModels(endpoint,result.data); + if(!row.isConnected||client.value!==endpoint)return; + model.setOptions(modelOptions(endpoint)); + status.textContent=`${result.data.models.length} models loaded.`; + }catch(error){ + if(!row.isConnected||client.value!==endpoint)return; + status.textContent=`Could not load models: ${error}. Type the model ID instead, or save a model-list key in the route editor.`; + } + }).type='button'; + if(kind==='random'&&random.length>2)button(tools,'Remove model',()=>{random.splice(index,1);render();}).type='button'; + }); + if(kind==='random')button(actions(rows),'Add model',()=>{random.push(fallback());render();rows.querySelector(`[data-role="${random.length-1}"] [role="combobox"]`)?.focus();}).type='button'; + }; + const choose=()=>{ + if(kind)roles().forEach((role,i)=>{if(role.tier)memory.set(role.tier,choices[i]);}); + kind=algorithm.value;summary.textContent=spec(kind).summary; + const restored=spec(kind).roles.map(role=>({...(memory.get(role.tier)||fallback())})); + if(kind==='random'){if(!random.length)random=restored;choices=random;}else choices=restored; + render(); + }; + algorithm.onchange=choose;choose(); + return { + value:()=>({algorithm:kind,choices:choices.map(choice=>({...choice}))}), + validate:()=>{let valid=snapshot.clients.length>0;rows.querySelectorAll('.model-id').forEach(model=>{valid=fieldError(model,model.value.trim()?'':'Enter or choose a model ID.')&&valid;});return valid;}, + }; +} +// A preview belongs to its exact inputs; obsolete replies never enable Apply. +function renderInstall(tool,parent) { + const c=card(tool.label);parent.append(c);c.dataset.tool=tool.tool; + c.append(node('p',tool.available?'Harness detected.':'Harness not detected. You can prepare settings and install the harness later.','muted')); + c.append(node('p',{codex_cli:'Choose the Codex CLI profile file to change.',codex_app:'Detected user settings change defaults shared by the Codex app and CLI. Custom files must be loaded by the harness.',claude:'Start a new Claude Code session after changing its settings.',pi:'Pi uses models.json and settings.json together. Model capabilities and prices are presets; check them for your chosen route.'}[tool.tool])); + const fields=node('div',undefined,'field-grid');c.append(fields); + const mode=field(fields,'Destination',combobox([['default','Detected settings','The harness’s usual settings file'],['account','Saved account','Settings for a separate login'],['custom','Custom file','A settings file path that you enter']],installView.mode),'The settings file that Switchyard changes.'); + const account=field(fields,'Saved account',combobox(tool.accounts.map(name=>[name,name]),installView.account)); + const file=field(fields,tool.tool==='pi'?'Custom models.json path':'Custom settings file path',input(installView.file));file.placeholder=tool.files?.[0]||'/absolute/path/to/settings'; + const chooseFile=button(c,'Choose settings file…',()=>choosePath(file,false,update)); + const route=routeField(fields,installView.route,tool.tool); + const comparison=node('div',undefined,'install-diff'),authentication=node('p',undefined,'install-auth'),progress=node('p',undefined,'install-progress');progress.setAttribute('role','status');progress.setAttribute('aria-live','polite');c.append(comparison,authentication,progress); + const target=()=>({tool:tool.tool,account:mode.value==='account'?account.value||null:null,settings_file:mode.value==='custom'?file.value||null:null}); + let plan,revision=0;const a=actions(c); + const install=mutation(a,'Apply routing…',()=>{if(!plan)return;const selected=plan;confirmAction(`Apply routing to ${tool.label}?`,`${selected.data.proposed}\n\n${selected.data.authentication}\n\nFiles: ${[...new Set(selected.data.changes.map(change=>change.file))].join(', ')}. Switchyard backs up the originals and preserves unrelated settings.`, 'Apply routing',async()=>{if(!c.isConnected||selected!==plan)return;progress.textContent='Saving settings…';const result=await run(selected.action,false);progress.textContent='';if(result)update();});},true); + const restore=mutation(a,'Review restore…',async()=>{ + blocked(install,true);plan=null;progress.textContent='Checking backup…'; + // The preview and confirmation must use the same destination revision. + const selectedTarget=target(),reviewedRevision=revision; + const result=await run({kind:'preview_restore',...selectedTarget},false); + if(!c.isConnected||reviewedRevision!==revision||JSON.stringify(selectedTarget)!==JSON.stringify(target()))return; + progress.textContent='';if(!result){update();return;} + const data=result.data; + confirmAction('Restore harness settings?',[data.warning,...(data.files||[]).map(file=>`${file.action}: ${file.file}${file.backup?`\nOriginal backup: ${file.backup}`:''}`)].join('\n\n'),'Restore settings',async()=>{if(!c.isConnected||reviewedRevision!==revision||JSON.stringify(selectedTarget)!==JSON.stringify(target()))return;progress.textContent='Restoring settings…';await run({kind:'restore',...selectedTarget,preview_token:data.preview_token},false);progress.textContent='';update();},update); + }); + const update=()=>{ + Object.assign(installView,{mode:mode.value,account:account.value,route:route.value,file:file.value}); + account.closest('.field').hidden=mode.value!=='account';file.closest('.field').hidden=mode.value!=='custom';chooseFile.hidden=mode.value!=='custom'; + const requested=++revision;plan=null;blocked(install,true);install.textContent='Checking settings…';comparison.setAttribute('aria-busy','true');comparison.replaceChildren(node('p','Loading settings preview…'));authentication.textContent='';progress.textContent='Checking preview…'; + const invalid=mode.value==='custom'&&(!file.value||/[\r\n]/.test(file.value));fieldError(file,invalid?'Enter an absolute settings file path without line breaks.':''); + if(invalid||mode.value==='account'&&!account.value){comparison.replaceChildren(node('p',invalid?'Enter an absolute settings file path.':'No saved account is available. Sign in below or choose Detected settings.'));install.textContent='Preview required';comparison.setAttribute('aria-busy','false');progress.textContent='';blocked(restore,true);return;} + blocked(restore,true);restore.title=''; + const incompatible=snapshot.routes.find(r=>r.key===route.value)?.compatibility?.[tool.tool]; + // A backup can be restored without a route, so check its destination before Apply's route gates. + const requestedTarget=target(); + enqueue(async()=>{ + if(!c.isConnected||requested!==revision)return; + try{ + let backupReady=false;try{await readAction({kind:'preview_restore',...requestedTarget});backupReady=true;}catch(error){if(c.isConnected&&requested===revision)restore.title=String(error);} + if(!c.isConnected||requested!==revision)return;blocked(restore,!backupReady); + if(incompatible){comparison.replaceChildren(node('p',incompatible,'field-error'));install.textContent='Choose a compatible route';progress.textContent='Preview unavailable for this harness.';return;} + if(!route.value){comparison.replaceChildren(node('p','Choose a route to preview its settings.'));button(comparison,'Choose a route',()=>navigate('routes'));install.textContent='Choose a route';progress.textContent='';return;} + const requestedRoute=routeArgs(route); + const result=await readAction({kind:'preview_install',...requestedTarget,...requestedRoute});if(!c.isConnected||requested!==revision)return; + comparison.replaceChildren();const summaries=node('div',undefined,'install-summary');comparison.append(summaries); + for(const [title,value] of [['Current settings',result.data.current],['After applying routing',result.data.proposed]])if(value){const summary=node('div');summary.append(node('h4',title),node('pre',value));summaries.append(summary);} + for(const file of result.data.file_actions||[])summaries.append(node('p',`${file.action==='create'?'Will create':file.action==='preserve'?'Will preserve':'Will update'}: ${file.file}`)); + authentication.textContent=result.data.authentication||''; + if(result.data.error){comparison.append(node('p',result.data.error,'field-error'));install.textContent='Preview unavailable';progress.textContent='Preview unavailable. Review the error.';blocked(restore,!backupReady);return;} + const grouped=new Map();for(const change of result.data.changes){if(!grouped.has(change.file))grouped.set(change.file,[]);grouped.get(change.file).push(change);} + for(const [path,changes] of grouped){const details=node('details');details.append(node('summary',`${path} · ${changes.length} changed settings`));for(const change of changes)details.append(node('pre',change.key,'diff-heading'),node('pre',`− ${change.before}`,'diff-remove'),node('pre',`+ ${change.after}`,'diff-add'));comparison.append(details);} + if(!result.data.changes.length)comparison.append(node('p','Settings already match this route. Saved settings do not confirm that the server is reachable or that the harness has loaded them.')); + const ready=result.data.changes.length>0;if(ready)plan={data:result.data,action:{kind:'install',...requestedTarget,...requestedRoute,preview_token:result.data.preview_token}}; + install.textContent=ready?'Apply routing…':'Routing already applied';blocked(install,!ready);blocked(restore,!backupReady);progress.textContent=ready?'The preview shows the files and authentication above. Review them before applying routing.':'Routing already applied.'; + }catch(error){if(c.isConnected&&requested===revision){install.textContent='Preview unavailable';comparison.replaceChildren(node('p',String(error),'field-error'));progress.textContent='Preview unavailable.';button(comparison,'Retry preview',update);}} + finally{if(c.isConnected&&requested===revision)comparison.setAttribute('aria-busy','false');} + }); + }; + for(const control of [mode,account,route])control.onchange=update; + file.oninput=()=>{installView.file=file.value;revision++;plan=null;blocked(install,true);blocked(restore,true);install.textContent='Preview required';progress.textContent='Finish entering the file path, then press Enter or leave the field to preview.';};file.onchange=update;file.onkeydown=event=>{if(event.key==='Enter')update();};update(); +} +function renderAccounts() { + const c=card('Subscription accounts');c.append(node('p','Account folders keep separate harness settings. Folder existence does not confirm a completed login.')); + for(const tool of snapshot.tools.filter(t=>['codex_cli','claude'].includes(t.tool))){ + if(tool.discovery_error)c.append(node('p',`${tool.label}: ${tool.discovery_error}`,'field-error')); + for(const item of tool.account_details||tool.accounts.map(name=>({name,status:'Login status unknown'}))){const row=node('div',undefined,'route-row');row.append(node('h4',`${tool.label} · ${item.name}`),node('p',item.status||'Login status unknown'),node('pre',item.path||''));if(item.path){button(actions(row),'Copy account path',()=>copyText(item.path));button(actions(row),'Open folder',()=>run({kind:'open_account',tool:tool.tool,name:item.name},false));}mutation(actions(row),'Sign in again…',()=>run({kind:'login_account',tool:tool.tool,name:item.name}));mutation(actions(row),'Review archive…',async()=>{const result=await run({kind:'preview_account_archive',tool:tool.tool,name:item.name},false);if(result)confirmAction('Archive this account folder?',`${result.data.notice}\n\nAccount: ${result.data.name}\nDirectory: ${result.data.path}`,'Archive account',()=>run({kind:'archive_account',tool:tool.tool,name:item.name,preview_token:result.data?.preview_token}));});c.append(row);} + } + const disclosure=node('details');disclosure.append(node('summary','Sign in to another subscription account…'));c.append(disclosure); + disclosure.append(node('p','Finish login in the Terminal window. API routes use credentials configured on the server. The Codex app manages its own account.')); + const tool=field(disclosure,'Account harness',combobox(snapshot.tools.filter(t=>['codex_cli','claude'].includes(t.tool)).map(t=>[t.tool,t.label]),accountView.tool)); + const name=field(disclosure,'Name for the separate account',input(accountView.name));disclosure.append(node('small','Use 1–64 ASCII letters, digits, hyphens, or underscores.')); + const add=mutation(actions(disclosure),'Open login in Terminal…',async()=>{const result=await run({kind:'add_account',tool:tool.value,name:name.value});if(result){accountView.name='';render();}}); + const changed=()=>{accountView.name=name.value;accountView.tool=tool.value;const error=!/^[A-Za-z0-9_-]{1,64}$/.test(name.value)?'Use 1–64 letters, digits, hyphens, or underscores.':snapshot.tools.find(t=>t.tool===tool.value)?.accounts.includes(name.value)?'That account name already exists. Choose another name.':'';fieldError(name,error);blocked(add,Boolean(error)||!snapshot.tools.find(t=>t.tool===tool.value)?.available);};name.oninput=changed;tool.onchange=changed;changed(); +} +// Every algorithm retains its complete choices until the user saves or discards the draft. +function modelListKey(client){return `${snapshot.settings?.config_file||generation}:${snapshot.clients.find(endpoint=>endpoint.name===client)?.host||''}:${client}`;} +function roleChoice(draft,route,role,index) { + return draft.byAlgorithm?.get(draft.algorithm)?.[index] || (role.tier && draft.memory.get(role.tier)) || + ((draft.algorithm===route.kind||draft.loadedAlgorithm===draft.algorithm)&&draft.choices[index]) || + {client:route.choices[0]?.client||snapshot.clients[0]?.name||'',model:''}; +} +function draftDirty(draft,route){return draft.algorithm!==route.kind||JSON.stringify(draft.choices)!==JSON.stringify(route.choices);} +function renderRoute(route,parent) { + const c=card(route.id);c.classList.add('route-editor');c.querySelector('h3').tabIndex=-1;parent.append(c);const key=draftKey(route),loadedGeneration=generation; + if(route.generated){c.append(node('p',`Generated by ${route.generated}. Edit the generator’s source to keep changes across regeneration; generated output may replace edits here.`,'warning-note'));button(actions(c),'Open server config',()=>run({kind:'open_config'},false));} + const remove=()=>confirmAction(`Delete route “${route.id}”?`,`This removes the public model ID from the server. Targets and harness settings are kept. Any harness using ${route.id} must choose another route. ${route.dependents?.length?`Known settings: ${route.dependents.map(d=>typeof d==='string'?d:`${d.label}${d.account?` (${d.account})`:''}: ${d.files.join(', ')}`).join('; ')}.`:''}`,'Delete route',()=>run({kind:'remove',generation:loadedGeneration,route:route.key,id:route.id,revision:drafts.get(key)?.revision||route.revision})); + if(!route.editable){c.append(node('p','This routing method has no visual editor. Edit its fields in the server config.'));button(actions(c),'Open server config',()=>run({kind:'open_config'},false));mutation(actions(c),'Delete route…',remove).classList.add('danger');return;} + let draft=drafts.get(key); + if(!draft){draft={algorithm:route.kind,choices:route.choices.map(c=>({...c})),memory:new Map(),byAlgorithm:new Map([[route.kind,route.choices.map(c=>({...c}))]]),revision:route.revision,generation:loadedGeneration,dirty:false};route.tiers?.forEach((tier,index)=>{if(tier)draft.memory.set(tier,{...route.choices[index]});});drafts.set(key,draft);} + const algorithm=field(c,'Routing algorithm',combobox(snapshot.algorithms.map(a=>[a.kind,a.title,a.summary]),draft.algorithm),'The algorithm decides which of the models below answers each request.'); + const description=node('p',undefined,'description'),diagram=node('div',undefined,'route-diagram'),roles=node('div',undefined,'role-fields'),editorState=node('p',undefined,'editor-state');editorState.setAttribute('role','status');c.append(description,diagram,roles);c.querySelector('h3').after(editorState); + const a=actions(c);a.classList.add('editor-footer');a.append(node('small','Saving a route restarts the server.','restart-note'));let revision=0; + if(draft.generation!==loadedGeneration){c.append(node('p','This draft was retained while another server config was selected. Review it against the current saved route before saving.','warning-note'));button(a,'Review restored draft…',()=>confirmAction('Use the current saved route as the baseline?',`Current saved route: ${route.kind} · ${routeSummary(route)}\n\nRetained draft: ${draft.algorithm} · ${draft.choices.map(choice=>`${choice.client} / ${choice.model}`).join(' → ')}\n\nThe next Save review will show settings removed or retained from the current file.`,'Use current baseline',()=>{draft.revision=route.revision;draft.generation=loadedGeneration;render();}));} + const updateDirty=()=>{draft.dirty=draftDirty(draft,route);editorState.textContent=draft.dirty?'Unsaved edits':'Saved route';blocked(save,!draft.dirty||draft.generation!==loadedGeneration);blocked(revert,!draft.dirty);const badge=document.querySelector(`[data-route="${CSS.escape(route.key)}"] .draft-badge`);if(badge)badge.hidden=!draft.dirty;}; + const updateDiagramChoices=()=>{diagram.querySelectorAll('[data-role-link]').forEach(item=>{const choice=draft.choices[Number(item.dataset.roleLink)];item.querySelector('small').textContent=choice?`${choice.client} / ${choice.model||'Choose model'}`:'';});}; + const saveChoices=()=>{roles.querySelectorAll('[data-role]').forEach(row=>{const controls=row.querySelectorAll('[role="combobox"]');const choice={client:controls[0].value,model:controls[1].value};draft.choices[Number(row.dataset.role)]=choice;if(row.dataset.tier)draft.memory.set(row.dataset.tier,choice);});draft.byAlgorithm.set(draft.algorithm,draft.choices.map(c=>({...c})));updateDiagramChoices();updateDirty();}; + const save=mutation(a,'Review changes…',async()=>{ + saveChoices();let valid=true;roles.querySelectorAll('[data-role] .model-id').forEach(control=>{valid=fieldError(control,!control.value.trim()?'Enter a model ID. Manual IDs are allowed.':'')&&valid;});if(!valid)return; + const args={generation:loadedGeneration,route:route.key,algorithm:draft.algorithm,choices:draft.choices.map(choice=>({...choice})),revision:draft.revision}; + const preview=await run({kind:'preview_route',...args},false);if(!preview)return; + if(draft.algorithm!==args.algorithm||JSON.stringify(draft.choices)!==JSON.stringify(args.choices)){notify('The route draft changed during review. Review the current edits again.','warning');return;} + confirmAction(`Save route “${route.id}”?`,[`Changed file: ${preview.data.file}.`,...preview.data.notes,'Switchyard validates the config, saves a backup, and restarts the server.'].join('\n\n'),'Save and restart',async()=>{const result=await run({kind:'apply',...args});if(result){if(draft.algorithm===args.algorithm&&JSON.stringify(draft.choices)===JSON.stringify(args.choices))drafts.delete(key);else{draft.revision=undefined;notify('Saved the reviewed route. Your newer edits remain unsaved.','warning',result.data?.details||[]);}render();document.querySelector('#title').focus();}}); + },true); + const revert=mutation(a,'Discard unsaved edits…',()=>{if(!draft.dirty)return;confirmAction('Discard route edits?','This returns the route editor to the saved configuration.','Discard edits',()=>{drafts.delete(key);refresh();});}); + button(a,'Reload saved route…',()=>confirmAction('Reload the saved route?','This discards the current route draft and reads the latest server config.','Reload route',()=>{drafts.delete(key);refresh();})); + mutation(a,'Duplicate…',()=>routeIdentity('duplicate_route',route));mutation(a,'Rename…',()=>routeIdentity('rename_route',route));mutation(a,'Delete route…',remove).classList.add('danger'); + const draw=async()=>{ + const current=++revision,chosen=draft.algorithm;editorState.textContent='Loading routing roles…';roles.setAttribute('aria-busy','true'); + try{const result=await readAction({kind:'editor',generation:loadedGeneration,route:route.key,algorithm:chosen});if(!c.isConnected||current!==revision)return; + description.textContent=result.message;if(!draft.revision)draft.revision=result.data.revision;roles.replaceChildren(); + let roleList=result.data.roles; + if(chosen==='random'&&draft.byAlgorithm.has(chosen)){const template=roleList[0];roleList=draft.byAlgorithm.get(chosen).map((_,i)=>({...template,label:`Model ${i+1}`,tier:null}));} + draft.choices=roleList.map((role,index)=>({...roleChoice(draft,route,role,index)})); + diagram.replaceChildren();diagram.append(node('small','Routing flow','diagram-caption')); + const flow=node('div',undefined,'diagram-flow');diagram.append(flow); + const connect=(label)=>{flow.append(node('span',label,'diagram-connector'));}; + const roleNode=index=>{const role=roleList[index],item=button(flow,'',()=>{const row=roles.querySelector(`[data-role="${index}"]`);row?.scrollIntoView({block:'nearest',behavior:'instant'});row?.querySelector('[role="combobox"]')?.focus();});item.dataset.roleLink=index;item.title=role.hint;item.append(node('strong',role.label),node('small'));return item;}; + flow.append(node('span','Request','diagram-request')); + if(chosen==='advisor'){connect('→');roleNode(0);connect('→ final answer review');roleNode(1);} + else if(chosen==='plan_execute'){connect('→');roleNode(0);connect('→ first file edit');roleNode(1);} + else {connect('→');if(roleList[0]?.tier==='Judge'){roleNode(0);connect('→ chooses');}const choices=node('div',undefined,'diagram-choices');flow.append(choices);for(let index=roleList[0]?.tier==='Judge'?1:0;index{ + const choice=draft.choices[index],row=node('div');row.dataset.role=index;row.dataset.tier=role.tier||'';row.id=`role-${route.key}-${index}`;row.append(node('h4',role.label,'role-heading'),node('p',role.hint,'role-hint')); + const client=field(row,`${role.label} endpoint`,combobox(clientOptions(),choice.client,{mono:true}),fieldHelp.endpoint); + const model=modelField(row,role.label==='Model'?'Model':`${role.label} model`,choice.client,choice.model); + const guidance=node('div'),listStatus=node('small',undefined,'model-list-status');row.append(guidance,listStatus); + const fill=()=>{const cached=lists.get(modelListKey(client.value)),values=cached?.models||snapshot.clients.find(c=>c.name===client.value)?.models||[];model.setOptions(values.map(value=>[value]));listStatus.textContent=`${values.length} suggestions · ${cached?`${cached.source==='cache'?'Switchyard cached suggestions':cached.source==='local'?'Codex cached suggestions':cached.source==='config'?'Configured targets':'Endpoint suggestions'} · ${Number.isFinite(cached.fetchedAt)&&cached.fetchedAt>0?`fetched ${new Date(cached.fetchedAt*1000).toLocaleString()}`:'fetch time unavailable'}${cached.warning?` · refresh failed at ${cached.time}`:''}`:'configured targets'}${values.length?'':' · enter a model ID manually'}.`; + }; + const endpointGuidance=()=>{guidance.replaceChildren();const endpoint=snapshot.clients.find(e=>e.name===client.value); + if(endpoint?.note)guidance.append(node('p',endpoint.note,'warning-note')); + if(endpoint?.accepts_key){const details=node('details');details.append(node('summary','Key for model suggestions'));const keyInput=field(details,'Model-list key (stored in macOS Keychain)',input('','password'));keyInput.autocomplete='off';details.append(node('small','This key loads model suggestions. Configure server credentials in the server config.')); + mutation(actions(details),'Save key and load models',async()=>{const endpoint=client.value;const result=await run({kind:'models',client:endpoint,refresh:true,key:keyInput.value},false);if(result){keyInput.value='';rememberModels(endpoint,result.data);fill();}});guidance.append(details);} + }; + client.onchange=()=>{fill();endpointGuidance();saveChoices();if(model.value)guidance.append(node('p','The endpoint changed. Check that it supports the retained model ID before saving.','warning-note'));};model.oninput=()=>{fieldError(model,'');saveChoices();};fill();endpointGuidance(); + mutation(actions(row),'Refresh suggestions',async()=>{const endpoint=client.value;listStatus.textContent='Loading suggestions…';const result=await run({kind:'models',client:endpoint,refresh:true,key:null},false);if(result){rememberModels(endpoint,result.data);fill();}else listStatus.textContent='Suggestions unavailable. Retry or enter a model ID manually.';}); + if(!choice.model)row.append(node('p','New role: choose its endpoint and model before saving.','warning-note')); + if(chosen==='random')mutation(actions(row),'Remove model',()=>{saveChoices();if(draft.choices.length<=2){notify('Random routing needs at least two models.','warning');return;}draft.choices.splice(index,1);draft.byAlgorithm.set(chosen,draft.choices.map(c=>({...c})));draw();}); + roles.append(row); + });draft.loadedAlgorithm=chosen;draft.byAlgorithm.set(chosen,draft.choices.map(c=>({...c}))); + if(chosen==='random')mutation(roles,'Add model',()=>{saveChoices();draft.choices.push({client:snapshot.clients[0]?.name||'',model:''});draft.byAlgorithm.set(chosen,draft.choices.map(c=>({...c})));draw();}); + updateDirty(); + }catch(error){if(c.isConnected&¤t===revision){editorState.textContent=`Could not load the editor: ${error}`;button(roles,'Retry editor',draw);blocked(save,true);}} + finally{roles.setAttribute('aria-busy','false');} + }; + algorithm.onchange=()=>{saveChoices();draft.algorithm=algorithm.value;draft.choices=draft.byAlgorithm.get(draft.algorithm)?.map(c=>({...c}))||[];draw();};updateDirty();enqueue(draw); +} +document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page)); +document.querySelector('#refresh').onclick=refresh; +document.querySelector('#dismiss-notification').onclick=dismissNotification; +document.querySelector('#dismiss-notification').onmousedown=event=>event.preventDefault(); +window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change',()=>{if(page==='overview'&&snapshot){const saved=capturePageState();render();restorePageState(saved);}}); +window.addEventListener?.('switchyard-notice',event=>notify(String(event.detail),'warning')); +window.addEventListener?.('switchyard-page',event=>{ + const next=typeof event.detail==='string'?event.detail:event.detail?.page; + if(!Object.hasOwn(pageNames,next))return; + if(next==='overview')Object.assign(analyticsView,{period:'all',group:'model',search:'',model:typeof event.detail==='object'?event.detail.model||null:null}); + navigate(next);if(!snapshot)refresh(); +}); +async function poll() { + if(!document.querySelector('[role="dialog"]')&&!busy&&document.activeElement?.tagName!=='SELECT')await refresh(false); + setTimeout(poll,(snapshot?.refresh_seconds||30)*1000); +} +content.append(node('p','Loading Switchyard…','empty-state')); +refresh().finally(()=>setTimeout(poll,(snapshot?.refresh_seconds||30)*1000)); + +window.addEventListener('switchyard-theme',()=>{const control=document.getElementById('appearance-select');if(control)control.value=window.switchyardAppearance.mode;if(snapshot&&['overview','usage'].includes(page)&&!busy)render();}); diff --git a/crates/switchyard-desktop/ui/basic.css b/crates/switchyard-desktop/ui/basic.css deleted file mode 100644 index d8e88ae37e..0000000000 --- a/crates/switchyard-desktop/ui/basic.css +++ /dev/null @@ -1,11 +0,0 @@ -/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ -/* SPDX-License-Identifier: Apache-2.0 */ -:root { color-scheme: light dark; font-family: system-ui, sans-serif; } -body { margin: 0; background: Canvas; color: CanvasText; } -main { max-width: 42rem; padding: 2rem; margin: auto; } -h1 { margin-top: 0; } -section { margin-block: 2rem; } -.actions { display: flex; flex-wrap: wrap; gap: .6rem; } -button { font: inherit; padding: .5rem .8rem; cursor: pointer; } -#server-url, #message { overflow-wrap: anywhere; } -#message { white-space: pre-wrap; } diff --git a/crates/switchyard-desktop/ui/basic.js b/crates/switchyard-desktop/ui/basic.js deleted file mode 100644 index 42f93fd205..0000000000 --- a/crates/switchyard-desktop/ui/basic.js +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -const invoke = window.__TAURI__.core.invoke; -const message = document.getElementById('message'); -let timer; -let busy = false; - -function counts(label, value) { - return value - ? `${label}: ${value.requests.toLocaleString()} requests · ${value.tokens.toLocaleString()} tokens` - : `${label}: usage unavailable`; -} - -async function refresh() { - if (busy) return; - busy = true; - clearTimeout(timer); - let seconds = 30; - try { - const view = await invoke('snapshot'); - document.getElementById('server').textContent = view.health.state === 'running' - ? 'Server is running' : `Server is not responding: ${view.health.reason}`; - document.getElementById('server-url').textContent = view.server_url; - document.getElementById('today').textContent = counts('Today', view.today); - document.getElementById('week').textContent = counts('Last 7 days', view.week); - message.textContent = view.errors.join('\n'); - seconds = view.refresh_seconds; - } catch (error) { - document.getElementById('server').textContent = 'Server status unavailable'; - document.getElementById('server-url').textContent = ''; - document.getElementById('today').textContent = counts('Today', null); - document.getElementById('week').textContent = counts('Last 7 days', null); - message.textContent = String(error); - } finally { - busy = false; - timer = setTimeout(refresh, Math.min(seconds * 1000, 2147483647)); - } -} - -document.getElementById('refresh').addEventListener('click', refresh); -for (const [id, kind] of [['open-config', 'config'], ['open-logs', 'logs']]) { - document.getElementById(id).addEventListener('click', async () => { - try { - await invoke('open_file', { kind }); - message.textContent = ''; - } catch (error) { - message.textContent = String(error); - } - }); -} -document.getElementById('quit').addEventListener('click', () => invoke('quit_app')); -refresh(); diff --git a/crates/switchyard-desktop/ui/index.html b/crates/switchyard-desktop/ui/index.html index 589aa4769e..15e9f1938e 100644 --- a/crates/switchyard-desktop/ui/index.html +++ b/crates/switchyard-desktop/ui/index.html @@ -1,32 +1,55 @@ - + Switchyard - - + + + + + + + +
-

Switchyard

-

Checking the server…

-

-
-

Recorded usage

-

Today: loading…

-

Last 7 days: loading…

-

Requests count answers. Tokens include classifier calls.

-
-
- - - - + +
+

Overview

+
Loading…
+
+ + + -

+