Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
70a871a
feat(menubar): add a routes window to change a route's algorithm and …
elyasmnvidian Oct 7, 2026
af6f852
feat(macos): add app installation and session usage
elyasmnvidian Oct 8, 2026
495cd8c
feat(app): add Tauri desktop and Ratatui terminal apps
elyasmnvidian Oct 8, 2026
87d5514
fix(app): reject model-list key saving outside macOS
elyasmnvidian Oct 8, 2026
5c45a14
fix(app): keep usage columns readable in narrow windows
elyasmnvidian Oct 8, 2026
8f8e61f
feat(app): preview harness installs and expand the tray menu
elyasmnvidian Oct 8, 2026
cd51943
fix(app): scope model keys and refresh restored backups
elyasmnvidian Oct 8, 2026
43ecf3f
feat(app): add searchable routes and installation diffs
elyasmnvidian Oct 9, 2026
46c089f
fix(macos): reject dots in Codex profile names
elyasmnvidian Oct 9, 2026
5778c88
fix(sessions): preserve Claude default login environment
elyasmnvidian Oct 9, 2026
d59fbdb
fix(docs): name the default route in profile examples
elyasmnvidian Oct 9, 2026
4e90bdb
chore(app): simplify control flow and prune redundant tests
elyasmnvidian Oct 9, 2026
85e23ad
feat(app): show model usage and prevent route label overlap
elyasmnvidian Oct 9, 2026
50d304b
fix(app): protect route edits and simplify model loading
elyasmnvidian Oct 9, 2026
1c3e9ed
fix(app): detect Codex and explain routing installation
elyasmnvidian Oct 9, 2026
200c1ce
fix(translation): read streamed custom tool input from the completed …
elyasmnvidian Oct 10, 2026
09cc09a
fix(translation): send an error event when a Responses snapshot confl…
elyasmnvidian Oct 10, 2026
d37a4b7
fix(client): define tools for replayed tool calls when a request has …
elyasmnvidian Oct 10, 2026
c7b515b
test(server): reproduce the Codex failures through a LiteLLM gateway
elyasmnvidian Oct 10, 2026
083cce8
feat(desktop): install and update the app with Rust on macOS, Linux, …
elyasmnvidian Oct 10, 2026
51dfc8c
chore(ci): validate Windows desktop on this branch
elyasmnvidian Oct 10, 2026
18fde20
fix(ci): use allowed actions for Windows validation
elyasmnvidian Oct 10, 2026
824b918
fix(desktop): isolate Linux terminal launch fixtures
elyasmnvidian Oct 10, 2026
f78ba23
fix(desktop): use an npm-style Windows launcher fixture
elyasmnvidian Oct 10, 2026
9d9cbf3
chore(ci): preserve Windows build cache after failed tests
elyasmnvidian Oct 10, 2026
96c6f97
fix(desktop): close task XML before Windows registration
elyasmnvidian Oct 10, 2026
a4da122
test(desktop): check updates from the Windows scheduled task
elyasmnvidian Oct 10, 2026
216c1b4
test(desktop): verify effective Windows task privileges
elyasmnvidian Oct 10, 2026
f037fe8
fix(desktop): preserve unchanged settings during reinstall
elyasmnvidian Oct 10, 2026
22d3224
fix(desktop): report Windows executable removal
elyasmnvidian Oct 10, 2026
6e7ec73
chore(ci): remove temporary Windows validation
elyasmnvidian Oct 10, 2026
13d47ca
fix(desktop): share server credentials through the installed helper
elyasmnvidian Oct 10, 2026
0e71e9b
fix(desktop): check macOS and Windows icon exports on every build
elyasmnvidian Oct 10, 2026
cfce457
chore(ci): validate desktop changes on Windows temporarily
elyasmnvidian Oct 10, 2026
b31fadc
feat(desktop): use the titanium turnout app icon
elyasmnvidian Oct 10, 2026
8d983db
chore(desktop): shorten icon generation notes
elyasmnvidian Oct 10, 2026
c4ae8e7
fix(desktop): preserve inputs and restore reviewed tool settings
elyasmnvidian Oct 10, 2026
d3881d6
chore(docs): clarify refresh and request format behavior
elyasmnvidian Oct 10, 2026
caffafe
chore(ci): remove temporary Windows validation
elyasmnvidian Oct 10, 2026
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
3,185 changes: 2,944 additions & 241 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ help:
@echo "install-linux Install the systemd user service and Codex profile"
@echo "install-linux-dry-run Preview installation without changes"
@echo "uninstall-linux Remove the service and Codex profile"
@echo "install-macos Install the background server and menu bar app"
@echo "install-macos Install the background server and desktop app"
@echo "install-macos-dry-run Preview installation without changes"
@echo "uninstall-macos Remove the LaunchAgents and Codex profile"

