diff --git a/Cargo.lock b/Cargo.lock index c781de5..e83da45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.35" +version = "0.22.36" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.35" +version = "0.22.36" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.35" +version = "0.22.36" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 1567f0a..c3e0964 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.35" +version = "0.22.36" edition = "2024" [dependencies] diff --git a/client/src/launcher/tests/ui_runtime.rs b/client/src/launcher/tests/ui_runtime.rs index 066490e..c726d48 100644 --- a/client/src/launcher/tests/ui_runtime.rs +++ b/client/src/launcher/tests/ui_runtime.rs @@ -15,6 +15,9 @@ use serial_test::serial; use std::{ cell::RefCell, collections::BTreeMap, + fs, + path::PathBuf, + process, rc::Rc, time::{Duration, Instant}, }; @@ -35,6 +38,93 @@ fn rail_button_text(button: >k::Button) -> Option { .map(|label| label.text().to_string()) } +fn unique_test_path(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "lesavka-relay-child-{label}-{}-{nanos}", + process::id() + )) +} + +fn unique_test_file(label: &str) -> PathBuf { + let path = unique_test_path(label); + fs::write(&path, b"test executable placeholder").expect("write relay child test file"); + path +} + +fn unique_deleted_exe_path(label: &str) -> PathBuf { + let path = unique_test_path(label); + let deleted_path = path.with_file_name(format!( + "{} (deleted)", + path.file_name() + .expect("test path has file name") + .to_string_lossy() + )); + fs::write(&deleted_path, b"test executable placeholder") + .expect("write deleted relay child test file"); + deleted_path +} + +#[test] +fn relay_child_exe_uses_current_exe_when_still_runnable() { + let current = unique_test_file("current"); + let installed = unique_test_file("installed"); + + let resolved = resolve_relay_child_exe_from(None, ¤t, &installed) + .expect("resolve relay child executable"); + + assert_eq!(resolved, current); +} + +#[test] +fn relay_child_exe_falls_back_when_launcher_exe_was_replaced() { + let current = unique_deleted_exe_path("current"); + let installed = unique_test_file("installed"); + + let resolved = resolve_relay_child_exe_from(None, ¤t, &installed) + .expect("resolve relay child executable"); + + assert_eq!(resolved, installed); +} + +#[test] +fn relay_child_exe_falls_back_when_current_exe_path_is_missing() { + let current = unique_test_path("missing-current"); + let installed = unique_test_file("installed"); + + let resolved = resolve_relay_child_exe_from(None, ¤t, &installed) + .expect("resolve relay child executable"); + + assert_eq!(resolved, installed); +} + +#[test] +fn relay_child_exe_honors_valid_env_override() { + let override_path = unique_test_file("override"); + let current = unique_test_file("current"); + let installed = unique_test_file("installed"); + + let resolved = resolve_relay_child_exe_from(Some(&override_path), ¤t, &installed) + .expect("resolve relay child executable"); + + assert_eq!(resolved, override_path); +} + +#[test] +fn relay_child_exe_rejects_invalid_env_override() { + let override_path = unique_test_path("missing-override"); + let current = unique_test_file("current"); + let installed = unique_test_file("installed"); + + let err = resolve_relay_child_exe_from(Some(&override_path), ¤t, &installed) + .expect_err("invalid override should fail loudly"); + + assert!(err.to_string().contains(CLIENT_BIN_ENV)); +} + #[test] fn local_test_detail_mentions_idle_and_running_modes() { assert!(local_test_detail(false, false, false, false).contains("idle")); diff --git a/client/src/launcher/ui_runtime/process_logs.rs b/client/src/launcher/ui_runtime/process_logs.rs index 9e0b986..1243b1f 100644 --- a/client/src/launcher/ui_runtime/process_logs.rs +++ b/client/src/launcher/ui_runtime/process_logs.rs @@ -1,3 +1,6 @@ +const CLIENT_BIN_ENV: &str = "LESAVKA_CLIENT_BIN"; +const INSTALLED_CLIENT_BIN: &str = "/usr/local/bin/lesavka-client"; + pub fn spawn_client_process( server_addr: &str, state: &LauncherState, @@ -6,8 +9,8 @@ pub fn spawn_client_process( input_state_path: &Path, input_toggle_control_path: &Path, ) -> Result { - let exe = std::env::current_exe()?; - let mut command = Command::new(exe); + let exe = resolve_relay_child_exe()?; + let mut command = Command::new(&exe); command.arg("--no-launcher"); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); @@ -54,7 +57,62 @@ pub fn spawn_client_process( for (key, value) in runtime_env_vars(state) { command.env(key, value); } - Ok(command.spawn()?) + let child = command.spawn().map_err(|err| { + anyhow::anyhow!("starting relay child {} failed: {err}", exe.display()) + })?; + Ok(child) +} + +fn resolve_relay_child_exe() -> Result { + let env_override = std::env::var_os(CLIENT_BIN_ENV).map(PathBuf::from); + let current_exe = std::env::current_exe().map_err(|err| { + anyhow::anyhow!("resolving launcher executable for relay child failed: {err}") + })?; + resolve_relay_child_exe_from( + env_override.as_deref(), + ¤t_exe, + Path::new(INSTALLED_CLIENT_BIN), + ) +} + +fn resolve_relay_child_exe_from( + env_override: Option<&Path>, + current_exe: &Path, + installed_client: &Path, +) -> Result { + if let Some(override_path) = env_override + && !override_path.as_os_str().is_empty() + { + if override_path.is_file() { + return Ok(override_path.to_path_buf()); + } + return Err(anyhow::anyhow!( + "{CLIENT_BIN_ENV} points at {}, but that file is not runnable", + override_path.display() + )); + } + + if relay_exe_path_is_runnable(current_exe) { + return Ok(current_exe.to_path_buf()); + } + + if installed_client.is_file() { + return Ok(installed_client.to_path_buf()); + } + + Err(anyhow::anyhow!( + "launcher executable {} is no longer runnable and fallback {} is missing; restart Lesavka or reinstall the client", + current_exe.display(), + installed_client.display() + )) +} + +fn relay_exe_path_is_runnable(path: &Path) -> bool { + path.is_file() + && !path + .as_os_str() + .to_string_lossy() + .ends_with(" (deleted)") } pub fn attach_child_log_streams(child: &mut RelayChild, tx: Sender) { diff --git a/common/Cargo.toml b/common/Cargo.toml index 3f563b5..1b623c1 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.35" +version = "0.22.36" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 520ff7f..4cbd54e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.35" +version = "0.22.36" edition = "2024" autobins = false