diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index f5bb3fca134..db1cf16767c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2543,6 +2543,7 @@ dependencies = [ name = "opencodex-desktop" version = "2.61.0" dependencies = [ + "dbus", "reqwest 0.12.24", "serde", "serde_json", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1ec7348b749..2b323a3cb3e 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -29,6 +29,12 @@ tauri-plugin-single-instance = "=2.4.0" tauri-plugin-updater = "=2.9.0" tokio = { version = "=1.45.1", features = ["sync", "time"] } +# Linux only, and already in this graph: `tao` enables its own `dbus` feature by default, so +# `libdbus-sys` is compiled for every Linux build of this shell today. Naming it here adds a +# session-bus probe for the StatusNotifier watcher without adding a package or a system library. +[target.'cfg(target_os = "linux")'.dependencies] +dbus = "=0.9.12" + [profile.release] codegen-units = 1 lto = "thin" diff --git a/desktop/src-tauri/src/discovery.rs b/desktop/src-tauri/src/discovery.rs deleted file mode 100644 index 2eaded758df..00000000000 --- a/desktop/src-tauri/src/discovery.rs +++ /dev/null @@ -1,116 +0,0 @@ -use serde::Deserialize; -use std::path::{Path, PathBuf}; - -pub const DEFAULT_PORT: u16 = 10100; -const HOME_ENV: &str = "OPENCODEX_HOME"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ProxyEndpoint { - pub host: &'static str, - pub port: u16, -} - -impl ProxyEndpoint { - pub fn url(&self, path: &str) -> String { - format!("http://{}:{}{}", self.host, self.port, path) - } -} - -#[derive(Debug, Deserialize)] -struct RuntimePort { - port: u16, -} - -pub fn config_directory(environment: impl Fn(&str) -> Option, home: &Path) -> PathBuf { - environment(HOME_ENV) - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()) - .map(|value| expand_tilde(PathBuf::from(value), home)) - .unwrap_or_else(|| home.join(".opencodex")) -} - -pub fn resolve(environment: impl Fn(&str) -> Option, home: &Path) -> ProxyEndpoint { - let directory = config_directory(environment, home); - let path = directory.join("runtime-port.json"); - let port = std::fs::read(&path) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .map(|record| record.port) - .filter(|port| (1..=u16::MAX).contains(port)) - .unwrap_or(DEFAULT_PORT); - ProxyEndpoint { - host: "127.0.0.1", - port, - } -} - -fn expand_tilde(path: PathBuf, home: &Path) -> PathBuf { - if path == Path::new("~") { - return home.to_path_buf(); - } - path.strip_prefix("~/") - .map(|rest| home.join(rest)) - .unwrap_or(path) -} - -pub fn current() -> (ProxyEndpoint, PathBuf) { - let home = dirs_home(); - let directory = config_directory(|key| std::env::var(key).ok(), &home); - let endpoint = resolve(|key| std::env::var(key).ok(), &home); - (endpoint, directory) -} - -fn dirs_home() -> PathBuf { - std::env::var_os("HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)) - .unwrap_or_else(|| PathBuf::from(".")) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs::{create_dir_all, write}; - - #[test] - fn resolves_home_override_and_runtime_port() { - let root = std::env::temp_dir().join(format!("ocx-discovery-{}", std::process::id())); - let home = root.join("home"); - let custom = home.join("custom"); - create_dir_all(&custom).unwrap(); - write( - custom.join("runtime-port.json"), - r#"{"pid":1,"port":12345}"#, - ) - .unwrap(); - let endpoint = resolve(|key| (key == HOME_ENV).then(|| "~/custom".into()), &home); - assert_eq!( - endpoint, - ProxyEndpoint { - host: "127.0.0.1", - port: 12345 - } - ); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn any_failure_falls_back_to_default() { - let home = std::env::temp_dir().join("ocx-missing-home"); - let endpoint = resolve(|_| None, &home); - assert_eq!(endpoint.port, DEFAULT_PORT); - } - - #[test] - fn empty_override_and_invalid_port_use_default() { - let root = - std::env::temp_dir().join(format!("ocx-discovery-invalid-{}", std::process::id())); - let home = root.join("home"); - let directory = root.join("custom"); - create_dir_all(&directory).unwrap(); - write(directory.join("runtime-port.json"), r#"{"port":0}"#).unwrap(); - let endpoint = resolve(|key| (key == HOME_ENV).then(|| " ".into()), &home); - assert_eq!(endpoint.port, DEFAULT_PORT); - let _ = std::fs::remove_dir_all(root); - } -} diff --git a/desktop/src-tauri/src/endpoint.rs b/desktop/src-tauri/src/endpoint.rs new file mode 100644 index 00000000000..09a78e22fb7 --- /dev/null +++ b/desktop/src-tauri/src/endpoint.rs @@ -0,0 +1,33 @@ +//! The loopback endpoint the shell talks to. +//! +//! This file was `discovery.rs`, and it resolved the endpoint itself: it read `runtime-port.json`, +//! fell back to 10100 and let the shell start there, so a user with a configured `config.port` was +//! started on a port they had not chosen. Resolution belongs to the bundled CLI now — see +//! `resolve.rs` — and what is left here is the value it hands back. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProxyEndpoint { + pub host: &'static str, + pub port: u16, +} + +impl ProxyEndpoint { + pub fn url(&self, path: &str) -> String { + format!("http://{}:{}{}", self.host, self.port, path) + } +} + +#[cfg(test)] +mod tests { + use super::ProxyEndpoint; + + #[test] + fn the_endpoint_is_loopback_and_carries_its_port() { + let endpoint = ProxyEndpoint { + host: "127.0.0.1", + port: 12345, + }; + assert_eq!(endpoint.url("/healthz"), "http://127.0.0.1:12345/healthz"); + assert_eq!(endpoint.url(""), "http://127.0.0.1:12345"); + } +} diff --git a/desktop/src-tauri/src/exit.rs b/desktop/src-tauri/src/exit.rs new file mode 100644 index 00000000000..aba0aab4b39 --- /dev/null +++ b/desktop/src-tauri/src/exit.rs @@ -0,0 +1,756 @@ +//! Who is allowed to end the process, and what has to happen first. +//! +//! Three gestures arrive looking like an exit: closing the window, the platform's own quit gesture +//! (Cmd+Q on macOS, Alt+F4 on Windows, the window manager's close on Linux), and the tray's Quit +//! item. Only the last one means "end the runtime". Until this module existed the shell had no +//! `ExitRequested` handler, so the quit gesture went straight to `RunEvent::Exit`, which called +//! `CommandChild::kill()` — a SIGKILL on Unix — on a keystroke the user reads as "hide". +//! +//! Tauri separates a user gesture from a programmatic exit: `RunEvent::ExitRequested` carries +//! `code: None` for the gesture and `Some(_)` for `AppHandle::exit` or `AppHandle::restart`. What +//! it cannot tell apart is the tray's Quit from an update's restart, and those end differently. +//! [`ExitReason`] records which one asked. macOS needs one more thing on top, because its menu Quit +//! never raises the event at all; see [`crate::menu`]. +//! +//! Everything that stops the runtime funnels through one phase here — the tray's Quit, the tray's +//! Stop, an update, and a window close on a session with no tray. Two of them running at once is +//! two stops racing over one child, so a second one waits rather than starting its own. +//! +//! A drain that does not complete is **not** recorded as a drain. A quit may still proceed on one, +//! because refusing to close is the worse answer and a standing runtime is recoverable. A restart +//! may not: coming back onto a runtime that was never stopped puts the user on the old version +//! while they believe they are on the new one. + +use crate::{ + proxy::ProxyClient, runtime_stop, sidecar, tray_availability::TrayAvailability, window, + AppState, +}; +use std::sync::{Mutex, MutexGuard, PoisonError}; +use tauri::{AppHandle, ExitRequestApi, Manager}; +use tokio::time::Instant; + +/// Why the process has been asked to end. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitReason { + /// The tray's Quit item, or a window close on a session with no tray to hide into. + UserQuit, + /// An installed update restarting the app. It drains exactly as a quit does and then comes + /// back, which is why it is a coordinated restart rather than an exception to the quit rule. + CoordinatedRestart, +} + +/// How far the one drain sequence has got. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitPhase { + /// Nothing is in flight. + Idle, + /// A runtime is being started. An exit arriving now is held until the child exists and is + /// recorded, because the alternative is a process nobody owns and nobody will stop. + Spawning, + /// The runtime is being stopped without ending the app: the tray's Stop. + Stopping, + /// The drain that ends the app is running. + Draining, + /// The runtime this app owned is confirmed stopped, or was never ours to stop. + Drained, + /// The stop was refused, or the runtime still answered after the deadline. + DrainFailed, + /// Who owns the runtime could not be established, so nothing was stopped and nothing may be + /// replaced on the assumption that it was. + OwnershipUnknown, +} + +/// What the event loop should do with an exit request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitDecision { + /// Nothing asked the app to end and there is a tray to come back from: hide instead. + Hide, + /// Hold the exit and drain for this reason; the exit is requested again when the drain reports. + Drain(ExitReason), + /// Something else is already draining. Hold the exit and let that one finish. + Wait, + /// The drain has reported success. Let the process end. + Proceed, + /// The drain did not complete, and this reason is one that may not proceed on that. + Refuse, +} + +/// What a drain established. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DrainVerdict { + /// The runtime this app owned is gone, or there was never one of ours to stop. + Drained, + /// The stop was refused, or the endpoint still answered after the deadline. + Failed, + /// The process answering could not be identified, so nothing was stopped. + OwnershipUnknown, +} + +/// Whether an update may start replacing files. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RestartReadiness { + /// The runtime is confirmed stopped. The install may proceed. + Ready, + /// The drain did not complete, so the install must not start. + DrainFailed, + /// Who owns the runtime could not be established. + OwnershipUnknown, + /// Something else is already ending or stopping the runtime. + Busy, +} + +impl RestartReadiness { + pub fn describe(self) -> &'static str { + match self { + Self::Ready => "the runtime is stopped", + Self::DrainFailed => "the runtime did not stop", + Self::OwnershipUnknown => "the running proxy could not be identified", + Self::Busy => "the app is already stopping its runtime", + } + } +} + +/// Decide what an exit request means. +/// +/// `reason` is what the app itself asked for and is `None` for a bare user gesture. +/// `hides_to_tray` is D6: on a session with no usable tray there is nowhere to hide, so a close is +/// a quit and takes the same graceful drain rather than leaving a running process unreachable. +pub fn decide(phase: ExitPhase, reason: Option, hides_to_tray: bool) -> ExitDecision { + match phase { + ExitPhase::Spawning | ExitPhase::Stopping | ExitPhase::Draining => ExitDecision::Wait, + ExitPhase::Drained => ExitDecision::Proceed, + // A quit that could not drain still closes the app: refusing to close is the worse answer + // and the runtime is recoverable. A restart is a different judgement — it would come back + // attached to a runtime that was never stopped, under a user who believes they upgraded. + ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => match reason { + Some(ExitReason::CoordinatedRestart) => ExitDecision::Refuse, + _ => ExitDecision::Proceed, + }, + ExitPhase::Idle => match reason { + Some(reason) => ExitDecision::Drain(reason), + None if hides_to_tray => ExitDecision::Hide, + None => ExitDecision::Drain(ExitReason::UserQuit), + }, + } +} + +struct Inner { + phase: ExitPhase, + reason: Option, + hides_to_tray: bool, + /// An exit that arrived while a runtime was being started or stopped, and still has to happen. + deferred: bool, +} + +/// The exit sequence's state, managed by the app. +pub struct ExitCoordinator { + inner: Mutex, +} + +impl ExitCoordinator { + pub fn new() -> Self { + Self { + inner: Mutex::new(Inner { + phase: ExitPhase::Idle, + reason: None, + // Until the probe answers, assume only what the platform guarantees. Assuming a + // tray that turns out not to exist is the exact failure D6 is about. + hides_to_tray: TrayAvailability::assumed().hides_to_tray(), + deferred: false, + }), + } + } + + fn inner(&self) -> MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Record the session's tray verdict once the probe has answered and an icon exists. + pub fn set_tray(&self, tray: TrayAvailability) { + self.inner().hides_to_tray = tray.hides_to_tray(); + } + + pub fn decision(&self) -> ExitDecision { + let inner = self.inner(); + decide(inner.phase, inner.reason, inner.hides_to_tray) + } + + /// Record why the app is ending, without starting anything. The first reason wins. + pub fn claim(&self, reason: ExitReason) { + let mut inner = self.inner(); + inner.reason.get_or_insert(reason); + } + + /// Take ownership of the drain, and with it the reason the app is ending. + /// + /// Claiming the reason and moving out of `Idle` is one step on purpose. Split apart, a Quit + /// that claimed first could still be overtaken by an update that started the drain, and the app + /// would restart under a user who asked it to stop. `fallback` is only used when nothing has + /// claimed a reason yet. + /// + /// While a runtime is being started or stopped the answer is "not yet": the reason is recorded + /// and the drain is handed to whichever of [`ExitCoordinator::finish_spawn`] or + /// [`ExitCoordinator::finish_stop`] is holding the phase. + pub fn claim_drain(&self, fallback: ExitReason) -> Option { + let mut inner = self.inner(); + match inner.phase { + ExitPhase::Idle => { + let reason = *inner.reason.get_or_insert(fallback); + inner.phase = ExitPhase::Draining; + Some(reason) + } + ExitPhase::Spawning | ExitPhase::Stopping => { + inner.reason.get_or_insert(fallback); + inner.deferred = true; + None + } + // A failed drain is a terminal failure, not work in flight, and retrying it is the + // recovery: the update stayed pending, so the next attempt runs the stop again. Without + // this the first refusal would be permanent until the app was restarted by hand — which + // is the one thing a user with a runtime that would not stop cannot easily do. + ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => { + let reason = *inner.reason.get_or_insert(fallback); + inner.phase = ExitPhase::Draining; + Some(reason) + } + ExitPhase::Draining | ExitPhase::Drained => None, + } + } + + /// Record what the drain established. A failure is not a drain. + pub fn finish_drain(&self, verdict: DrainVerdict) { + self.inner().phase = match verdict { + DrainVerdict::Drained => ExitPhase::Drained, + DrainVerdict::Failed => ExitPhase::DrainFailed, + DrainVerdict::OwnershipUnknown => ExitPhase::OwnershipUnknown, + }; + } + + /// Reserve the right to start a runtime. False once something else owns the phase. + /// + /// The reservation exists instead of holding the lock across the spawn. Holding it would make + /// the main thread's exit handler wait on process creation, so a wedged spawn would be a Quit + /// that never responds. An exit arriving in between is deferred rather than lost — which is the + /// thing that must not happen, because a quit that reads "we own nothing" leaves the child it + /// just missed running forever. + pub fn begin_spawn(&self) -> bool { + self.begin(ExitPhase::Spawning) + } + + /// Release the spawn reservation, handing back a reason that arrived meanwhile. + pub fn finish_spawn(&self) -> Option { + self.finish(ExitPhase::Spawning) + } + + /// Reserve the runtime for a stop that does not end the app. + pub fn begin_stop(&self) -> bool { + self.begin(ExitPhase::Stopping) + } + + /// Release the stop, handing back a reason that arrived meanwhile. + pub fn finish_stop(&self) -> Option { + self.finish(ExitPhase::Stopping) + } + + fn begin(&self, phase: ExitPhase) -> bool { + let mut inner = self.inner(); + if inner.phase != ExitPhase::Idle { + return false; + } + inner.phase = phase; + true + } + + fn finish(&self, phase: ExitPhase) -> Option { + let mut inner = self.inner(); + if inner.phase != phase { + return None; + } + if inner.deferred { + let reason = *inner.reason.get_or_insert(ExitReason::UserQuit); + inner.phase = ExitPhase::Draining; + inner.deferred = false; + return Some(reason); + } + inner.phase = ExitPhase::Idle; + None + } + + #[cfg(test)] + fn phase(&self) -> ExitPhase { + self.inner().phase + } + + #[cfg(test)] + fn hides_to_tray(&self) -> bool { + self.inner().hides_to_tray + } +} + +impl Default for ExitCoordinator { + fn default() -> Self { + Self::new() + } +} + +/// The platform's quit gesture, and what closing the window means. +/// +/// It is not a request to end. D2 makes it mean the same thing on all three platforms: hide where +/// there is a tray to come back from, and a graceful quit where there is not. Closing the window +/// arrives here too: one decision point, so the two gestures cannot drift apart. +pub fn gesture(app: &AppHandle) { + let Some(coordinator) = app.try_state::() else { + return; + }; + match coordinator.decision() { + ExitDecision::Hide => hide_windows(app), + ExitDecision::Drain(reason) => start_drain(app, reason), + ExitDecision::Wait | ExitDecision::Proceed | ExitDecision::Refuse => {} + } +} + +/// Ask the app to end for a stated reason. This is the only way the shell ends itself. +pub fn request(app: &AppHandle, reason: ExitReason) { + if let Some(coordinator) = app.try_state::() { + coordinator.claim(reason); + } + app.exit(0); +} + +/// Stop the runtime without ending the app: the tray's Stop item. +/// +/// It takes the same phase the quit path takes, so pressing Stop twice, or Stop and then Quit, or +/// Stop during an update, is one execution rather than two racing over one child. Unlike a quit it +/// returns the coordinator to idle, because the app is still running and may start a runtime again. +pub fn request_stop(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let Some(claimed) = app + .try_state::() + .map(|coordinator| coordinator.begin_stop()) + else { + return; + }; + if !claimed { + return; + } + let verdict = drain_current(&app).await; + if verdict == DrainVerdict::Drained { + if let Some(state) = app.try_state::() { + state.release(); + } + crate::tray::set_owned(&app, false); + } else { + crate::logging::log_once("the runtime could not be stopped", verdict.describe()); + } + let deferred = app + .try_state::() + .and_then(|coordinator| coordinator.finish_stop()); + if let Some(reason) = deferred { + drain_now(&app, reason); + } + }); +} + +impl DrainVerdict { + pub fn describe(self) -> &'static str { + match self { + Self::Drained => "the runtime is stopped", + Self::Failed => "the stop was refused or the runtime still answered", + Self::OwnershipUnknown => "the running proxy could not be identified", + } + } +} + +/// Handle `RunEvent::ExitRequested`. +pub fn on_exit_requested(app: &AppHandle, code: Option, api: &ExitRequestApi) { + // `AppHandle::restart` documents that `prevent_exit` is ignored for its own exit code, so a + // restart cannot be held here even to drain. The update path therefore drains before it + // restarts, and this branch only records the reason so nothing reads the restart as a quit. + if code == Some(tauri::RESTART_EXIT_CODE) { + if let Some(coordinator) = app.try_state::() { + coordinator.claim(ExitReason::CoordinatedRestart); + } + return; + } + let Some(coordinator) = app.try_state::() else { + return; + }; + match coordinator.decision() { + ExitDecision::Hide => { + api.prevent_exit(); + hide_windows(app); + } + ExitDecision::Wait => api.prevent_exit(), + ExitDecision::Refuse => api.prevent_exit(), + ExitDecision::Drain(reason) => { + api.prevent_exit(); + start_drain(app, reason); + } + ExitDecision::Proceed => {} + } +} + +/// Drain and then ask to end again. +pub fn start_drain(app: &AppHandle, reason: ExitReason) { + let Some(coordinator) = app.try_state::() else { + return; + }; + let Some(reason) = coordinator.claim_drain(reason) else { + return; + }; + drain_now(app, reason); +} + +/// Run the drain for a reason the coordinator has already moved to draining for. +pub fn drain_now(app: &AppHandle, reason: ExitReason) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + finish_and_exit_after(&app, reason).await; + }); +} + +async fn finish_and_exit_after(app: &AppHandle, reason: ExitReason) { + let verdict = drain_current(app).await; + if let Some(coordinator) = app.try_state::() { + coordinator.finish_drain(verdict); + } + match (reason, verdict) { + (ExitReason::UserQuit, _) => app.exit(0), + (ExitReason::CoordinatedRestart, DrainVerdict::Drained) => { + app.restart(); + } + (ExitReason::CoordinatedRestart, _) => { + crate::logging::log_once("the update restart was refused", verdict.describe()); + } + } +} + +/// Prepare for an update's restart: confirm ownership, drain, and confirm the child is gone. +/// +/// This is awaited rather than fired and forgotten, because the pinned updater's Windows install +/// ends the process itself. A restart asked for after `install` returns is a restart that never +/// happens there, so the stop has to be finished before the installer is started at all. +pub async fn prepare_restart(app: &AppHandle) -> RestartReadiness { + let Some(reason) = app + .try_state::() + .and_then(|coordinator| coordinator.claim_drain(ExitReason::CoordinatedRestart)) + else { + return RestartReadiness::Busy; + }; + if reason != ExitReason::CoordinatedRestart { + // A quit claimed the exit first. It owns the drain now, and the update does not install + // into an app that is on its way out. + drain_now(app, reason); + return RestartReadiness::Busy; + } + let verdict = drain_current(app).await; + if let Some(coordinator) = app.try_state::() { + coordinator.finish_drain(verdict); + } + match verdict { + DrainVerdict::Drained => RestartReadiness::Ready, + DrainVerdict::Failed => RestartReadiness::DrainFailed, + DrainVerdict::OwnershipUnknown => RestartReadiness::OwnershipUnknown, + } +} + +/// Come back, once the installer has finished and returned. +pub fn complete_restart(app: &AppHandle) -> ! { + app.restart() +} + +/// Stop the runtime this app owns and confirm it is gone. +/// +/// Ownership is re-established here rather than read off a flag. A flag set when the child was +/// spawned says nothing about the process answering the endpoint now: the child can have exited and +/// a service can have taken the port back. Sending an owner's stop to that listener is sending it +/// to somebody else's runtime, so the pid is checked first and a listener that cannot be identified +/// is left alone. +/// +/// The stop itself is the bundled `ocx stop` (D4), not a management call from inside this process. +/// The CLI's stop owns the receipt-backed teardown, the drain, the Windows respawn verification and +/// the client-configuration restore, and an in-process endpoint cannot own its own teardown: launchd +/// and systemd can terminate the request handler during self-unload. Nothing kills the child either +/// — the original path did, with `CommandChild::kill()`, a SIGKILL on Unix that cut off exactly the +/// work the CLI's stop exists to finish. +pub async fn drain_current(app: &AppHandle) -> DrainVerdict { + let Some((proxy, child_pid, watch)) = app + .try_state::() + .map(|state| (state.proxy(), state.child_pid(), state.watch.clone())) + else { + return DrainVerdict::OwnershipUnknown; + }; + let Some(proxy) = proxy else { + // Nothing resolved, so there is nothing of ours listening anywhere. + return DrainVerdict::Drained; + }; + let Some(child_pid) = child_pid else { + // This app never started a runtime, so it does not stop one. + return DrainVerdict::Drained; + }; + match confirm(&proxy, child_pid, &watch).await { + Ownership::Gone => DrainVerdict::Drained, + Ownership::Foreign => DrainVerdict::Drained, + Ownership::Unknown => DrainVerdict::OwnershipUnknown, + Ownership::Ours => { + let deadline = Instant::now() + runtime_stop::DEADLINE; + let result = runtime_stop::run(app, deadline).await; + if result.is_stopped() { + DrainVerdict::Drained + } else { + // The CLI's own outcome and exit code, carried rather than reinterpreted. A stop + // that did not end in exit 0 with the runtime down is a stop that did not happen. + crate::logging::log_once("the bundled stop did not complete", &result.describe()); + DrainVerdict::Failed + } + } + } +} + +enum Ownership { + /// The process answering is the child this app started. + Ours, + /// Something else holds the port. + Foreign, + /// Nothing is listening, and the child has reported its own exit. + Gone, + /// The listener could not be identified. + Unknown, +} + +async fn confirm(proxy: &ProxyClient, child_pid: u32, watch: &sidecar::SidecarWatch) -> Ownership { + match proxy.identify().await { + Ok(identity) if identity.pid == child_pid => Ownership::Ours, + Ok(_) => Ownership::Foreign, + Err(error) if error.is_unreachable() => { + // Nothing is listening. That is only proof the child is gone if the child said so. + if watch.exit().is_some() { + Ownership::Gone + } else { + Ownership::Unknown + } + } + Err(_) => Ownership::Unknown, + } +} + +fn hide_windows(app: &AppHandle) { + for window in app.webview_windows().values() { + window::hide(window); + } +} + +#[cfg(test)] +mod tests { + use super::{ + decide, DrainVerdict, ExitCoordinator, ExitDecision, ExitPhase, ExitReason, + RestartReadiness, + }; + use crate::tray_availability::TrayAvailability; + + #[test] + fn a_bare_gesture_hides_when_there_is_a_tray_to_come_back_from() { + assert_eq!(decide(ExitPhase::Idle, None, true), ExitDecision::Hide); + } + + #[test] + fn a_bare_gesture_quits_through_the_drain_when_there_is_no_tray() { + assert_eq!( + decide(ExitPhase::Idle, None, false), + ExitDecision::Drain(ExitReason::UserQuit) + ); + } + + #[test] + fn an_explicit_quit_drains_even_though_a_tray_exists() { + assert_eq!( + decide(ExitPhase::Idle, Some(ExitReason::UserQuit), true), + ExitDecision::Drain(ExitReason::UserQuit) + ); + } + + #[test] + fn every_in_flight_phase_holds_the_exit() { + for phase in [ + ExitPhase::Spawning, + ExitPhase::Stopping, + ExitPhase::Draining, + ] { + assert_eq!( + decide(phase, Some(ExitReason::UserQuit), true), + ExitDecision::Wait + ); + assert_eq!(decide(phase, None, false), ExitDecision::Wait); + } + } + + #[test] + fn only_a_reported_drain_lets_the_process_end() { + assert_eq!( + decide(ExitPhase::Drained, Some(ExitReason::UserQuit), true), + ExitDecision::Proceed + ); + } + + #[test] + fn a_quit_tolerates_a_failed_drain_and_a_restart_refuses_it() { + for phase in [ExitPhase::DrainFailed, ExitPhase::OwnershipUnknown] { + assert_eq!( + decide(phase, Some(ExitReason::UserQuit), true), + ExitDecision::Proceed + ); + assert_eq!( + decide(phase, Some(ExitReason::CoordinatedRestart), true), + ExitDecision::Refuse + ); + } + } + + #[test] + fn a_failed_drain_is_not_recorded_as_a_drain() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::CoordinatedRestart); + coordinator.finish_drain(DrainVerdict::Failed); + assert_eq!(coordinator.phase(), ExitPhase::DrainFailed); + // The restart that asked for it does not get to proceed on that. + assert_eq!(coordinator.decision(), ExitDecision::Refuse); + } + + #[test] + fn an_unidentified_runtime_is_its_own_state() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::CoordinatedRestart); + coordinator.finish_drain(DrainVerdict::OwnershipUnknown); + assert_eq!(coordinator.phase(), ExitPhase::OwnershipUnknown); + assert_eq!(coordinator.decision(), ExitDecision::Refuse); + } + + #[test] + fn a_refused_restart_can_be_tried_again() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::CoordinatedRestart); + coordinator.finish_drain(DrainVerdict::Failed); + // The update stayed pending, so pressing Install again runs the stop again rather than + // finding the app permanently unable to try. + assert_eq!( + coordinator.claim_drain(ExitReason::CoordinatedRestart), + Some(ExitReason::CoordinatedRestart) + ); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + coordinator.finish_drain(DrainVerdict::Drained); + assert_eq!(coordinator.decision(), ExitDecision::Proceed); + } + + #[test] + fn a_successful_drain_is_not_re_entered() { + let coordinator = ExitCoordinator::new(); + coordinator.claim_drain(ExitReason::UserQuit); + coordinator.finish_drain(DrainVerdict::Drained); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + } + + #[test] + fn the_first_claimed_reason_wins_the_drain() { + let coordinator = ExitCoordinator::new(); + coordinator.claim(ExitReason::UserQuit); + assert_eq!( + coordinator.claim_drain(ExitReason::CoordinatedRestart), + Some(ExitReason::UserQuit) + ); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + } + + #[test] + fn only_one_caller_owns_the_drain() { + let coordinator = ExitCoordinator::new(); + assert_eq!( + coordinator.claim_drain(ExitReason::UserQuit), + Some(ExitReason::UserQuit) + ); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + coordinator.finish_drain(DrainVerdict::Drained); + assert_eq!(coordinator.phase(), ExitPhase::Drained); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + } + + #[test] + fn a_stop_and_a_spawn_both_hold_the_runtime_alone() { + for begin in [ExitPhase::Spawning, ExitPhase::Stopping] { + let coordinator = ExitCoordinator::new(); + let started = match begin { + ExitPhase::Spawning => coordinator.begin_spawn(), + _ => coordinator.begin_stop(), + }; + assert!(started); + assert!(!coordinator.begin_spawn()); + assert!(!coordinator.begin_stop()); + assert_eq!(coordinator.decision(), ExitDecision::Wait); + } + } + + #[test] + fn a_quit_during_a_stop_is_deferred_rather_than_lost() { + let coordinator = ExitCoordinator::new(); + assert!(coordinator.begin_stop()); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + assert_eq!(coordinator.phase(), ExitPhase::Stopping); + assert_eq!(coordinator.finish_stop(), Some(ExitReason::UserQuit)); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + assert_eq!(coordinator.finish_stop(), None); + } + + #[test] + fn a_quit_during_a_spawn_is_deferred_rather_than_lost() { + let coordinator = ExitCoordinator::new(); + assert!(coordinator.begin_spawn()); + assert_eq!(coordinator.decision(), ExitDecision::Wait); + assert_eq!(coordinator.claim_drain(ExitReason::UserQuit), None); + assert_eq!(coordinator.finish_spawn(), Some(ExitReason::UserQuit)); + assert_eq!(coordinator.phase(), ExitPhase::Draining); + } + + #[test] + fn a_deferred_update_restart_keeps_its_own_reason() { + let coordinator = ExitCoordinator::new(); + assert!(coordinator.begin_spawn()); + assert_eq!( + coordinator.claim_drain(ExitReason::CoordinatedRestart), + None + ); + assert_eq!( + coordinator.finish_spawn(), + Some(ExitReason::CoordinatedRestart) + ); + } + + #[test] + fn an_undrained_runtime_is_never_reported_as_ready_to_install_over() { + assert_eq!(RestartReadiness::Ready.describe(), "the runtime is stopped"); + for refused in [ + RestartReadiness::DrainFailed, + RestartReadiness::OwnershipUnknown, + RestartReadiness::Busy, + ] { + assert_ne!(refused, RestartReadiness::Ready); + assert!(!refused.describe().is_empty()); + } + } + + #[test] + fn the_tray_verdict_replaces_the_platform_assumption() { + let coordinator = ExitCoordinator::new(); + assert_eq!( + coordinator.hides_to_tray(), + TrayAvailability::assumed().hides_to_tray() + ); + coordinator.set_tray(TrayAvailability::Unavailable); + assert!(!coordinator.hides_to_tray()); + assert_eq!( + coordinator.decision(), + ExitDecision::Drain(ExitReason::UserQuit) + ); + coordinator.set_tray(TrayAvailability::Available); + assert_eq!(coordinator.decision(), ExitDecision::Hide); + } +} diff --git a/desktop/src-tauri/src/first_run.rs b/desktop/src-tauri/src/first_run.rs index e6a0417ed97..3fc7616b93f 100644 --- a/desktop/src-tauri/src/first_run.rs +++ b/desktop/src-tauri/src/first_run.rs @@ -5,6 +5,30 @@ use tauri_plugin_autostart::ManagerExt; /// Marker file recording that the one-time Start at Login default has already been applied. const MARKER: &str = "start-at-login-claimed"; +/// Marker file recording that the login item names the launch-origin argument. +const ORIGIN_MARKER: &str = "start-at-login-origin-flag"; + +/// What the one-time Start at Login decision did on this launch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StartAtLogin { + /// This installation had already decided, so whatever the user set is left alone. + AlreadyDecided, + /// Turned on for the first time on this installation. + Enabled, + /// The default could not be applied. The app still starts and the tray item still toggles it. + Unavailable, +} + +impl StartAtLogin { + pub fn describe(self) -> &'static str { + match self { + Self::AlreadyDecided => "already decided on this installation, left as the user set it", + Self::Enabled => "turned on for this installation", + Self::Unavailable => "could not be registered; the tray item still toggles it", + } + } +} + /// Turn Start at Login on once, the first time this installation runs. /// /// A menu bar app that is not running has no menu bar item. Leaving autostart off by default @@ -18,24 +42,70 @@ const MARKER: &str = "start-at-login-claimed"; /// failed or partial enable retries on every launch, and would eventually flip the setting back on /// under a user who had deliberately turned it off in between. /// -/// Every failure is silent on purpose. Not being able to write a marker or register a login item -/// is not a reason to stop the app from starting, and the user can still toggle the menu item. -pub fn apply_start_at_login_default(app: &AppHandle) { +/// No failure stops the app. Not being able to write a marker or register a login item is not a +/// reason to refuse to start, and the user can still toggle the menu item. What has changed is that +/// the outcome is returned rather than swallowed: D7's startup sequence reports this decision as +/// one of its named states, so a registration that did not happen is visible instead of silent. +pub fn apply_start_at_login_default(app: &AppHandle) -> StartAtLogin { let Ok(dir) = app.path().app_config_dir() else { - return; + return StartAtLogin::Unavailable; }; let marker = dir.join(MARKER); if marker.exists() { - return; + return StartAtLogin::AlreadyDecided; } if fs::create_dir_all(&dir).is_err() { - return; + return StartAtLogin::Unavailable; } if fs::write(&marker, b"").is_err() { - return; + return StartAtLogin::Unavailable; } if app.autolaunch().is_enabled().unwrap_or(false) { + return StartAtLogin::AlreadyDecided; + } + match app.autolaunch().enable() { + Ok(()) => StartAtLogin::Enabled, + Err(_) => StartAtLogin::Unavailable, + } +} + +/// Rewrite an existing login item so a launch from it can be recognised as one. +/// +/// The autostart entry is written once, carrying whatever arguments the plugin was configured with +/// at the time. An installation that registered before the launch-origin argument existed has an +/// entry without it, and a bare launch carries nothing else that distinguishes login from manual — +/// so D7's hidden login start would quietly never happen for exactly the users who already had +/// autostart on. Re-registering rewrites the entry with the current arguments. +/// +/// It runs once, behind its own marker, and only where autostart is already on. It never turns the +/// setting on and never turns it off; the worst case is an entry that keeps its old arguments and a +/// login launch that shows its window, which is the visible failure rather than the silent one. +pub fn adopt_launch_origin_argument(app: &AppHandle) { + let Ok(dir) = app.path().app_config_dir() else { return; + }; + let claimed = dir.join(ORIGIN_MARKER); + if claimed.exists() { + return; + } + if fs::create_dir_all(&dir).is_err() { + return; + } + // Unlike the default above, the marker is written *after* the work, and that difference is + // deliberate. Writing first exists there to stop a failed enable from flipping a setting the + // user turned off. Here there is no setting to flip: the rewrite only ever runs on an entry + // that is already enabled, so retrying after a transient registry, LaunchAgent or desktop-file + // error is free — and claiming the marker first would suppress the migration permanently and + // leave a login launch showing its window forever. + match app.autolaunch().is_enabled() { + Ok(true) => { + if app.autolaunch().enable().is_err() { + return; + } + } + // Nothing registered to migrate. A later enable writes the current arguments anyway. + Ok(false) => {} + Err(_) => return, } - let _ = app.autolaunch().enable(); + let _ = fs::write(&claimed, b""); } diff --git a/desktop/src-tauri/src/identity.rs b/desktop/src-tauri/src/identity.rs new file mode 100644 index 00000000000..9deb1c32e0a --- /dev/null +++ b/desktop/src-tauri/src/identity.rs @@ -0,0 +1,111 @@ +//! This installation's own identity. +//! +//! The shared service install state records who owns the running proxy, and the claim names the +//! owning *installation* rather than the user or the machine. So the app has to hold a value of its +//! own to compare against, which is this: an opaque id written once into the app's config directory +//! and never rewritten. +//! +//! Two records rather than one is D3, and its cost is recorded there. An id stored only in the +//! shared record would be whoever wrote it last, which gives a reinstalled app no way to tell its +//! own prior consent from another installation's. The price is that a reinstall which keeps this +//! directory keeps its consent, and one that loses it has to ask again. + +use std::{ + fs, + io::{ErrorKind, Write}, + path::Path, +}; +use tauri::{AppHandle, Manager}; +use uuid::Uuid; + +/// The file holding this installation's id, in the app's own config directory. +const FILE: &str = "install-id"; + +pub fn install_id(app: &AppHandle) -> Option { + let directory = app.path().app_config_dir().ok()?; + install_id_in(&directory) +} + +/// Read this installation's id, minting it the first time. +/// +/// The mint is exclusive and the value is read back afterwards, so two launches racing each other +/// both answer to the id that won rather than to two different ones. Two ids would be two +/// installations as far as the recorded claim is concerned, and the second would find a claim that +/// is not its own and ask again for consent the user had already given. +pub fn install_id_in(directory: &Path) -> Option { + let path = directory.join(FILE); + if let Some(existing) = read(&path) { + return Some(existing); + } + fs::create_dir_all(directory).ok()?; + match mint(&path) { + Ok(()) => {} + // The file is there and says nothing: a blank or truncated write from an interrupted first + // run. An empty id matches nothing, so every comparison against the recorded claim would + // quietly be false and the app would ask for consent it already had. Replace it. + Err(ErrorKind::AlreadyExists) => { + if read(&path).is_none() { + fs::write(&path, Uuid::new_v4().to_string()).ok()?; + } + } + Err(_) => return None, + } + read(&path) +} + +fn mint(path: &Path) -> Result<(), ErrorKind> { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .and_then(|mut file| file.write_all(Uuid::new_v4().to_string().as_bytes())) + .map_err(|error| error.kind()) +} + +fn read(path: &Path) -> Option { + let value = fs::read_to_string(path).ok()?; + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::{install_id_in, FILE}; + use std::fs; + + fn scratch(name: &str) -> std::path::PathBuf { + let directory = + std::env::temp_dir().join(format!("ocx-identity-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + directory + } + + #[test] + fn the_id_is_minted_once_and_then_read_back() { + let directory = scratch("mint"); + let first = install_id_in(&directory).expect("an id"); + assert!(!first.is_empty()); + assert_eq!(install_id_in(&directory).as_deref(), Some(first.as_str())); + let _ = fs::remove_dir_all(&directory); + } + + #[test] + fn two_installations_do_not_share_an_id() { + let one = scratch("one"); + let two = scratch("two"); + assert_ne!(install_id_in(&one), install_id_in(&two)); + let _ = fs::remove_dir_all(&one); + let _ = fs::remove_dir_all(&two); + } + + #[test] + fn a_blank_record_is_replaced_rather_than_answered_with() { + let directory = scratch("blank"); + fs::create_dir_all(&directory).unwrap(); + fs::write(directory.join(FILE), " \n").unwrap(); + let minted = install_id_in(&directory).expect("an id"); + assert!(!minted.trim().is_empty()); + assert_eq!(install_id_in(&directory).as_deref(), Some(minted.as_str())); + let _ = fs::remove_dir_all(&directory); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5ee02473c7e..d24fefba790 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,40 +1,120 @@ mod auth; -mod discovery; +mod endpoint; +mod exit; mod first_run; mod formatting; +mod identity; mod logging; +// macOS only: it exists to replace one item in a menu no other platform installs. Compiling it +// elsewhere would leave its contents unreachable, which -D warnings rejects. +#[cfg(target_os = "macos")] +mod menu; +mod ownership; mod proxy; +mod resolve; +mod runtime_stop; mod sidecar; +mod startup; mod tray; +mod tray_availability; mod updater; mod widget; mod window; use std::sync::{ atomic::{AtomicBool, Ordering}, - Mutex, + Mutex, MutexGuard, PoisonError, }; use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_shell::process::CommandChild; pub struct AppState { - pub proxy: proxy::ProxyClient, - pub spawned_by_us: AtomicBool, - pub child: Mutex>, + /// Absent until the startup sequence has resolved a home and a port. Nothing guesses an + /// endpoint any more, so there is no client to hand out before that. + proxy: Mutex>, + child: Mutex>, + /// The pid of the child this app started, if it started one. + child_pid: Mutex>, + /// Whether the process answering the endpoint has been confirmed to be that child. + /// + /// Durable consent and current process ownership are different facts. Consent is a recorded + /// claim that survives restarts; this is a statement about the process on the other end of the + /// endpoint right now, and it has to be re-established whenever the endpoint or the answering + /// process can have changed. Carrying a bool across an attach is how a retry that lands on a + /// foreign runtime would still send it an owner's stop. + confirmed: AtomicBool, + /// The consumed spawn event stream of the child, if this app started one. + pub watch: sidecar::SidecarWatch, } impl AppState { - pub fn shutdown_child(&self) { - if !self.spawned_by_us.swap(false, Ordering::AcqRel) { - return; - } - if let Ok(mut child) = self.child.lock() { - if let Some(child) = child.take() { - let _ = child.kill(); - } + pub fn new() -> Self { + Self { + proxy: Mutex::new(None), + child: Mutex::new(None), + child_pid: Mutex::new(None), + confirmed: AtomicBool::new(false), + watch: sidecar::SidecarWatch::default(), } } + + fn slot(lock: &Mutex) -> MutexGuard<'_, T> { + lock.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub fn proxy(&self) -> Option { + Self::slot(&self.proxy).clone() + } + + /// Point at a runtime. Nothing is owned until it is confirmed again. + pub fn attach(&self, proxy: proxy::ProxyClient) { + self.confirmed.store(false, Ordering::Release); + *Self::slot(&self.proxy) = Some(proxy); + } + + pub fn owns_runtime(&self) -> bool { + self.confirmed.load(Ordering::Acquire) + } + + pub fn child_pid(&self) -> Option { + *Self::slot(&self.child_pid) + } + + /// Confirm that the instance answering is the child this app started. + /// + /// This is the only thing that grants ownership. A spawn records a pid; it does not record that + /// the pid is what holds the port, because between the two the child can exit and a service can + /// take the port back. + pub fn confirm_ownership(&self, identity: proxy::RuntimeIdentity) -> bool { + let ours = self.child_pid() == Some(identity.pid); + self.confirmed.store(ours, Ordering::Release); + ours + } + + pub fn adopt(&self, child: CommandChild) { + *Self::slot(&self.child_pid) = Some(child.pid()); + *Self::slot(&self.child) = Some(child); + // Spawned, not yet confirmed: the health probe is what establishes that this pid is the + // one answering. + self.confirmed.store(false, Ordering::Release); + } + + /// Let go of a runtime that has already been drained. + /// + /// Dropping the handle does not signal the process — the shell plugin installs no `Drop` — so + /// this releases ownership without reintroducing the `kill()` that D2 removed. + pub fn release(&self) { + self.confirmed.store(false, Ordering::Release); + let _ = Self::slot(&self.child_pid).take(); + let _ = Self::slot(&self.child).take(); + } +} + +impl Default for AppState { + fn default() -> Self { + Self::new() + } } #[tauri::command] @@ -51,8 +131,33 @@ fn hide_dashboard(app: tauri::AppHandle) { } } +/// Everything the startup sequence has said so far, including the states it has already finished. +/// +/// The page asks for this when it loads rather than relying only on the event stream: the first +/// states finish in milliseconds and an event emitted before the listener exists is simply gone. +#[tauri::command] +fn startup_snapshot(app: tauri::AppHandle) -> Option { + app.try_state::() + .map(|startup| startup.latest()) +} + +/// The named states the startup sequence moves through, in order. +/// +/// The page asks for them instead of restating them, so a state added in the shell appears in the +/// UI and one removed cannot leave a row behind. +#[tauri::command] +fn startup_phases() -> Vec { + startup::phase_list() +} + +/// Run the startup sequence again. A run already in flight is left alone. +#[tauri::command] +fn retry_startup(app: tauri::AppHandle) { + startup::begin(&app); +} + pub fn run() { - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { window::show(&window); @@ -60,52 +165,59 @@ pub fn run() { })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_process::init()) + // The argument is what makes a login launch recognisable. Nothing else in a bare launch + // distinguishes it from a person opening the app, and D7 needs the difference. .plugin(tauri_plugin_autostart::init( MacosLauncher::LaunchAgent, - None, + Some(vec![startup::AUTOSTART_FLAG]), )) .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .invoke_handler(tauri::generate_handler![show_dashboard, hide_dashboard]) + .plugin(tauri_plugin_updater::Builder::new().build()); + + // macOS is the one platform where the event loop cannot enforce D2 on its own: Tauri's default + // menu carries a predefined Quit wired to Cocoa's terminate:, and the pinned tao raises no + // cancellable event for it. Replacing that one item is what lets Cmd+Q mean hide. + #[cfg(target_os = "macos")] + let builder = builder + .menu(menu::build) + .on_menu_event(|app, event| menu::on_event(app, event.id().as_ref())); + + builder + .invoke_handler(tauri::generate_handler![ + show_dashboard, + hide_dashboard, + startup_snapshot, + startup_phases, + retry_startup + ]) .setup(|app| { - let (endpoint, home) = discovery::current(); - let proxy = proxy::ProxyClient::new(endpoint, auth::Auth::new(home)) - .map_err(|error| error.to_string())?; - let child = tauri::async_runtime::block_on(sidecar::ensure_proxy( - app.handle(), - &proxy, - endpoint, - )) - .map_err(std::io::Error::other)?; - app.manage(AppState { - proxy: proxy.clone(), - spawned_by_us: AtomicBool::new(child.is_some()), - child: Mutex::new(child), - }); + app.manage(AppState::new()); app.manage(updater::PendingUpdate(Mutex::new(None))); app.manage(tray::TrayState::default()); + app.manage(exit::ExitCoordinator::new()); + app.manage(startup::Startup::new()); - let window = WebviewWindowBuilder::new( - app, - "main", - WebviewUrl::App(format!("index.html?port={}", endpoint.port).into()), - ) - .title("OpenCodex") - .inner_size(1100.0, 720.0) - .visible(false) - .user_agent(&window::webview_user_agent()) - .on_navigation(window::navigation_allowed(endpoint)) - .build()?; + // D7: the window is created and shown before anything is registered, resolved, probed + // or started, so every state below has somewhere to be reported. A login launch stays + // hidden until the tray verdict, because R1 shows it after all when there turns out to + // be nowhere to hide. + let window = + WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) + .title("OpenCodex") + .inner_size(1100.0, 720.0) + .visible(false) + .user_agent(&window::webview_user_agent()) + .on_navigation(window::navigation_allowed(app.handle().clone())) + .build()?; window::configure(&window); - window::set_tray_policy(app.handle(), false); - let dashboard = endpoint.url("/#/usage"); - if tauri::async_runtime::block_on(proxy.is_alive()).is_ok() { - let _ = window.eval(format!("window.location.replace({dashboard:?})")); + if startup::LaunchOrigin::detect() == startup::LaunchOrigin::User { + window::show(&window); + } else { + window::set_tray_policy(app.handle(), false); } - // Before the tray, so its Start at Login checkbox reads the state this leaves behind - // rather than the state from before first run. - first_run::apply_start_at_login_default(app.handle()); - tray::install(app.handle(), proxy)?; + + startup::begin(app.handle()); + if !cfg!(debug_assertions) { updater::start_background_checks(app.handle().clone()); } @@ -114,10 +226,11 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building OpenCodex desktop shell") .run(|app, event| { - if matches!(event, tauri::RunEvent::Exit) { - if let Some(state) = app.try_state::() { - state.shutdown_child(); - } + // Window close and the platform quit gesture arrive here as an exit request, and until + // this handler existed they went straight through to a SIGKILL of the runtime. D2 makes + // them hide; only the tray's Quit, and an update's coordinated restart, get past. + if let tauri::RunEvent::ExitRequested { code, api, .. } = event { + exit::on_exit_requested(app, code, &api); } }); } diff --git a/desktop/src-tauri/src/menu.rs b/desktop/src-tauri/src/menu.rs new file mode 100644 index 00000000000..58bc4346613 --- /dev/null +++ b/desktop/src-tauri/src/menu.rs @@ -0,0 +1,120 @@ +//! The macOS application menu. +//! +//! Tauri installs a default menu when the app sets none, and that menu's Quit is a predefined item +//! wired straight to Cocoa's `terminate:`. The pinned tao implements only +//! `applicationWillTerminate`, never the cancellable `applicationShouldTerminate`, so a Cmd+Q +//! through that item reaches `RunEvent::Exit` without ever raising `RunEvent::ExitRequested`. +//! Nothing can hold it, which means D2's rule — the quit gesture hides, only the tray's Quit ends +//! the app — cannot be enforced from the event loop alone on macOS. The one item is replaced here +//! with an ordinary item on the same accelerator, routed through the same gesture path as closing +//! the window. +//! +//! The rest is reproduced rather than mutated: `Menu::default` is not decomposable, and dropping it +//! would take Cut, Copy, Paste and Select All with it — which the startup diagnostic needs the user +//! to be able to use. This mirrors `tauri::menu::Menu::default` for the pinned version, minus that +//! item. + +/// The id of the replacement Quit item. Nothing else in the app uses it, so a menu event carrying +/// it is unambiguously this one. +pub const QUIT_ID: &str = "app-menu-quit"; + +pub fn build(app: &tauri::AppHandle) -> tauri::Result> { + use tauri::menu::{ + AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, + WINDOW_SUBMENU_ID, + }; + + let package = app.package_info(); + let config = app.config(); + let about = AboutMetadata { + name: Some(package.name.clone()), + version: Some(package.version.to_string()), + copyright: config.bundle.copyright.clone(), + authors: config + .bundle + .publisher + .clone() + .map(|publisher| vec![publisher]), + ..Default::default() + }; + + // Labelled as a quit because that is the gesture the user is making. What it means here is + // D2's answer to that gesture: the window goes away and the runtime keeps serving. + let quit = MenuItem::with_id( + app, + QUIT_ID, + format!("Quit {}", package.name), + true, + Some("CmdOrCtrl+Q"), + )?; + + Menu::with_items( + app, + &[ + &Submenu::with_items( + app, + package.name.clone(), + true, + &[ + &PredefinedMenuItem::about(app, None, Some(about))?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::services(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::separator(app)?, + &quit, + ], + )?, + &Submenu::with_items( + app, + "File", + true, + &[&PredefinedMenuItem::close_window(app, None)?], + )?, + &Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?, + &Submenu::with_items( + app, + "View", + true, + &[&PredefinedMenuItem::fullscreen(app, None)?], + )?, + &Submenu::with_id_and_items( + app, + WINDOW_SUBMENU_ID, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::close_window(app, None)?, + ], + )?, + &Submenu::with_id_and_items(app, HELP_SUBMENU_ID, "Help", true, &[])?, + ], + ) +} + +/// Route an application-menu event. +/// +/// Only the replacement Quit is ours. Tray menu events are handled by the tray's own handler and +/// carry different ids, so an id that is not [`QUIT_ID`] is left alone. +pub fn on_event(app: &tauri::AppHandle, id: &str) { + if id == QUIT_ID { + crate::exit::gesture(app); + } +} diff --git a/desktop/src-tauri/src/ownership.rs b/desktop/src-tauri/src/ownership.rs new file mode 100644 index 00000000000..a27bbf6a8e2 --- /dev/null +++ b/desktop/src-tauri/src/ownership.rs @@ -0,0 +1,249 @@ +//! Who owns the running proxy, as the shared service install state records it. +//! +//! The rule is not this lane's to invent. `src/service/state.ts` defines the claim — an owner, an +//! opaque install id naming the owning installation, and a consent generation — and +//! `ownershipGrantedTo` defines the comparison an installation applies to its own locally stored +//! install id. This is that comparison, and the three-valued reading it is applied to, so the shell +//! reaches the same verdict the CLI does instead of a weaker one of its own. +//! +//! What the shell deliberately does not do is read the record itself. Resolving a claim means +//! reading every state path and failing closed on an unreadable one, on a corrupt anchor record and +//! on paths that name different owners; absence is the only thing that means nobody owns the +//! runtime. Reimplementing that here is how `discovery.rs` ended up asking a weaker liveness +//! question than the one core already answered. The bundled CLI answers it: see [`resolve`]. +//! +//! The types below are the CLI's own answer as it will arrive on the wire, field for field, so the +//! contract that lands fills a hole rather than reshaping this file. + +use serde::Deserialize; +use tauri::AppHandle; + +/// Who a claim names. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Owner { + Cli, + Desktop, +} + +/// A recorded ownership claim. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Claim { + pub owner: Owner, + pub install_id: String, + /// Moves once per grant, and never back: the recorded ceiling survives a release so a later + /// grant cannot reuse a number an app-local record may still be holding. + /// + /// The record accepts any non-negative integer, and this accepts the ones it can represent. A + /// generation outside that range fails to parse, which makes the whole answer unreadable and + /// so refuses a takeover — the safe direction, and unreachable in practice by a counter that + /// moves by one per grant. + pub consent_generation: u64, +} + +/// What the recorded state says, in the CLI's own three answers. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Recorded { + /// No claim. The CLI install that registered the service owns the runtime, which is also what + /// every record written before the field existed says. + None, + /// A claim, whoever it names. + Owned { ownership: Claim }, + /// The claim could not be read for a decision. This is not "nobody owns it": an unreadable + /// path, a corrupt anchor record and paths naming different owners all land here. + Unknown { reason: String }, +} + +/// The comparison `ownershipGrantedTo` defines: same owner, same install id. +/// +/// True means this installation already holds consent. False against a recorded claim means a +/// different installation owns the runtime and consent has to be asked again. No claim means the +/// CLI install still owns it. The generation is not part of the comparison. +pub fn granted_to(claim: Option<&Claim>, owner: Owner, install_id: &str) -> bool { + matches!(claim, Some(claim) if claim.owner == owner && claim.install_id == install_id) +} + +/// What this installation should do about consent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Consent { + /// This installation already holds consent. Asked once, owned permanently — so a second launch + /// does not ask again. + Held, + /// Nothing is recorded, so the CLI install still owns the runtime. This is the first discovery + /// of an existing installation, and the one time the user is asked. + AskFirstTime, + /// A claim exists and it is not ours: another installation, or the CLI explicitly. + AskAgain, + /// The record could not be read for a decision, so nothing is taken over on a guess. + Refuse, +} + +pub fn consent(recorded: &Recorded, install_id: &str) -> Consent { + match recorded { + Recorded::Unknown { .. } => Consent::Refuse, + Recorded::None => Consent::AskFirstTime, + Recorded::Owned { ownership } => { + if granted_to(Some(ownership), Owner::Desktop, install_id) { + Consent::Held + } else { + Consent::AskAgain + } + } + } +} + +/// Read the recorded claim through the bundled CLI. +/// +/// Empty on purpose. Lane A publishes the machine-readable resolve the shell drives, and this is +/// the one call site that changes when it lands: it has to return the CLI's own answer, including +/// its refusals, rather than a verdict computed here. Until then the answer is *unavailable*, which +/// is not [`Recorded::None`] — the shell has not been told that nobody owns the runtime, it has not +/// asked — so no takeover is attempted and nothing is recorded. +pub fn resolve(_app: &AppHandle) -> Option { + None +} + +/// One line for the startup state and for the diagnostic. +pub fn describe(recorded: Option<&Recorded>, install_id: Option<&str>) -> String { + let installation = match install_id { + Some(id) => format!("installation {id}"), + None => "installation id unavailable".to_owned(), + }; + let verdict = match (recorded, install_id) { + (None, _) => { + "recorded owner not read: the bundled CLI's resolve contract has not landed".to_owned() + } + (Some(Recorded::Unknown { reason }), _) => { + format!("recorded owner could not be read ({reason}), so nothing is claimed") + } + (Some(_), None) => { + "recorded owner read, but this installation has no id to compare".to_owned() + } + (Some(recorded), Some(id)) => match (consent(recorded, id), recorded) { + (Consent::Held, Recorded::Owned { ownership }) => format!( + "this installation owns the runtime (consent generation {})", + ownership.consent_generation + ), + (Consent::AskFirstTime, _) => "the CLI install owns the runtime".to_owned(), + (Consent::AskAgain, _) => "another installation owns the runtime".to_owned(), + _ => "recorded owner could not be read, so nothing is claimed".to_owned(), + }, + }; + format!("{installation}; {verdict}") +} + +#[cfg(test)] +mod tests { + use super::{consent, describe, granted_to, Claim, Consent, Owner, Recorded}; + + fn owned(owner: Owner, install_id: &str, generation: u64) -> Recorded { + Recorded::Owned { + ownership: Claim { + owner, + install_id: install_id.to_owned(), + consent_generation: generation, + }, + } + } + + fn claim(owner: Owner, install_id: &str) -> Claim { + Claim { + owner, + install_id: install_id.to_owned(), + consent_generation: 1, + } + } + + #[test] + fn the_comparison_is_the_owner_and_the_install_id_together() { + let ours = claim(Owner::Desktop, "abc"); + assert!(granted_to(Some(&ours), Owner::Desktop, "abc")); + assert!(!granted_to(Some(&ours), Owner::Desktop, "def")); + assert!(!granted_to(Some(&ours), Owner::Cli, "abc")); + assert!(!granted_to(None, Owner::Desktop, "abc")); + } + + #[test] + fn the_generation_is_not_part_of_the_comparison() { + let mut later = claim(Owner::Desktop, "abc"); + later.consent_generation = 9; + assert!(granted_to(Some(&later), Owner::Desktop, "abc")); + } + + #[test] + fn consent_is_asked_once_and_then_held() { + assert_eq!( + consent(&owned(Owner::Desktop, "abc", 1), "abc"), + Consent::Held + ); + assert_eq!(consent(&Recorded::None, "abc"), Consent::AskFirstTime); + } + + #[test] + fn a_claim_that_is_not_ours_asks_again() { + assert_eq!( + consent(&owned(Owner::Desktop, "other", 2), "abc"), + Consent::AskAgain + ); + assert_eq!( + consent(&owned(Owner::Cli, "abc", 1), "abc"), + Consent::AskAgain + ); + } + + #[test] + fn an_unreadable_record_refuses_instead_of_reading_as_unowned() { + let unknown = Recorded::Unknown { + reason: "a service state path could not be read".to_owned(), + }; + assert_eq!(consent(&unknown, "abc"), Consent::Refuse); + assert!(describe(Some(&unknown), Some("abc")).contains("could not be read")); + } + + #[test] + fn the_wire_shape_is_the_one_the_cli_records() { + let resolution: Recorded = serde_json::from_str( + r#"{"kind":"owned","ownership":{"owner":"desktop","installId":"abc","consentGeneration":3}}"#, + ) + .expect("the recorded resolution"); + assert_eq!(resolution, owned(Owner::Desktop, "abc", 3)); + assert_eq!(consent(&resolution, "abc"), Consent::Held); + assert!(describe(Some(&resolution), Some("abc")).contains("consent generation 3")); + assert_eq!( + serde_json::from_str::(r#"{"kind":"none"}"#).expect("no claim"), + Recorded::None + ); + assert_eq!( + serde_json::from_str::(r#"{"kind":"unknown","reason":"why"}"#) + .expect("a refusal"), + Recorded::Unknown { + reason: "why".to_owned() + } + ); + } + + #[test] + fn the_description_separates_not_asked_from_nobody_owns_it() { + let not_asked = describe(None, Some("abc")); + let unowned = describe(Some(&Recorded::None), Some("abc")); + assert!(not_asked.contains("abc")); + assert_ne!(not_asked, unowned); + assert!(describe(None, None).contains("unavailable")); + } + + #[test] + fn a_generation_this_cannot_represent_is_not_read_as_a_claim() { + // Refusing beats granting on a number we cannot compare, and the claim is what a takeover + // would be authorised against. + assert!(serde_json::from_str::( + r#"{"kind":"owned","ownership":{"owner":"desktop","installId":"abc","consentGeneration":-1}}"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{"kind":"owned","ownership":{"owner":"cli","installId":"abc","consentGeneration":"3"}}"# + ) + .is_err()); + } +} diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs index caea7288563..fc45d820256 100644 --- a/desktop/src-tauri/src/proxy.rs +++ b/desktop/src-tauri/src/proxy.rs @@ -1,13 +1,42 @@ -use crate::{auth::Auth, discovery::ProxyEndpoint}; -use reqwest::{Client, Method, StatusCode}; +use crate::{auth::Auth, endpoint::ProxyEndpoint}; +use reqwest::{redirect, Client, Method, StatusCode}; use serde_json::Value; -use std::time::Duration; +use std::{ + sync::{Arc, Mutex, MutexGuard, PoisonError}, + time::Duration, +}; +use tokio::time::{timeout_at, Instant}; + +/// Which instance answered, taken from the unauthenticated health body. +/// +/// The management token is the admin credential for this machine's proxy. Sending it to whatever +/// happens to hold the port is the thing to avoid, so identity is established first — from a +/// response that needs no credential to read — and the credential follows only if the answer is the +/// instance the shell decided to trust. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RuntimeIdentity { + pub pid: u32, + pub port: u16, +} + +/// The instance this client is bound to, and the binding it was bound under. +/// +/// The generation moves every time the shell binds to a runtime. A request authorised under an +/// earlier binding is not authorised under this one, which is what stops an in-flight management +/// call from landing on a runtime the shell rebound to in between. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RuntimeBinding { + pub identity: RuntimeIdentity, + pub generation: u64, +} #[derive(Clone)] pub struct ProxyClient { client: Client, endpoint: ProxyEndpoint, auth: Auth, + binding: Arc>>, + generations: Arc>, } #[derive(Debug)] @@ -16,6 +45,38 @@ pub enum ProxyError { Unauthorized, Http(StatusCode), Decode(reqwest::Error), + /// The listener answered, but not as the instance this client is bound to — a foreign service + /// on the port, or a different process than the one the shell confirmed. + Foreign, +} + +impl ProxyError { + /// Whether nothing is listening on the endpoint at all. + /// + /// This is the only error that says anything about the process behind the port. A timeout, an + /// unauthorized reply or a body that will not parse all mean the listener answered or might + /// still be there, and a stop that reads any of them as "gone" reports a drain that did not + /// happen. + pub fn is_unreachable(&self) -> bool { + matches!(self, Self::Unreachable) + } +} + +/// Read an identity out of a health body. +/// +/// The marker is required: a 200 from something else on the port is not this proxy. The port is +/// required to be the one addressed, so a body describing a different listener cannot authorise a +/// credential for this one. +pub fn identity_from(body: &Value, addressed_port: u16) -> Option { + if body.get("service").and_then(Value::as_str) != Some("opencodex") { + return None; + } + let pid = u32::try_from(body.get("pid").and_then(Value::as_u64)?).ok()?; + let port = u16::try_from(body.get("port").and_then(Value::as_u64)?).ok()?; + if port != addressed_port { + return None; + } + Some(RuntimeIdentity { pid, port }) } impl ProxyClient { @@ -24,13 +85,23 @@ impl ProxyClient { client: Client::builder() .timeout(Duration::from_secs(4)) .user_agent(Auth::user_agent()) - // The admin token attached to these requests is for loopback only. - // reqwest honours system proxy configuration by default, which would - // route the credential through whatever proxy the machine declares. + // The admin token attached to these requests is for the loopback endpoint and + // nowhere else. Two defaults would carry it off that endpoint, so both are turned + // off here rather than re-checked anywhere in the request path. + // + // A redirect is the first: the pinned client does not treat this custom credential + // header as sensitive, so it would follow the hop to wherever it pointed. + .redirect(redirect::Policy::none()) + // System proxy resolution is the second: reqwest honours system proxy + // configuration by default, which would route the credential through whatever + // proxy the machine declares and put another process between the shell and its + // own runtime. .no_proxy() .build()?, endpoint, auth, + binding: Arc::new(Mutex::new(None)), + generations: Arc::new(Mutex::new(0)), }) } @@ -38,10 +109,47 @@ impl ProxyClient { self.endpoint } + fn slot(lock: &Mutex) -> MutexGuard<'_, T> { + lock.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Bind this client to an instance, and return the binding it is now on. + pub fn bind(&self, identity: RuntimeIdentity) -> RuntimeBinding { + let mut generations = Self::slot(&self.generations); + *generations += 1; + let binding = RuntimeBinding { + identity, + generation: *generations, + }; + *Self::slot(&self.binding) = Some(binding); + binding + } + + pub fn binding(&self) -> Option { + *Self::slot(&self.binding) + } + + /// Ask the endpoint who it is, without sending anything secret. + pub async fn identify(&self) -> Result { + let response = self.send(&Method::GET, "/healthz", None).await?; + let body = decode(response).await?; + identity_from(&body, self.endpoint.port).ok_or(ProxyError::Foreign) + } + pub async fn is_alive(&self) -> Result { self.get("/healthz").await } + /// A health probe that cannot outlive the caller's deadline. + /// + /// The client's own timeout is per request and knows nothing about the budget the caller is + /// working to. A probe started a moment before a deadline would otherwise overrun it by that + /// whole timeout, which is how a stated 30-second startup ceiling quietly becomes 34. + /// `None` means the deadline arrived first. + pub async fn alive_within(&self, deadline: Instant) -> Option> { + timeout_at(deadline, self.is_alive()).await.ok() + } + pub async fn companion_settings(&self) -> Result { self.get("/api/companion/settings").await } @@ -66,10 +174,6 @@ impl ProxyClient { self.get(&format!("/api/usage/timeline?{query}")).await } - pub async fn stop(&self) -> Result { - self.request(Method::POST, "/api/stop").await - } - async fn get(&self, path: &str) -> Result { self.request(Method::GET, path).await } @@ -77,13 +181,33 @@ impl ProxyClient { async fn request(&self, method: Method, path: &str) -> Result { let response = self.send(&method, path, None).await?; if response.status() == StatusCode::UNAUTHORIZED { - let token = self.auth.token().ok_or(ProxyError::Unauthorized)?; + let token = self.authorised_token().await?; let response = self.send(&method, path, Some(token)).await?; return decode(response).await; } decode(response).await } + /// The management token, but only for the instance this client is bound to. + /// + /// The binding is re-confirmed here rather than trusted from when it was made: between then and + /// now the child can have exited and something else can hold the port. A request is therefore + /// bound to a pid, a port and the generation the shell authorised, and a mismatch is refused + /// instead of being sent the credential. + async fn authorised_token(&self) -> Result { + let Some(binding) = self.binding() else { + return Err(ProxyError::Unauthorized); + }; + let identity = self.identify().await?; + if identity != binding.identity { + return Err(ProxyError::Foreign); + } + if self.binding() != Some(binding) { + return Err(ProxyError::Foreign); + } + self.auth.token().ok_or(ProxyError::Unauthorized) + } + async fn send( &self, method: &Method, @@ -113,3 +237,41 @@ async fn decode(response: reqwest::Response) -> Result { } response.json().await.map_err(ProxyError::Decode) } + +#[cfg(test)] +mod tests { + use super::{identity_from, RuntimeIdentity}; + use serde_json::json; + + #[test] + fn a_health_body_without_the_marker_is_not_this_proxy() { + let body = json!({ "status": "ok", "pid": 42, "port": 10100 }); + assert!(identity_from(&body, 10100).is_none()); + let foreign = json!({ "service": "something-else", "pid": 42, "port": 10100 }); + assert!(identity_from(&foreign, 10100).is_none()); + } + + #[test] + fn the_body_has_to_describe_the_listener_that_was_addressed() { + let body = json!({ "service": "opencodex", "pid": 42, "port": 10101 }); + assert!(identity_from(&body, 10100).is_none()); + } + + #[test] + fn a_complete_body_identifies_the_instance() { + let body = json!({ "service": "opencodex", "version": "2.61.0", "pid": 42, "port": 10100 }); + assert_eq!( + identity_from(&body, 10100), + Some(RuntimeIdentity { + pid: 42, + port: 10100 + }) + ); + } + + #[test] + fn a_body_missing_the_instance_facts_identifies_nothing() { + assert!(identity_from(&json!({ "service": "opencodex", "port": 10100 }), 10100).is_none()); + assert!(identity_from(&json!({ "service": "opencodex", "pid": 42 }), 10100).is_none()); + } +} diff --git a/desktop/src-tauri/src/resolve.rs b/desktop/src-tauri/src/resolve.rs new file mode 100644 index 00000000000..8172006d0ff --- /dev/null +++ b/desktop/src-tauri/src/resolve.rs @@ -0,0 +1,329 @@ +//! What the bundled CLI says about this machine's runtime. +//! +//! D5: the shell stops resolving the configuration home, the port and liveness itself. It used to, +//! in a file called `discovery.rs` that read `runtime-port.json`, fell back to 10100 and started on +//! that port — so a user with a configured `config.port` was started somewhere else. The tuned probe +//! budgets it should have been using exist because a shell-side reimplementation answered "nobody is +//! listening" twice and started duplicate proxies. This asks instead. +//! +//! Liveness has three answers and the third one is the point. `live` means attach. `absent-proven` +//! means every recorded and configured endpoint was definitively dead, and only that authorises +//! starting a runtime. Anything else is unknown, and the CLI exits 1 rather than putting absence on +//! the wire. Everything that can go wrong on this side — a missing binary, a timeout, output that +//! will not parse, a schema this shell does not know — folds into the same unknown, because the one +//! reading that must never happen is "the resolve failed, so nobody must be listening". + +use crate::endpoint::ProxyEndpoint; +use serde::Deserialize; +use std::path::PathBuf; +use tauri::AppHandle; +use tauri_plugin_shell::ShellExt; +use tokio::time::{timeout_at, Instant}; + +/// The wire version this shell understands. A document announcing anything else is unknown. +pub const SCHEMA: &str = "ocx-resolve/1"; + +/// The CLI's liveness verdict. Only two reach the wire; the third exits 1. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Status { + Live, + AbsentProven, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Liveness { + pub status: Status, + pub pid: Option, + pub port: Option, + /// The bind address that answered. Absent on a proven absence, because nothing answered. + pub hostname: Option, + pub version: Option, + pub role: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Port { + /// The port a client should use: the live listener's, or the configured one. + pub effective: u16, + /// What a start would prefer. + pub configured: u16, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Resolved { + pub schema: String, + pub cli_version: String, + pub config_home: String, + pub port: Port, + pub liveness: Liveness, +} + +impl Resolved { + pub fn endpoint(&self) -> ProxyEndpoint { + ProxyEndpoint { + host: "127.0.0.1", + port: self.port.effective, + } + } + + pub fn home(&self) -> PathBuf { + PathBuf::from(&self.config_home) + } +} + +/// What the shell got back. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Resolution { + /// The CLI produced a verdict this shell trusts. + Answered(Box), + /// It did not, for whatever reason. Never read as absence. + Unknown(String), +} + +impl Resolution { + pub fn resolved(&self) -> Option<&Resolved> { + match self { + Self::Answered(resolved) => Some(resolved.as_ref()), + Self::Unknown(_) => None, + } + } + + pub fn reason(&self) -> Option<&str> { + match self { + Self::Unknown(reason) => Some(reason), + Self::Answered(_) => None, + } + } +} + +/// Whether the shell may start a runtime of its own. +/// +/// Proven absence and nothing else. `live` means attach to what is there, and unknown means refuse: +/// a resolution that could not be trusted must never read as "nobody is listening", which is the +/// reading that puts a second proxy next to the one already running. +pub fn may_start(resolution: &Resolution) -> bool { + matches!( + resolution + .resolved() + .map(|resolved| resolved.liveness.status), + Some(Status::AbsentProven) + ) +} + +/// What the shell may do with a listener the CLI found alive. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LiveVerdict { + /// Nothing is listening; this verdict does not apply. + NotLive, + /// It is a proxy, on an address this shell can reach. Attach as a guest. + Attach, + /// Something is listening and this shell cannot use it. Never a reason to start a second one. + Unusable(String), +} + +/// Whether the address the CLI reported is one this shell can reach on loopback. +/// +/// The shell speaks to loopback and nothing else — that is what makes sending the management token +/// to it safe. A proxy bound to either loopback spelling, or to every interface, is reachable at +/// 127.0.0.1. One bound to the IPv6 loopback or to a specific external address is not, and +/// addressing 127.0.0.1 anyway would turn a running proxy into a health wait that times out. +pub fn loopback_reachable(hostname: Option<&str>) -> bool { + matches!( + hostname, + None | Some("127.0.0.1") | Some("localhost") | Some("0.0.0.0") + ) +} + +/// Read a live verdict. +/// +/// Liveness answers "is something there", and core's predicate accepts a connected client's +/// listener on purpose so duplicate-start avoidance can see it. This shell needs the management +/// plane, so it has to discriminate on the role the CLI carried: a client listener serves machine +/// routes, not `/api/*`, and attaching to it would report Ready against an endpoint the dashboard +/// and the tray cannot use. +pub fn live_verdict(resolution: &Resolution) -> LiveVerdict { + let Some(resolved) = resolution.resolved() else { + return LiveVerdict::NotLive; + }; + if resolved.liveness.status != Status::Live { + return LiveVerdict::NotLive; + } + if resolved.liveness.role.as_deref() == Some("client") { + return LiveVerdict::Unusable( + "a connected client is listening on this port, not a proxy this app can manage".into(), + ); + } + if !loopback_reachable(resolved.liveness.hostname.as_deref()) { + return LiveVerdict::Unusable(format!( + "the runtime is bound to {} and this app only speaks to loopback", + resolved + .liveness + .hostname + .as_deref() + .unwrap_or("an unknown address") + )); + } + LiveVerdict::Attach +} + +/// Read one resolve document, refusing anything that is not exactly one. +/// +/// The CLI puts the document on stdout and its human output on stderr, so stdout is parsed whole. +/// A non-zero exit is the CLI's own refusal — including the exit 1 it uses for unknown liveness and +/// for a config it will not guess at — and is carried through rather than reinterpreted here. +pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> Resolution { + if exit_code != Some(0) { + let detail = String::from_utf8_lossy(stderr); + let detail = detail.trim(); + let code = exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "no exit code".to_owned()); + return Resolution::Unknown(if detail.is_empty() { + format!("the bundled CLI could not resolve the runtime (exit {code})") + } else { + format!("the bundled CLI could not resolve the runtime (exit {code}): {detail}") + }); + } + let text = String::from_utf8_lossy(stdout); + let resolved: Resolved = match serde_json::from_str(text.trim()) { + Ok(resolved) => resolved, + Err(error) => { + return Resolution::Unknown(format!( + "the bundled CLI's resolve output could not be read ({error})" + )) + } + }; + if resolved.schema != SCHEMA { + return Resolution::Unknown(format!( + "the bundled CLI answered with schema {} and this app understands {SCHEMA}", + resolved.schema + )); + } + Resolution::Answered(Box::new(resolved)) +} + +/// Ask the bundled CLI, under the caller's deadline. +pub async fn run(app: &AppHandle, deadline: Instant) -> Resolution { + let command = match app.shell().sidecar("ocx") { + Ok(command) => command.args(["resolve", "--json"]), + Err(error) => { + return Resolution::Unknown(format!("the bundled CLI could not be started ({error})")) + } + }; + match timeout_at(deadline, command.output()).await { + Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr), + Ok(Err(error)) => { + Resolution::Unknown(format!("the bundled CLI could not be run ({error})")) + } + Err(_) => Resolution::Unknown( + "the bundled CLI did not answer before the startup deadline".to_owned(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::{ + live_verdict, loopback_reachable, may_start, read, LiveVerdict, Resolution, Status, SCHEMA, + }; + + const LIVE: &str = r#"{"schema":"ocx-resolve/1","cliVersion":"2.61.0","configHome":"/h", + "port":{"effective":10100,"configured":10100,"source":"runtime-record"}, + "liveness":{"status":"live","pid":42,"port":10100,"source":"runtime-record","version":"2.61.0"}}"#; + const ABSENT: &str = r#"{"schema":"ocx-resolve/1","cliVersion":"2.61.0","configHome":"/h", + "port":{"effective":10100,"configured":10100,"source":"config"}, + "liveness":{"status":"absent-proven","pid":null,"port":null,"source":null}}"#; + + #[test] + fn a_live_verdict_is_read_whole() { + let resolution = read(Some(0), LIVE.as_bytes(), b""); + let resolved = resolution.resolved().expect("a document"); + assert_eq!(resolved.schema, SCHEMA); + assert_eq!(resolved.liveness.status, Status::Live); + assert_eq!(resolved.liveness.pid, Some(42)); + assert_eq!(resolved.endpoint().port, 10100); + assert_eq!(resolved.home().display().to_string(), "/h"); + assert_eq!(live_verdict(&resolution), LiveVerdict::Attach); + assert!(!may_start(&resolution)); + } + + #[test] + fn only_a_proven_absence_authorises_a_start() { + let resolution = read(Some(0), ABSENT.as_bytes(), b""); + assert_eq!( + resolution.resolved().map(|r| r.liveness.status), + Some(Status::AbsentProven) + ); + assert!(may_start(&resolution)); + assert_eq!(live_verdict(&resolution), LiveVerdict::NotLive); + } + + #[test] + fn a_connected_client_is_live_but_not_a_runtime_to_attach_to() { + let client = LIVE.replace( + r#""version":"2.61.0""#, + r#""version":"2.61.0","role":"client""#, + ); + let resolution = read(Some(0), client.as_bytes(), b""); + assert!(matches!( + live_verdict(&resolution), + LiveVerdict::Unusable(_) + )); + // Live and unusable is still live: it is never a reason to start a second one. + assert!(!may_start(&resolution)); + } + + #[test] + fn only_a_loopback_bind_is_addressed_as_loopback() { + for reachable in [None, Some("127.0.0.1"), Some("localhost"), Some("0.0.0.0")] { + assert!(loopback_reachable(reachable), "{reachable:?}"); + } + for elsewhere in [Some("::1"), Some("192.168.1.10"), Some("example.internal")] { + assert!(!loopback_reachable(elsewhere), "{elsewhere:?}"); + } + let bound = LIVE.replace(r#""pid":42"#, r#""pid":42,"hostname":"::1""#); + let resolution = read(Some(0), bound.as_bytes(), b""); + assert!(matches!( + live_verdict(&resolution), + LiveVerdict::Unusable(_) + )); + assert!(!may_start(&resolution)); + } + + #[test] + fn the_clis_own_refusal_is_unknown_and_never_authorises_a_start() { + // Exit 1 is what the CLI uses for unknown liveness and for a config it will not guess at. + let resolution = read(Some(1), b"", b"resolve: liveness is unknown"); + assert!(matches!(resolution, Resolution::Unknown(_))); + assert!(resolution.reason().unwrap().contains("liveness is unknown")); + assert!(!may_start(&resolution)); + assert_eq!(live_verdict(&resolution), LiveVerdict::NotLive); + } + + #[test] + fn everything_that_can_go_wrong_here_folds_into_unknown() { + for (code, out) in [ + (Some(64), &b""[..]), + (None, &b""[..]), + (Some(0), &b"not json"[..]), + (Some(0), &b"{}"[..]), + ] { + let resolution = read(code, out, b""); + assert!(matches!(resolution, Resolution::Unknown(_)), "{code:?}"); + assert!(!may_start(&resolution)); + } + } + + #[test] + fn a_schema_this_app_does_not_know_is_unknown() { + let future = LIVE.replace("ocx-resolve/1", "ocx-resolve/2"); + let resolution = read(Some(0), future.as_bytes(), b""); + assert!(matches!(resolution, Resolution::Unknown(_))); + assert!(resolution.reason().unwrap().contains("ocx-resolve/2")); + assert!(!may_start(&resolution)); + } +} diff --git a/desktop/src-tauri/src/runtime_stop.rs b/desktop/src-tauri/src/runtime_stop.rs new file mode 100644 index 00000000000..c4099b8304b --- /dev/null +++ b/desktop/src-tauri/src/runtime_stop.rs @@ -0,0 +1,306 @@ +//! Stopping a runtime through the bundled CLI. +//! +//! D4: the shell drives the real `ocx stop` as a child process, so the receipt-backed teardown, the +//! drain, the Windows respawn verification and the client-configuration restore all run exactly as +//! they do from a terminal. An in-process management call cannot own that teardown — launchd and +//! systemd can terminate the request handler during self-unload, and the Windows respawn window can +//! only be verified after the process exits — so the shell reads the run's result instead of +//! performing it. +//! +//! The result is a document, not a guess. `ocx stop --json` puts one summary on stdout and its +//! human output on stderr, and this consumes the outcome and the exit code rather than inferring +//! either. A stop that did not end in exit 0 with the runtime down is a stop that did not happen. + +use serde::Deserialize; +use std::time::Duration; +use tauri::AppHandle; +use tauri_plugin_shell::ShellExt; +use tokio::time::{timeout_at, Instant}; + +/// The wire version this shell understands. +pub const SCHEMA: &str = "ocx-stop/1"; + +/// How long the stop may take. +/// +/// The CLI's stop drains in-flight requests, restores client configuration and verifies the Windows +/// respawn window, so this is generous on purpose: it bounds a hang, it does not pace a healthy +/// stop. Overrunning it is a failure, not a stop, because the caller's next step is to end the app +/// or replace the files the runtime is serving out of. +pub const DEADLINE: Duration = Duration::from_secs(30); + +/// The outcomes the CLI can report. An outcome this shell does not know fails to parse, which is +/// the same answer as a stop that did not happen. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Outcome { + Stopped, + NotRunning, + HistoryIncomplete, + HistoryDeferred, + Failed, +} + +impl Outcome { + pub fn as_str(self) -> &'static str { + match self { + Self::Stopped => "stopped", + Self::NotRunning => "not-running", + Self::HistoryIncomplete => "history-incomplete", + Self::HistoryDeferred => "history-deferred", + Self::Failed => "failed", + } + } +} + +/// How the proxy half of the stop ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Proxy { + Stopped, + StoppedOrphan, + NotRunning, + StopFailed, + OwnershipRefused, + UnresolvablePid, + Respawned, + Unknown, +} + +impl Proxy { + pub fn as_str(self) -> &'static str { + match self { + Self::Stopped => "stopped", + Self::StoppedOrphan => "stopped-orphan", + Self::NotRunning => "not-running", + Self::StopFailed => "stop-failed", + Self::OwnershipRefused => "ownership-refused", + Self::UnresolvablePid => "unresolvable-pid", + Self::Respawned => "respawned", + Self::Unknown => "unknown", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StopSummary { + pub schema: String, + /// Strict exit-code view: true only for exit 0. + pub ok: bool, + pub outcome: Outcome, + pub exit_code: i32, + /// True when this stop left no proxy of this home running by its own paths. + pub runtime_down: bool, + pub proxy: Proxy, + pub message: String, +} + +/// What the shell concluded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StopResult { + /// The CLI reported a clean stop and a runtime that is down. + Stopped(StopSummary), + /// It reported anything else, or the run could not be read at all. + Failed(String), +} + +impl StopResult { + pub fn is_stopped(&self) -> bool { + matches!(self, Self::Stopped(_)) + } + + pub fn describe(&self) -> String { + match self { + Self::Stopped(summary) => summary.message.clone(), + Self::Failed(reason) => reason.clone(), + } + } +} + +/// Read one stop summary. +/// +/// Five facts have to hold together, and no four of them are enough. +/// +/// The process has to have exited 0, and the document has to say so too: `ok` is the strict +/// exit-code view and `exitCode` is the number behind it, so 1, 79 and 80 are refusals however the +/// rest of the document reads. Reading only the document would take a run's word for its own exit +/// status; reading only the status would accept a summary that disagrees with it. And `runtimeDown` +/// is the CLI's own statement that no proxy of this home is left running — a service that failed +/// while the proxy happened to stop satisfies that and not the others, and it is exactly the case +/// that may respawn the runtime a moment later. +/// +/// The fifth is that the document agrees with itself. The CLI's own summarizer cannot emit a +/// `failed` outcome beside a `stopped` proxy, but a reader that assumes that is trusting a +/// document to be self-consistent rather than checking. Only the two shapes that mean a runtime is +/// down are accepted, and an outcome or a proxy state this shell does not know fails to parse — +/// which is the same answer as a stop that did not happen. +pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> StopResult { + let text = String::from_utf8_lossy(stdout); + let summary: StopSummary = match serde_json::from_str(text.trim()) { + Ok(summary) => summary, + Err(error) => { + let detail = String::from_utf8_lossy(stderr); + let detail = detail.trim(); + let code = exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "no exit code".to_owned()); + return StopResult::Failed(if detail.is_empty() { + format!("the bundled CLI's stop output could not be read (exit {code}: {error})") + } else { + format!("the bundled CLI's stop output could not be read (exit {code}): {detail}") + }); + } + }; + if summary.schema != SCHEMA { + return StopResult::Failed(format!( + "the bundled CLI answered with schema {} and this app understands {SCHEMA}", + summary.schema + )); + } + let agrees = matches!( + (summary.outcome, summary.proxy), + (Outcome::Stopped, Proxy::Stopped) + | (Outcome::Stopped, Proxy::StoppedOrphan) + | (Outcome::NotRunning, Proxy::NotRunning) + ); + if exit_code != Some(0) + || !summary.ok + || summary.exit_code != 0 + || !summary.runtime_down + || !agrees + { + return StopResult::Failed(format!( + "{} (outcome {}, proxy {}, exit {}, process exit {})", + summary.message, + summary.outcome.as_str(), + summary.proxy.as_str(), + summary.exit_code, + exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "none".to_owned()) + )); + } + StopResult::Stopped(summary) +} + +/// Run the bundled `ocx stop --json`, under the caller's deadline. +pub async fn run(app: &AppHandle, deadline: Instant) -> StopResult { + let command = match app.shell().sidecar("ocx") { + Ok(command) => command.args(["stop", "--json"]), + Err(error) => { + return StopResult::Failed(format!("the bundled CLI could not be started ({error})")) + } + }; + match timeout_at(deadline, command.output()).await { + Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr), + Ok(Err(error)) => StopResult::Failed(format!("the bundled CLI could not be run ({error})")), + Err(_) => { + StopResult::Failed("the bundled CLI did not finish stopping before the deadline".into()) + } + } +} + +#[cfg(test)] +mod tests { + use super::{read, Outcome, Proxy, StopResult, SCHEMA}; + + fn document(ok: bool, outcome: &str, exit: i32, down: bool, proxy: &str) -> String { + format!( + r#"{{"schema":"ocx-stop/1","ok":{ok},"outcome":"{outcome}","exitCode":{exit}, + "runtimeDown":{down},"service":"absent","proxy":"{proxy}", + "sharedTeardown":"restored","message":"a message"}}"# + ) + } + + #[test] + fn a_clean_stop_with_the_runtime_down_is_the_only_success() { + let ok = document(true, "stopped", 0, true, "stopped"); + let result = read(Some(0), ok.as_bytes(), b""); + assert!(result.is_stopped()); + match result { + StopResult::Stopped(summary) => { + assert_eq!(summary.schema, SCHEMA); + assert_eq!(summary.outcome, Outcome::Stopped); + assert_eq!(summary.proxy, Proxy::Stopped); + assert!(summary.runtime_down); + } + StopResult::Failed(reason) => panic!("{reason}"), + } + // Nothing was running is equally a runtime that is down. + assert!(read( + Some(0), + document(true, "not-running", 0, true, "not-running").as_bytes(), + b"" + ) + .is_stopped()); + } + + #[test] + fn a_non_zero_exit_is_never_folded_into_success() { + // 79 and 80 report a proxy that went down with an obligation still owed. The runtime may + // be down, but the run did not succeed, and an update must not install over it. + for (outcome, exit) in [ + ("history-incomplete", 79), + ("history-deferred", 80), + ("failed", 1), + ] { + let document = document(false, outcome, exit, true, "stopped"); + let result = read(Some(exit), document.as_bytes(), b""); + assert!(!result.is_stopped(), "{outcome}"); + assert!(result.describe().contains(outcome)); + } + } + + #[test] + fn the_process_status_and_the_document_have_to_agree() { + let clean = document(true, "stopped", 0, true, "stopped"); + // A run that exited non-zero is a refusal even when its summary reads clean: taking the + // document's word for its own exit status is taking one claim as evidence of itself. + assert!(!read(Some(1), clean.as_bytes(), b"").is_stopped()); + assert!(!read(None, clean.as_bytes(), b"").is_stopped()); + // And a summary that contradicts its own exit code is not a stop either. + let contradictory = document(true, "stopped", 1, true, "stopped"); + assert!(!read(Some(0), contradictory.as_bytes(), b"").is_stopped()); + } + + #[test] + fn a_document_that_contradicts_itself_is_not_a_stop() { + // The CLI's summarizer cannot emit this, and the reader does not assume that. + let mixed = document(true, "failed", 0, true, "respawned"); + assert!(!read(Some(0), mixed.as_bytes(), b"").is_stopped()); + let orphan = document(true, "stopped", 0, true, "stopped-orphan"); + assert!(read(Some(0), orphan.as_bytes(), b"").is_stopped()); + // An outcome or a proxy state this shell does not know is not read at all. + let future = document(true, "stopped", 0, true, "stopped") + .replace("\"proxy\":\"stopped\"", "\"proxy\":\"parked\""); + assert!(!read(Some(0), future.as_bytes(), b"").is_stopped()); + } + + #[test] + fn a_runtime_still_up_is_a_failure_however_the_exit_reads() { + for proxy in [ + "respawned", + "stop-failed", + "ownership-refused", + "unresolvable-pid", + ] { + let document = document(true, "stopped", 0, false, proxy); + assert!( + !read(Some(0), document.as_bytes(), b"").is_stopped(), + "{proxy}" + ); + } + } + + #[test] + fn output_that_cannot_be_read_is_a_failure_not_a_stop() { + assert!(!read(Some(0), b"", b"boom").is_stopped()); + assert!(!read(Some(0), b"not json", b"").is_stopped()); + assert!(!read(None, b"", b"").is_stopped()); + let future = + document(true, "stopped", 0, true, "stopped").replace("ocx-stop/1", "ocx-stop/2"); + let result = read(Some(0), future.as_bytes(), b""); + assert!(!result.is_stopped()); + assert!(result.describe().contains("ocx-stop/2")); + } +} diff --git a/desktop/src-tauri/src/sidecar.rs b/desktop/src-tauri/src/sidecar.rs index 434c1d62883..2a1da6e90c7 100644 --- a/desktop/src-tauri/src/sidecar.rs +++ b/desktop/src-tauri/src/sidecar.rs @@ -1,27 +1,145 @@ -use crate::{discovery::ProxyEndpoint, proxy::ProxyClient}; -use tauri::{AppHandle, Manager}; -use tauri_plugin_shell::{process::CommandChild, ShellExt}; -use tokio::time::{sleep, timeout, Duration, Instant}; +//! Starting and watching the runtime this app owns. +//! +//! The spawn event stream used to be discarded into `_events`, which is why a sidecar that exited +//! immediately — a binary built for a CPU instruction set this machine does not have, a port +//! already taken, a corrupt install — presented as the same generic health failure as a slow start. +//! The child's exit code and its last output were both available and both thrown away. They are +//! consumed here instead, and they are what the startup diagnostic is made of. +//! +//! Stopping it is not here. D4 gives that to the bundled `ocx stop`, which owns the receipt-backed +//! teardown this process cannot perform on itself; see `runtime_stop.rs`. -pub async fn ensure_proxy( - app: &AppHandle, - proxy: &ProxyClient, - endpoint: ProxyEndpoint, -) -> Result, String> { - let deadline = Instant::now() + Duration::from_secs(2); - loop { - if matches!( - timeout(Duration::from_millis(250), proxy.is_alive()).await, - Ok(Ok(_)) - ) { - return Ok(None); +use crate::endpoint::ProxyEndpoint; +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; +use tauri::{async_runtime::Receiver, AppHandle, Manager}; +use tauri_plugin_shell::{ + process::{CommandChild, CommandEvent}, + ShellExt, +}; +/// How much sidecar output the diagnostic keeps. Enough to carry a stack trace or a startup +/// refusal, bounded so a chatty runtime cannot grow the buffer for the life of the process. +const MAX_LINES: usize = 40; + +/// How the sidecar process ended. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SidecarExit { + pub code: Option, + pub signal: Option, +} + +impl SidecarExit { + pub fn describe(&self) -> String { + match (self.code, self.signal) { + (Some(code), _) => format!("exit code {code}"), + (None, Some(signal)) => format!("terminated by signal {signal}"), + (None, None) => "exited without reporting a code".to_owned(), } - if Instant::now() >= deadline { - break; + } +} + +/// One thing the spawned child told us. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SidecarEvent { + Line(String), + Exited(SidecarExit), +} + +#[derive(Default)] +struct WatchInner { + lines: VecDeque, + exit: Option, +} + +impl WatchInner { + fn record(&mut self, event: SidecarEvent) { + match event { + SidecarEvent::Line(line) => { + let line = line.trim_end().to_owned(); + if line.is_empty() { + return; + } + if self.lines.len() == MAX_LINES { + self.lines.pop_front(); + } + self.lines.push_back(line); + } + SidecarEvent::Exited(exit) => self.exit = Some(exit), + } + } +} + +/// The consumed spawn event stream of the child this app started. +#[derive(Clone, Default)] +pub struct SidecarWatch { + inner: Arc>, +} + +impl SidecarWatch { + pub fn record(&self, event: SidecarEvent) { + if let Ok(mut inner) = self.inner.lock() { + inner.record(event); } - sleep(Duration::from_millis(150)).await; } + pub fn exit(&self) -> Option { + self.inner.lock().ok().and_then(|inner| inner.exit) + } + + pub fn lines(&self) -> Vec { + self.inner + .lock() + .map(|inner| inner.lines.iter().cloned().collect()) + .unwrap_or_default() + } + + /// Forget the previous attempt so a retry's diagnostic describes the retry. + pub fn reset(&self) { + if let Ok(mut inner) = self.inner.lock() { + inner.lines.clear(); + inner.exit = None; + } + } + + /// Drain the spawn event stream into this record for as long as the child lives. + pub fn follow(&self, mut events: Receiver) { + let watch = self.clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = events.recv().await { + if let Some(event) = translate(event) { + watch.record(event); + } + } + }); + } +} + +fn translate(event: CommandEvent) -> Option { + match event { + CommandEvent::Stdout(bytes) | CommandEvent::Stderr(bytes) => Some(SidecarEvent::Line( + String::from_utf8_lossy(&bytes).into_owned(), + )), + CommandEvent::Error(message) => Some(SidecarEvent::Line(format!("error: {message}"))), + CommandEvent::Terminated(payload) => Some(SidecarEvent::Exited(SidecarExit { + code: payload.code, + signal: payload.signal, + })), + _ => None, + } +} + +/// Start the bundled runtime and begin consuming what it says. +/// +/// The port is still passed explicitly. D5 hands that resolution to the bundled CLI so a user on a +/// custom `config.port` is not started on a different one; this is the call site that changes when +/// lane A's resolve verb lands, and nothing else here depends on where the number came from. +pub fn start( + app: &AppHandle, + endpoint: ProxyEndpoint, + watch: &SidecarWatch, +) -> Result { let gui_dist = app .path() .resource_dir() @@ -34,14 +152,75 @@ pub async fn ensure_proxy( .map_err(|error| error.to_string())? .args(["start", "--port", &endpoint.port.to_string()]) .env("OPENCODEX_GUI_DIST", gui_dist); - let (_events, child) = command.spawn().map_err(|error| error.to_string())?; + let (events, child) = command.spawn().map_err(|error| error.to_string())?; + watch.follow(events); + Ok(child) +} + +#[cfg(test)] +mod tests { + use super::{SidecarEvent, SidecarExit, SidecarWatch, MAX_LINES}; + + #[test] + fn the_exit_code_survives_the_event_stream() { + let watch = SidecarWatch::default(); + watch.record(SidecarEvent::Line("listening on 10100".into())); + watch.record(SidecarEvent::Exited(SidecarExit { + code: Some(1), + signal: None, + })); + assert_eq!(watch.exit().and_then(|exit| exit.code), Some(1)); + assert_eq!(watch.lines(), vec!["listening on 10100".to_owned()]); + } - for _ in 0..20 { - tokio::time::sleep(std::time::Duration::from_millis(150)).await; - if proxy.is_alive().await.is_ok() { - return Ok(Some(child)); + #[test] + fn output_is_bounded_and_keeps_the_end() { + let watch = SidecarWatch::default(); + for index in 0..(MAX_LINES + 5) { + watch.record(SidecarEvent::Line(format!("line {index}"))); } + let lines = watch.lines(); + assert_eq!(lines.len(), MAX_LINES); + assert_eq!(lines.first().unwrap(), "line 5"); + assert_eq!(lines.last().unwrap(), &format!("line {}", MAX_LINES + 4)); + } + + #[test] + fn blank_output_is_not_recorded_and_a_reset_forgets_the_attempt() { + let watch = SidecarWatch::default(); + watch.record(SidecarEvent::Line(" \n".into())); + assert!(watch.lines().is_empty()); + watch.record(SidecarEvent::Line("boom".into())); + watch.record(SidecarEvent::Exited(SidecarExit { + code: None, + signal: Some(9), + })); + watch.reset(); + assert!(watch.lines().is_empty()); + assert!(watch.exit().is_none()); + } + + #[test] + fn an_exit_reads_as_a_code_a_signal_or_neither() { + assert_eq!( + SidecarExit { + code: Some(2), + signal: None + } + .describe(), + "exit code 2" + ); + assert_eq!( + SidecarExit { + code: None, + signal: Some(9) + } + .describe(), + "terminated by signal 9" + ); + assert_eq!( + SidecarExit::default().describe(), + "exited without reporting a code" + ); } - let _ = child.kill(); - Err("the OpenCodex sidecar did not become healthy".into()) } diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs new file mode 100644 index 00000000000..bcc08ba2279 --- /dev/null +++ b/desktop/src-tauri/src/startup.rs @@ -0,0 +1,876 @@ +//! The startup sequence, as named states inside a window the user can already see. +//! +//! Everything below used to run inside `setup()` before any window existed, and the window was +//! then created hidden. That ordering is why a failed start had no surface: the spawn event stream +//! was discarded, so the child's exit code was gone, and a run of probes that time out rather than +//! refuse takes over a minute with nothing on screen to explain it. D7 inverts it. The window is +//! created and shown first, and the sequence runs inside it as named states under one overall +//! deadline, with a retry, the child's exit code and a diagnostic the user can copy. +//! +//! Registration comes first, before the runtime is touched at all. The order looks backwards until +//! you follow the failing case: a login launch starts hidden, and if the tray were installed only +//! after a successful start then a start that failed would leave a running process with no window +//! and no icon — invisible. The app establishes its own surface, then deals with the runtime. +//! +//! A launch that came from login autostart starts hidden, and that is the only difference — except +//! where there is no usable tray to hide into, which is R1 and lives in [`shows_window`]. + +use crate::{ + auth::Auth, + endpoint::ProxyEndpoint, + first_run::{self, StartAtLogin}, + identity, ownership, + proxy::{ProxyClient, RuntimeIdentity}, + resolve, + sidecar::{self, SidecarWatch}, + tray_availability::{self, TrayAvailability}, + AppState, +}; +use serde::Serialize; +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, MutexGuard, PoisonError, + }, +}; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::time::{sleep, Duration, Instant}; + +/// The event the bootstrap page listens on. +pub const PHASE_EVENT: &str = "startup-phase"; + +/// One deadline for the whole sequence. +/// +/// Per-step budgets were what produced the unbounded case: a two-second attach loop whose probes +/// each cost a four-second client timeout, followed by twenty more waits, adds up to something no +/// single number in the code admitted to. One ceiling over the whole run is a promise that can be +/// read — and every probe under it is bounded by the remaining time rather than by its own +/// timeout, because otherwise the last probe overruns the ceiling by the whole client timeout. +pub const DEADLINE: Duration = Duration::from_secs(30); + +const POLL: Duration = Duration::from_millis(250); + +/// Where the launch came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LaunchOrigin { + /// A person opened the app. + User, + /// The login item started it. + Autostart, +} + +/// The argument the autostart registration passes back to us. Nothing else supplies it, so its +/// presence is the launch origin. +pub const AUTOSTART_FLAG: &str = "--autostart"; + +impl LaunchOrigin { + pub fn from_args(mut args: impl Iterator) -> Self { + if args.any(|argument| argument == AUTOSTART_FLAG) { + Self::Autostart + } else { + Self::User + } + } + + pub fn detect() -> Self { + Self::from_args(std::env::args()) + } +} + +/// Whether this launch shows its window. +/// +/// D7 shows it always and exempts a login launch, which starts hidden. D6 shows it wherever there +/// is no usable tray. A no-tray login launch satisfies both rules and they disagree, so R1 settles +/// it: tray availability wins. Starting hidden is a property of having somewhere to be hidden in, +/// not of how the process was started. +pub fn shows_window(origin: LaunchOrigin, tray: TrayAvailability) -> bool { + !tray.is_available() || origin == LaunchOrigin::User +} + +/// A named state of the startup sequence. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase { + Registering, + Resolving, + Probing, + Attaching, + Starting, + Waiting, + Ready, + Failed, +} + +/// Every phase, in the order they run. The bootstrap page derives its checklist from this rather +/// than restating it, so a phase cannot exist in one place and be missing from the other. +pub const PHASES: [Phase; 8] = [ + Phase::Registering, + Phase::Resolving, + Phase::Probing, + Phase::Attaching, + Phase::Starting, + Phase::Waiting, + Phase::Ready, + Phase::Failed, +]; + +impl Phase { + /// The stable identifier the bootstrap page keys on. + pub fn id(self) -> &'static str { + match self { + Self::Registering => "registering", + Self::Resolving => "resolving", + Self::Probing => "probing", + Self::Attaching => "attaching", + Self::Starting => "starting", + Self::Waiting => "waiting", + Self::Ready => "ready", + Self::Failed => "failed", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Registering => "Registering the tray and the login item", + Self::Resolving => "Resolving the configuration home and port", + Self::Probing => "Looking for a runtime that is already listening", + Self::Attaching => "Attaching to the runtime that answered", + Self::Starting => "Starting the bundled runtime", + Self::Waiting => "Waiting for the runtime to report healthy", + Self::Ready => "Ready", + Self::Failed => "OpenCodex could not start its runtime", + } + } + + pub fn is_terminal(self) -> bool { + matches!(self, Self::Ready | Self::Failed) + } +} + +/// One phase, as the bootstrap page sees it. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PhaseInfo { + pub id: &'static str, + pub label: &'static str, + pub terminal: bool, +} + +/// The phase list the page renders. Derived from [`PHASES`] so the two cannot drift. +pub fn phase_list() -> Vec { + PHASES + .iter() + .map(|phase| PhaseInfo { + id: phase.id(), + label: phase.label(), + terminal: phase.is_terminal(), + }) + .collect() +} + +/// What the bootstrap page is told. +/// +/// It carries the phases already finished, not just the current one. An event emitted before the +/// page's listener exists is gone, and the early phases finish in milliseconds, so a page that +/// reconstructed history from events alone would show a run in progress with nothing behind it. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Progress { + pub phase: &'static str, + pub label: &'static str, + pub detail: Option, + pub completed: Vec<&'static str>, + pub failed_phase: Option<&'static str>, + pub elapsed_ms: u64, + pub dashboard: Option, + pub diagnostic: Option, + pub can_retry: bool, +} + +impl Progress { + fn new(phase: Phase, elapsed_ms: u64) -> Self { + Self { + phase: phase.id(), + label: phase.label(), + detail: None, + completed: Vec::new(), + failed_phase: None, + elapsed_ms, + dashboard: None, + diagnostic: None, + can_retry: phase == Phase::Failed, + } + } +} + +/// Where the sequence is pointed, once the CLI has said. +#[derive(Clone)] +struct Target { + endpoint: ProxyEndpoint, + home: PathBuf, +} + +/// What registering established about this installation. +#[derive(Clone, Debug)] +pub struct Registration { + pub login: StartAtLogin, + /// This installation's own id, and what the recorded runtime owner says about it. + pub identity: String, +} + +struct Live { + latest: Progress, + reported: Vec<&'static str>, +} + +/// The sequence's managed state: the latest thing it said, what it has already finished, and +/// whether it is running, so a retry cannot start a second run alongside the first. +pub struct Startup { + live: Mutex, + running: AtomicBool, + /// The outcome of the one-time registration, once it has happened. + registered: Mutex>, +} + +impl Startup { + pub fn new() -> Self { + Self { + live: Mutex::new(Live { + latest: Progress::new(Phase::Registering, 0), + reported: Vec::new(), + }), + running: AtomicBool::new(false), + registered: Mutex::new(None), + } + } + + fn live(&self) -> MutexGuard<'_, Live> { + self.live.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn registration(&self) -> Option { + self.registered + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + fn remember_registration(&self, registration: Registration) { + *self + .registered + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(registration); + } + + /// The whole state of the run so far, which is what the page asks for when it loads. + pub fn latest(&self) -> Progress { + self.live().latest.clone() + } + + fn restart(&self) { + let mut live = self.live(); + live.reported.clear(); + live.latest = Progress::new(Phase::Registering, 0); + } + + fn publish(&self, progress: &mut Progress, failed_in: Option) { + let mut live = self.live(); + if !live.reported.contains(&progress.phase) + && progress.phase != Phase::Ready.id() + && progress.phase != Phase::Failed.id() + { + live.reported.push(progress.phase); + } + progress.completed = live + .reported + .iter() + .copied() + .filter(|id| *id != progress.phase) + .collect(); + progress.failed_phase = failed_in.map(Phase::id); + live.latest = progress.clone(); + } +} + +impl Default for Startup { + fn default() -> Self { + Self::new() + } +} + +/// Run the sequence, unless it is already running. This is also the retry. +pub fn begin(app: &AppHandle) { + let Some(startup) = app.try_state::() else { + return; + }; + if startup + .running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + startup.restart(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + run(&app).await; + if let Some(startup) = app.try_state::() { + startup.running.store(false, Ordering::Release); + } + }); +} + +async fn run(app: &AppHandle) { + let started = Instant::now(); + let deadline = started + DEADLINE; + let Some(watch) = app.try_state::().map(|state| state.watch.clone()) else { + return; + }; + report(app, started, Phase::Registering, None); + let registration = register(app, deadline).await; + report( + app, + started, + Phase::Registering, + Some(format!( + "{}; {}", + registration.login.describe(), + registration.identity + )), + ); + + report(app, started, Phase::Resolving, None); + // D5: the shell no longer resolves the home, the port or liveness. It asks the bundled CLI, + // which owns the tuned probe budgets that exist because a shell-side reimplementation answered + // "nobody is listening" twice and started duplicate proxies. The call is inside the sequence, so + // a CLI that is missing or slow has a state, a diagnostic and a retry rather than a guess. + let resolution = resolve::run(app, deadline).await; + let Some(answer) = resolution.resolved() else { + // Fail-closed. A resolution that could not be trusted is not an absence, and nothing below + // may read it as one. + fail( + app, + started, + None, + ®istration, + &watch, + Phase::Resolving, + resolution + .reason() + .unwrap_or("the runtime could not be resolved") + .to_owned(), + ); + return; + }; + let endpoint = answer.endpoint(); + let target = Target { + endpoint, + home: answer.home(), + }; + let proxy = match ProxyClient::new(endpoint, Auth::new(answer.home())) { + Ok(proxy) => proxy, + Err(error) => { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Resolving, + error.to_string(), + ); + return; + } + }; + if let Some(state) = app.try_state::() { + state.attach(proxy.clone()); + } + report( + app, + started, + Phase::Resolving, + Some(format!( + "{} with a configuration home of {}, resolved by the bundled CLI {}", + target.endpoint.url(""), + target.home.display(), + answer.cli_version + )), + ); + + report( + app, + started, + Phase::Probing, + Some(match answer.liveness.status { + resolve::Status::Live => "a runtime is already listening".to_owned(), + resolve::Status::AbsentProven => { + "no runtime is listening, and that absence was proven".to_owned() + } + }), + ); + match resolve::live_verdict(&resolution) { + resolve::LiveVerdict::Attach => { + report( + app, + started, + Phase::Attaching, + Some("a runtime was already listening, so this app is a guest on it".to_owned()), + ); + if bind(app, &proxy, deadline).await.is_none() { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Attaching, + "the runtime answered but did not identify itself, so this app did not attach" + .to_owned(), + ); + return; + } + finish(app, started, endpoint); + return; + } + // Something holds the port and this app cannot manage it. That is not an absence, so it + // does not authorise starting a second runtime beside it either. + resolve::LiveVerdict::Unusable(reason) => { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Attaching, + reason, + ); + return; + } + resolve::LiveVerdict::NotLive => {} + } + if !resolve::may_start(&resolution) { + // Only a proven absence authorises a start. Nothing else may fall through to one. + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Probing, + "the runtime's liveness could not be established, so no runtime was started".to_owned(), + ); + return; + } + + // A retry must not leave a second proxy behind. A child that has not reported an exit is still + // out there, whatever the last run concluded, so the retry waits on that one rather than + // starting another and racing it for the port. + let owns_live_child = app + .try_state::() + .is_some_and(|state| state.owns_runtime()) + && watch.exit().is_none(); + if owns_live_child { + report( + app, + started, + Phase::Starting, + Some("the runtime this app started has not exited; waiting on it again".to_owned()), + ); + } else { + report(app, started, Phase::Starting, None); + watch.reset(); + match spawn_runtime(app, endpoint, &watch) { + Some(Ok(())) => {} + Some(Err(error)) => { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Starting, + error, + ); + return; + } + // An exit is already in flight, so starting a runtime now would orphan it. + None => return, + } + } + + report(app, started, Phase::Waiting, None); + while Instant::now() < deadline { + if matches!(proxy.alive_within(deadline).await, Some(Ok(_))) { + if bind(app, &proxy, deadline).await.is_none() { + // Healthy is not the same as identified: a 200 with a body that does not carry the + // marker is something else holding the port, and the token is never sent to it. + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Waiting, + "the runtime reported healthy but did not identify itself".to_owned(), + ); + return; + } + finish(app, started, endpoint); + return; + } + // A child that has already exited will never answer, so the deadline is not worth waiting + // out. This is the case the discarded event stream used to hide behind a generic timeout. + if let Some(exit) = watch.exit() { + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Waiting, + format!("the runtime {}", exit.describe()), + ); + return; + } + sleep(POLL).await; + } + fail( + app, + started, + Some(&target), + ®istration, + &watch, + Phase::Waiting, + format!( + "the runtime did not report healthy within {} seconds", + DEADLINE.as_secs() + ), + ); +} + +/// Establish the app's own surface: the tray verdict, the tray, and the login item. +/// +/// It happens once per process. A retry re-runs the runtime half of the sequence, and running this +/// half again would build a second tray icon with its own refresh loop and its own menu handlers — +/// the failure would look like the app duplicating itself every time the user pressed Retry. +async fn register(app: &AppHandle, deadline: Instant) -> Registration { + if let Some(done) = app + .try_state::() + .and_then(|startup| startup.registration()) + { + return done; + } + + // The probe blocks on a session-bus round trip, so it does not belong on an async worker — and + // it is bounded by the sequence's own deadline, because a bus that never answers would + // otherwise leave the page in this state with a retry that could do nothing about it. + let tray = match tokio::time::timeout_at( + deadline, + tauri::async_runtime::spawn_blocking(tray_availability::detect), + ) + .await + { + Ok(Ok(tray)) => tray, + _ => TrayAvailability::assumed(), + }; + + // Before the tray, so its Start at Login checkbox reads the state this leaves behind rather + // than the state from before first run. + let login = first_run::apply_start_at_login_default(app); + first_run::adopt_launch_origin_argument(app); + + // The verdict is published only once an icon actually exists. Announcing a tray and then + // failing to install it would hide the window into nothing, which is the exact stranding D6 + // exists to prevent. + let verdict = if tray.is_available() && install_tray(app, deadline).await { + TrayAvailability::Available + } else { + TrayAvailability::Unavailable + }; + if let Some(coordinator) = app.try_state::() { + coordinator.set_tray(verdict); + } + + if let Some(window) = app.get_webview_window("main") { + if shows_window(LaunchOrigin::detect(), verdict) { + crate::window::show(&window); + } + } + // This installation's own id, and what the recorded runtime owner says about it. The claim + // lives in the shared service install state and the CLI is what reads it; the comparison + // against our own id is the rule that record publishes. + let install_id = identity::install_id(app); + let registration = Registration { + login, + identity: ownership::describe(ownership::resolve(app).as_ref(), install_id.as_deref()), + }; + if let Some(startup) = app.try_state::() { + startup.remember_registration(registration.clone()); + } + registration +} + +/// Build the tray on the main thread, which is where GTK requires it on Linux. +async fn install_tray(app: &AppHandle, deadline: Instant) -> bool { + let handle = app.clone(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + if app + .run_on_main_thread(move || { + let _ = sender.send(crate::tray::install(&handle).map_err(|error| error.to_string())); + }) + .is_err() + { + return false; + } + match tokio::time::timeout_at(deadline, receiver).await { + Ok(Ok(Ok(()))) => true, + Ok(Ok(Err(error))) => { + crate::logging::log_once("the tray could not be installed", &error); + false + } + _ => { + crate::logging::log_once( + "the tray could not be installed", + "the main thread did not answer", + ); + false + } + } +} +/// Start the runtime, unless an exit is already in flight. +/// +/// The coordinator reserves the spawn rather than holding its lock across it: holding it would put +/// process creation in front of the main thread's exit handler, so a wedged spawn would be a Quit +/// that never answers. A quit arriving in between is deferred until the child is ours and then +/// drains it, so it cannot observe "we own nothing" and leave a proxy running that nothing stops. +fn spawn_runtime( + app: &AppHandle, + endpoint: ProxyEndpoint, + watch: &SidecarWatch, +) -> Option> { + let coordinator = app.try_state::()?; + if !coordinator.begin_spawn() { + return None; + } + let outcome = match sidecar::start(app, endpoint, watch) { + Ok(child) => { + if let Some(state) = app.try_state::() { + state.adopt(child); + } + Ok(()) + } + Err(error) => Err(error), + }; + if let Some(reason) = coordinator.finish_spawn() { + // A quit landed while the child was being created. It is ours now, so it gets drained. + crate::exit::drain_now(app, reason); + return None; + } + Some(outcome) +} + +/// Establish which instance is answering, and whether it is the child this app started. +/// +/// The health body is unauthenticated and carries the marker, the pid and the port, so identity is +/// settled before any credential is sent. It is also the only thing that grants process ownership: +/// a spawn records a pid, and this is what says that pid is the one holding the port. An answer +/// that cannot be read leaves the app owning nothing, which is the safe way round — an owner's stop +/// sent to a listener that is not ours is a stop sent to somebody else's runtime. +/// +/// The answer is returned rather than swallowed, because a sequence that cannot identify what it is +/// talking to has not finished. Reporting Ready there would navigate the window to a dashboard the +/// shell cannot authenticate against, since the management token is only sent to a bound instance. +async fn bind(app: &AppHandle, proxy: &ProxyClient, deadline: Instant) -> Option { + let identity = match tokio::time::timeout_at(deadline, proxy.identify()).await { + Ok(Ok(identity)) => identity, + _ => return None, + }; + proxy.bind(identity); + if let Some(state) = app.try_state::() { + state.confirm_ownership(identity); + } + Some(identity) +} + +fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { + // Ownership is whatever the confirmation above established, not whatever a spawn assumed. + crate::tray::set_owned( + app, + app.try_state::() + .is_some_and(|state| state.owns_runtime()), + ); + let dashboard = endpoint.url("/#/usage"); + let mut progress = Progress::new(Phase::Ready, elapsed(started)); + progress.dashboard = Some(dashboard.clone()); + emit(app, progress, None); + if let Some(window) = app.get_webview_window("main") { + // justified: replacing the bootstrap page with the dashboard is how this window has always + // navigated, and the string is a URL this process resolved, not anything a page supplied. + let _ = window.eval(format!("window.location.replace({dashboard:?})")); + } +} + +#[allow(clippy::too_many_arguments)] +fn fail( + app: &AppHandle, + started: Instant, + target: Option<&Target>, + registration: &Registration, + watch: &SidecarWatch, + phase: Phase, + reason: String, +) { + let elapsed_ms = elapsed(started); + let mut progress = Progress::new(Phase::Failed, elapsed_ms); + progress.diagnostic = Some(diagnostic( + target.map(|target| (target.endpoint, target.home.clone())), + registration, + watch, + phase, + &reason, + elapsed_ms, + )); + progress.detail = Some(reason); + emit(app, progress, Some(phase)); +} + +/// The text the failure surface offers for copying. +/// +/// It names the state it stopped in, the endpoint and home it was using, how the child ended and +/// what the child last said. Those together are what separates "the port was taken" from "the +/// binary will not run on this CPU" from "the home is not the one the accounts are in", and none of +/// them were reachable from the generic health failure this replaces. +pub fn diagnostic( + target: Option<(ProxyEndpoint, PathBuf)>, + registration: &Registration, + watch: &SidecarWatch, + phase: Phase, + reason: &str, + elapsed_ms: u64, +) -> String { + let mut lines = vec![ + format!( + "OpenCodex desktop {} on {}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS + ), + format!("state: {}", phase.id()), + format!("reason: {reason}"), + format!("elapsed: {elapsed_ms}ms"), + ]; + match target { + Some((endpoint, home)) => { + lines.push(format!("endpoint: {}", endpoint.url(""))); + lines.push(format!("home: {}", home.display())); + } + None => lines.push("endpoint: not resolved".to_owned()), + } + lines.push(format!("start at login: {}", registration.login.describe())); + lines.push(format!("runtime ownership: {}", registration.identity)); + lines.push(match watch.exit() { + Some(exit) => format!("runtime process: {}", exit.describe()), + None => "runtime process: still running or never started".to_owned(), + }); + let output = watch.lines(); + if output.is_empty() { + lines.push("runtime output: none".to_owned()); + } else { + lines.push("runtime output:".to_owned()); + lines.extend(output.into_iter().map(|line| format!(" {line}"))); + } + lines.join("\n") +} + +fn report(app: &AppHandle, started: Instant, phase: Phase, detail: Option) { + let mut progress = Progress::new(phase, elapsed(started)); + progress.detail = detail; + emit(app, progress, None); +} + +fn emit(app: &AppHandle, mut progress: Progress, failed_in: Option) { + if let Some(startup) = app.try_state::() { + startup.publish(&mut progress, failed_in); + } + let _ = app.emit(PHASE_EVENT, progress); +} + +fn elapsed(started: Instant) -> u64 { + started.elapsed().as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::{shows_window, LaunchOrigin, Phase, AUTOSTART_FLAG, DEADLINE, PHASES, POLL}; + use crate::tray_availability::TrayAvailability; + use tokio::time::Duration; + + #[test] + fn only_the_autostart_argument_marks_a_login_launch() { + let user = ["/Applications/OpenCodex.app".to_owned()]; + assert_eq!( + LaunchOrigin::from_args(user.into_iter()), + LaunchOrigin::User + ); + let login = [ + "/Applications/OpenCodex.app".to_owned(), + AUTOSTART_FLAG.to_owned(), + ]; + assert_eq!( + LaunchOrigin::from_args(login.into_iter()), + LaunchOrigin::Autostart + ); + } + + #[test] + fn a_manual_launch_always_shows_the_window() { + assert!(shows_window( + LaunchOrigin::User, + TrayAvailability::Available + )); + assert!(shows_window( + LaunchOrigin::User, + TrayAvailability::Unavailable + )); + } + + #[test] + fn a_login_launch_hides_only_where_there_is_a_tray_to_hide_in() { + assert!(!shows_window( + LaunchOrigin::Autostart, + TrayAvailability::Available + )); + assert!(shows_window( + LaunchOrigin::Autostart, + TrayAvailability::Unavailable + )); + } + + #[test] + fn registration_runs_before_the_runtime_is_touched() { + let order: Vec<&str> = PHASES.iter().map(|phase| phase.id()).collect(); + let registering = order.iter().position(|id| *id == "registering").unwrap(); + for later in ["resolving", "probing", "starting", "waiting"] { + assert!(registering < order.iter().position(|id| *id == later).unwrap()); + } + } + + #[test] + fn every_phase_has_a_distinct_identifier_and_a_label() { + let mut ids: Vec<&str> = PHASES.iter().map(|phase| phase.id()).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), PHASES.len()); + assert!(PHASES.iter().all(|phase| !phase.label().is_empty())); + assert_eq!(PHASES.iter().filter(|phase| phase.is_terminal()).count(), 2); + assert!(PHASES.contains(&Phase::Ready)); + } + + #[test] + fn the_whole_sequence_is_bounded_well_under_the_minute_it_used_to_take() { + let budgets = [DEADLINE, POLL]; + assert!(budgets + .iter() + .all(|budget| *budget <= Duration::from_secs(45))); + assert!(POLL < DEADLINE); + } +} diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index 5e5e818b182..57923e19340 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -1,4 +1,9 @@ -use crate::{formatting, proxy::ProxyClient, updater, widget, window}; +use crate::{ + exit::{self, ExitReason}, + formatting, + proxy::ProxyClient, + updater, widget, window, +}; use serde_json::Value; use std::sync::{ atomic::{AtomicBool, Ordering}, @@ -13,13 +18,15 @@ use tauri_plugin_autostart::ManagerExt; use tauri_plugin_opener::OpenerExt; pub struct TrayState { - pub menu: Mutex>, + pub menu: Mutex>, pub installing: AtomicBool, } -pub struct UpdateMenu { +#[derive(Clone)] +pub struct TrayMenu { check_updates: MenuItem, install_update: MenuItem, + stop: MenuItem, } impl Default for TrayState { @@ -31,7 +38,11 @@ impl Default for TrayState { } } -pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { +/// Build the tray. +/// +/// The proxy is not passed in. The tray is installed before a runtime has been resolved, so every +/// use reads the current client from the app instead of holding one that might not exist yet. +pub fn install(app: &AppHandle) -> tauri::Result<()> { let open = MenuItem::with_id(app, "open-dashboard", "Open Dashboard", true, None::<&str>)?; let browser = MenuItem::with_id(app, "open-browser", "Open in Browser", true, None::<&str>)?; let login = CheckMenuItem::with_id( @@ -42,12 +53,12 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { app.autolaunch().is_enabled().unwrap_or(false), None::<&str>, )?; - let spawned_by_us = app - .state::() - .spawned_by_us - .load(Ordering::Relaxed); - let stop = MenuItem::with_id(app, "stop-proxy", "Stop proxy", spawned_by_us, None::<&str>)?; - let stop_item = stop.clone(); + // The tray is built before the startup sequence has decided anything, so nothing owns a + // runtime yet. Ownership arrives later and reaches this item through [`set_owned`]. + let owned = app + .try_state::() + .is_some_and(|state| state.owns_runtime()); + let stop = MenuItem::with_id(app, "stop-proxy", "Stop proxy", owned, None::<&str>)?; let check_updates = MenuItem::with_id( app, "check-updates", @@ -74,9 +85,10 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { ], )?; if let Ok(mut state) = app.state::().menu.lock() { - *state = Some(UpdateMenu { + *state = Some(TrayMenu { check_updates: check_updates.clone(), install_update: install_update.clone(), + stop: stop.clone(), }); } @@ -103,7 +115,13 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { } } "open-browser" => { - let endpoint = app.state::().proxy.endpoint(); + let Some(endpoint) = app + .state::() + .proxy() + .map(|proxy| proxy.endpoint()) + else { + return; + }; let _ = app .opener() .open_url(format!("{}#/usage", endpoint.url("/")), None::); @@ -117,31 +135,9 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { } } "stop-proxy" => { - if app - .state::() - .spawned_by_us - .load(Ordering::Relaxed) - { - let proxy = app.state::().proxy.clone(); - let app = app.clone(); - let stop_item = stop_item.clone(); - tauri::async_runtime::spawn(async move { - let mut stopped = proxy.stop().await.is_ok(); - for _ in 0..10 { - if stopped || proxy.is_alive().await.is_err() { - stopped = true; - break; - } - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - } - if stopped { - app.state::().shutdown_child(); - let _ = stop_item.set_enabled(false); - } else { - eprintln!("tray: proxy still answering /healthz after stop request"); - } - }); - } + // Through the coordinator, not beside it: Stop pressed twice, Stop then Quit, and + // Stop during an update all have to be one execution over one child. + exit::request_stop(app); } "check-updates" => { let app = app.clone(); @@ -175,18 +171,26 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { } }); } - "quit" => app.exit(0), + // The only gesture that ends the app. It does not call `exit` itself: the coordinator + // holds the exit, drains an app-owned runtime and only then lets the process end. + "quit" => exit::request(app, ExitReason::UserQuit), _ => {} }) .build(app)?; - refresh_title(&tray, &proxy); - widget::refresh(&proxy); + refresh(app, &tray); let tray = tray.clone(); + let app = app.clone(); tauri::async_runtime::spawn(async move { let mut tick = 0; loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; + let Some(proxy) = app + .try_state::() + .and_then(|state| state.proxy()) + else { + continue; + }; refresh_title(&tray, &proxy); tick += 1; if tick % 5 == 0 { @@ -197,30 +201,52 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> { Ok(()) } +fn refresh(app: &AppHandle, tray: &tauri::tray::TrayIcon) { + let Some(proxy) = app + .try_state::() + .and_then(|state| state.proxy()) + else { + return; + }; + refresh_title(tray, &proxy); + widget::refresh(&proxy); +} + +/// Take a copy of the menu handles, holding the lock only for the copy. +/// +/// Every Tauri menu setter dispatches to the main thread and waits for it. The tray is built *on* +/// the main thread and takes this same mutex while doing so, so calling a setter with the lock held +/// is a cycle: a background update owns the mutex and waits for the main thread, and the main +/// thread waits for the mutex. The app would stop answering Quit. +fn menu_handles(app: &AppHandle) -> Option { + let state = app.try_state::()?; + let handles = state.menu.lock().ok()?; + handles.as_ref().cloned() +} + +/// Reflect who owns the runtime in the tray's Stop item. +pub fn set_owned(app: &AppHandle, owned: bool) { + if let Some(menu) = menu_handles(app) { + let _ = menu.stop.set_enabled(owned); + } +} + pub fn show_update_available(app: &AppHandle, version: &str) { - if let Some(state) = app.try_state::() { - if let Ok(menu) = state.menu.lock() { - if let Some(menu) = menu.as_ref() { - let _ = menu.install_update.set_text(updater::update_label(version)); - let _ = menu.install_update.set_enabled(true); - let _ = menu.check_updates.set_enabled(true); - let _ = menu.check_updates.set_text("Check for Updates…"); - } - } + if let Some(menu) = menu_handles(app) { + let _ = menu.install_update.set_text(updater::update_label(version)); + let _ = menu.install_update.set_enabled(true); + let _ = menu.check_updates.set_enabled(true); + let _ = menu.check_updates.set_text("Check for Updates…"); } } pub fn show_up_to_date(app: &AppHandle) { - if let Some(state) = app.try_state::() { - if let Ok(menu) = state.menu.lock() { - if let Some(menu) = menu.as_ref() { - let _ = menu - .check_updates - .set_text(format!("Up to date (v{})", env!("CARGO_PKG_VERSION"))); - let _ = menu.check_updates.set_enabled(true); - let _ = menu.install_update.set_enabled(false); - } - } + if let Some(menu) = menu_handles(app) { + let _ = menu + .check_updates + .set_text(format!("Up to date (v{})", env!("CARGO_PKG_VERSION"))); + let _ = menu.check_updates.set_enabled(true); + let _ = menu.install_update.set_enabled(false); } } @@ -232,15 +258,13 @@ pub fn is_installing(app: &AppHandle) -> bool { fn set_installing(app: &AppHandle, version: &str) { if let Some(state) = app.try_state::() { state.installing.store(true, Ordering::Release); - if let Ok(menu) = state.menu.lock() { - if let Some(menu) = menu.as_ref() { - let _ = menu - .install_update - .set_text(format!("Installing update v{version}…")); - let _ = menu.install_update.set_enabled(false); - let _ = menu.check_updates.set_enabled(false); - } - } + } + if let Some(menu) = menu_handles(app) { + let _ = menu + .install_update + .set_text(format!("Installing update v{version}…")); + let _ = menu.install_update.set_enabled(false); + let _ = menu.check_updates.set_enabled(false); } } diff --git a/desktop/src-tauri/src/tray_availability.rs b/desktop/src-tauri/src/tray_availability.rs new file mode 100644 index 00000000000..404c5b75e34 --- /dev/null +++ b/desktop/src-tauri/src/tray_availability.rs @@ -0,0 +1,131 @@ +//! Whether this session actually has a tray, as opposed to a tray backend that accepts an icon. +//! +//! `TrayIconBuilder::build` returning `Ok` proves nothing on Linux. The pinned backend creates an +//! AppIndicator and reports success without checking that anything will display it, so on stock +//! GNOME — which ships no AppIndicator extension — construction succeeds and no icon ever appears. +//! The shell's macOS-shaped assumptions then compound it: the window was created hidden and close +//! always hid, which leaves a running process with no way back in. +//! +//! So the question is asked of the session bus. Not whether the watcher exists — a watcher with no +//! host attached still accepts registrations and still draws nothing — but whether it reports a +//! host registered, which is the StatusNotifier specification's own answer to "is there somewhere +//! for an icon to appear". macOS and Windows have a status area that is always present and answer +//! without a probe. + +/// The result of asking whether this session can display a tray icon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrayAvailability { + /// There is somewhere for the icon to appear, so hiding to the tray is a real place to hide. + Available, + /// There is not. The window is shown on launch and closing it ends the app through the drain. + Unavailable, +} + +impl TrayAvailability { + pub fn is_available(self) -> bool { + matches!(self, Self::Available) + } + + /// Whether a window close or a platform quit gesture may hide the app instead of ending it. + pub fn hides_to_tray(self) -> bool { + self.is_available() + } + + /// What to assume before the probe has answered. + /// + /// The probe is asynchronous, and a window close can land before it returns. Assuming a tray + /// that turns out not to exist is the failure this whole module is about, so the platforms that + /// need a probe assume nothing until they have one. + pub fn assumed() -> Self { + if cfg!(target_os = "linux") { + Self::Unavailable + } else { + Self::Available + } + } +} + +/// The StatusNotifier watcher, and the property that says a host is attached to it. +#[cfg(target_os = "linux")] +pub const WATCHER_NAME: &str = "org.kde.StatusNotifierWatcher"; +#[cfg(target_os = "linux")] +pub const WATCHER_PATH: &str = "/StatusNotifierWatcher"; +#[cfg(target_os = "linux")] +pub const HOST_REGISTERED: &str = "IsStatusNotifierHostRegistered"; + +/// How long the session-bus probe may take. +#[cfg(target_os = "linux")] +const PROBE_TIMEOUT_MS: u64 = 750; + +/// Read a host-registered answer as an availability verdict. +/// +/// `None` means the question could not be asked at all — no session bus, no watcher on it, no +/// reply, a malformed one. That is deliberately folded into the same answer as a watcher with no +/// host, because the two are indistinguishable from here and the safe response to both is +/// identical: show the window and let close mean close. Guessing the other way strands the user. +pub fn from_host_registered(registered: Option) -> TrayAvailability { + match registered { + Some(true) => TrayAvailability::Available, + Some(false) | None => TrayAvailability::Unavailable, + } +} + +#[cfg(not(target_os = "linux"))] +pub fn detect() -> TrayAvailability { + // macOS and Windows both have a status area that is always there, so the answer is known + // without asking anything. It still goes through the same reading so there is one place where + // an availability verdict is produced. + from_host_registered(Some(true)) +} + +#[cfg(target_os = "linux")] +pub fn detect() -> TrayAvailability { + from_host_registered(host_registered()) +} + +#[cfg(target_os = "linux")] +fn host_registered() -> Option { + use dbus::blocking::{stdintf::org_freedesktop_dbus::Properties, Connection}; + use std::time::Duration; + + let connection = Connection::new_session().ok()?; + let watcher = connection.with_proxy( + WATCHER_NAME, + WATCHER_PATH, + Duration::from_millis(PROBE_TIMEOUT_MS), + ); + // A watcher nobody owns makes this call fail rather than answer, which is the same verdict. + watcher.get(WATCHER_NAME, HOST_REGISTERED).ok() +} + +#[cfg(test)] +mod tests { + use super::{from_host_registered, TrayAvailability}; + + #[test] + fn only_a_registered_host_is_an_available_tray() { + assert_eq!( + from_host_registered(Some(true)), + TrayAvailability::Available + ); + assert_eq!( + from_host_registered(Some(false)), + TrayAvailability::Unavailable + ); + assert_eq!(from_host_registered(None), TrayAvailability::Unavailable); + } + + #[test] + fn hiding_is_only_offered_where_the_icon_would_be_drawn() { + assert!(TrayAvailability::Available.hides_to_tray()); + assert!(!TrayAvailability::Unavailable.hides_to_tray()); + } + + #[test] + fn nothing_is_assumed_on_the_platform_that_needs_a_probe() { + assert_eq!( + TrayAvailability::assumed().is_available(), + !cfg!(target_os = "linux") + ); + } +} diff --git a/desktop/src-tauri/src/updater.rs b/desktop/src-tauri/src/updater.rs index 57086bd9193..456ffe76c5c 100644 --- a/desktop/src-tauri/src/updater.rs +++ b/desktop/src-tauri/src/updater.rs @@ -1,4 +1,4 @@ -use crate::{logging, tray}; +use crate::{exit::RestartReadiness, logging, tray}; use std::sync::Mutex; use tauri::{AppHandle, Manager}; use tauri_plugin_updater::{Update, UpdaterExt}; @@ -48,11 +48,30 @@ pub async fn check(app: &AppHandle) -> Result, String> { } pub async fn install(app: &AppHandle, update: Update) -> Result<(), String> { - update - .download_and_install(|_, _| {}, || {}) + // Download and verify first, and separately from installing. The pinned updater checks the + // release signature inside `download`, so these bytes are the ones the key signed; nothing has + // been replaced yet, and a failure here costs only the download. + let package = update + .download(|_, _| {}, || {}) .await .map_err(|error| error.to_string())?; - app.restart(); + + // Then stop the runtime, and confirm it stopped, *before* anything is replaced. Asking for the + // restart after `install` is the shape that does not work: the pinned Windows installer hands off + // to the installer process and ends this one, so the call after it is never reached and the + // update would replace files under a runtime that is still serving. R2 still holds — this is a + // coordinated restart and not a quit — but the coordination has to finish first. + let readiness = crate::exit::prepare_restart(app).await; + if readiness != RestartReadiness::Ready { + return Err(format!( + "the update was downloaded but not installed: {}", + readiness.describe() + )); + } + + update.install(package).map_err(|error| error.to_string())?; + // Only reached where the installer returns. On Windows it does not. + crate::exit::complete_restart(app) } pub fn update_label(version: &str) -> String { diff --git a/desktop/src-tauri/src/widget.rs b/desktop/src-tauri/src/widget.rs index 4db3005f034..a2e38cfc78e 100644 --- a/desktop/src-tauri/src/widget.rs +++ b/desktop/src-tauri/src/widget.rs @@ -68,6 +68,8 @@ mod macos { Unauthorized, Http, Decode, + /// The port answered, but as something other than the runtime this shell is bound to. + Foreign, } fn state_for_error( @@ -85,6 +87,18 @@ mod macos { "Needs API key", Some("This proxy requires an API key.".into()), ), + // A runtime this app did not start is a different event from a fault, so it does not + // borrow the vocabulary of one. "degraded" would claim the proxy is misbehaving and + // "unreachable" would claim nothing is there; a user who started the runtime from npm + // or the CLI themselves would read either as a defect in a setup that is working. + // The widget has no red for this: `tone` in `app/Sources/OpenCodexWidget/Views.swift` + // maps a state it does not know to the neutral secondary colour, which is the right + // signal for "serving, just not ours". + ErrorKind::Foreign => ( + "foreign", + "External runtime", + Some("This port is served by a runtime this app did not start.".into()), + ), ErrorKind::Http | ErrorKind::Decode => ("degraded", "Degraded", detail), } } @@ -95,6 +109,7 @@ mod macos { ProxyError::Unauthorized => (ErrorKind::Unauthorized, None), ProxyError::Http(status) => (ErrorKind::Http, Some(format!("HTTP {status}"))), ProxyError::Decode(error) => (ErrorKind::Decode, Some(error.to_string())), + ProxyError::Foreign => (ErrorKind::Foreign, None), } } @@ -434,7 +449,7 @@ mod macos { } #[test] - fn error_state_mapping_covers_four_kinds() { + fn error_state_mapping_covers_every_kind() { assert_eq!( state_for_error(ErrorKind::Unreachable, None).0, "unreachable" @@ -451,6 +466,19 @@ mod macos { state_for_error(ErrorKind::Decode, Some("bad".into())).0, "degraded" ); + assert_eq!(state_for_error(ErrorKind::Foreign, None).0, "foreign"); + } + + #[test] + fn a_foreign_runtime_is_not_reported_as_a_failure() { + // The mapping is the whole point of the variant. Folding it into either neighbour + // tells a user whose own CLI or npm runtime holds the port that something is broken, + // and the widget is the one surface where that claim is read without any context. + assert_eq!(proxy_error(&ProxyError::Foreign).0, ErrorKind::Foreign); + let (state, title, detail) = state_for_error(ErrorKind::Foreign, None); + assert_eq!(state, "foreign"); + assert_eq!(title, "External runtime"); + assert!(detail.unwrap().contains("did not start")); } #[test] diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index 10a1425dec1..81969f7189e 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -1,4 +1,4 @@ -use crate::{auth::Auth, discovery::ProxyEndpoint}; +use crate::{auth::Auth, exit, AppState}; use tauri::{AppHandle, Manager, Url, WebviewWindow, WindowEvent}; pub fn webview_user_agent() -> String { @@ -12,40 +12,66 @@ pub fn webview_user_agent() -> String { format!("{platform} {}", Auth::user_agent()) } +/// Decide what closing this window means, at the moment it is closed. +/// +/// The answer is not known when the window is built: on Linux it depends on a session-bus probe +/// that the startup sequence runs afterwards. So it is read here rather than captured. With a tray +/// a close hides and the runtime keeps serving; without one there is nowhere to hide, so D6 makes +/// the close a quit — and it takes the same graceful drain the tray's Quit does. pub fn configure(window: &WebviewWindow) { let window_for_close = window.clone(); window.on_window_event(move |event| { if let WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); - let _ = window_for_close.hide(); - apply_tray_policy(window_for_close.app_handle(), false); + exit::gesture(window_for_close.app_handle()); } }); } -pub fn navigation_allowed(endpoint: ProxyEndpoint) -> impl Fn(&Url) -> bool { +/// Where this window may navigate. +/// +/// The loopback endpoint is read from the app rather than captured, because the window now exists +/// before anything has been resolved. Until it has, an http target is refused outright instead of +/// being handed to the browser: nothing should be navigating anywhere yet, and opening an +/// unresolved address in the user's browser is a worse answer than doing nothing. +pub fn navigation_allowed(app: AppHandle) -> impl Fn(&Url) -> bool { move |url| { if is_app_origin(url) { return true; } - if url.scheme() == "http" && url.host_str() == Some(endpoint.host) { - return url.port_or_known_default() == Some(endpoint.port); + if url.scheme() == "about" && url.as_str() == "about:blank" { + return true; } - if matches!(url.scheme(), "http" | "https") { - let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>); - return false; + let endpoint = app + .try_state::() + .and_then(|state| state.proxy()) + .map(|proxy| proxy.endpoint()); + if let Some(endpoint) = endpoint { + if url.scheme() == "http" && url.host_str() == Some(endpoint.host) { + return url.port_or_known_default() == Some(endpoint.port); + } + if matches!(url.scheme(), "http" | "https") { + let _ = tauri_plugin_opener::open_url(url.as_str(), None::<&str>); + } } - url.scheme() == "about" && url.as_str() == "about:blank" + false } } -/// The bundled `frontendDist` origin. Tauri serves it as `tauri://localhost` on macOS and -/// Linux, and as `http://tauri.localhost` on Windows, where WebView2 has no custom-scheme -/// support. +/// The bundled `frontendDist` origin. +/// +/// Tauri serves it as `tauri://localhost` on macOS and Linux, and as `http://tauri.localhost` on +/// Windows, where WebView2 has no custom-scheme support. Without that second spelling the window's +/// first navigation to its own page on Windows falls through to the branch that hands a URL to the +/// external browser. +/// +/// It is that one host and nothing near it. `https` is not the scheme the pinned Tauri serves the +/// app over, and a port means something else is answering rather than the app — neither localhost +/// generally, nor a name that merely ends in it, is this origin. fn is_app_origin(url: &Url) -> bool { match url.scheme() { "tauri" => true, - "http" => url.host_str() == Some("tauri.localhost"), + "http" => url.host_str() == Some("tauri.localhost") && url.port().is_none(), _ => false, } } @@ -81,29 +107,41 @@ pub fn set_tray_policy(app: &AppHandle, visible: bool) { #[cfg(test)] mod tests { - use super::{is_app_origin, navigation_allowed, webview_user_agent}; - use crate::discovery::ProxyEndpoint; + use super::{is_app_origin, webview_user_agent}; use tauri::Url; + fn url(value: &str) -> Url { + Url::parse(value).expect("a url") + } + #[test] - fn navigation_allows_the_app_origin_on_every_platform() { - let allowed = navigation_allowed(ProxyEndpoint { - host: "127.0.0.1", - port: 10100, - }); - assert!(allowed( - &Url::parse("tauri://localhost/index.html?port=10100").unwrap() - )); - assert!(allowed( - &Url::parse("http://tauri.localhost/index.html?port=10100").unwrap() - )); - assert!(allowed( - &Url::parse("http://127.0.0.1:10100/#/usage").unwrap() - )); - assert!(!is_app_origin( - &Url::parse("https://tauri.localhost/index.html").unwrap() - )); - assert!(!allowed(&Url::parse("file:///C:/index.html").unwrap())); + fn the_app_origin_is_allowed_by_both_spellings_on_every_platform() { + // The custom scheme everywhere, and the http spelling WebView2 needs on Windows. The + // second is not gated on the platform: the origin is the app's wherever it is served. + assert!(is_app_origin(&url( + "tauri://localhost/index.html?port=10100" + ))); + assert!(is_app_origin(&url( + "http://tauri.localhost/index.html?port=10100" + ))); + } + + #[test] + fn nothing_near_that_origin_is_that_origin() { + for value in [ + // Not the scheme the pinned Tauri serves the app over. + "https://tauri.localhost/index.html", + // A port means something else is answering. + "http://tauri.localhost:8080/", + // Neither localhost generally nor a name that merely contains it. + "http://localhost/", + "http://127.0.0.1/", + "http://evil.tauri.localhost/", + "http://tauri.localhost.example.com/", + "file:///C:/index.html", + ] { + assert!(!is_app_origin(&url(value)), "{value}"); + } } #[test] diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 6af54b5e1c2..83b2dc18886 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -8,6 +8,7 @@ "devUrl": "http://localhost:1420" }, "app": { + "withGlobalTauri": true, "security": { "csp": "default-src 'self'; connect-src 'self' http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; script-src 'self'" } diff --git a/desktop/ui/index.html b/desktop/ui/index.html index e4d5c9a7de0..0a10a6e4534 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -7,22 +7,49 @@

OpenCodex

-

Connecting to the OpenCodex proxy…

- +

Starting OpenCodex…

+

+
    +
    diff --git a/desktop/ui/main.js b/desktop/ui/main.js index 85c4ea87326..3f4516bda1a 100644 --- a/desktop/ui/main.js +++ b/desktop/ui/main.js @@ -1,34 +1,134 @@ -const params = new URLSearchParams(window.location.search); -const port = Number(params.get("port") || "10100"); -const origin = `http://127.0.0.1:${port}`; -const dashboardUrl = `${origin}/#/usage`; -const status = document.querySelector("#status"); +// The bootstrap page is the startup surface. It does not probe anything itself: the shell owns the +// sequence, its deadline and its diagnostic, and this page renders what it is told. The phase list +// is asked for rather than written here, so a state added in the shell appears without a second +// edit — and one removed cannot leave a row behind. +// +// What each row shows comes from the shell too, including the states already finished. Rebuilding +// that history from events would be wrong: the first states finish in milliseconds, so a page whose +// listener attached a moment late would show a run in progress with nothing behind it. +// +// Nothing here uses alert, confirm or prompt. The embedded webview implements none of the +// WKUIDelegate panel methods on macOS, so a platform dialog is silently declined and the user sees +// nothing at all. Every message this page has goes into the page — including its own failures, +// because a surface that cannot report is the problem this file exists to fix. + +const bridge = window.__TAURI__; +const invoke = bridge && bridge.core && bridge.core.invoke; +const listen = bridge && bridge.event && bridge.event.listen; + +const headline = document.querySelector("#headline"); +const detail = document.querySelector("#detail"); +const phaseList = document.querySelector("#phases"); +const failure = document.querySelector("#failure"); const retry = document.querySelector("#retry"); -let checking = false; +const copy = document.querySelector("#copy"); +const copyState = document.querySelector("#copyState"); +const diagnostic = document.querySelector("#diagnostic"); -async function check() { - if (checking) return; - checking = true; - status.textContent = `Connecting to OpenCodex proxy at 127.0.0.1:${port}…`; - retry.disabled = true; - try { - const response = await fetch(`${origin}/healthz`, { - cache: "no-store", - }); - if (response.ok) { - status.textContent = "Proxy is ready. Loading dashboard…"; - window.location.replace(dashboardUrl); - return; +const MARKS = { done: "✓", failed: "✕", active: "…", pending: "·" }; + +let phases = []; + +function render(progress) { + const completed = new Set((progress && progress.completed) || []); + const failedPhase = (progress && progress.failedPhase) || null; + const current = progress && progress.phase; + phaseList.replaceChildren(); + for (const phase of phases) { + let state = "pending"; + if (phase.id === failedPhase) { + state = "failed"; + } else if (phase.id === current) { + state = "active"; + } else if (completed.has(phase.id)) { + state = "done"; } - throw new Error(`HTTP ${response.status}`); + const row = document.createElement("li"); + row.dataset.state = state; + const mark = document.createElement("span"); + mark.className = "mark"; + mark.textContent = MARKS[state]; + const label = document.createElement("span"); + label.textContent = phase.label; + row.append(mark, label); + phaseList.append(row); + } +} + +function apply(progress) { + if (!progress) return; + headline.textContent = progress.label; + detail.textContent = progress.detail || ""; + const failed = progress.phase === "failed"; + failure.hidden = !failed; + retry.disabled = !progress.canRetry; + if (failed) { + diagnostic.value = progress.diagnostic || ""; + copyState.textContent = ""; + } + render(progress); +} + +function reportPageFailure(message, error) { + const cause = error && error.message ? error.message : String(error); + headline.textContent = "OpenCodex could not read its own startup state."; + detail.textContent = message; + failure.hidden = false; + retry.disabled = false; + diagnostic.value = [message, cause].join("\n"); +} + +async function copyDiagnostic() { + const text = diagnostic.value; + if (!text) return; + try { + await navigator.clipboard.writeText(text); + copyState.textContent = "Copied to the clipboard."; + return; + } catch { + // A webview without clipboard access is the reason the text is on screen in the first place. + } + diagnostic.focus(); + diagnostic.select(); + let copied = false; + try { + copied = document.execCommand("copy"); } catch { - status.textContent = "The proxy is not reachable yet."; - } finally { - checking = false; - retry.disabled = false; + copied = false; + } + copyState.textContent = copied + ? "Copied to the clipboard." + : "The text above is selected — copy it with your keyboard."; +} + +retry.addEventListener("click", async () => { + if (!invoke) return; + copyState.textContent = ""; + retry.disabled = true; + try { + await invoke("retry_startup"); + } catch (error) { + reportPageFailure("The retry could not be sent to the shell.", error); + } +}); +copy.addEventListener("click", copyDiagnostic); + +async function start() { + if (!invoke || !listen) { + headline.textContent = "This page is the OpenCodex desktop shell's startup surface."; + detail.textContent = "Open it from the OpenCodex app."; + return; + } + try { + phases = (await invoke("startup_phases")).filter((phase) => !phase.terminal); + render(null); + // The listener goes on before the snapshot is read, so a transition landing between the two is + // delivered rather than lost. + await listen("startup-phase", (event) => apply(event.payload)); + apply(await invoke("startup_snapshot")); + } catch (error) { + reportPageFailure("The startup surface could not reach the shell.", error); } } -retry.addEventListener("click", check); -check(); -setInterval(check, 1500); +start(); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 543c53a5773..41daa0cd488 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -720,9 +720,15 @@ "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", + "desktop-cli-contracts.test.ts": "clients", + "desktop-exit-ownership.test.ts": "clients", + "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-startup-surface.test.ts": "clients", + "desktop-tray-availability.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", + "desktop-runtime-identity.test.ts": "clients", "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers", diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index cb5768a0c92..29408205c78 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -5,11 +5,141 @@ discovers the loopback proxy, lazily retries management authentication, starts the bundled `ocx` sidecar only when the configured endpoint is unreachable, and owns the tray, autostart, single-instance, and window lifecycle behavior. -`desktop/ui/` is only a short bootstrap page. Once `/healthz` answers, the shell -navigates the webview to the proxy's loopback dashboard -(`/#/usage`) rather than bundling or serving `gui/dist` itself. -Only the bootstrap page has Tauri IPC capability; the loopback dashboard never -does because `dangerousRemoteDomainIpcAccess` is not configured. +`desktop/ui/` is the startup surface. Once the runtime reports healthy the shell navigates the +webview to the proxy's loopback dashboard (`/#/usage`) rather than bundling or serving `gui/dist` +itself. The page renders what the shell tells it and probes nothing on its own; it asks +`startup_phases` for the state list rather than restating it, takes the current state from +`startup_snapshot` on load because the first states finish in milliseconds, and then follows the +`startup-phase` event. It uses no `alert`, `confirm` or `prompt`: the embedded webview implements +none of the matching WKUIDelegate panel methods on macOS, so a platform dialog is declined without +drawing anything. +`withGlobalTauri` is on so that page can invoke without a bundler. Only the local app origin +carries a capability, so the loopback dashboard reaches no command: `capabilities/default.json` +declares no `remote` entry, and Tauri checks the ACL for any invoke from a non-local origin. + +## Startup, quit and the tray + +The window is created and shown before anything is registered, resolved, probed or started, and +`desktop/src-tauri/src/startup.rs` runs the whole sequence inside it as named states — +registering, resolving, probing, attaching or starting, waiting, then ready or failed — under one +30-second deadline. Every call beneath that deadline is bounded by the time left rather than by its +own timeout, so the ceiling is the ceiling. The failure state carries a retry, the +child's exit code and a copyable diagnostic naming the state, the endpoint, the configuration home +and the runtime's last output; `desktop/src-tauri/src/sidecar.rs` consumes the spawn event stream +into that record instead of discarding it, which is what makes an immediate sidecar exit +distinguishable from a slow start. The page asks for the state list and the run's progress rather +than reconstructing either, because the early states finish faster than a listener can attach. + +The shell resolves nothing itself. Resolving runs the bundled `ocx resolve --json` and reads one +`ocx-resolve/1` document: the configuration home, the effective port, and a liveness verdict with +three answers rather than two. `live` means attach as a guest; `absent-proven` means every +recorded and configured endpoint was definitively dead, and **only that authorises starting a +runtime**. Everything else is unknown — a non-zero exit, a timeout, output that will not parse, a +schema this shell does not know, a missing binary — and unknown fails the state with a diagnostic +and a retry. It is never read as absence, because that reading is what put a second proxy next to +the one already running. This replaces a file that read `runtime-port.json`, fell back to 10100 and +started there, so a user with a configured `config.port` was started on a port they had not +chosen; the probe budgets that decision needs live in the CLI, where they were tuned. + +Registering runs first, before the runtime is touched. A login launch starts hidden, so a tray +installed only after a successful start would leave a failed start with no window and no icon. The +login item is registered in that state too, before the tray, so its Start at Login checkbox reads +the state first run leaves behind. A launch carrying the `--autostart` argument that the login +item passes back is the only one that starts hidden, and only where there is a tray to hide in: a +manual launch shows its window before the sequence begins, a login launch after the tray verdict. +Registering happens once per process, so a retry re-runs only the runtime half and cannot build a +second tray icon with its own refresh loop. + +`desktop/src-tauri/src/exit.rs` owns what ends the process. Where there is a usable tray, closing +the window and the platform's quit gesture both hide; only the tray's Quit asks to end, and an +installed update asks for a coordinated restart. Where there is no usable tray, closing the window +is the quit. macOS needs one thing beyond the event loop: Tauri's default menu carries a predefined +Quit wired to Cocoa's `terminate:` and the pinned tao raises no cancellable event for it, so +`desktop/src-tauri/src/menu.rs` rebuilds that menu with an ordinary item on the same accelerator. + +Every ending drains first, and so does the tray's Stop, which is not an ending: all of them take the +same phase, so Stop pressed twice, Stop then Quit, and Stop during an update are one execution over +one child rather than several racing. Ownership is re-established at the start of each drain rather +than read off a flag — the pid the endpoint reports has to be the child this app started — because +between the spawn and now the child can have exited and a service can have taken the port back, and +an owner's stop sent to that listener is a stop sent to somebody else's runtime. A listener that +cannot be identified is left alone. + +A runtime counts as gone only when the child reports its own exit or the endpoint refuses a +connection; a timeout or an unauthorized reply is not proof. The stop itself is the bundled +`ocx stop --json`, not a management call from inside this process: the CLI's stop owns the +receipt-backed teardown, the drain, the Windows respawn verification and the client-configuration +restore, and an in-process endpoint cannot own its own teardown because launchd and systemd can +terminate the request handler during self-unload. The shell reads that run's `ocx-stop/1` summary +rather than inferring it, and treats a stop as done only when the CLI reported exit 0 **and** that +no proxy of this home is left running. A service that failed while the proxy happened to stop +satisfies the second and not the first, and it is exactly the case that may respawn the runtime a +moment later. Nothing kills the child. + +A drain that does not complete within `DRAIN_DEADLINE` is **not** recorded as a drain. It becomes +`DrainFailed`, and an unidentifiable runtime becomes `OwnershipUnknown`. A user's quit still +proceeds from either — refusing to close when the user asked is the worse answer, and a standing +runtime is recoverable with `ocx stop`. A coordinated restart does not: coming back onto a runtime +that was never stopped puts the user on the old version while they believe they upgraded. A runtime +this app did not start is never stopped. A quit that arrives while the sequence is starting one is +held: the coordinator reserves the spawn rather than holding its lock across process creation, and +the quit is deferred until the child is owned and then drains it. + +An in-app update downloads and signature-checks the package, confirms who owns the running runtime, +drains it and confirms the child is gone, and only then installs. The order is not cosmetic: the +pinned updater's Windows installer hands off to the installer process and ends this one, so a +restart asked for after `install` is never reached, and the package would be replaced under a +runtime still serving out of those files. A drain that did not complete refuses the install and +leaves the update pending. + +The window may navigate to the `tauri://` scheme, to the loopback endpoint the sequence resolved, +and on Windows to `tauri.localhost`, which is where the pinned Tauri serves the app itself because +wry needs an http origin there. That is the one host and no port — not localhost generally, and not +a widening of what the loopback dashboard may reach. + +`desktop/src-tauri/src/proxy.rs` is the local management client and has its own network policy, +separate from the updater's download client. It refuses redirects and system proxies, and it will +not send the management token until it has confirmed, from the unauthenticated health body, that the +instance answering is the one the shell bound to: the marker, the pid, and the port it addressed. +The binding carries a generation, so a request authorised under an earlier binding is not authorised +after the shell rebinds. + +`desktop/src-tauri/src/tray_availability.rs` asks the session bus whether +`org.kde.StatusNotifierWatcher` reports a host registered; macOS and Windows answer yes without a +probe. Neither construction success nor the watcher's mere existence is the question — the pinned +Linux backend creates an AppIndicator and reports success with no host attached, and a watcher with +no host accepts registrations and draws nothing. Until the probe answers, Linux assumes no tray, so +a window closed in the first moments quits rather than vanishing, and the verdict is published only +once an icon actually exists — a tray that fails to build is a session with no tray, not a claimed +one. Where the answer is no, no tray icon is claimed, the window is shown on launch whatever the +launch origin, and closing it quits through the same drain. The update controls live in the tray +menu, so a session without one checks for updates in the background and has no place to install +them from. + +Every tray menu setter dispatches to the main thread and waits for it, and the tray is built on the +main thread while holding the menu mutex, so the handles are copied out from under that mutex before +any setter is called. Holding it across a setter is a cycle, and the symptom would be an app that +stops answering Quit. + +## Runtime ownership, from the app's side + +`desktop/src-tauri/src/identity.rs` holds this installation's own install id: an opaque value minted +once into the app's config directory and never rewritten, exclusively so two launches racing each +other answer to the same one. It exists because the recorded claim names the owning *installation*, +so the app needs a value of its own to compare against; an id kept only in the shared record would +be whoever wrote it last, and a reinstalled app could not tell its own prior consent from another +installation's. The cost is that a reinstall which keeps the directory keeps its consent and one +that loses it asks again. + +`desktop/src-tauri/src/ownership.rs` mirrors the claim, the three answers a read can give and the +comparison, all of which are defined by +[background-service runtime ownership](runtime.md#background-service-runtime-ownership) and not +here. The shell does not read the record: resolving a claim means reading every state path and +failing closed on an unreadable one, on a corrupt anchor and on paths that disagree, and a second +weaker implementation of a question core already answers is the mistake this tree has made before. +The bundled CLI answers it. Until that contract lands, `resolve` returns *unavailable*, which is +not the same as "nobody owns it" — the question has not been put — so the shell attempts no takeover +and records nothing, and the startup state and the diagnostic say which of the two it is. `desktop/src-tauri/src/first_run.rs` turns Start at Login on once per installation, before the tray is built so its checkbox reads the resulting state. A menu bar app diff --git a/structure/overview.md b/structure/overview.md index 55b6f89b21c..5c226f2dfc0 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -170,6 +170,28 @@ still cover the rule, which is a judgement only review makes. pages are compared against their English source, so a changed default fails a check instead of leaving two documents to disagree; see [`chat-compat.md`](providers/chat-compat.md). Enforced by `tests/ci-workflows/docs-developer-role-policy.test.ts`. +- **INV-DESKTOP-01** — Where the desktop app has a usable tray, only the tray's Quit ends it: + closing the window and the platform's quit gesture hide, which on macOS needs the default menu's + predefined Quit replaced because it raises no cancellable event. Every ending drains first — the + tray's Quit, an update's coordinated restart, and a window close on a session with no tray all + hold the exit, stop the app-owned runtime through the management stop, and treat only an observed + child exit or a refused connection as proof it stopped. The stop is the bundled `ocx stop --json`, + accepted only on exit 0 with the runtime reported down. Ownership is re-established from the pid + the endpoint reports rather than carried in a flag, a drain that does not complete is recorded as + failed rather than drained — which a quit tolerates and a coordinated restart refuses — and an + update installs only after the runtime it is replacing is confirmed stopped. No shell file kills + the child, a runtime this app did not start is never stopped, and a quit that lands while one is + being started or stopped is deferred rather than lost; + see [`desktop-shell.md`](desktop-shell.md). + Enforced by `tests/clients/desktop-exit-ownership.test.ts`. +- **INV-DESKTOP-02** — Tray availability is an answer from the session, not the tray backend's + construction result and not the watcher's mere existence: the shell asks whether + `org.kde.StatusNotifierWatcher` reports a host registered, and reads an unanswerable probe the + same way as an unregistered one. Linux assumes no tray until the probe answers, and the verdict is + published only once an icon exists, so a tray that fails to build is a session without one. Where + there is none, no icon is claimed, the window is shown on launch whatever the launch origin, and + closing it quits through the same drain; see [`desktop-shell.md`](desktop-shell.md). + Enforced by `tests/clients/desktop-tray-availability.test.ts`. CI enumerates that domain layout through `scripts/ci/run-bun-test-batches.sh`. Its default general scope and 12-file/120-second process shape leave the dedicated Linux storage-policy and api-usage diff --git a/tests/clients/desktop-cli-contracts.test.ts b/tests/clients/desktop-cli-contracts.test.ts new file mode 100644 index 00000000000..d4ae062c866 --- /dev/null +++ b/tests/clients/desktop-cli-contracts.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The two CLI surfaces the shell drives, read against the CLI that defines them. + * + * D5 and D4: the shell stops resolving the home, the port and liveness itself, and stops performing + * the teardown itself. `src/cli/resolve.ts` and `src/cli/stop-report.ts` own those answers; the Rust + * side is a reader. Both halves are asserted here together so a schema, a status value or an + * outcome name cannot change on one side and be discovered on a user's machine. + * + * The rule that matters most is the one a reader can get wrong quietly: liveness has three answers, + * and only a proven absence authorises starting a runtime. Everything that can go wrong on the + * shell side has to fold into the third one, because the reading that must never happen is "the + * resolve failed, so nobody must be listening". + */ +const SHELL = "desktop/src-tauri/src"; +const RESOLVE_RS = repoPath(`${SHELL}/resolve.rs`); +const STOP_RS = repoPath(`${SHELL}/runtime_stop.rs`); +const STARTUP = repoPath(`${SHELL}/startup.rs`); +const RESOLVE_TS = repoPath("src/cli/resolve.ts"); +const STOP_TS = repoPath("src/cli/stop-report.ts"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop CLI contracts", () => { + const resolveRs = code(RESOLVE_RS); + const stopRs = code(STOP_RS); + const resolveTs = code(RESOLVE_TS); + const stopTs = code(STOP_TS); + + test("both sides name the same wire versions", () => { + expect(resolveTs).toContain('RESOLVE_SCHEMA = "ocx-resolve/1"'); + expect(resolveRs).toContain('pub const SCHEMA: &str = "ocx-resolve/1"'); + expect(stopTs).toContain('STOP_SUMMARY_SCHEMA = "ocx-stop/1"'); + expect(stopRs).toContain('pub const SCHEMA: &str = "ocx-stop/1"'); + // A document announcing anything else is not understood rather than half-read. + expect(resolveRs).toContain("resolved.schema != SCHEMA"); + expect(stopRs).toContain("summary.schema != SCHEMA"); + }); + + test("liveness keeps its three answers, and only one of them authorises a start", () => { + // Two reach the wire; the third exits 1 before the document is built. + expect(resolveTs).toContain('status: "live" | "absent-proven"'); + expect(resolveRs).toContain('#[serde(rename_all = "kebab-case")]'); + expect(resolveRs).toContain(" Live,"); + expect(resolveRs).toContain(" AbsentProven,"); + const rule = resolveRs.slice(resolveRs.indexOf("pub fn may_start(")); + const body = rule.slice(0, rule.indexOf("\n}")); + expect(body).toContain("Some(Status::AbsentProven)"); + expect(body).not.toContain("Status::Live"); + }); + + test("a live listener this app cannot manage is neither attached to nor started beside", () => { + // Core's liveness predicate accepts a connected client's listener on purpose, so + // duplicate-start avoidance can see it; a caller that needs the management plane has to + // discriminate on the role rather than narrow that predicate. + expect(resolveTs).toContain("role?: string"); + const verdict = resolveRs.slice(resolveRs.indexOf("pub fn live_verdict(")); + const body = verdict.slice(0, verdict.indexOf("\n}")); + expect(body).toContain('role.as_deref() == Some("client")'); + expect(body).toContain("LiveVerdict::Unusable"); + // And an address this shell cannot reach on loopback is the same kind of answer. + expect(body).toContain("loopback_reachable(resolved.liveness.hostname.as_deref())"); + const startup = code(STARTUP); + const unusable = startup.indexOf("resolve::LiveVerdict::Unusable(reason) =>"); + const spawn = startup.indexOf("spawn_runtime(app, endpoint, &watch)"); + expect(unusable).toBeGreaterThan(-1); + expect(startup.slice(unusable, spawn)).toContain("return;"); + }); + + test("everything that can go wrong on this side folds into unknown", () => { + const reader = resolveRs.slice(resolveRs.indexOf("pub fn read("), resolveRs.indexOf("pub async fn run(")); + // A non-zero exit is the CLI's own refusal, including the exit 1 it uses for unknown liveness. + expect(reader).toContain("if exit_code != Some(0) {"); + expect(reader).toContain("Resolution::Unknown"); + const runner = resolveRs.slice(resolveRs.indexOf("pub async fn run(")); + const body = runner.slice(0, runner.indexOf("\n}")); + // A missing binary, a failed spawn and a deadline all answer the same way. + expect(body.match(/Resolution::Unknown/g) || []).toHaveLength(3); + expect(body).toContain("timeout_at(deadline, command.output())"); + }); + + test("the startup sequence refuses to start on anything but a proven absence", () => { + const startup = code(STARTUP); + const run = startup.slice(startup.indexOf("async fn run(app: &AppHandle)")); + const unknown = run.indexOf("let Some(answer) = resolution.resolved() else {"); + const attach = run.indexOf("match resolve::live_verdict(&resolution) {"); + const guard = run.indexOf("if !resolve::may_start(&resolution) {"); + const spawn = run.indexOf("spawn_runtime(app, endpoint, &watch)"); + expect(unknown).toBeGreaterThan(-1); + expect(attach).toBeGreaterThan(unknown); + expect(guard).toBeGreaterThan(attach); + expect(spawn).toBeGreaterThan(guard); + // The unresolved branch fails the state; it does not fall through to a start. + expect(run.slice(unknown, attach)).toContain("Phase::Resolving,"); + expect(run.slice(unknown, attach)).toContain("return;"); + }); + + test("a stop is a success only when the CLI said so twice", () => { + // The process status and the document have to agree, and the document has to say the runtime + // is down: taking the summary's word for its own exit status is taking a claim as its own + // evidence. + expect(stopTs).toContain("ok: signals.exitCode === 0"); + expect(stopTs).toContain("runtimeDown: record.proxy ==="); + const reader = stopRs.slice(stopRs.indexOf("pub fn read("), stopRs.indexOf("pub async fn run(")); + expect(reader).toContain("if exit_code != Some(0)"); + expect(reader).toContain("|| !summary.ok"); + expect(reader).toContain("|| summary.exit_code != 0"); + expect(reader).toContain("|| !summary.runtime_down"); + // And the document has to agree with itself rather than be trusted to. + expect(reader).toContain("|| !agrees"); + expect(reader).toContain("(Outcome::Stopped, Proxy::Stopped)"); + expect(reader).toContain("(Outcome::NotRunning, Proxy::NotRunning)"); + expect(reader).toContain("StopResult::Failed"); + const stopped = reader.indexOf("StopResult::Stopped(summary)"); + expect(stopped).toBeGreaterThan(reader.indexOf("if exit_code != Some(0)")); + }); + + test("the outcomes the shell can be handed are the outcomes the CLI can emit", () => { + expect(stopTs).toContain( + 'outcome: "stopped" | "not-running" | "history-incomplete" | "history-deferred" | "failed"', + ); + // The shell does not re-derive the outcome; it carries the CLI's own words into its diagnostic. + expect(stopRs).toContain("summary.outcome"); + expect(stopRs).toContain("summary.exit_code"); + expect(stopRs).toContain("summary.message"); + expect(stopRs).not.toContain('== "stopped"'); + }); +}); diff --git a/tests/clients/desktop-exit-ownership.test.ts b/tests/clients/desktop-exit-ownership.test.ts new file mode 100644 index 00000000000..27cc075f3fa --- /dev/null +++ b/tests/clients/desktop-exit-ownership.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * INV-DESKTOP-01 — only the tray's Quit ends the app, and nothing ends the runtime by force. + * + * Three gestures used to mean the same thing. Closing the window hid it, but Cmd+Q reached + * `RunEvent::Exit` with no `ExitRequested` handler in between, and that called + * `CommandChild::kill()` — a SIGKILL on Unix — on the runtime this app had started. The CLI's own + * stop restores client configuration, lets in-flight requests finish and clears state files; none + * of that survived a keystroke the user reads as "hide". The updater took the same path. + * + * macOS needs one thing more than the event handler, and it is the part that is easiest to get + * wrong while believing it works: Tauri's default menu carries a predefined Quit wired straight to + * Cocoa's `terminate:`, and the pinned tao raises no cancellable event for it, so `prevent_exit` + * never sees it. The replacement item is therefore part of this contract, not a detail. + * + * The wiring is the contract and it is not visible from behaviour alone — CI builds the shell + * against a zero-byte sidecar and has no session to press Cmd+Q in — so it is read out of the + * source, the way the Start at Login ordering already is. + */ +const SRC = "desktop/src-tauri/src"; +const LIB = repoPath(`${SRC}/lib.rs`); +const EXIT = repoPath(`${SRC}/exit.rs`); +const MENU = repoPath(`${SRC}/menu.rs`); +const TRAY = repoPath(`${SRC}/tray.rs`); +const WINDOW = repoPath(`${SRC}/window.rs`); +const UPDATER = repoPath(`${SRC}/updater.rs`); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); +} + +/** Every Rust file in the shell, walked from disk so a new module cannot opt itself out. */ +function shellSources(directory: string = SRC): string[] { + return readdirSync(repoPath(directory), { withFileTypes: true }).flatMap((entry) => { + const path = `${directory}/${entry.name}`; + if (entry.isDirectory()) return shellSources(path); + return entry.name.endsWith(".rs") ? [path] : []; + }); +} + +describe("desktop exit ownership", () => { + test("the event loop intercepts the exit request instead of letting it through", () => { + const lib = code(LIB); + expect(lib).toContain("RunEvent::ExitRequested"); + expect(lib).toContain("exit::on_exit_requested(app, code, &api)"); + }); + + test("no file in the shell kills the runtime process", () => { + const sources = shellSources(); + expect(sources.length).toBeGreaterThan(10); + for (const source of sources) { + expect(code(repoPath(source))).not.toContain(".kill()"); + } + }); + + test("the tray's Quit asks the coordinator rather than ending the process itself", () => { + const tray = code(TRAY); + expect(tray).toContain('"quit" => exit::request(app, ExitReason::UserQuit)'); + expect(tray).not.toContain("app.exit("); + }); + + test("the macOS menu's Quit is an ordinary item routed through the gesture path", () => { + const menu = code(MENU); + // The predefined item is the one that cannot be held: it calls Cocoa's terminate: directly. + expect(menu).not.toContain("PredefinedMenuItem::quit"); + expect(menu).toContain('Some("CmdOrCtrl+Q")'); + expect(menu).toContain("crate::exit::gesture(app)"); + // Losing the default menu would take the clipboard items with it, and the failure diagnostic + // is a block of text the user is asked to copy. + for (const item of ["cut", "copy", "paste", "select_all"]) { + expect(menu).toContain(`PredefinedMenuItem::${item}`); + } + const lib = code(LIB); + expect(lib).toContain(".menu(menu::build)"); + expect(lib).toContain("menu::on_event(app, event.id().as_ref())"); + }); + + test("a restart is told apart from a quit and keeps its own reason", () => { + const exit = code(EXIT); + expect(exit).toContain("ExitReason::CoordinatedRestart"); + expect(exit).toContain("tauri::RESTART_EXIT_CODE"); + const updater = code(UPDATER); + expect(updater).toContain("crate::exit::prepare_restart(app).await"); + expect(updater).not.toContain("app.restart()"); + }); + + test("an update stops the runtime before it replaces anything", () => { + const updater = code(UPDATER); + const install = updater.slice(updater.indexOf("pub async fn install(")); + const body = install.slice(0, install.indexOf("\n}")); + const downloaded = body.indexOf(".download("); + const prepared = body.indexOf("crate::exit::prepare_restart(app).await"); + const installed = body.indexOf("update.install(package)"); + expect(downloaded).toBeGreaterThan(-1); + expect(prepared).toBeGreaterThan(downloaded); + expect(installed).toBeGreaterThan(prepared); + // The combined call is the shape that cannot drain first. + expect(body).not.toContain("download_and_install"); + // A drain that did not complete refuses the install rather than proceeding. + expect(body.slice(prepared, installed)).toContain("if readiness != RestartReadiness::Ready {"); + expect(body.slice(prepared, installed)).toContain("return Err("); + }); + + test("an update restart drains through the same path a quit does", () => { + const exit = code(EXIT); + const prepare = exit.indexOf("pub async fn prepare_restart"); + expect(prepare).toBeGreaterThan(-1); + const body = exit.slice(prepare, exit.indexOf("pub fn complete_restart", prepare)); + expect(body).toContain("claim_drain(ExitReason::CoordinatedRestart)"); + expect(body).toContain("drain_current(app).await"); + expect(body).toContain("coordinator.finish_drain(verdict)"); + // D4: the stop is the bundled CLI's, which owns the receipt-backed teardown this process + // cannot perform on itself. + expect(exit).toContain("runtime_stop::run(app, deadline).await"); + }); + + test("a failed drain is not recorded as a drain, and a restart refuses it", () => { + const exit = code(EXIT); + const record = exit.slice(exit.indexOf("pub fn finish_drain(")); + const body = record.slice(0, record.indexOf("\n }")); + expect(body).toContain("DrainVerdict::Drained => ExitPhase::Drained"); + expect(body).toContain("DrainVerdict::Failed => ExitPhase::DrainFailed"); + expect(body).toContain("DrainVerdict::OwnershipUnknown => ExitPhase::OwnershipUnknown"); + const rule = exit.slice(exit.indexOf("pub fn decide("), exit.indexOf("struct Inner {")); + expect(rule).toContain("ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => match reason"); + expect(rule).toContain("Some(ExitReason::CoordinatedRestart) => ExitDecision::Refuse"); + // A quit still closes the app on one, which is the trade that is defensible. + expect(rule).toContain("_ => ExitDecision::Proceed"); + // And the refusal is recoverable: the update stayed pending, so the next attempt runs the + // stop again rather than finding the app permanently unable to try. + const claim = exit.slice(exit.indexOf("pub fn claim_drain"), exit.indexOf("pub fn finish_drain")); + expect(claim).toContain("ExitPhase::DrainFailed | ExitPhase::OwnershipUnknown => {"); + expect(claim).toContain("ExitPhase::Draining | ExitPhase::Drained => None,"); + }); + + test("stop, quit and update are one execution over one child", () => { + const tray = code(TRAY); + expect(tray).toContain("exit::request_stop(app)"); + expect(tray).not.toContain("sidecar::drain"); + const exit = code(EXIT); + const stop = exit.slice(exit.indexOf("pub fn request_stop(")); + const body = stop.slice(0, stop.indexOf("\n}")); + expect(body).toContain("coordinator.begin_stop()"); + expect(body).toContain("drain_current(&app).await"); + // A quit that landed during the stop is handed back and run, not dropped. + expect(body).toContain("coordinator.finish_stop()"); + expect(body).toContain("drain_now(&app, reason)"); + }); + + test("the reason and the drain are claimed in one step", () => { + const exit = code(EXIT); + const claim = exit.indexOf("pub fn claim_drain"); + expect(claim).toBeGreaterThan(-1); + const body = exit.slice(claim, exit.indexOf("pub fn finish_drain", claim)); + expect(body.length).toBeGreaterThan(0); + // One match over the phase, so the reason a caller wins and the move out of Idle cannot be + // separated by a second caller arriving between them. + expect(body).toContain("match inner.phase {"); + const idle = body.indexOf("ExitPhase::Idle => {"); + expect(idle).toBeGreaterThan(-1); + const arm = body.slice( + idle, + body.indexOf("ExitPhase::Spawning | ExitPhase::Stopping => {", idle), + ); + expect(arm).toContain("inner.reason.get_or_insert(fallback)"); + expect(arm).toContain("inner.phase = ExitPhase::Draining;"); + // Nothing else in the file moves the phase to draining. + expect(exit.split("inner.phase = ExitPhase::Draining;")).toHaveLength(4); + }); + + test("a quit that lands while a runtime is starting is deferred, not lost", () => { + const exit = code(EXIT); + // The lock is never held across process creation, so the main thread's exit handler cannot + // end up waiting on a spawn; the exit is held by the phase instead. + expect(exit).toContain("ExitPhase::Spawning"); + expect(exit).toContain( + "ExitPhase::Spawning | ExitPhase::Stopping | ExitPhase::Draining => ExitDecision::Wait", + ); + const claim = exit.slice(exit.indexOf("pub fn claim_drain"), exit.indexOf("pub fn finish_drain")); + expect(claim).toContain("ExitPhase::Spawning | ExitPhase::Stopping => {"); + expect(claim).toContain("inner.deferred = true;"); + const finish = exit.slice( + exit.indexOf("fn finish(&self, phase: ExitPhase)"), + exit.indexOf("impl Default for ExitCoordinator"), + ); + expect(finish).toContain("inner.deferred"); + expect(finish).toContain("inner.phase = ExitPhase::Draining;"); + const startup = code(repoPath(`${SRC}/startup.rs`)); + const spawn = startup.slice(startup.indexOf("fn spawn_runtime(")); + expect(spawn).toContain("if !coordinator.begin_spawn() {"); + expect(spawn).toContain("crate::exit::drain_now(app, reason)"); + expect(spawn.indexOf("state.adopt(child)")).toBeLessThan(spawn.indexOf("coordinator.finish_spawn()")); + }); + + test("no tray setter is called while the tray mutex is held", () => { + const tray = code(repoPath(`${SRC}/tray.rs`)); + // Those setters dispatch to the main thread and wait for it, and the tray is built on the main + // thread while holding this mutex, so the two together are a cycle. + expect(tray).toContain("fn menu_handles(app: &AppHandle) -> Option"); + // Every setter is reached through the copy, never through a live guard: the nearest thing + // before it is the handle copy, not the lock. + const setters = [...tray.matchAll(/\.(?:set_enabled|set_text)\(/g)]; + expect(setters.length).toBeGreaterThan(5); + for (const setter of setters) { + const before = tray.slice(0, setter.index); + expect(before.lastIndexOf("menu_handles(app)")).toBeGreaterThan( + before.lastIndexOf("menu.lock()"), + ); + } + }); + + test("the exit is held until the drain reports", () => { + const exit = code(EXIT); + const handler = exit.slice( + exit.indexOf("pub fn on_exit_requested"), + exit.indexOf("pub fn start_drain"), + ); + expect(handler.length).toBeGreaterThan(0); + for (const arm of [ + "ExitDecision::Hide =>", + "ExitDecision::Wait =>", + "ExitDecision::Drain(reason) =>", + ]) { + const at = handler.indexOf(arm); + expect(at).toBeGreaterThan(-1); + expect(handler.slice(at, at + 160)).toContain("api.prevent_exit()"); + } + const proceed = handler.indexOf("ExitDecision::Proceed =>"); + expect(proceed).toBeGreaterThan(-1); + expect(handler.slice(proceed)).not.toContain("prevent_exit"); + expect(exit).toContain("coordinator.finish_drain(verdict)"); + }); + + test("closing the window takes the same decision the quit gesture does", () => { + const window = code(WINDOW); + const close = window.indexOf("CloseRequested"); + expect(close).toBeGreaterThan(-1); + const branch = window.slice(close, window.indexOf("});", close)); + expect(branch).toContain("api.prevent_close()"); + expect(branch).toContain("exit::gesture("); + const exit = code(EXIT); + const gesture = exit.slice(exit.indexOf("pub fn gesture(")); + const body = gesture.slice(0, gesture.indexOf("\n}")); + expect(body).toContain("ExitDecision::Hide => hide_windows(app)"); + expect(body).toContain("ExitDecision::Drain(reason) => start_drain(app, reason)"); + }); + + test("a drain never runs against a runtime this app did not start", () => { + const exit = code(EXIT); + const drain = exit.slice(exit.indexOf("pub async fn drain_current(")); + const body = drain.slice(0, drain.indexOf("\nenum Ownership")); + // Only a confirmed-ours runtime is stopped. A foreign one is left alone, and one that cannot + // be identified stops nothing at all. + expect(body).toContain("Ownership::Foreign => DrainVerdict::Drained"); + expect(body).toContain("Ownership::Unknown => DrainVerdict::OwnershipUnknown"); + expect(body.indexOf("Ownership::Ours =>")).toBeLessThan(body.indexOf("runtime_stop::run")); + // Nothing in the shell performs the stop itself any more. + expect(code(repoPath(`${SRC}/sidecar.rs`))).not.toContain("fn drain("); + expect(code(repoPath(`${SRC}/proxy.rs`))).not.toContain("Method::POST"); + }); + + test("only an observed exit or a refused connection proves the runtime stopped", () => { + const exit = code(EXIT); + const start = exit.indexOf("async fn confirm("); + expect(start).toBeGreaterThan(-1); + const body = exit.slice(start, exit.indexOf("fn hide_windows(", start)); + expect(body).toContain("watch.exit().is_some()"); + expect(body).toContain("error.is_unreachable()"); + // Any-error-means-gone is the shape this replaces. + expect(exit).not.toContain("proxy.is_alive().await.is_err()"); + }); +}); diff --git a/tests/clients/desktop-install-identity.test.ts b/tests/clients/desktop-install-identity.test.ts new file mode 100644 index 00000000000..797b2192e1b --- /dev/null +++ b/tests/clients/desktop-install-identity.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The desktop app's half of the runtime-ownership claim. + * + * The claim lives in the shared service install state, which core owns across two files: the + * validation that decides what a record may say is in `src/service/install-state-contract.mjs`, + * and the types, the three answers a read can give and `ownershipGrantedTo` — the comparison an + * installation applies to its own locally stored install id — are in `src/service/state.ts`. The + * shell holds the other half, an id of its own to compare against, and mirrors the rule rather + * than inventing one, because a weaker version of a question core already answers is how the + * shell ended up guessing a port it should have been told. + * + * Both halves are read here together, so a change on either side breaks this rather than leaving + * the two to disagree in a place only a takeover would reveal. + */ +const SHELL = "desktop/src-tauri/src"; +const IDENTITY = repoPath(`${SHELL}/identity.rs`); +const OWNERSHIP = repoPath(`${SHELL}/ownership.rs`); +const STARTUP = repoPath(`${SHELL}/startup.rs`); +const STATE = repoPath("src/service/state.ts"); +const CONTRACT = repoPath("src/service/install-state-contract.mjs"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop install identity", () => { + const identity = code(IDENTITY); + const ownership = code(OWNERSHIP); + const state = code(STATE); + const contract = code(CONTRACT); + + test("the installation's id is minted once and never rewritten", () => { + // Exclusive, because two launches racing to mint would answer to two ids, and the second one + // would find a claim that is not its own and ask again for consent already given. + expect(identity).toContain(".create_new(true)"); + const mint = identity.slice(identity.indexOf("pub fn install_id_in")); + const body = mint.slice(0, mint.indexOf("\n}")); + expect(body.indexOf("if let Some(existing) = read(&path)")).toBeLessThan( + body.indexOf("mint(&path)"), + ); + expect(body).toContain("return Some(existing);"); + // It is the app's own directory, not the shared record: an id stored only in the shared one + // would be whoever wrote it last. + expect(identity).toContain("app_config_dir()"); + }); + + test("a blank record is replaced rather than answered with", () => { + expect(identity).toContain("ErrorKind::AlreadyExists"); + const replace = identity.slice(identity.indexOf("ErrorKind::AlreadyExists")); + expect(replace.slice(0, 300)).toContain("read(&path).is_none()"); + }); + + test("the owner values are the ones the record accepts", () => { + // Both halves of core's answer are read. The runtime rejection is what a record on disk meets, + // and the exported type is what every caller is compiled against; a parse that accepted a + // third owner and a type that forbade it would disagree exactly where a takeover happens. + expect(contract).toContain('value.owner !== "cli" && value.owner !== "desktop"'); + expect(state).toContain('export type ServiceOwner = "cli" | "desktop"'); + expect(ownership).toContain('#[serde(rename_all = "lowercase")]'); + expect(ownership).toContain(" Cli,"); + expect(ownership).toContain(" Desktop,"); + }); + + test("the claim's wire fields are the recorded ones", () => { + for (const field of ["installId", "consentGeneration"]) { + expect(state).toContain(`ownership.${field}`); + } + expect(ownership).toContain('#[serde(rename_all = "camelCase")]'); + expect(ownership).toContain("pub install_id: String"); + expect(ownership).toContain("pub consent_generation: u64"); + }); + + test("the three answers a read can give are all three", () => { + for (const kind of ["none", "owned", "unknown"]) { + expect(state).toContain(`kind: "${kind}"`); + } + expect(ownership).toContain('#[serde(tag = "kind", rename_all = "lowercase")]'); + expect(ownership).toContain(" None,"); + expect(ownership).toContain("Owned { ownership: Claim }"); + expect(ownership).toContain("Unknown { reason: String }"); + }); + + test("the comparison is the one the record publishes, and no more", () => { + const rule = state.slice(state.indexOf("export function ownershipGrantedTo")); + expect(rule.slice(0, 300)).toContain( + "ownership.owner === owner && ownership.installId === installId", + ); + const mirror = ownership.slice(ownership.indexOf("pub fn granted_to")); + const body = mirror.slice(0, mirror.indexOf("\n}")); + expect(body).toContain("claim.owner == owner && claim.install_id == install_id"); + // The generation moves on every grant; comparing it would make a held consent look foreign. + expect(body).not.toContain("consent_generation"); + }); + + test("an unreadable record refuses instead of reading as unowned", () => { + const verdict = ownership.slice(ownership.indexOf("pub fn consent(")); + const body = verdict.slice(0, verdict.indexOf("\n}")); + expect(body).toContain("Recorded::Unknown { .. } => Consent::Refuse"); + expect(body).toContain("Recorded::None => Consent::AskFirstTime"); + }); + + test("the shell does not read the recorded claim itself", () => { + // Resolving means reading every state path and failing closed on an unreadable one, a corrupt + // anchor and paths that disagree. That answer belongs to the CLI. + for (const leak of ["service-state", "serviceStatePaths", "read_to_string", "fs::"]) { + expect(ownership).not.toContain(leak); + } + const seam = ownership.slice(ownership.indexOf("pub fn resolve(_app: &AppHandle)")); + expect(seam.slice(0, 120)).toContain("None"); + }); + + test("not having asked is distinct from nobody owning it", () => { + // Option::None means the CLI has not been asked; Recorded::None means it answered that no + // claim exists. Collapsing them would let a takeover proceed on a question never put. + expect(ownership).toContain("pub fn resolve(_app: &AppHandle) -> Option"); + expect(ownership).toContain("(None, _) => "); + const startup = code(STARTUP); + expect(startup).toContain("ownership::describe(ownership::resolve(app).as_ref()"); + expect(startup).toContain("identity::install_id(app)"); + expect(startup).toContain('format!("runtime ownership: {}", registration.identity)'); + }); +}); diff --git a/tests/clients/desktop-runtime-identity.test.ts b/tests/clients/desktop-runtime-identity.test.ts new file mode 100644 index 00000000000..8ce8f2de491 --- /dev/null +++ b/tests/clients/desktop-runtime-identity.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * Which instance the shell is talking to, and what it will send there. + * + * The management token is the admin credential for this machine's proxy, and the endpoint is a + * port a local process can take. So identity comes first, from the unauthenticated health body the + * runtime already publishes — the marker, the pid and the port — and the credential follows only + * for the instance the shell decided to trust. The same facts answer a second question the shell + * used to answer with a boolean: whether the process holding the port is the child it started. + * + * Both are read out of the source, because CI has no running proxy to address and no Windows + * webview to navigate. + */ +const SHELL = "desktop/src-tauri/src"; +const PROXY = repoPath(`${SHELL}/proxy.rs`); +const EXIT = repoPath(`${SHELL}/exit.rs`); +const LIB = repoPath(`${SHELL}/lib.rs`); +const STARTUP = repoPath(`${SHELL}/startup.rs`); +const WINDOW = repoPath(`${SHELL}/window.rs`); +const SERVE = repoPath("src/server/index/serve-options.ts"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop runtime identity", () => { + const proxy = code(PROXY); + const exit = code(EXIT); + + test("the identity it reads is the one the runtime publishes", () => { + // Unauthenticated, so it can be read before anything secret is sent. + const health = code(SERVE); + for (const field of ['service: "opencodex"', "pid: process.pid", "port: healthPort"]) { + expect(health).toContain(field); + } + const reader = proxy.slice(proxy.indexOf("pub fn identity_from(")); + const body = reader.slice(0, reader.indexOf("\n}")); + expect(body).toContain('body.get("service")'); + expect(body).toContain('body.get("pid")'); + expect(body).toContain('body.get("port")'); + // A 200 from something else on the port is not this proxy, and a body describing a different + // listener does not authorise a credential for this one. + expect(body).toContain("if port != addressed_port {"); + }); + + test("the credential is never sent to an unconfirmed instance", () => { + const start = proxy.indexOf("async fn authorised_token("); + expect(start).toBeGreaterThan(-1); + const body = proxy.slice(start, proxy.indexOf("async fn send(", start)); + expect(body).toContain("let Some(binding) = self.binding() else"); + // Re-confirmed here, not trusted from when it was made: in between, the child can exit and + // something else can hold the port. + expect(body).toContain("let identity = self.identify().await?;"); + expect(body).toContain("if identity != binding.identity"); + expect(body).toContain("if self.binding() != Some(binding)"); + const token = body.indexOf("self.auth.token()"); + expect(token).toBeGreaterThan(body.indexOf("if self.binding() != Some(binding)")); + }); + + test("a request is bound to the pid, the port and the generation it was authorised under", () => { + expect(proxy).toContain("pub struct RuntimeIdentity {"); + expect(proxy).toContain("pub pid: u32"); + expect(proxy).toContain("pub port: u16"); + expect(proxy).toContain("pub struct RuntimeBinding {"); + expect(proxy).toContain("pub generation: u64"); + const bind = proxy.slice(proxy.indexOf("pub fn bind(")); + expect(bind.slice(0, bind.indexOf("\n }"))).toContain("*generations += 1;"); + }); + + test("the local management client refuses redirects and system proxies", () => { + const builder = proxy.slice(proxy.indexOf("Client::builder()"), proxy.indexOf(".build()?")); + expect(builder).toContain("redirect(redirect::Policy::none())"); + expect(builder).toContain(".no_proxy()"); + }); + + test("attaching to a runtime does not carry ownership of the last one", () => { + const lib = code(LIB); + const attach = lib.slice(lib.indexOf("pub fn attach(")); + const body = attach.slice(0, attach.indexOf("\n }")); + expect(body).toContain("self.confirmed.store(false, Ordering::Release)"); + // A spawn records a pid; it does not record that the pid is the one holding the port. + const adopt = lib.slice(lib.indexOf("pub fn adopt(")); + expect(adopt.slice(0, adopt.indexOf("\n }"))).toContain( + "self.confirmed.store(false, Ordering::Release)", + ); + const confirm = lib.slice(lib.indexOf("pub fn confirm_ownership(")); + expect(confirm.slice(0, confirm.indexOf("\n }"))).toContain( + "self.child_pid() == Some(identity.pid)", + ); + }); + + test("ownership is confirmed from the answering pid before anything is stopped", () => { + const start = exit.indexOf("async fn confirm("); + expect(start).toBeGreaterThan(-1); + const body = exit.slice(start, exit.indexOf("fn hide_windows(", start)); + expect(body).toContain("identity.pid == child_pid => Ownership::Ours"); + expect(body).toContain("Ok(_) => Ownership::Foreign"); + // Nothing listening is only proof the child is gone if the child said so. + expect(body).toContain("watch.exit().is_some()"); + expect(body).toContain("Ownership::Unknown"); + const drain = exit.slice(exit.indexOf("pub async fn drain_current(")); + const drainBody = drain.slice(0, drain.indexOf("\nenum Ownership")); + expect(drainBody).toContain("Ownership::Foreign => DrainVerdict::Drained"); + expect(drainBody).toContain("Ownership::Unknown => DrainVerdict::OwnershipUnknown"); + }); + + test("the startup sequence is what grants ownership, and only on a readable answer", () => { + const startup = code(STARTUP); + const bind = startup.slice(startup.indexOf("async fn bind(")); + const body = bind.slice(0, bind.indexOf("\n}")); + expect(body).toContain("proxy.identify()"); + expect(body).toContain("proxy.bind(identity)"); + expect(body).toContain("state.confirm_ownership(identity)"); + // An answer that cannot be read leaves the app owning nothing. + expect(body).toContain("_ => return None,"); + // And the sequence does not report Ready against an instance it could not identify: the + // management token is only ever sent to a bound one, so a dashboard there would not load. + const callers = startup.match(/bind\(app, &proxy, deadline\)\.await\.is_none\(\)/g) || []; + expect(callers).toHaveLength(2); + }); + + test("the app's own origin is allowed by both spellings, and nothing wider", () => { + const window = code(WINDOW); + const rule = window.slice(window.indexOf("fn is_app_origin(")); + const body = rule.slice(0, rule.indexOf("\n}")); + // The custom scheme everywhere, and the http spelling WebView2 needs on Windows. + expect(body).toContain('"tauri" => true'); + expect(body).toContain('url.host_str() == Some("tauri.localhost")'); + // Not https, which is not the scheme the pinned Tauri serves the app over, and not a port, + // which would mean something else is answering. + expect(body).toContain("url.port().is_none()"); + expect(body).not.toContain('"https"'); + expect(window).toContain("if is_app_origin(url) {"); + // Not localhost generally, and not a remote IPC widening. + expect(window).not.toContain('Some("localhost")'); + expect(readFileSync(repoPath("desktop/src-tauri/capabilities/default.json"), "utf8")).not.toContain( + "remote", + ); + }); +}); diff --git a/tests/clients/desktop-start-at-login-default.test.ts b/tests/clients/desktop-start-at-login-default.test.ts index abf3472b79c..f968fd97ae5 100644 --- a/tests/clients/desktop-start-at-login-default.test.ts +++ b/tests/clients/desktop-start-at-login-default.test.ts @@ -12,9 +12,12 @@ import { repoPath } from "../helpers/repo-root"; * the current state, an existing marker returns early, and the tray is built afterwards so its * checkbox reflects the result. Get the write order backwards and a user who turns the setting off * has it turned back on for them on the next launch. + * + * The one-time rewrite that teaches an existing login item to announce itself is the exception, and + * it writes its marker the other way round on purpose — see the comment on it. */ const FIRST_RUN = repoPath("desktop/src-tauri/src/first_run.rs"); -const LIB = repoPath("desktop/src-tauri/src/lib.rs"); +const STARTUP = repoPath("desktop/src-tauri/src/startup.rs"); function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); @@ -52,11 +55,24 @@ describe("start at login default", () => { }); test("it runs before the tray is installed", () => { - const lib = code(LIB); - const applied = lib.indexOf("first_run::apply_start_at_login_default"); - const tray = lib.indexOf("tray::install"); + const startup = code(STARTUP); + const applied = startup.indexOf("first_run::apply_start_at_login_default"); + const tray = startup.indexOf("crate::tray::install"); expect(applied).toBeGreaterThan(-1); expect(tray).toBeGreaterThan(-1); expect(applied).toBeLessThan(tray); }); + + test("the launch-origin rewrite claims its marker only once it has succeeded", () => { + const start = firstRun.indexOf("pub fn adopt_launch_origin_argument"); + expect(start).toBeGreaterThan(-1); + const body = firstRun.slice(start); + const enable = body.indexOf("autolaunch().enable()"); + const claim = body.indexOf("fs::write(&claimed"); + expect(enable).toBeGreaterThan(-1); + expect(claim).toBeGreaterThan(enable); + // It never turns the setting on or off; it only rewrites an entry that is already there. + expect(body).toContain("Ok(true) =>"); + expect(body).not.toContain("autolaunch().disable()"); + }); }); diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts new file mode 100644 index 00000000000..781416398c5 --- /dev/null +++ b/tests/clients/desktop-startup-surface.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * The startup surface exists before the work it reports on. + * + * Discovery, the liveness probe, the sidecar spawn and the health wait all used to run inside + * `setup()`, and the window was created hidden afterwards. Every failure in that stretch was + * therefore invisible: the spawn event stream was destructured into `_events` and dropped, so the + * child's exit code went with it, and a run of probes that time out rather than refuse takes over a + * minute with nothing on screen. Ordering is the whole of the fix, and a state that reports work + * already finished elsewhere is not a state — it is a label. Both are read out of the source. + */ +const SRC = "desktop/src-tauri/src"; +const LIB = repoPath(`${SRC}/lib.rs`); +const SIDECAR = repoPath(`${SRC}/sidecar.rs`); +const STARTUP = repoPath(`${SRC}/startup.rs`); +const PROXY = repoPath(`${SRC}/proxy.rs`); +const PAGE = repoPath("desktop/ui/main.js"); +const CONFIG = repoPath("desktop/src-tauri/tauri.conf.json"); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop startup surface", () => { + const lib = code(LIB); + const startup = code(STARTUP); + + test("the window is built and shown before the sequence that reports into it", () => { + const setup = lib.indexOf(".setup(|app|"); + expect(setup).toBeGreaterThan(-1); + const built = lib.indexOf("WebviewWindowBuilder::new", setup); + const shown = lib.indexOf("window::show(&window)", setup); + const begun = lib.indexOf("startup::begin(app.handle())", setup); + expect(built).toBeGreaterThan(-1); + expect(shown).toBeGreaterThan(built); + expect(begun).toBeGreaterThan(shown); + }); + + test("setup resolves nothing, registers nothing and starts nothing", () => { + const setup = lib.slice( + lib.indexOf(".setup(|app|"), + lib.indexOf(".build(tauri::generate_context!())"), + ); + expect(setup.length).toBeGreaterThan(0); + for (const call of [ + "block_on", + "ensure_proxy", + "resolve::run", + "ProxyClient::new", + "tray_availability::detect()", + "tray::install", + "first_run::", + "sidecar::", + ]) { + expect(setup).not.toContain(call); + } + }); + + test("resolving and registering are states that own their work", () => { + expect(startup).toContain("Phase::Resolving"); + // D5: the shell asks the bundled CLI rather than reading a port record and guessing. + expect(startup).toContain("resolve::run(app, deadline).await"); + expect(startup).toContain("ProxyClient::new(endpoint"); + expect(startup).toContain("Phase::Registering"); + expect(startup).toContain("tray_availability::detect"); + expect(startup).toContain("first_run::apply_start_at_login_default(app)"); + expect(startup).toContain("crate::tray::install(&handle)"); + }); + + test("the app's own surface is registered before the runtime is touched", () => { + const registering = startup.indexOf("Phase::Registering, None)"); + const resolving = startup.indexOf("Phase::Resolving, None)"); + const starting = startup.indexOf("Phase::Starting, None)"); + expect(registering).toBeGreaterThan(-1); + expect(resolving).toBeGreaterThan(registering); + expect(starting).toBeGreaterThan(resolving); + }); + + test("the spawn event stream is consumed rather than discarded", () => { + const sidecar = code(SIDECAR); + expect(sidecar).not.toContain("_events"); + expect(sidecar).toContain("let (events, child) = command.spawn()"); + expect(sidecar).toContain("watch.follow(events)"); + expect(sidecar).toContain("CommandEvent::Terminated(payload)"); + }); + + test("the child's exit code is what ends the wait early", () => { + const wait = startup.indexOf("Phase::Waiting, None);"); + expect(wait).toBeGreaterThan(-1); + const loop = startup.slice(wait, startup.indexOf("async fn register(", wait)); + expect(loop).toContain("watch.exit()"); + expect(loop).toContain("exit.describe()"); + }); + + test("one deadline covers the whole sequence and bounds every probe under it", () => { + expect(startup).toContain("pub const DEADLINE: Duration"); + expect(startup).toContain("let deadline = started + DEADLINE;"); + // The budget for finding an existing runtime is the CLI's now, not a second one here: the + // tuned probe budgets exist because a shell-side reimplementation answered "nobody is + // listening" twice and started duplicate proxies. + expect(startup).not.toContain("ATTACH_BUDGET"); + expect(startup).not.toContain("fn healthy_by"); + expect(startup).toContain("resolve::run(app, deadline).await"); + // A probe bounded only by the client's own timeout overruns whatever budget it was started + // under, which is how a stated ceiling becomes an unstated one. + expect(startup).not.toContain("proxy.is_alive()"); + expect(startup).toContain("proxy.alive_within(deadline)"); + // Registration waits on a session bus and on the main thread, and both can stall; neither is + // allowed to leave the page in a state whose retry could do nothing. + expect(startup).toContain("tokio::time::timeout_at(\n deadline,"); + expect(startup).toContain("tokio::time::timeout_at(deadline, receiver)"); + const proxy = code(PROXY); + expect(proxy).toContain("timeout_at(deadline, self.is_alive())"); + // The stop is the bundled CLI's now, under its own deadline. + const stop = code(repoPath("desktop/src-tauri/src/runtime_stop.rs")); + expect(stop).toContain("timeout_at(deadline, command.output())"); + expect(stop).toContain("pub const DEADLINE: Duration"); + }); + + test("a retry waits on the child it already started rather than starting a second one", () => { + expect(startup).toContain("&& watch.exit().is_none()"); + const guard = startup.indexOf("if owns_live_child {"); + const spawn = startup.indexOf("sidecar::start(app, endpoint, watch)"); + expect(guard).toBeGreaterThan(-1); + expect(spawn).toBeGreaterThan(guard); + }); + + test("the diagnostic names the state, the endpoint, the home and how the child ended", () => { + const start = startup.indexOf("pub fn diagnostic("); + expect(start).toBeGreaterThan(-1); + const body = startup.slice(start, startup.indexOf("fn report(", start)); + for (const field of [ + "state:", + "reason:", + "elapsed:", + "endpoint:", + "home:", + "runtime process:", + "runtime output", + ]) { + expect(body).toContain(field); + } + expect(body).toContain("exit.describe()"); + }); + + test("the snapshot carries the finished states, not just the current one", () => { + expect(startup).toContain("pub completed: Vec<&'static str>"); + expect(startup).toContain("pub failed_phase: Option<&'static str>"); + const page = readFileSync(PAGE, "utf8"); + expect(page).toContain("progress.completed"); + expect(page).toContain("progress.failedPhase"); + }); + + test("the retry, the snapshot and the phase list are reachable from the page", () => { + const handler = lib.slice( + lib.indexOf("generate_handler!["), + lib.indexOf("])", lib.indexOf("generate_handler![")), + ); + for (const command of ["startup_snapshot", "startup_phases", "retry_startup"]) { + expect(lib).toContain(`fn ${command}(`); + expect(handler).toContain(command); + } + expect(JSON.parse(readFileSync(CONFIG, "utf8")).app.withGlobalTauri).toBe(true); + }); + + test("the page derives its phases instead of restating them", () => { + const page = readFileSync(PAGE, "utf8"); + expect(page).toContain('invoke("startup_phases")'); + expect(page).toContain('invoke("startup_snapshot")'); + expect(page).toContain('invoke("retry_startup")'); + expect(page).toContain('listen("startup-phase"'); + for (const phase of ["resolving", "probing", "attaching", "starting", "waiting", "registering"]) { + expect(page).not.toContain(`"${phase}"`); + } + }); + + test("every call into the shell can fail without leaving the page blank", () => { + const page = readFileSync(PAGE, "utf8"); + expect(page).toContain("function reportPageFailure"); + // Both entry points — the first load and the retry — have to catch, because either one + // failing silently leaves a window that says "Starting…" forever. + expect(page.match(/reportPageFailure\(/g) || []).toHaveLength(3); + const retry = page.slice(page.indexOf('retry.addEventListener')); + expect(retry.slice(0, 400)).toContain("catch"); + }); + + test("the page never reaches for a dialog the webview cannot draw", () => { + const page = readFileSync(PAGE, "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/[^\n]*/g, ""); + // The call form, not the word: a method on a receiver or a property of that name is fine. + expect(page).not.toMatch(/(^|[^.\w$])(?:window\s*\.\s*)?(?:alert|confirm|prompt)\s*\(/); + expect(page).toContain("#diagnostic"); + expect(page).toContain("clipboard.writeText"); + }); +}); diff --git a/tests/clients/desktop-tray-availability.test.ts b/tests/clients/desktop-tray-availability.test.ts new file mode 100644 index 00000000000..d230f606edd --- /dev/null +++ b/tests/clients/desktop-tray-availability.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * INV-DESKTOP-02 — tray availability is an answer from the session, and where there is none the + * window is shown and closing it quits through the same graceful drain. + * + * The pinned Linux backend creates an AppIndicator and reports success without checking that + * anything will display it, so `TrayIconBuilder::build` returning `Ok` proves nothing. Nor does the + * watcher merely existing: a StatusNotifierWatcher with no host attached still accepts + * registrations and still draws nothing, which is why the question asked is the specification's own + * — is a host registered. On stock GNOME the answer is no, and the shell's macOS-shaped assumptions + * (a window created hidden, a close that always hides) then left a running process with no way back + * in. The probe and the branches it feeds are read out of the source because a hosted Linux runner + * has no graphical session to observe them in. + */ +const SRC = "desktop/src-tauri/src"; +const AVAILABILITY = repoPath(`${SRC}/tray_availability.rs`); +const LIB = repoPath(`${SRC}/lib.rs`); +const EXIT = repoPath(`${SRC}/exit.rs`); +const STARTUP = repoPath(`${SRC}/startup.rs`); + +function code(path: string): string { + return readFileSync(path, "utf8").replace(/\/\/[^\n]*/g, ""); +} + +describe("desktop tray availability", () => { + const availability = code(AVAILABILITY); + const startup = code(STARTUP); + + test("the probe asks whether a host is registered, not whether a watcher exists", () => { + expect(availability).toContain('"org.kde.StatusNotifierWatcher"'); + expect(availability).toContain('"IsStatusNotifierHostRegistered"'); + expect(availability).toContain("Connection::new_session()"); + expect(availability).toContain('#[cfg(target_os = "linux")]'); + // A name that merely has an owner is the weaker question this replaces. + expect(availability).not.toContain("NameHasOwner"); + }); + + test("an unanswerable probe is read the same way as a watcher with no host", () => { + const start = availability.indexOf("pub fn from_host_registered"); + expect(start).toBeGreaterThan(-1); + const body = availability.slice(start, availability.indexOf("\n}", start)); + expect(body).toContain("Some(true) => TrayAvailability::Available"); + expect(body).toContain("Some(false) | None => TrayAvailability::Unavailable"); + }); + + test("nothing is assumed on the platform that needs the probe", () => { + const start = availability.indexOf("pub fn assumed()"); + expect(start).toBeGreaterThan(-1); + const body = availability.slice(start, availability.indexOf("\n }", start)); + expect(body).toContain('cfg!(target_os = "linux")'); + expect(body).toContain("Self::Unavailable"); + // The window can be closed before the probe answers, so the coordinator starts from the + // platform assumption rather than from optimism. + expect(code(EXIT)).toContain("TrayAvailability::assumed().hides_to_tray()"); + }); + + test("the verdict reaches the coordinator that decides what a close means", () => { + expect(startup).toContain("coordinator.set_tray(verdict)"); + expect(code(EXIT)).toContain("pub fn set_tray(&self, tray: TrayAvailability)"); + expect(code(repoPath(`${SRC}/window.rs`))).toContain("exit::gesture("); + expect(code(EXIT)).toContain("decide(inner.phase, inner.reason, inner.hides_to_tray)"); + }); + + test("a tray is only claimed once an icon exists to claim", () => { + // The verdict is published after the install, not before it: announcing a tray and then + // failing to build one would hide the window into nothing. + const verdict = startup.indexOf("let verdict = if tray.is_available() && install_tray(app, deadline).await"); + const published = startup.indexOf("coordinator.set_tray(verdict)"); + expect(verdict).toBeGreaterThan(-1); + expect(published).toBeGreaterThan(verdict); + expect(startup.slice(verdict, published)).toContain("TrayAvailability::Unavailable"); + }); + + test("a retry does not register a second tray", () => { + const register = startup.slice(startup.indexOf("async fn register("), startup.indexOf("async fn install_tray(")); + expect(register).toContain("startup.registration()"); + expect(register.indexOf("return done;")).toBeLessThan(register.indexOf("install_tray(app, deadline)")); + expect(register).toContain("startup.remember_registration(registration.clone())"); + }); + + test("tray availability decides the launch, not the origin of the launch", () => { + const start = startup.indexOf("pub fn shows_window"); + expect(start).toBeGreaterThan(-1); + const body = startup.slice(start, startup.indexOf("\n}", start)); + expect(body).toContain("!tray.is_available() || origin == LaunchOrigin::User"); + // The window is shown from inside the sequence, once the verdict is in, so a login launch on a + // session with no tray is not left hidden with nothing to reopen it from. + expect(startup).toContain("if shows_window(LaunchOrigin::detect(), verdict)"); + expect(code(LIB)).toContain("startup::LaunchOrigin::detect() == startup::LaunchOrigin::User"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a257ad0551d..a7a7fcee72f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -551,9 +551,15 @@ "desktop-3p-removal.test.ts": "clients", "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", + "desktop-cli-contracts.test.ts": "clients", + "desktop-exit-ownership.test.ts": "clients", + "desktop-install-identity.test.ts": "clients", "desktop-profile.test.ts": "clients", + "desktop-startup-surface.test.ts": "clients", + "desktop-tray-availability.test.ts": "clients", "desktop-widget-entry.test.ts": "clients", "desktop-remote-store.test.ts": "clients", + "desktop-runtime-identity.test.ts": "clients", "desktop-start-at-login-default.test.ts": "clients", "destination-policy-resolved.test.ts": "routing", "devin-adapter.test.ts": "providers",