diff --git a/Cargo.lock b/Cargo.lock index 14ebd4442..79507aae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3656,6 +3656,7 @@ dependencies = [ name = "switchyard-desktop" version = "0.3.0" dependencies = [ + "chrono", "dbus-secret-service", "image", "libc", @@ -3666,10 +3667,12 @@ dependencies = [ "serde", "serde_json", "switchyard-costs", + "switchyard-protocol", "switchyard-runner", "tempfile", "toml 0.9.12+spec-1.1.0", "toml_edit 0.25.13+spec-1.1.0", + "url", "windows-sys 0.61.2", ] diff --git a/crates/switchyard-desktop/Cargo.toml b/crates/switchyard-desktop/Cargo.toml index 53ab0c50e..42d05821d 100644 --- a/crates/switchyard-desktop/Cargo.toml +++ b/crates/switchyard-desktop/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "switchyard-desktop" version.workspace = true -description = "Installer and server key helper for a local Switchyard server" +description = "Installer and usage summary for a local Switchyard server" authors.workspace = true edition.workspace = true homepage = "https://github.com/NVIDIA-NeMo/Switchyard" @@ -16,11 +16,14 @@ publish = false [dependencies] switchyard-runner.workspace = true switchyard-costs.workspace = true +switchyard-protocol.workspace = true +chrono = { version = "0.4", default-features = false, features = ["std", "clock", "serde"] } serde.workspace = true serde_json.workspace = true tempfile = "3" toml = "0.9" toml_edit = "0.25" +url = "2" [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3" @@ -35,7 +38,7 @@ dbus-secret-service = { version = "4.1", features = ["crypto-rust", "vendored"] [target.'cfg(windows)'.dependencies] mslnk = "0.1.8" self-replace = "1.5" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_Security_Credentials", "Win32_System_Console"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_Security_Credentials", "Win32_System_Console", "Win32_Storage_FileSystem"] } [dev-dependencies] image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/switchyard-desktop/src/config.rs b/crates/switchyard-desktop/src/config.rs new file mode 100644 index 000000000..5b558b1ed --- /dev/null +++ b/crates/switchyard-desktop/src/config.rs @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module reads desktop settings and supplies installation defaults. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::install::Settings; + +pub(crate) const DEFAULT_PORT: u16 = 4123; +pub(crate) const HOME_DIR: &str = ".switchyard"; +pub(crate) const SERVER_LABEL: &str = "com.nvidia.switchyard.server"; +pub(crate) const ROUTING_LOG: &str = "routing.jsonl"; +pub(crate) const SERVER_CONFIG: &str = "composite.toml"; +pub(crate) const PRICES_FILE: &str = "prices.json"; +pub(crate) const DESKTOP_CONFIG: &str = "desktop.toml"; + +pub(crate) fn server_url(port: u16) -> String { + format!("http://127.0.0.1:{port}") +} + +pub(crate) fn home_dir() -> Result { + std::env::home_dir() + .filter(|home| home.is_absolute()) + .ok_or_else(|| "Could not find your home directory.".into()) +} + +/// Config stores the settings the apps use to find the server and compare costs. +/// Missing keys use the selected installation directory and port. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// This field sets the HTTP base URL used for health checks; an omitted port means 80. + pub server_url: String, + /// This field names the JSONL file where the server appends routing records. + pub routing_log: PathBuf, + /// This field names the server TOML file opened by "Open server config". + pub config_file: PathBuf, + /// This field names the LaunchAgent used to restart the server. + pub launchd_label: String, + /// This field sets the usage refresh interval in seconds. + pub refresh_seconds: u64, + /// This field names the model whose rates price the answers as if routing had not run. + pub baseline_model: String, + /// This optional file replaces bundled rates for the baseline estimate. + #[serde(rename = "pricing_file", skip_serializing_if = "Option::is_none")] + pub prices_file: Option, +} + +impl Config { + pub(crate) fn for_directory(directory: &Path, port: u16) -> Self { + Self { + server_url: server_url(port), + routing_log: directory.join(ROUTING_LOG), + config_file: directory.join(SERVER_CONFIG), + launchd_label: SERVER_LABEL.into(), + refresh_seconds: 30, + baseline_model: "gpt-6-sol".into(), + prices_file: None, + } + } + + /// This function builds defaults for the app settings file at `path` without reading it. + /// Adjacent `install.toml` selects the installation directory and port. + /// Missing metadata uses the file's parent directory and port 4123. + /// Invalid metadata returns an error. + /// Callers can use these defaults while the user repairs invalid app settings. + pub fn defaults_for(path: &Path) -> Result { + let directory = path.parent().unwrap_or(Path::new(".")); + let metadata = directory.join("install.toml"); + if metadata.try_exists().map_err(|e| e.to_string())? { + let settings = Settings::load(&metadata)?; + Ok(Self::for_directory(&settings.sy_home, settings.port)) + } else { + Ok(Self::for_directory(directory, DEFAULT_PORT)) + } + } + + /// This function parses settings and uses adjacent `install.toml` for directory and port defaults. + /// Without installation metadata, default paths use the settings directory and the port is 4123. + /// An omitted pricing_file selects bundled rates. + 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())?; + 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) => return Err(format!("read {}: {error}", path.display())), + } + let moved = if values.contains_key("prices") { + " Remove the prices table to use bundled rates, or convert it to prices.toml and select it with pricing_file." + } else { + "" + }; + let mut config: Self = values + .try_into() + .map_err(|error| format!("parse {}: {error}{moved}", path.display()))?; + crate::health::validate_url(&config.server_url)?; + config.routing_log = expand_home(&config.routing_log)?; + config.config_file = expand_home(&config.config_file)?; + config.prices_file = config.prices_file.as_deref().map(expand_home).transpose()?; + Ok(config) + } + + /// The app reads bundled installation metadata so shortcuts retain a custom `SY_HOME`. + /// Without that metadata, `SY_HOME` selects the directory; if unset, it is `~/.switchyard`. + pub fn default_path() -> Result { + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + let metadata = crate::install::bundle_metadata(&executable); + if metadata.try_exists().map_err(|e| e.to_string())? { + return Settings::load(&metadata).map(|s| s.desktop_settings()); + } + let directory = match std::env::var_os("SY_HOME") { + Some(value) => expand_home(Path::new(&value))?, + None => home_dir()?.join(HOME_DIR), + }; + if !directory.is_absolute() { + return Err("SY_HOME must be an absolute path.".into()); + } + Ok(directory.join(DESKTOP_CONFIG)) + } +} + +pub fn save_preferences(path: &Path, baseline: &str, refresh: u64) -> Result<(), String> { + use std::io::Write; + 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()); + } + let original = match std::fs::read_to_string(path) { + Ok(text) => Some(text), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.to_string()), + }; + let mut doc = original + .as_deref() + .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())?); + 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. + let mut values = + toml::Table::try_from(Config::defaults_for(path)?).map_err(|error| error.to_string())?; + values.extend(toml::from_str::(&text).map_err(|error| error.to_string())?); + let config: Config = values.try_into().map_err(|error| error.to_string())?; + crate::health::validate_url(&config.server_url)?; + let destination = match std::fs::symlink_metadata(path) { + Ok(_) => std::fs::canonicalize(path).map_err(|error| error.to_string())?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => path.to_path_buf(), + Err(error) => return Err(error.to_string()), + }; + let parent = destination + .parent() + .ok_or("App settings need a parent folder.")?; + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|error| error.to_string())?; + temp.write_all(text.as_bytes()) + .and_then(|()| temp.as_file().sync_all()) + .map_err(|error| error.to_string())?; + if let Ok(metadata) = std::fs::metadata(path) { + temp.as_file() + .set_permissions(metadata.permissions()) + .map_err(|error| error.to_string())?; + } + let current = match std::fs::read_to_string(path) { + Ok(text) => Some(text), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.to_string()), + }; + 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())?; + Ok(()) +} + +pub fn validate_toml_file(path: &Path) -> Result<(), String> { + let text = std::fs::read_to_string(path) + .map_err(|error| format!("read {}: {error}", path.display()))?; + toml::from_str::(&text) + .map_err(|error| format!("parse {}: {error}", path.display()))?; + Ok(()) +} + +/// This function expands a leading `~` in manually written settings and reports a missing home. +pub fn expand_home(path: &Path) -> Result { + match path.strip_prefix("~") { + Ok(rest) => Ok(home_dir()?.join(rest)), + Err(_) => Ok(path.to_path_buf()), + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn preference_edits_preserve_symlinks_and_reject_dangling_targets() { + let dir = tempfile::tempdir().expect("fixture"); + let path = dir.path().join("desktop.toml"); + let target = dir.path().join("missing.toml"); + std::os::unix::fs::symlink(&target, &path).expect("symlink"); + assert!(save_preferences(&path, "baseline", 30).is_err()); + assert_eq!( + std::fs::read_link(&path).expect("preserved symlink"), + target + ); + assert!(!target.exists()); + std::fs::write(&target, "refresh_seconds=30\n").expect("target"); + save_preferences(&path, "baseline", 12).expect("save through symlink"); + assert_eq!( + std::fs::read_link(&path).expect("preserved symlink"), + target + ); + assert_eq!( + Config::load(&target) + .expect("target settings") + .refresh_seconds, + 12 + ); + } +} diff --git a/crates/switchyard-desktop/src/health.rs b/crates/switchyard-desktop/src/health.rs new file mode 100644 index 000000000..74fcd666b --- /dev/null +++ b/crates/switchyard-desktop/src/health.rs @@ -0,0 +1,367 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module checks the configured server's `/health` endpoint. +//! +//! The health check supports plain HTTP base URLs. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::sync::{OnceLock, mpsc}; +use std::time::{Duration, Instant}; + +/// TIMEOUT limits the whole probe, including DNS and every connection attempt. +const TIMEOUT: Duration = Duration::from_millis(500); +const MAX_STATUS_LINE: usize = 1024; + +/// ServerStatus records whether the server answered its health check. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServerStatus { + Running, + Stopped, +} + +/// Callers retain one Probe so repeated checks share one worker and at most one waiting request. +/// A stuck OS name lookup occupies the worker, but callers still stop waiting after 500 ms. +/// Dropping the Probe closes the queue without waiting for the worker to finish. +#[derive(Default)] +pub struct Probe { + worker: OnceLock, String>>, +} + +struct Request { + authority: String, + deadline: Instant, + reply: mpsc::SyncSender>, +} + +impl Probe { + /// This method checks `/health`, treating any failure as a stopped server. + pub fn probe(&self, server_url: &str) -> ServerStatus { + match self.request(server_url) { + Ok(true) => ServerStatus::Running, + _ => ServerStatus::Stopped, + } + } + + /// This method includes the reason when a health check cannot confirm HTTP 200. + pub fn details(&self, server_url: &str) -> serde_json::Value { + match self.request(server_url) { + Ok(true) => serde_json::json!({"state":"running","reason":null}), + Ok(false) => { + serde_json::json!({"state":"unreachable","reason":"The health endpoint did not return HTTP 200."}) + } + Err(error) => serde_json::json!({"state":"unreachable","reason":error.to_string()}), + } + } + + fn request(&self, server_url: &str) -> std::io::Result { + let deadline = Instant::now() + TIMEOUT; + let authority = authority(server_url).map_err(std::io::Error::other)?; + let worker = self + .worker + .get_or_init(|| { + start_worker(|authority| authority.to_socket_addrs().map(Iterator::collect)) + }) + .as_ref() + .map_err(|error| std::io::Error::other(error.clone()))?; + let (reply, receiver) = mpsc::sync_channel(1); + worker + .try_send(Request { + authority, + deadline, + reply, + }) + .map_err(|_| std::io::Error::other("A previous health check is still in progress."))?; + let result = receiver + .recv_timeout(remaining(deadline)?) + .map_err(|_| timeout())?; + remaining(deadline)?; + result + } +} + +// The queue retains at most one waiting request. Expired requests never start DNS or connect. +fn start_worker( + resolve: fn(&str) -> std::io::Result>, +) -> Result, String> { + let (sender, receiver) = mpsc::sync_channel::(1); + std::thread::Builder::new() + .name("switchyard-health".into()) + .spawn(move || { + while let Ok(request) = receiver.recv() { + let result = remaining(request.deadline) + .and_then(|_| request_status(&request.authority, request.deadline, resolve)); + let _ = request.reply.send(result); + } + }) + .map_err(|error| error.to_string())?; + Ok(sender) +} + +fn timeout() -> std::io::Error { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "The health check exceeded its 500 ms deadline.", + ) +} + +fn remaining(deadline: Instant) -> std::io::Result { + deadline + .checked_duration_since(Instant::now()) + .filter(|duration| !duration.is_zero()) + .ok_or_else(timeout) +} + +fn request_status( + authority: &str, + deadline: Instant, + resolve: fn(&str) -> std::io::Result>, +) -> std::io::Result { + remaining(deadline)?; + let addresses = resolve(authority)?; + // localhost can resolve to IPv6 first, so the check tries each address within one deadline. + let mut stream = Err(std::io::Error::other(format!("no address for {authority}"))); + for address in addresses { + stream = TcpStream::connect_timeout(&address, remaining(deadline)?); + if stream.is_ok() { + break; + } + } + let mut stream = stream?; + let request = format!("GET /health HTTP/1.0\r\nHost: {authority}\r\n\r\n"); + let mut pending = request.as_bytes(); + while !pending.is_empty() { + stream.set_write_timeout(Some(remaining(deadline)?))?; + match stream.write(pending) { + Ok(0) => { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "Could not write the health request.", + )); + } + Ok(count) => pending = &pending[count..], + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } + let mut bytes = [0; MAX_STATUS_LINE]; + let mut used = 0; + while used < bytes.len() { + stream.set_read_timeout(Some(remaining(deadline)?))?; + let count = match stream.read(&mut bytes[used..]) { + Ok(0) => { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "The HTTP status line has no newline.", + )); + } + Ok(count) => count, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + }; + if let Some(end) = bytes[used..used + count] + .iter() + .position(|byte| *byte == b'\n') + { + remaining(deadline)?; + return parse_status_line(&bytes[..used + end]); + } + used += count; + } + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "The HTTP status line exceeds 1,024 bytes or has no newline.", + )) +} + +fn parse_status_line(bytes: &[u8]) -> std::io::Result { + let line = std::str::from_utf8(bytes) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + let mut fields = line.split_ascii_whitespace(); + if !matches!(fields.next(), Some("HTTP/1.0" | "HTTP/1.1")) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "The HTTP status line has an invalid version.", + )); + } + let code = fields.next().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "The HTTP status line has no status code.", + ) + })?; + if code.len() != 3 || !code.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "The HTTP status line has an invalid status code.", + )); + } + Ok(code == "200") +} + +fn authority(server_url: &str) -> Result { + let url = validate_url(server_url)?; + // Url removes an explicit HTTP port of 80, so a missing parsed port still means 80. + let port = url.port_or_known_default().ok_or("Missing server port")?; + Ok(format!( + "{}:{port}", + &url[url::Position::BeforeHost..url::Position::AfterHost] + )) +} + +pub(crate) fn validate_url(server_url: &str) -> Result { + if server_url.trim() != server_url + || server_url.chars().any(char::is_control) + || !server_url + .split_once("://") + .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http")) + { + return Err( + "Server URL must start with http:// and contain no whitespace or control characters." + .into(), + ); + } + let url = + url::Url::parse(server_url).map_err(|error| format!("Invalid server URL: {error}"))?; + if url.scheme() != "http" + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !matches!(url.path(), "" | "/") + { + return Err( + "Server URL must be an HTTP base URL with a host and optional port, \ + without credentials, a path, query, or fragment." + .into(), + ); + } + Ok(url) +} + +#[cfg(test)] +mod tests { + use super::{Probe, start_worker}; + use std::io::Write; + use std::net::SocketAddr; + use std::sync::OnceLock; + use std::time::{Duration, Instant}; + + // The listener stops waiting after two seconds if the probe never connects. + fn response(bytes: Vec, delay: Duration) -> (String, std::thread::JoinHandle<()>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener"); + listener.set_nonblocking(true).expect("nonblocking"); + let url = format!("http://{}", listener.local_addr().expect("address")); + let thread = std::thread::spawn(move || { + let until = Instant::now() + Duration::from_secs(2); + while Instant::now() < until { + match listener.accept() { + Ok((mut stream, _)) => { + use std::io::Read; + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .expect("read timeout"); + let _ = stream.read(&mut [0; 1024]); + stream + .set_write_timeout(Some(Duration::from_millis(200))) + .expect("timeout"); + for byte in bytes { + if stream.write_all(&[byte]).is_err() { + break; + } + std::thread::sleep(delay); + } + break; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept: {error}"), + } + } + }); + (url, thread) + } + + #[test] + fn dns_uses_the_same_deadline_and_a_bounded_worker() { + static LOOKUPS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + // This lookup outlasts the caller's deadline so later checks must reuse the occupied worker. + fn slow_dns(_: &str) -> std::io::Result> { + LOOKUPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(1600)); + Ok(vec!["127.0.0.1:1".parse().expect("address")]) + } + let probe = Probe { + worker: OnceLock::from(start_worker(slow_dns)), + }; + let start = Instant::now(); + assert_eq!(probe.details("http://slow.test")["state"], "unreachable"); + assert!(start.elapsed() < Duration::from_millis(900)); + assert_eq!(probe.details("http://slow.test")["state"], "unreachable"); + let start = Instant::now(); + assert_eq!(probe.details("http://slow.test")["state"], "unreachable"); + assert!( + start.elapsed() < Duration::from_millis(200), + "a full queue must not wait" + ); + assert_eq!(LOOKUPS.load(std::sync::atomic::Ordering::SeqCst), 1); + std::thread::sleep(Duration::from_millis(700)); + assert_eq!( + LOOKUPS.load(std::sync::atomic::Ordering::SeqCst), + 1, + "expired requests must not resolve" + ); + } + + #[test] + fn trickled_status_line_uses_one_deadline() { + let (url, thread) = response(b"HTTP/1.1 200 OK\r\n".to_vec(), Duration::from_millis(100)); + let start = Instant::now(); + let result = Probe::default().details(&url); + let elapsed = start.elapsed(); + thread.join().expect("server"); + assert_eq!(result["state"], "unreachable"); + assert!( + elapsed < Duration::from_millis(900), + "probe took {elapsed:?}" + ); + } + + #[test] + fn status_lines_require_a_terminator_and_fit_the_limit() { + for (line, expected) in [ + (b"HTTP/1.1 200 OK\r\n".to_vec(), "running"), + (b"HTTP/1.0 200 OK\r\n".to_vec(), "running"), + ( + [b"HTTP/1.1 200 ".as_slice(), &[b'A'; 1009], b"\r\n"].concat(), + "running", + ), + ( + [b"HTTP/1.1 200 ".as_slice(), &[b'A'; 1010], b"\r\n"].concat(), + "unreachable", + ), + (b"HTTP/2 200 OK\r\n".to_vec(), "unreachable"), + (b"HTTP/1.1 20x OK\r\n".to_vec(), "unreachable"), + (b"HTTP/1.1\r\n".to_vec(), "unreachable"), + (b"HTTP/1.1 200 \xff\r\n".to_vec(), "unreachable"), + ( + b"HTTP/1.1 200 OK\r\nignored headers and body".to_vec(), + "running", + ), + (b"HTTP/1.0 503 Unavailable\r\n".to_vec(), "unreachable"), + (b"HTTP/1.1 200 OK".to_vec(), "unreachable"), + ( + [b"HTTP/1.1 200 ".as_slice(), &[b'A'; 2048], b"\r\n"].concat(), + "unreachable", + ), + ] { + let (url, thread) = response(line, Duration::ZERO); + let result = Probe::default().details(&url); + thread.join().expect("server"); + assert_eq!(result["state"], expected); + } + } +} diff --git a/crates/switchyard-desktop/src/install.rs b/crates/switchyard-desktop/src/install.rs index e173f8d0d..4e4bd7837 100644 --- a/crates/switchyard-desktop/src/install.rs +++ b/crates/switchyard-desktop/src/install.rs @@ -16,6 +16,10 @@ use std::{ use switchyard_costs::Pricing; +use crate::config::{ + self, Config, DEFAULT_PORT, DESKTOP_CONFIG, HOME_DIR, PRICES_FILE, ROUTING_LOG, SERVER_CONFIG, +}; + pub mod credentials; // Linux and any other system without launchd or Task Scheduler use systemd; execute rejects // unsupported systems before they change anything. @@ -36,7 +40,6 @@ pub fn executable_name(name: &str) -> String { format!("{name}{}", std::env::consts::EXE_SUFFIX) } -const SERVER: &str = "com.nvidia.switchyard.server"; 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."; @@ -87,10 +90,7 @@ impl Settings { Ok(selected) } pub fn from_env() -> Result { - let home = std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) - .ok_or("Could not find your home directory.")?; + let home = config::home_dir()?; let path = |name, default| std::env::var_os(name).map(PathBuf::from).unwrap_or(default); let value = |name: &str, default: &str| { std::env::var(name).or_else(|error| match error { @@ -98,12 +98,12 @@ impl Settings { std::env::VarError::NotUnicode(_) => Err(format!("{name} must be UTF-8.")), }) }; - let port = value("SY_PORT", "4123")?; + let port = value("SY_PORT", &DEFAULT_PORT.to_string())?; if !port.bytes().all(|b| b.is_ascii_digit()) { return Err(PORT_ERROR.into()); } let settings = Self { - sy_home: path("SY_HOME", home.join(".switchyard")), + sy_home: path("SY_HOME", home.join(HOME_DIR)), codex_home: path("CODEX_HOME", home.join(".codex")), service_dir: if cfg!(target_os = "macos") { home.join("Library/LaunchAgents") @@ -202,11 +202,11 @@ impl Settings { self.bin_dir().join(executable_name(name)) } pub fn desktop_settings(&self) -> PathBuf { - self.sy_home.join("desktop.toml") + self.sy_home.join(DESKTOP_CONFIG) } /// This optional file overrides bundled rates for the server and app baseline. pub fn prices_file(&self) -> PathBuf { - self.sy_home.join("prices.json") + self.sy_home.join(PRICES_FILE) } /// This method builds the installed server command with any existing price override. /// The caller configures and starts the process. @@ -214,12 +214,12 @@ impl Settings { let mut command = Command::new(self.binary("switchyard-server")); command .arg("--config") - .arg(self.sy_home.join("composite.toml")); + .arg(self.sy_home.join(SERVER_CONFIG)); 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.jsonl")); + .arg(self.sy_home.join(ROUTING_LOG)); let prices = self.prices_file(); if prices.exists() { @@ -491,8 +491,8 @@ fn installation_plan(s: &Settings, action: &str) -> String { ]; for (path, label) in [ (s.desktop_settings(), "app settings"), - (s.sy_home.join("composite.toml"), "server config"), - (s.sy_home.join("routing.jsonl"), "usage history"), + (s.sy_home.join(SERVER_CONFIG), "server config"), + (s.sy_home.join(ROUTING_LOG), "usage history"), ] { let verb = if removing || path.exists() { "Preserve" @@ -776,7 +776,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("composite.toml"); + let config = s.sy_home.join(SERVER_CONFIG); let existing = present(&config)?; let staged = tempfile::NamedTempFile::new().map_err(err)?; let checked = if existing { @@ -830,11 +830,11 @@ 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("composite.toml"), BUNDLED_CONFIG)?; + 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))?; + 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)?; @@ -944,9 +944,9 @@ fn reconcile_desktop_port(s: &Settings) -> Result, String> { let old_port = if previous.exists() { Settings::load(&previous)?.port } else { - 4123 + DEFAULT_PORT }; - let expected = format!("http://127.0.0.1:{old_port}"); + let expected = config::server_url(old_port); let current = document .get("server_url") .map(|value| { @@ -971,7 +971,7 @@ fn reconcile_desktop_port(s: &Settings) -> Result, String> { if current.is_some_and(|url| url != expected || s.port == old_port) { return Ok(None); } - document["server_url"] = toml_edit::value(format!("http://127.0.0.1:{}", s.port)); + document["server_url"] = toml_edit::value(config::server_url(s.port)); let updated = document.to_string(); Ok((updated != text).then_some(updated)) } @@ -980,7 +980,7 @@ fn reconcile_desktop_port(s: &Settings) -> 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("composite.toml"); + let path = s.sy_home.join(SERVER_CONFIG); 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()))?; @@ -1051,34 +1051,16 @@ fn merge_codex_profile( Ok(document.to_string()) } -fn desktop_config(s: &Settings) -> String { - let mut config = format!( - "server_url = {:?}\n\ - routing_log = {}\n\ - config_file = {}\n\ - launchd_label = {:?}\n\ - refresh_seconds = 30\n\ - \n\ - # Savings compare recorded costs with answer tokens priced at baseline_model rates.\n\ - # The app uses bundled rates unless pricing_file is set.\n\ - baseline_model = \"gpt-6-sol\"\n", - format!("http://127.0.0.1:{}", s.port), - toml_string(&s.sy_home.join("routing.jsonl").to_string_lossy()), - toml_string(&s.sy_home.join("composite.toml").to_string_lossy()), - SERVER, - ); +// The installer and app share defaults so generated settings keep the selected directory and port. +fn desktop_config(s: &Settings) -> Result { + let mut config = Config::for_directory(&s.sy_home, s.port); let prices = s.prices_file(); if prices.exists() { - config.push_str(&format!( - "pricing_file = {}\n", - toml_string(&prices.to_string_lossy()) - )); + config.prices_file = Some(prices); } - config -} -fn toml_string(value: &str) -> String { - toml::Value::String(value.into()).to_string() + toml::to_string(&config).map_err(err) } + #[cfg(any(target_os = "macos", windows))] fn xml(value: &str) -> String { value diff --git a/crates/switchyard-desktop/src/install/macos.rs b/crates/switchyard-desktop/src/install/macos.rs index 7fc834522..888f70307 100644 --- a/crates/switchyard-desktop/src/install/macos.rs +++ b/crates/switchyard-desktop/src/install/macos.rs @@ -11,7 +11,9 @@ use std::{ time::Duration, }; -use super::{SERVER, Settings, err, present, remove, run, write, xml}; +use crate::config::SERVER_LABEL as SERVER; + +use super::{Settings, err, present, remove, run, write, xml}; const DESKTOP: &str = "com.nvidia.switchyard.desktop"; diff --git a/crates/switchyard-desktop/src/lib.rs b/crates/switchyard-desktop/src/lib.rs index be41a3ff1..0902bf450 100644 --- a/crates/switchyard-desktop/src/lib.rs +++ b/crates/switchyard-desktop/src/lib.rs @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The installer that `cargo desktop`, the installed `switchyard-desktop-install` helper, and the -//! desktop app share. +//! Desktop settings, server checks, and installation support. +pub mod config; +pub mod health; pub mod install; diff --git a/crates/switchyard-desktop/tests/desktop.rs b/crates/switchyard-desktop/tests/desktop.rs index 3c0cc1c84..71e560b5b 100644 --- a/crates/switchyard-desktop/tests/desktop.rs +++ b/crates/switchyard-desktop/tests/desktop.rs @@ -302,6 +302,12 @@ esac &settings, &("baseline_model = 'custom'\n".to_owned() + &url(f.s.port)), ); + let app = + switchyard_desktop::config::Config::load(&settings).expect("partial app settings"); + assert_eq!(app.routing_log, sy_home.join("routing.jsonl")); + assert!(app.prices_file.is_none()); + assert_eq!(app.config_file, sy_home.join("composite.toml")); + assert_eq!(app.server_url, format!("http://127.0.0.1:{}", f.s.port)); let first = read(&f.profile()); let profile = toml(&f.profile()); assert_eq!(profile["model"].as_str(), Some(f.s.model.as_str()));