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
113 changes: 113 additions & 0 deletions crates/switchyard-desktop/src/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,32 @@ use std::{collections::BTreeMap, path::PathBuf};
#[strum_discriminants(name(ActionKind), derive(strum::EnumIter))]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Action {
Restart {},
PreviewUpdate {},
Update {
preview_token: Option<String>,
},
OpenLogs {},
OpenUpdateLog {},
OpenConfig {},
OpenPrices {},
OpenSettings {},
CheckConfig {},
ServerKeys {},
SaveServerKey {
name: String,
key: String,
},
ImportServerKey {
name: String,
},
RemoveServerKey {
name: String,
},
SavePreferences {
baseline_model: String,
refresh_seconds: u64,
},
}

/// Snapshot holds the data shown by the apps. The terminal app destructures every field,
Expand Down Expand Up @@ -116,6 +135,7 @@ pub struct Controller {
config: Config,
reader: rollup::Reader,
health_probe: crate::health::Probe,
update_review: Option<(String, Value)>,
}
impl Controller {
pub fn new(config: Config, settings: PathBuf) -> Self {
Expand All @@ -124,12 +144,25 @@ impl Controller {
settings,
reader: rollup::Reader::default(),
health_probe: crate::health::Probe::default(),
update_review: None,
}
}
fn reload(&mut self) -> Result<(), String> {
self.config = Config::load(&self.settings)?;
Ok(())
}
// Key actions accept only names that the server config declares.
fn declared_key(&self, name: &str) -> Result<(), String> {
if switchyard_desktop::install::credentials::variables(&self.config.config_file)?
.contains(name)
{
Ok(())
} else {
Err(format!(
"The server config does not name {name} with api_key_env. Refresh and try again."
))
}
}
pub fn snapshot(&mut self) -> Result<Snapshot, String> {
self.reload()?;
let mut errors = Vec::new();
Expand Down Expand Up @@ -274,13 +307,83 @@ impl Controller {
server::command("open", &[&logs_dir()?.display().to_string()])?;
Ok(Reply::message("Opened Switchyard logs.".into()))
}
Action::Restart {} => server::restart_checked(&self.config).map(Reply::message),
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::SaveServerKey { name, key } => {
self.declared_key(&name)?;
server::save_server_key(&name, &key)?;
Ok(Reply::message(format!(
"Saved the key for {name}. Restart the server to use it."
)))
}
Action::ImportServerKey { name } => {
self.declared_key(&name)?;
server::import_server_key(&name)?;
Ok(Reply::message(format!(
"Saved {name} from your login shell. Restart the server to use it."
)))
}
Action::RemoveServerKey { name } => {
self.declared_key(&name)?;
server::remove_server_key(&name)?;
Ok(Reply::message(format!(
"Removed the saved key for {name}. The server will not start until it has one."
)))
}
Action::SavePreferences {
baseline_model,
refresh_seconds,
} => {
crate::config::save_preferences(&self.settings, &baseline_model, refresh_seconds)?;
self.reload()?;
Ok(Reply::message(format!(
"Changed {}.",
self.settings.display()
)))
}
Action::OpenSettings {} => unreachable!(),
Action::PreviewUpdate {} => {
let mut data = switchyard_desktop::install::preview_update(&install_metadata(
&self.settings,
)?)?;
let token = uuid::Uuid::now_v7().to_string();
let mut reviewed = data.clone();
reviewed
.as_object_mut()
.ok_or("Invalid update preview.")?
.remove("status");
self.update_review = Some((token.clone(), reviewed));
data["preview_token"] = json!(token);
Ok(Reply {
message: String::new(),
data,
})
}
Action::Update { preview_token } => {
let (token, reviewed) = self
.update_review
.as_ref()
.ok_or("Review the source update first.")?;
let mut current = switchyard_desktop::install::preview_update(&install_metadata(
&self.settings,
)?)?;
current
.as_object_mut()
.ok_or("Invalid update preview.")?
.remove("status");
if preview_token.as_deref() != Some(token.as_str()) || &current != reviewed {
return Err(
"The source checkout or installation changed. Review the update again."
.into(),
);
}
update(&self.settings).map(Reply::message)
}
Action::OpenUpdateLog {} => {
let log = self.update_log()?;
server::command("open", &[&log.display().to_string()])?;
Expand Down Expand Up @@ -323,6 +426,16 @@ fn install_metadata(settings: &std::path::Path) -> Result<PathBuf, String> {
};
Ok(metadata)
}
fn update(settings: &std::path::Path) -> Result<String, String> {
let metadata = install_metadata(settings)?;
let log = switchyard_desktop::install::start_update(&metadata)?;
Ok(format!(
"Started rebuilding and reinstalling from the saved source checkout. \
Progress and errors are recorded in {}. \
The app restarts after a successful install.",
log.display()
))
}

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

pub fn restart(s: &Settings) -> Result<(), String> {
stop_task(s, false)?;
stop_images(&[s.binary("switchyard-server")])?;
// The terminal app calls this, so the scheduler command must not print onto its screen.
if task_succeeds(s, false, "/Run")? {
Ok(())
} else {
Err(format!("schtasks /Run /TN {} failed.", task_name(s, false)))
}
}

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));
Expand Down
78 changes: 74 additions & 4 deletions crates/switchyard-desktop/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};

