Skip to content

Commit 465463a

Browse files
committed
feat(desktop): add the Settings page, server actions, and editor handoff
Signed-off-by: Elyas Mehtabuddin <emehtabuddin@nvidia.com>
1 parent d968f15 commit 465463a

5 files changed

Lines changed: 687 additions & 21 deletions

File tree

‎crates/switchyard-desktop/src/controller.rs‎

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,24 @@
66
//! terminal app matches `ActionKind` without a wildcard arm, so adding an action breaks the
77
//! build until the terminal app can run it.
88
9-
use crate::{config::Config, history, pricing, rollup};
10-
use serde::Serialize;
9+
use crate::{config::Config, history, pricing, rollup, server};
10+
use serde::{Deserialize, Serialize};
1111
use serde_json::{Value, json};
1212
use std::{collections::BTreeMap, path::PathBuf};
1313

14+
#[derive(Clone, Debug, Deserialize, strum::EnumDiscriminants)]
15+
#[strum_discriminants(name(ActionKind), derive(strum::EnumIter))]
16+
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
17+
pub enum Action {
18+
OpenLogs {},
19+
OpenUpdateLog {},
20+
OpenConfig {},
21+
OpenPrices {},
22+
OpenSettings {},
23+
CheckConfig {},
24+
ServerKeys {},
25+
}
26+
1427
/// Snapshot holds everything that the apps show. The terminal app destructures it without
1528
/// `..`, so adding a field breaks the build until the terminal app shows it.
1629
#[derive(Clone, Default, Serialize)]
@@ -90,6 +103,20 @@ pub struct Counts {
90103
pub tokens: u64,
91104
}
92105

106+
#[derive(Serialize)]
107+
pub struct Reply {
108+
pub message: String,
109+
pub data: Value,
110+
}
111+
impl Reply {
112+
fn message(message: String) -> Self {
113+
Self {
114+
message,
115+
data: Value::Null,
116+
}
117+
}
118+
}
119+
93120
pub struct Controller {
94121
settings: PathBuf,
95122
config: Config,
@@ -219,6 +246,75 @@ impl Controller {
219246
}),
220247
})
221248
}
249+
/// The TUI uses these file paths to run an editor in the foreground.
250+
pub fn editor_target(&mut self, action: &Action) -> Result<Option<PathBuf>, String> {
251+
match action {
252+
Action::OpenSettings {} => Ok(Some(self.settings.clone())),
253+
Action::OpenConfig {} => {
254+
self.reload()?;
255+
Ok(Some(self.config.config_file.clone()))
256+
}
257+
// When `prices_file` is unset, this arm returns `None` and `dispatch` describes the
258+
// bundled rates.
259+
Action::OpenPrices {} => {
260+
self.reload()?;
261+
Ok(self.config.prices_file.clone())
262+
}
263+
Action::OpenUpdateLog {} => self.update_log().map(Some),
264+
Action::OpenLogs {} => logs_dir().map(Some),
265+
_ => Ok(None),
266+
}
267+
}
268+
fn update_log(&self) -> Result<PathBuf, String> {
269+
let metadata =
270+
switchyard_desktop::install::Settings::load(&install_metadata(&self.settings)?)?;
271+
Ok(metadata.sy_home.join("logs/update.log"))
272+
}
273+
pub fn dispatch(&mut self, action: Action) -> Result<Reply, String> {
274+
if matches!(action, Action::OpenSettings {}) {
275+
server::command("open", &[&self.settings.display().to_string()])?;
276+
return Ok(Reply::message("Opened app settings.".into()));
277+
}
278+
self.reload()?;
279+
match action {
280+
Action::OpenLogs {} => {
281+
server::command("open", &[&logs_dir()?.display().to_string()])?;
282+
Ok(Reply::message("Opened Switchyard logs.".into()))
283+
}
284+
Action::CheckConfig {} => server::check_config(&self.config)
285+
.map(|()| Reply::message("Server config check passed.".into())),
286+
Action::ServerKeys {} => Ok(Reply {
287+
message: String::new(),
288+
data: json!({"keys": server::server_keys(&self.config)?, "store": switchyard_desktop::install::credentials::STORE}),
289+
}),
290+
Action::OpenSettings {} => unreachable!(),
291+
Action::OpenUpdateLog {} => {
292+
let log = self.update_log()?;
293+
server::command("open", &[&log.display().to_string()])?;
294+
Ok(Reply::message("Opened the update log.".into()))
295+
}
296+
Action::OpenConfig {} => {
297+
server::command("open", &[&self.config.config_file.display().to_string()])?;
298+
Ok(Reply::message(
299+
"Opened the server config in your default editor.".into(),
300+
))
301+
}
302+
Action::OpenPrices {} => {
303+
let Some(path) = self.config.prices_file.as_ref() else {
304+
return Ok(Reply::message("Bundled model prices are active. To use custom rates, set pricing_file in app settings and pass the same file to the server with --pricing-file.".into()));
305+
};
306+
server::command("open", &[&path.display().to_string()])?;
307+
Ok(Reply::message(
308+
"Opened the model prices in your default editor. Restart the server after \
309+
you save them; it reads the prices when it starts."
310+
.into(),
311+
))
312+
}
313+
}
314+
}
315+
}
316+
fn logs_dir() -> Result<PathBuf, String> {
317+
crate::config::expand_home(std::path::Path::new("~/.switchyard/logs"))
222318
}
223319
fn install_metadata(settings: &std::path::Path) -> Result<PathBuf, String> {
224320
let executable = std::env::current_exe().map_err(|e| e.to_string())?;

‎crates/switchyard-desktop/src/install/windows.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,3 +472,26 @@ pub(crate) fn delete_credential(target: &str) -> Result<(), String> {
472472
}
473473
Ok(())
474474
}
475+
476+
pub fn open(value: &str) -> Result<(), String> {
477+
use windows_sys::Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL};
478+
let value = wide(std::ffi::OsStr::new(value));
479+
let verb = wide(std::ffi::OsStr::new("open"));
480+
let status = unsafe {
481+
ShellExecuteW(
482+
std::ptr::null_mut(),
483+
verb.as_ptr(),
484+
value.as_ptr(),
485+
std::ptr::null(),
486+
std::ptr::null(),
487+
SW_SHOWNORMAL,
488+
)
489+
} as isize;
490+
if status > 32 {
491+
Ok(())
492+
} else {
493+
Err(format!(
494+
"Windows could not open the file or URL (error {status})."
495+
))
496+
}
497+
}

