Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/switchyard-desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_

[target.'cfg(any(target_os = "macos", windows))'.dependencies]
rfd = { version = "0.17", default-features = false }
tauri = { version = "2", features = ["tray-icon", "custom-protocol"] }
tauri = { version = "2", features = ["tray-icon", "custom-protocol", "macos-private-api"] }

[target.'cfg(any(target_os = "macos", windows))'.build-dependencies]
tauri-build = "2"
Expand Down
38 changes: 38 additions & 0 deletions crates/switchyard-desktop/SETUP.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions crates/switchyard-desktop/capabilities/main-chrome.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
12 changes: 10 additions & 2 deletions crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
243 changes: 233 additions & 10 deletions crates/switchyard-desktop/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,17 @@ impl Config {
let defaults = Self::defaults_for(path)?;
let service_config = defaults.service_config.clone();
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") {
Expand Down Expand Up @@ -139,14 +143,136 @@ 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<bool, String> {
fn present(path: &Path) -> Result<bool, String> {
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> {
// The managed server starts on the URL's port, and a URL without one would mean port 80.
if crate::health::validate_url(&config.server_url)?
.port()
.is_none()
{
return Err(
"Include the port in the server address, for example http://127.0.0.1:4123.".into(),
);
}
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::<Result<Vec<_>, _>>()?;
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())?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// The price table matches model IDs exactly, so surrounding spaces would hide the estimate.
values.insert("baseline_model".into(), config.baseline_model.trim().into());
// 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<PathBuf, String> {
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,
Expand All @@ -157,9 +283,17 @@ pub fn save_preferences(path: &Path, baseline: &str, refresh: u64) -> Result<(),
.unwrap_or("")
.parse::<toml_edit::DocumentMut>()
.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.
Expand Down Expand Up @@ -194,8 +328,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(())
}

Expand All @@ -215,9 +377,68 @@ pub fn expand_home(path: &Path) -> Result<PathBuf, String> {
}
}

#[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]
Expand All @@ -226,6 +447,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"),
Expand Down
Loading
Loading