Expand All @@ -24,14 +24,14 @@ install-linux-dry-run:
uninstall-linux:
@scripts/linux/uninstall.sh

## Install the Switchyard background server and menu bar app for this user.
## This target installs the Switchyard background server and desktop app for this user.
install-macos:
@scripts/macos/install.sh

## Print what install-macos would do, without changing anything.
## This target prints the commands that install-macos would run.
install-macos-dry-run:
@scripts/macos/install.sh --dry-run

## Remove the LaunchAgents, the sy Codex profile, and the codex alias.
## This target removes the app, LaunchAgents, selected Codex profile, and legacy codex alias.
uninstall-macos:
@scripts/macos/uninstall.sh
1 change: 1 addition & 0 deletions crates/switchyard-menubar/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/gen/
21 changes: 14 additions & 7 deletions crates/switchyard-menubar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[package]
name = "switchyard-menubar"
version.workspace = true
description = "macOS menu bar companion for a locally running Switchyard server"
description = "Desktop and terminal app for a local Switchyard server"
authors.workspace = true
edition.workspace = true
homepage = "https://github.com/NVIDIA-NeMo/Switchyard"
Expand All @@ -14,16 +14,23 @@ rust-version.workspace = true
publish = false

[dependencies]
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
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"
uuid.workspace = true
ratatui = "0.30.2"

[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = "0.3"
objc2-foundation = "0.3"
tray-icon = "0.25"
security-framework = "3"
tauri = { version = "2", features = ["tray-icon", "custom-protocol"] }

[target.'cfg(target_os = "macos")'.build-dependencies]
tauri-build = "2"

[dev-dependencies]
tempfile = "3"
# The tests check each edited config with the server's own config parser.
switchyard-runner.workspace = true
295 changes: 244 additions & 51 deletions crates/switchyard-menubar/README.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions crates/switchyard-menubar/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

fn main() {
#[cfg(target_os = "macos")]
tauri_build::build();
}
Binary file added crates/switchyard-menubar/icons/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added crates/switchyard-menubar/icons/tray.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added crates/switchyard-menubar/icons/tray.rgba
Binary file not shown.
3 changes: 3 additions & 0 deletions crates/switchyard-menubar/icons/tray.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
182 changes: 182 additions & 0 deletions crates/switchyard-menubar/src/accounts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Named login directories use the coding tool's own authentication commands.

use crate::harness::{self, Harness};
use std::path::{Path, PathBuf};

// PendingAccount removes only an empty directory if login setup fails.
// The coding tool may already have saved login files, which must stay.
struct PendingAccount(Option<PathBuf>);

impl Drop for PendingAccount {
fn drop(&mut self) {
if let Some(path) = &self.0 {
let _ = std::fs::remove_dir(path);
}
}
}

fn root(tool: Harness) -> Result<PathBuf, String> {
let name = match tool {
Harness::CodexCli => "codex",
Harness::Claude => "claude",
_ => return Err("Named accounts are available for Codex CLI and Claude Code.".into()),
};
let home = crate::config::expand_home(Path::new("~"));
let mut path = home;
for component in [".switchyard", "accounts", name] {
path.push(component);
if std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink()) {
return Err(format!(
"{} is a symlink. Edit its target yourself.",
path.display()
));
}
}
Ok(path)
}

pub fn validate(tool: Harness, path: &Path) -> Result<(), String> {
let root = root(tool)?;
if path.parent() != Some(root.as_path())
|| !std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir() && !m.file_type().is_symlink())
{
return Err("Choose a supported coding tool.".into());
}
Ok(())
}

pub fn list(tool: Harness) -> Vec<(String, PathBuf)> {
let Ok(root) = root(tool) else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut accounts: Vec<_> = entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|e| {
e.file_name()
.into_string()
.ok()
.map(|name| (name, e.path()))
})
.collect();
accounts.sort_by(|a, b| a.0.cmp(&b.0));
accounts
}