‎crates/switchyard-desktop/src/main.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ mod controller;
1313
mod history;
1414
mod pricing;
1515
mod rollup;
16+
mod server;
1617
mod sessions;
1718
mod summary;
1819
mod tui;
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! This module validates server configs and restarts the server service.
5+
6+
use std::io::Write;
7+
use std::path::{Path, PathBuf};
8+
use std::process::{Command, Stdio};
9+
10+
use switchyard_desktop::install::credentials::KeyStatus;
11+
12+
use crate::config::Config;
13+
14+
/// This function finds the installed helper, which is the only program that reads or writes
15+
/// server keys.
16+
fn helper() -> Result<PathBuf, String> {
17+
Ok(
18+
server_binary()?.with_file_name(switchyard_desktop::install::executable_name(
19+
"switchyard-desktop-install",
20+
)),
21+
)
22+
}
23+
24+
/// This function runs one of the helper's `key` commands. A key goes to the helper's standard
25+
/// input, never its command line, where other processes could read it.
26+
fn key_command(args: &[&str], key: Option<&str>) -> Result<String, String> {
27+
let helper = helper()?;
28+
let mut child = Command::new(&helper)
29+
.arg("key")
30+
.args(args)
31+
.stdin(if key.is_some() {
32+
Stdio::piped()
33+
} else {
34+
Stdio::null()
35+
})
36+
.stdout(Stdio::piped())
37+
.stderr(Stdio::piped())
38+
.spawn()
39+
.map_err(|error| {
40+
format!(
41+
"Could not run {}: {error}. \
42+
Run cargo desktop install from the Switchyard repository to install it.",
43+
helper.display()
44+
)
45+
})?;
46+
if let (Some(key), Some(mut stdin)) = (key, child.stdin.take()) {
47+
stdin
48+
.write_all(key.as_bytes())
49+
.map_err(|error| format!("Could not pass the key to the helper: {error}"))?;
50+
}
51+
let output = child
52+
.wait_with_output()
53+
.map_err(|error| error.to_string())?;
54+
if output.status.success() {
55+
return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
56+
}
57+
Err(String::from_utf8_lossy(&output.stderr)
58+
.trim()
59+
.trim_start_matches("switchyard-desktop-install: ")
60+
.to_string())
61+
}
62+
63+
/// This function reports which `api_key_env` variables in the server config have a saved key.
64+
pub fn server_keys(settings: &Config) -> Result<Vec<KeyStatus>, String> {
65+
let config = std::fs::canonicalize(&settings.config_file).map_err(|error| error.to_string())?;
66+
let output = key_command(&["status", "--config", &config.display().to_string()], None)?;
67+
serde_json::from_str(&output).map_err(|error| error.to_string())
68+
}
69+
70+
/// This function finds the installed server from the app's installation metadata.
71+
fn server_binary() -> Result<PathBuf, String> {
72+
let executable = std::env::current_exe().map_err(|error| {
73+
format!(
74+
"Could not find switchyard-server next to the desktop app: {error}. \
75+
Run cargo desktop install from the Switchyard repository to install it."
76+
)
77+
})?;
78+
let metadata = switchyard_desktop::install::bundle_metadata(&executable);
79+
if metadata.try_exists().map_err(|error| error.to_string())? {
80+
return switchyard_desktop::install::Settings::load(&metadata)
81+
.map(|settings| settings.binary("switchyard-server"));
82+
}
83+
Ok(
84+
executable.with_file_name(switchyard_desktop::install::executable_name(
85+
"switchyard-server",
86+
)),
87+
)
88+
}
89+
90+
/// This function asks the installed helper to run the server's dry-run on `config` with the
91+
/// saved server keys, the same keys the installed server starts with.
92+
fn check(binary: &Path, config: &Path) -> Result<(), String> {
93+
let config = std::fs::canonicalize(config).map_err(|error| error.to_string())?;
94+
let helper = binary.with_file_name(switchyard_desktop::install::executable_name(
95+
"switchyard-desktop-install",
96+
));
97+
let mut validator = Command::new(&helper);
98+
validator
99+
.arg("validate")
100+
.arg("--server")
101+
.arg(binary)
102+
.arg("--config")
103+
.arg(&config);
104+
let output = validator.output().map_err(|error| {
105+
format!(
106+
"Could not run {}: {error}. \
107+
Apply needs the installed server and credential helper. \
108+
Run cargo desktop install from the Switchyard repository to install them.",
109+
helper.display()
110+
)
111+
})?;
112+
if output.status.success() {
113+
return Ok(());
114+
}
115+
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
116+
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
117+
Err(if stderr.is_empty() { stdout } else { stderr })
118+
}
119+
120+
pub fn check_config(settings: &Config) -> Result<(), String> {
121+
check(&server_binary()?, &settings.config_file)
122+
}
123+
124+
/// This function runs a command, returning its stdout or a message naming what failed.
125+
pub fn command(program: &str, args: &[&str]) -> Result<String, String> {
126+
if program == "open" {
127+
#[cfg(windows)]
128+
{
129+
return switchyard_desktop::install::windows::open(
130+
args.first().ok_or("Choose a file or URL.")?,
131+
)
132+
.map(|()| String::new());
133+
}
134+
#[cfg(target_os = "linux")]
135+
{
136+
return command("xdg-open", args);
137+
}
138+
}
139+
140+
let output = Command::new(program)
141+
.args(args)
142+
.output()
143+
.map_err(|error| format!("Could not run {program}: {error}"))?;
144+
if !output.status.success() {
145+
return Err(format!(
146+
"{program} {} failed: {}",
147+
args.join(" "),
148+
String::from_utf8_lossy(&output.stderr).trim()
149+
));
150+
}
151+
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
152+
}

0 commit comments

Comments
 (0)