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
1 change: 1 addition & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ rustflags = ["-C", "target-cpu=x86-64-v3", "-C", "force-frame-pointers=yes"]
rustflags = ["-C", "target-cpu=neoverse-n1", "-C", "force-frame-pointers=yes"]

[alias]
desktop = "run --package switchyard-desktop --bin switchyard-desktop-install --"
desktop-icons = "run --package switchyard-desktop --example desktop-icons --"
17 changes: 17 additions & 0 deletions INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ See [Getting Started](docs/getting_started.md#server-path) for a complete TOML
deployment and [`switchyard-server`](crates/switchyard-server/README.md) for the
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.

The installer requires the repository's Rust toolchain. macOS uses LaunchAgents;
Linux requires a systemd user session. Windows requires MSVC build tools,
WebView2, and a Task Scheduler user session.

On Windows, run install and uninstall from the source checkout with Cargo.
The installed helper rejects changes to its own installation because Windows
locks running executables. The app's Update command uses a temporary helper
copy. The installed helper still supports `uninstall --dry-run`.

## Linux Codex Service

This setup is for single-user Linux machines. It requires a systemd user
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 @@ -34,7 +34,7 @@ dbus-secret-service = { version = "4.1", features = ["crypto-rust", "vendored"]
[target.'cfg(windows)'.dependencies]
mslnk = "0.1.8"
self-replace = "1.5"
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Globalization", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_Security_Credentials", "Win32_Security_Authorization", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Console"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_Security_Credentials", "Win32_System_Console"] }

[dev-dependencies]
image = { version = "0.25", default-features = false, features = ["png"] }
Expand Down
128 changes: 24 additions & 104 deletions crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ Example: SY_PROFILE=team SY_MODEL=my-route cargo desktop install
Preview the installation or removal plan with --dry-run. Installation requires
a Rust toolchain and LaunchAgents (macOS) or a systemd user session (Linux).
Windows requires MSVC build tools, WebView2, and a Task Scheduler user session.
The Cargo command waits for completion. On Windows, running the installed installer
starts a temporary process and returns; read SY_HOME/logs/update.log for progress and errors.
The Cargo command waits for completion. On Windows, run these commands with cargo desktop:
the installed installer cannot replace or remove itself while it runs.
Use uninstall --settings FILE to select another recorded installation.
Preview its removal with: cargo desktop uninstall --settings FILE --dry-run";

Expand Down Expand Up @@ -56,16 +56,6 @@ fn run() -> Result<(), String> {
[action @ ("serve" | "desktop"), "--settings", file] => {
windows::launch_managed(&Settings::load(Path::new(file))?, *action == "desktop")
}
#[cfg(windows)]
[
"apply",
"--handoff-record",
file,
"--action",
action @ ("install" | "update" | "uninstall"),
"--wait",
pid,
] => apply(Path::new(file), action, pid).map_err(|e| e.to_string()),
["--help" | "-h"] | ["install" | "uninstall", "--help" | "-h"] => {
println!("{HELP}");
Ok(())
Expand Down Expand Up @@ -102,9 +92,28 @@ fn perform(action: &str, dry_run: bool, file: Option<&Path>) -> Result<(), Strin
None if action == "uninstall" => Settings::for_uninstall(),
None => Settings::from_env(),
}?;
// Windows cannot replace or delete a running executable, so the installed helper must not
// change its own installation. The app's update runs a temporary copy instead.
#[cfg(windows)]
if !dry_run && handoff(&settings, action).map_err(|e| e.to_string())? {
return Ok(());
if !dry_run {
let installed = settings.binary("switchyard-desktop-install");
if installed.try_exists().map_err(|e| e.to_string())?
&& std::env::current_exe()
.and_then(std::fs::canonicalize)
.map_err(|e| e.to_string())?
== installed.canonicalize().map_err(|e| e.to_string())?
{
return Err(format!(
"The installed installer cannot {action} its own installation, because Windows \
locks running programs. Run cargo desktop {} from the Switchyard source \
checkout instead.",
if action == "update" {
"install"
} else {
action
}
));
}
}
execute(&settings, action, dry_run)
}
Expand Down Expand Up @@ -201,7 +210,7 @@ fn main() -> ExitCode {
.nth(1)
.as_deref()
.and_then(std::ffi::OsStr::to_str),
Some("serve" | "desktop" | "apply" | "update" | "validate")
Some("serve" | "desktop" | "update" | "validate")
) {
unsafe { windows_sys::Win32::System::Console::AttachConsole(u32::MAX) };
}
Expand All @@ -226,92 +235,3 @@ fn main() -> ExitCode {
}
}
}

/// Handoff is the record that the installed helper passes to its temporary copy.
#[cfg(windows)]
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Handoff<T> {
settings: T,
}

/// When this process runs the installed helper, this function hands `action` to a temporary
/// copy and returns true, because the installed executable cannot replace itself while Windows
/// holds it open. Any other copy returns false without doing anything.
#[cfg(windows)]
fn handoff(settings: &Settings, action: &str) -> Result<bool, Box<dyn std::error::Error>> {
use std::io::Write;
use std::process::{Command, Stdio};
let installed = settings.binary("switchyard-desktop-install");
if !installed.try_exists()?
|| std::fs::canonicalize(std::env::current_exe()?)? != std::fs::canonicalize(installed)?
{
return Ok(false);
}
windows::record_handoff(settings)?;
let helper = windows::temporary_updater(&std::env::current_exe()?)?;
let mut record = tempfile::Builder::new()
.prefix("switchyard-handoff-")
.suffix(".toml")
.tempfile()?;
record.write_all(toml::to_string(&Handoff { settings })?.as_bytes())?;
let record = record.into_temp_path();
let log_path = settings.sy_home.join("logs/update.log");
std::fs::create_dir_all(settings.sy_home.join("logs"))?;
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)?;
let mut child = Command::new(&helper);
windows::hide_console(&mut child);
child
.arg("apply")
.arg("--handoff-record")
.arg(&record)
.arg("--action")
.arg(action)
.arg("--wait")
.arg(std::process::id().to_string())
.stdin(Stdio::null())
.stderr(log.try_clone()?)
.stdout(log)
.spawn()?;
helper.keep()?;
record.keep()?;
println!(
"Started {action}. Progress and errors: {}",
log_path.display()
);
Ok(true)
}

/// This function checks the handoff record at `path`, waits for the installed helper with
/// process ID `pid` to exit, and runs `action`. It deletes the record when it finishes.
#[cfg(windows)]
fn apply(path: &Path, action: &str, pid: &str) -> Result<(), Box<dyn std::error::Error>> {
const INVALID: &str = "Invalid handoff record.";
let metadata = std::fs::symlink_metadata(path)?;
let (Some(parent), Some(name)) = (
path.parent(),
path.file_name().and_then(std::ffi::OsStr::to_str),
) else {
return Err(INVALID.into());
};
if !metadata.file_type().is_file()
|| !name.starts_with("switchyard-handoff-")
|| !name.ends_with(".toml")
|| std::fs::canonicalize(parent)? != std::fs::canonicalize(std::env::temp_dir())?
{
return Err(INVALID.into());
}
let handoff: Handoff<Settings> = toml::from_str(&std::fs::read_to_string(path)?)?;
handoff.settings.validate()?;
let pid = pid
.parse::<u32>()
.ok()
.filter(|&pid| pid != 0)
.ok_or("Invalid parent process ID.")?;
let _record = tempfile::TempPath::try_from_path(path)?;
windows::wait_parent(pid)?;
Ok(execute(&handoff.settings, action, false)?)
}
Loading
Loading