pub fn config_paths(tool: Harness, account: Option<&Path>) -> Vec<PathBuf> {
let Some(account) = account else {
return harness::paths(tool);
};
match tool {
Harness::CodexCli => vec![account.join("sy.config.toml")],
Harness::Claude => vec![account.join("settings.json")],
_ => harness::paths(tool),
}
}

pub fn add(tool: Harness, name: &str) -> Result<String, String> {
if name.is_empty()
|| name.len() > 64
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err("Account names need 1–64 letters, numbers, hyphens, or underscores.".into());
}
let root = root(tool)?;
let binary = harness::binary(tool).ok_or("Install the selected coding tool first.")?;
std::fs::create_dir_all(&root).map_err(|e| e.to_string())?;
let path = root.join(name);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new().mode(0o700).create(&path).map_err(|e|format!("Create account: {e}. Pick an existing account after Refresh, or choose a new name."))?;
}
#[cfg(not(unix))]
std::fs::create_dir(&path).map_err(|e| e.to_string())?;
let mut pending = PendingAccount(Some(path.clone()));
let variable = match tool {
Harness::CodexCli => "CODEX_HOME",
Harness::Claude => "CLAUDE_CONFIG_DIR",
_ => return Err("Choose a supported coding tool.".into()),
};
let login = match tool {
Harness::CodexCli => "login",
_ => "auth login",
};
let script = format!(
"#!/bin/bash\nrm -- \"$0\"\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\nexport {variable}={}\n{} {login}",
crate::sessions::quote(&path.display().to_string()),
crate::sessions::quote(&binary.display().to_string())
);
crate::server::open_terminal_script("switchyard-login-", &script)?;
pending.0 = None;
Ok(format!(
"Opened the coding tool's login in Terminal for account {name}. Complete login, click Refresh, and select this account. Switchyard does not read the login tokens. The selected account applies to new sessions only. Account directory: {}",
path.display()
))
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsafe_names_fail_before_any_side_effect() {
for name in ["", "..", "a/b", "$(touch bad)", "a\nb"] {
assert!(add(Harness::CodexCli, name).is_err());
}
assert!(add(Harness::Pi, "valid").is_err());
}

#[test]
fn a_login_setup_failure_removes_only_its_empty_directory() {
let root = tempfile::tempdir().expect("directory");
let path = root.path().join("account");
std::fs::create_dir(&path).expect("account");
drop(PendingAccount(Some(path.clone())));
assert!(!path.exists());
std::fs::create_dir(&path).expect("account");
std::fs::write(path.join("auth.json"), "private").expect("auth");
drop(PendingAccount(Some(path.clone())));
assert!(path.join("auth.json").is_file());
}
// The subprocess isolates HOME so parallel tests keep their own settings.
#[cfg(unix)]
#[test]
fn symlinked_account_roots_are_not_listed_or_used_for_login() {
if std::env::var_os("SWITCHYARD_ACCOUNT_FIXTURE").is_some() {
assert!(list(Harness::CodexCli).is_empty());
assert!(add(Harness::CodexCli, "new").is_err());
let path = crate::config::expand_home(Path::new("~/.switchyard/accounts/codex/named"));
assert!(validate(Harness::CodexCli, &path).is_err());
return;
}
let dir = tempfile::tempdir().expect("fixture");
let home = dir.path().join("home");
let target = dir.path().join("target");
std::fs::create_dir(&home).expect("home");
std::fs::create_dir_all(target.join("accounts/codex/named")).expect("accounts");
std::os::unix::fs::symlink(&target, home.join(".switchyard")).expect("symlink");
let output = std::process::Command::new(std::env::current_exe().expect("runner"))
.args([
"--exact",
"accounts::tests::symlinked_account_roots_are_not_listed_or_used_for_login",
])
.env("SWITCHYARD_ACCOUNT_FIXTURE", "1")
.env("HOME", &home)
.output()
.expect("child");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stdout)
);
assert!(!target.join("accounts/codex/new").exists());
}
}
57 changes: 0 additions & 57 deletions crates/switchyard-menubar/src/app.rs

This file was deleted.

Loading
Loading