use switchyard_desktop::install::credentials::KeyStatus;

use crate::config::Config;
use crate::health::{Probe, ServerStatus};

/// RESTART_WAIT limits how long the app waits for the restarted server to answer `/health`.
const RESTART_WAIT: Duration = Duration::from_secs(10);

/// This function finds the installed helper, which is the only program that reads or writes
/// server keys.
Expand Down Expand Up @@ -43,10 +48,14 @@ fn key_command(args: &[&str], key: Option<&str>) -> Result<String, String> {
helper.display()
)
})?;
if let (Some(key), Some(mut stdin)) = (key, child.stdin.take()) {
stdin
.write_all(key.as_bytes())
.map_err(|error| format!("Could not pass the key to the helper: {error}"))?;
if let (Some(key), Some(mut stdin)) = (key, child.stdin.take())
&& let Err(error) = stdin.write_all(key.as_bytes())
{
// The app tries to stop and reap the helper while preserving the original input error.
// Cleanup errors may occur if the helper already exited.
let _ = child.kill();
let _ = child.wait();
return Err(format!("Could not pass the key to the helper: {error}"));
}
let output = child
.wait_with_output()
Expand All @@ -67,6 +76,18 @@ pub fn server_keys(settings: &Config) -> Result<Vec<KeyStatus>, String> {
serde_json::from_str(&output).map_err(|error| error.to_string())
}

pub fn save_server_key(name: &str, key: &str) -> Result<(), String> {
key_command(&["set", name], Some(key)).map(drop)
}

pub fn import_server_key(name: &str) -> Result<(), String> {
key_command(&["import", name], None).map(drop)
}

pub fn remove_server_key(name: &str) -> Result<(), String> {
key_command(&["remove", name], None).map(drop)
}

/// This function finds the installed server from the app's installation metadata.
fn server_binary() -> Result<PathBuf, String> {
let executable = std::env::current_exe().map_err(|error| {
Expand Down Expand Up @@ -121,6 +142,55 @@ pub fn check_config(settings: &Config) -> Result<(), String> {
check(&server_binary()?, &settings.config_file)
}

pub fn restart_checked(settings: &Config) -> Result<String, String> {
check_config(settings)?;
restart(&settings.launchd_label)?;
if !wait_for_health(&settings.server_url) {
return Err(format!(
"Switchyard did not answer {}/health within {} seconds. Open the server log and retry.",
settings.server_url.trim_end_matches('/'),
RESTART_WAIT.as_secs()
));
}
Ok("Restarted Switchyard.".into())
}

/// This function restarts the server's LaunchAgent, systemd user service, or Windows scheduled task.
pub fn restart(launchd_label: &str) -> Result<(), String> {
#[cfg(windows)]
{
let _ = launchd_label;
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let settings = switchyard_desktop::install::Settings::load(
&switchyard_desktop::install::bundle_metadata(&exe),
)?;
switchyard_desktop::install::windows::restart(&settings)
}
#[cfg(not(windows))]
{
if cfg!(target_os = "linux") {
return command("systemctl", &["--user", "restart", "switchyard.service"]).map(|_| ());
}
let uid = command("id", &["-u"])?;
let target = format!("gui/{}/{launchd_label}", uid.trim());
command("launchctl", &["kickstart", "-k", &target]).map(|_| ())
}
}

pub(crate) fn wait_for_health(server_url: &str) -> bool {
let deadline = Instant::now() + RESTART_WAIT;
let probe = Probe::default();
loop {
if probe.probe(server_url) == ServerStatus::Running {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(250));
}
}

/// This function runs a command, returning its stdout or a message naming what failed.
pub fn command(program: &str, args: &[&str]) -> Result<String, String> {
if program == "open" {
Expand Down
Loading
Loading