Skip to content
Merged
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
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/switchyard-desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ uuid.workspace = true
# Popups and the Data errors panel use Paragraph::line_count to size wrapped text.
ratatui = { version = "0.30.2", features = ["unstable-rendered-line-info"] }
shlex = "2"
windows-args = "0.2"
strum = { version = "0.28", features = ["derive"] }

[target.'cfg(target_os = "macos")'.dependencies]
Expand Down
161 changes: 154 additions & 7 deletions crates/switchyard-desktop/src/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,30 @@
//! The terminal controller reloads settings and combines usage and server health into snapshots.
//! It retains the log reader and health probe across refreshes.

use crate::{config::Config, history, pricing, rollup};
use serde::Serialize;
use crate::{config::Config, history, pricing, rollup, server};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{collections::BTreeMap, path::PathBuf};

/// Snapshot holds the terminal app's usage, recent calls, server health, errors, and refresh interval.
#[derive(Clone, Debug, Deserialize, strum::EnumDiscriminants)]
#[strum_discriminants(name(ActionKind), derive(strum::EnumIter))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Action {
OpenLogs {},
OpenUpdateLog {},
OpenConfig {},
OpenPrices {},
OpenSettings {},
CheckConfig {},
ServerKeys {},
}

/// Snapshot holds the data shown by the apps. The terminal app destructures every field,
/// so adding a field requires an update to `draw`.
#[derive(Clone, Default, Serialize)]
pub struct Snapshot {
pub update_status: Value,
pub settings: Option<AppSettings>,
pub data_state: DataState,
pub metrics: Metrics,
pub today: chrono::NaiveDate,
Expand All @@ -28,6 +44,20 @@ pub struct Snapshot {
pub refresh_seconds: u64,
}

/// AppSettings holds the app settings and their file path. `Controller::snapshot` destructures
/// every `Config` field, so adding a setting requires an update here too.
#[derive(Clone, Default, Serialize)]
pub struct AppSettings {
pub path: PathBuf,
pub server_url: String,
pub routing_log: PathBuf,
pub config_file: PathBuf,
pub launchd_label: String,
pub refresh_seconds: u64,
pub baseline_model: String,
pub prices_file: Option<PathBuf>,
}

#[derive(Clone, Default, Serialize)]
pub struct DataState {
pub usage: Source,
Expand Down Expand Up @@ -67,6 +97,20 @@ pub struct Counts {
pub tokens: u64,
}

#[derive(Serialize)]
pub struct Reply {
pub message: String,
pub data: Value,
}
impl Reply {
fn message(message: String) -> Self {
Self {
message,
data: Value::Null,
}
}
}

pub struct Controller {
settings: PathBuf,
config: Config,
Expand Down Expand Up @@ -133,10 +177,30 @@ impl Controller {
history::History::default()
});
let activity = history::hourly_calls(&history.entries, chrono::Utc::now());
let server_url = self.config.server_url.clone();
let routing_log = self.config.routing_log.clone();
let refresh_seconds = self.config.refresh_seconds;
let Config {
server_url,
routing_log,
config_file,
launchd_label,
refresh_seconds,
baseline_model,
prices_file,
} = self.config.clone();
let update_status = install_metadata(&self.settings)
.and_then(|path| switchyard_desktop::install::update_status(&path))
.unwrap_or_else(|error| json!({"state":"unavailable","message":error}));
Ok(Snapshot {
update_status,
settings: Some(AppSettings {
path: self.settings.clone(),
server_url: server_url.clone(),
routing_log: routing_log.clone(),
config_file,
launchd_label,
refresh_seconds,
baseline_model: baseline_model.clone(),
prices_file,
}),
data_state: DataState {
usage: Source {
state: usage_state,
Expand Down Expand Up @@ -165,7 +229,7 @@ impl Controller {
today,
analytics: usage,
estimates,
baseline_model: self.config.baseline_model.clone(),
baseline_model,
activity,
sessions: history.sessions(),
entries: history.entries,
Expand All @@ -175,6 +239,89 @@ impl Controller {
refresh_seconds: refresh_seconds.max(1),
})
}
/// The TUI uses these file paths to run an editor in the foreground.
pub fn editor_target(&mut self, action: &Action) -> Result<Option<PathBuf>, String> {
match action {
Action::OpenSettings {} => Ok(Some(self.settings.clone())),
Action::OpenConfig {} => {
self.reload()?;
Ok(Some(self.config.config_file.clone()))
}
// When `prices_file` is unset, this arm returns `None` and `dispatch` describes the
// bundled rates.
Action::OpenPrices {} => {
self.reload()?;
Ok(self.config.prices_file.clone())
}
Action::OpenUpdateLog {} => self.update_log().map(Some),
Action::OpenLogs {} => logs_dir().map(Some),
_ => Ok(None),
}
}
fn update_log(&self) -> Result<PathBuf, String> {
let metadata =
switchyard_desktop::install::Settings::load(&install_metadata(&self.settings)?)?;
Ok(metadata.sy_home.join("logs/update.log"))
}
pub fn dispatch(&mut self, action: Action) -> Result<Reply, String> {
if matches!(action, Action::OpenSettings {}) {
server::command("open", &[&self.settings.display().to_string()])?;
return Ok(Reply::message("Opened app settings.".into()));
}
self.reload()?;
match action {
Action::OpenLogs {} => {
server::command("open", &[&logs_dir()?.display().to_string()])?;
Ok(Reply::message("Opened Switchyard logs.".into()))
}
Action::CheckConfig {} => server::check_config(&self.config)
.map(|()| Reply::message("Server config check passed.".into())),
Action::ServerKeys {} => Ok(Reply {
message: String::new(),
data: json!({"keys": server::server_keys(&self.config)?, "store": switchyard_desktop::install::credentials::STORE}),
}),
Action::OpenSettings {} => unreachable!(),
Action::OpenUpdateLog {} => {
let log = self.update_log()?;
server::command("open", &[&log.display().to_string()])?;
Ok(Reply::message("Opened the update log.".into()))
}
Action::OpenConfig {} => {
server::command("open", &[&self.config.config_file.display().to_string()])?;
Ok(Reply::message(
"Opened the server config in your default editor.".into(),
))
}
Action::OpenPrices {} => {
let Some(path) = self.config.prices_file.as_ref() else {
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()));
};
server::command("open", &[&path.display().to_string()])?;
Ok(Reply::message(
"Opened the model prices in your default editor. Restart the server after \
you save them; it reads the prices when it starts."
.into(),
))
}
}
}
}
fn logs_dir() -> Result<PathBuf, String> {
crate::config::expand_home(std::path::Path::new("~/.switchyard/logs"))
}

fn install_metadata(settings: &std::path::Path) -> Result<PathBuf, String> {
let executable = std::env::current_exe().map_err(|e| e.to_string())?;
let bundle = switchyard_desktop::install::bundle_metadata(&executable);
let metadata = if bundle.is_file() {
bundle
} else {
settings
.parent()
.ok_or("The settings file has no parent directory.")?
.join("install.toml")
};
Ok(metadata)
}

#[cfg(test)]
Expand Down
23 changes: 23 additions & 0 deletions crates/switchyard-desktop/src/install/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -472,3 +472,26 @@ pub(crate) fn delete_credential(target: &str) -> Result<(), String> {
}
Ok(())
}

pub fn open(value: &str) -> Result<(), String> {
use windows_sys::Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL};
let value = wide(std::ffi::OsStr::new(value));
let verb = wide(std::ffi::OsStr::new("open"));
let status = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
verb.as_ptr(),
value.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 or URL (error {status})."
))
}
}
1 change: 1 addition & 0 deletions crates/switchyard-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod controller;
#[cfg(any(target_os = "macos", windows))]
mod gui;
mod pricing;
mod server;
mod summary;
mod tui;
#[cfg(windows)]
Expand Down
Loading
Loading