From 8caddb43fa3910820c25441306790ad0d769f854 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 14:16:29 +1100 Subject: [PATCH 01/14] refactor(staged): split show_a_window into pick and raise helpers Step 1 of the `stagedctl project open` plan: the upcoming control command needs to know which window a reveal lands on before raising it, so it can address a request to that window. `show_a_window` did both in one go. Move it into a new `app_lifecycle::window_raise` submodule and split it: - `pick_window_to_show` chooses the window in the existing Dock-click order: focused, then visible, then `main`, then any. - `raise_window` shows, unminimizes and focuses a window and restores its PR-poll focus hint. - `show_a_window` is now the two run back to back and is re-exported from `app_lifecycle`, so the Dock reopen and `Window > Staged` callers are unchanged in both call site and behaviour. `set_native_focus` becomes generic over the runtime so the raise helper can run on tauri's mock runtime. The pick order is tested with mock-runtime windows. The mock runtime reports every window as visible and none as focused, so the order lives in a small function that takes the two probes, and the tests supply their own to reach each tier. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/app_lifecycle.rs | 45 +---- .../src/app_lifecycle/window_raise.rs | 173 ++++++++++++++++++ 2 files changed, 181 insertions(+), 37 deletions(-) create mode 100644 apps/staged/src-tauri/src/app_lifecycle/window_raise.rs diff --git a/apps/staged/src-tauri/src/app_lifecycle.rs b/apps/staged/src-tauri/src/app_lifecycle.rs index 5ac230f0a..0e81f6299 100644 --- a/apps/staged/src-tauri/src/app_lifecycle.rs +++ b/apps/staged/src-tauri/src/app_lifecycle.rs @@ -67,6 +67,10 @@ use crate::session_commands::{self, ActiveSessionInfo}; use crate::session_runner::SessionRegistry; use crate::store::{CompletionReason, Session, SessionStatus, Store}; +pub(crate) mod window_raise; + +pub use window_raise::show_a_window; + /// Title of the quit confirmation alert. const QUIT_PROMPT_TITLE: &str = "Quit Staged?"; @@ -271,46 +275,13 @@ fn hide_window(window: &Window) { set_native_focus(window.app_handle(), window.label(), false); } -/// Bring a window back on screen: the Dock-icon click and `Window ▸ Staged` -/// both funnel here. Deliberately *not* on the quit path — see the module docs. -/// -/// Prefers where the user already is (focused, then visible), then falls back to -/// unhiding one: `main` for its restored geometry, else any surviving `win-N` -/// peer. Finds nothing only if every window has been destroyed, which no close -/// path produces — closing the last window hides it instead. -pub fn show_a_window(app: &AppHandle) { - let windows = app.webview_windows(); - let Some(window) = windows - .values() - .find(|window| window.is_focused().unwrap_or(false)) - .or_else(|| { - windows - .values() - .find(|window| window.is_visible().unwrap_or(false)) - }) - .or_else(|| windows.get(MAIN_WINDOW_LABEL)) - .or_else(|| windows.values().next()) - else { - log::warn!("No window left to show"); - return; - }; - - if let Err(e) = window.show() { - log::warn!("Failed to show window: {e}"); - } - if let Err(e) = window.unminimize() { - log::warn!("Failed to unminimize window: {e}"); - } - if let Err(e) = window.set_focus() { - log::warn!("Failed to focus window: {e}"); - } - set_native_focus(app, window.label(), true); -} - /// Mirror a native window's visibility onto its PR-poll client's focus hint. /// Paired with the webview's own focus events, which report the same value once /// the window is back on screen. -fn set_native_focus(app: &AppHandle, window_label: &str, focused: bool) { +/// +/// Generic over the runtime so [`window_raise`]'s mock-app tests can raise a +/// window too. +fn set_native_focus(app: &AppHandle, window_label: &str, focused: bool) { if let Some(scheduler) = app.try_state::>() { crate::pr_poll_scheduler::set_tauri_client_focus(&scheduler, window_label, focused); } diff --git a/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs b/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs new file mode 100644 index 000000000..2f59341bc --- /dev/null +++ b/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs @@ -0,0 +1,173 @@ +//! Bringing a window back on screen, in two steps a caller can take apart: +//! [`pick_window_to_show`] chooses the window and [`raise_window`] reveals it. +//! +//! [`show_a_window`] is the two run back to back, for callers that only want +//! *a* window on screen. A caller that has something to say to the window it +//! lands on picks first, so it knows which window that is before raising it. + +use std::collections::HashMap; + +use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; + +use super::{set_native_focus, MAIN_WINDOW_LABEL}; + +/// Bring a window back on screen: the Dock-icon click and `Window ▸ Staged` +/// both funnel here. Deliberately *not* on the quit path — see the +/// [`app_lifecycle`](super) module docs. +/// +/// Finds nothing only if every window has been destroyed, which no close path +/// produces — closing the last window hides it instead. +pub fn show_a_window(app: &AppHandle) { + let Some(window) = pick_window_to_show(app) else { + log::warn!("No window left to show"); + return; + }; + raise_window(&window); +} + +/// The window a reveal should land on, or `None` when every window has been +/// destroyed. +/// +/// Prefers where the user already is (focused, then visible), then falls back to +/// one that needs unhiding: `main` for its restored geometry, else any surviving +/// `win-N` peer. +pub(crate) fn pick_window_to_show(app: &AppHandle) -> Option> { + let windows = app.webview_windows(); + pick_window( + &windows, + |window| window.is_focused().unwrap_or(false), + |window| window.is_visible().unwrap_or(false), + ) + .cloned() +} + +/// [`pick_window_to_show`]'s order, with the focus and visibility probes passed +/// in: tauri's mock runtime reports every window as visible and none as +/// focused, so the tests supply their own to reach each tier. +/// +/// Each tier is probed only when the ones above it found nothing. +fn pick_window( + windows: &HashMap, + is_focused: impl Fn(&W) -> bool, + is_visible: impl Fn(&W) -> bool, +) -> Option<&W> { + windows + .values() + .find(|window| is_focused(window)) + .or_else(|| windows.values().find(|window| is_visible(window))) + .or_else(|| windows.get(MAIN_WINDOW_LABEL)) + .or_else(|| windows.values().next()) +} + +/// Show, unminimize and focus `window`, and put its PR-poll client back on the +/// focused tier. +/// +/// Best effort: each step that fails is logged and the rest still run, so a +/// window that can't take focus is at least left on screen. +pub(crate) fn raise_window(window: &WebviewWindow) { + if let Err(e) = window.show() { + log::warn!("Failed to show window: {e}"); + } + if let Err(e) = window.unminimize() { + log::warn!("Failed to unminimize window: {e}"); + } + if let Err(e) = window.set_focus() { + log::warn!("Failed to focus window: {e}"); + } + set_native_focus(window.app_handle(), window.label(), true); +} + +#[cfg(test)] +mod tests { + use super::*; + use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; + + /// A mock app with one window per label. + fn mock_app(labels: &[&str]) -> tauri::App { + let app = mock_builder() + .build(mock_context(noop_assets())) + .expect("failed to build mock app"); + for label in labels { + tauri::WebviewWindowBuilder::new(&app, *label, Default::default()) + .build() + .expect("failed to create mock window"); + } + app + } + + /// Label [`pick_window`] lands on when exactly the `focused` and `visible` + /// windows report themselves as such. + fn picked(app: &tauri::App, focused: &[&str], visible: &[&str]) -> Option { + let windows = app.webview_windows(); + pick_window( + &windows, + |window| focused.contains(&window.label()), + |window| visible.contains(&window.label()), + ) + .map(|window| window.label().to_string()) + } + + #[test] + fn the_focused_window_wins_over_visible_ones_and_main() { + let app = mock_app(&["main", "win-2", "win-3"]); + + assert_eq!( + picked(&app, &["win-3"], &["main", "win-2", "win-3"]).as_deref(), + Some("win-3") + ); + } + + #[test] + fn a_visible_window_wins_over_a_hidden_main() { + let app = mock_app(&["main", "win-2", "win-3"]); + + assert_eq!(picked(&app, &[], &["win-2"]).as_deref(), Some("win-2")); + } + + #[test] + fn main_wins_when_every_window_is_hidden() { + let app = mock_app(&["win-2", "main", "win-3"]); + + assert_eq!(picked(&app, &[], &[]).as_deref(), Some("main")); + } + + #[test] + fn a_hidden_peer_is_picked_once_main_is_gone() { + let app = mock_app(&["win-2"]); + + assert_eq!(picked(&app, &[], &[]).as_deref(), Some("win-2")); + } + + #[test] + fn nothing_is_picked_with_no_windows() { + let app = mock_app(&[]); + + assert_eq!(picked(&app, &[], &[]), None); + assert!(pick_window_to_show(app.handle()).is_none()); + } + + /// The probes are wired to the real window state: the mock runtime reports + /// `win-2` as visible and unfocused, so it is found on the visible tier + /// rather than by falling through to "any". + #[test] + fn the_app_level_pick_reads_the_windows_own_state() { + let app = mock_app(&["win-2"]); + + let window = pick_window_to_show(app.handle()).expect("expected a window"); + + assert_eq!(window.label(), "win-2"); + assert!(window.is_visible().unwrap()); + assert!(!window.is_focused().unwrap()); + } + + /// Smoke test for the raise half on the mock runtime. Raising needs no + /// managed state: with no PR-poll scheduler on the app there is simply no + /// focus hint to update. + #[test] + fn raising_a_window_needs_no_scheduler() { + let app = mock_app(&["main"]); + let window = pick_window_to_show(app.handle()).expect("expected a window"); + + raise_window(&window); + } +} From 1d78196d75295c5c017fbdfb04585060b5d454c5 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 14:37:28 +1100 Subject: [PATCH 02/14] feat(staged): add pending project-open state and its window commands Step 2 of the `stagedctl project open` plan. The backend cannot switch a window's project itself: each window owns its selection, and only it knows whether a dialog is open or a diff comment still needs saving. So the upcoming control command needs somewhere to park a request for the window it picked, and a way for that window to say what it did. Add `project_open`, an in-memory state managed on the app: - `ProjectOpenState::request` parks a request for a window label and returns a handle its caller waits on. A window holds at most one: a newer request supersedes the older, whose caller reads `Superseded`. - A request lives only while its handle does. A wait that times out, or a caller that goes away, withdraws it, so a window that loads later never navigates for a caller already told it failed, and nothing needs pruning. - Outcomes are sent under the state's lock. A caller whose timeout fires as an answer lands therefore reads the answer the window was told had been recorded, not `TimedOut`. Add the window's side in `project_open/commands.rs`: - `list_pending_project_opens` is the snapshot a window takes once its listener is live, so a request sent before the window loaded is not lost. It mirrors the consent dialog's event-plus-snapshot pairing. - `answer_project_open` reports opened, dialog open, diff comment unsaved, or project not found. Both act on the calling window's label only, are refused by the web dispatch, and stay out of the stagedctl allowlist. Nothing parks a request yet; the control command that does is the next step. The state and the commands are tested for answer, supersede, timeout and withdrawal, and for window scoping on tauri's mock runtime. Nothing is persisted and there is no schema change, but this does add backend types (`ProjectOpenRequest`, `ProjectOpenAnswer`, `ProjectOpenOutcome`), which AGENTS.md asks a human to review. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/lib.rs | 5 + apps/staged/src-tauri/src/project_open.rs | 382 ++++++++++++++++++ .../src-tauri/src/project_open/commands.rs | 122 ++++++ apps/staged/src-tauri/src/web_server.rs | 9 + 4 files changed, 518 insertions(+) create mode 100644 apps/staged/src-tauri/src/project_open.rs create mode 100644 apps/staged/src-tauri/src/project_open/commands.rs diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index f1e8af1e8..8c07bac92 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -39,6 +39,7 @@ pub mod pr_poll_scheduler; mod preference_events; pub mod project_commands; pub mod project_mcp; +pub mod project_open; pub mod project_search; pub mod project_transfer; pub mod prs; @@ -2142,6 +2143,7 @@ pub fn run() { app.manage(pr_scheduler); app.manage(window_commands::NewWindowState::new()); app.manage(window_commands::UpdaterWindowState::default()); + app.manage(project_open::ProjectOpenState::default()); app.manage(rate_limit_scheduler); app.manage(action_executor); app.manage(action_registry); @@ -2579,6 +2581,9 @@ pub fn run() { control_server::commands::answer_control_authorization, control_server::commands::list_control_grants, control_server::commands::revoke_control_grant, + // stagedctl project open (the window's side of a request) + project_open::commands::list_pending_project_opens, + project_open::commands::answer_project_open, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/apps/staged/src-tauri/src/project_open.rs b/apps/staged/src-tauri/src/project_open.rs new file mode 100644 index 000000000..cfd80fb8e --- /dev/null +++ b/apps/staged/src-tauri/src/project_open.rs @@ -0,0 +1,382 @@ +//! Requests to show a project that come from outside the window, held until +//! that window answers. +//! +//! `stagedctl project open` asks a running app to put a project on screen. +//! The backend cannot do that on its own: every window has its own selection, +//! and only the window knows whether a dialog is open or a diff comment still +//! needs saving. So the caller parks a [`ProjectOpenRequest`] here for the +//! window it picked, and that window reports what it did through +//! [`answer_project_open`](commands::answer_project_open). +//! +//! **Not lost before the window loads.** A request can arrive before the +//! window's event listener is live — the CLI may have only just launched the +//! app. As with the consent dialog (`control_server::consent`), the frontend +//! pairs the event with a snapshot: once it is listening it takes +//! [`list_pending_project_opens`](commands::list_pending_project_opens). +//! +//! **One per window.** A newer request for a window supersedes the older one, +//! whose caller reads [`ProjectOpenOutcome::Superseded`], so requests cannot +//! queue up navigations behind each other. +//! +//! **Only while someone waits.** A request lives exactly as long as the +//! [`PendingProjectOpen`] its caller holds. When the wait times out, or the +//! caller goes away without waiting it out, the request is withdrawn — a +//! window that comes up later never navigates for a caller that has already +//! been told it failed, and there is nothing to expire or prune. +//! +//! In memory only: nothing here is persisted. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::sync::oneshot; + +pub mod commands; + +/// How long a caller waits for the window's answer. A window that is already +/// up answers at once; this covers one that is still loading because the app +/// was launched for the request. +pub const ANSWER_TIMEOUT: Duration = Duration::from_secs(15); + +/// What a window is asked to do: the `list_pending_project_opens` row. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectOpenRequest { + pub request_id: String, + pub project_id: String, +} + +/// What the window did with a request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ProjectOpenAnswer { + /// The project is on screen: the window switched to it, or was already + /// showing it. + Opened, + /// A dialog is open in the window; the project is unchanged. + DialogOpen, + /// The diff view could not save the comment being typed, so the window + /// stayed on it rather than lose the text. + DiffCommentUnsaved, + /// The window knows no project with that id. + ProjectNotFound, +} + +/// How a request ended, as its caller reads it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectOpenOutcome { + Answered(ProjectOpenAnswer), + /// A newer request for the same window replaced this one before the + /// window answered it. + Superseded, + /// The window did not answer in time. + TimedOut, +} + +/// A request waiting on its window, with the way to tell its caller. +struct Parked { + request: ProjectOpenRequest, + outcome: oneshot::Sender, +} + +/// The requests awaiting an answer, at most one per window label. +/// +/// Every outcome is sent while the lock is held, so a caller that finds its +/// request gone from the map can rely on the outcome already being in its +/// channel (see [`PendingProjectOpen::give_up`]). +#[derive(Clone, Default)] +pub struct ProjectOpenState { + parked: Arc>>, +} + +impl ProjectOpenState { + /// Park a request for `window_label` to show `project_id`, superseding + /// any request that window has not answered yet. + /// + /// The request is pending for as long as the returned handle is held; + /// dropping it withdraws the request. + #[must_use = "dropping the handle withdraws the request"] + pub fn request(&self, window_label: &str, project_id: &str) -> PendingProjectOpen { + let request = ProjectOpenRequest { + request_id: uuid::Uuid::new_v4().to_string(), + project_id: project_id.to_string(), + }; + let (outcome, receiver) = oneshot::channel(); + let mut parked = self.parked.lock().unwrap(); + let older = parked.insert( + window_label.to_string(), + Parked { + request: request.clone(), + outcome, + }, + ); + if let Some(older) = older { + let _ = older.outcome.send(ProjectOpenOutcome::Superseded); + } + PendingProjectOpen { + state: self.clone(), + window_label: window_label.to_string(), + request, + outcome: receiver, + } + } + + /// The request `window_label` has yet to answer, if any. + pub fn pending_for(&self, window_label: &str) -> Option { + let parked = self.parked.lock().unwrap(); + parked.get(window_label).map(|p| p.request.clone()) + } + + /// Record `window_label`'s answer to `request_id`. `false` when that is + /// not the request the window currently holds: it was superseded, its + /// caller stopped waiting, or it belongs to another window. + #[must_use] + pub fn answer(&self, window_label: &str, request_id: &str, answer: ProjectOpenAnswer) -> bool { + let mut parked = self.parked.lock().unwrap(); + if !Self::holds(&parked, window_label, request_id) { + return false; + } + parked + .remove(window_label) + .is_some_and(|p| p.outcome.send(ProjectOpenOutcome::Answered(answer)).is_ok()) + } + + fn holds(parked: &HashMap, window_label: &str, request_id: &str) -> bool { + parked + .get(window_label) + .is_some_and(|p| p.request.request_id == request_id) + } +} + +/// The caller's end of a parked request: what to tell the window, and where +/// its answer arrives. +pub struct PendingProjectOpen { + state: ProjectOpenState, + window_label: String, + request: ProjectOpenRequest, + outcome: oneshot::Receiver, +} + +impl PendingProjectOpen { + /// The request as the window is told it. + pub fn request(&self) -> &ProjectOpenRequest { + &self.request + } + + /// Wait up to `timeout` for the request to end. On timeout the request + /// is withdrawn, so a late answer is refused rather than recorded for + /// nobody. + pub async fn wait(mut self, timeout: Duration) -> ProjectOpenOutcome { + match tokio::time::timeout(timeout, &mut self.outcome).await { + Ok(Ok(outcome)) => outcome, + Ok(Err(_)) | Err(_) => self.give_up(), + } + } + + /// Stop waiting. If the request is still parked nobody ended it, and + /// withdrawing it makes that final. If it is already gone, whoever took + /// it sent the outcome before releasing the lock — an answer that landed + /// as the timeout fired — so the window is not told "recorded" while the + /// caller reads "timed out". + fn give_up(&mut self) -> ProjectOpenOutcome { + if self.withdraw() { + return ProjectOpenOutcome::TimedOut; + } + self.outcome + .try_recv() + .unwrap_or(ProjectOpenOutcome::TimedOut) + } + + /// Remove this request if it is still the one parked for its window. + /// Returns whether it was. + fn withdraw(&self) -> bool { + let mut parked = self.state.parked.lock().unwrap(); + let ours = ProjectOpenState::holds(&parked, &self.window_label, &self.request.request_id); + if ours { + parked.remove(&self.window_label); + } + ours + } +} + +impl Drop for PendingProjectOpen { + /// A caller that goes away without an outcome — the CLI was interrupted, + /// so its handler was dropped — takes its request with it. + fn drop(&mut self) { + self.withdraw(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn an_answer_reaches_the_waiting_caller_and_clears_the_request() { + let state = ProjectOpenState::default(); + let pending = state.request("main", "p1"); + let request = pending.request().clone(); + assert_eq!(request.project_id, "p1"); + assert_eq!(state.pending_for("main"), Some(request.clone())); + + let waiting = tokio::spawn(pending.wait(ANSWER_TIMEOUT)); + tokio::task::yield_now().await; + assert!(state.answer("main", &request.request_id, ProjectOpenAnswer::Opened)); + + assert_eq!( + waiting.await.unwrap(), + ProjectOpenOutcome::Answered(ProjectOpenAnswer::Opened) + ); + assert_eq!(state.pending_for("main"), None); + assert!( + !state.answer("main", &request.request_id, ProjectOpenAnswer::Opened), + "a second answer has no request to land on" + ); + } + + /// The window may answer before its caller starts waiting; the answer is + /// held for it. A refusal is carried through as it was given. + #[tokio::test] + async fn an_answer_given_before_the_wait_is_kept() { + let state = ProjectOpenState::default(); + let pending = state.request("main", "p1"); + let id = pending.request().request_id.clone(); + + assert!(state.answer("main", &id, ProjectOpenAnswer::DialogOpen)); + + assert_eq!( + pending.wait(ANSWER_TIMEOUT).await, + ProjectOpenOutcome::Answered(ProjectOpenAnswer::DialogOpen) + ); + } + + #[tokio::test] + async fn a_newer_request_supersedes_the_windows_older_one() { + let state = ProjectOpenState::default(); + let older = state.request("main", "p1"); + let older_id = older.request().request_id.clone(); + let newer = state.request("main", "p2"); + let newer_request = newer.request().clone(); + + assert_eq!( + older.wait(ANSWER_TIMEOUT).await, + ProjectOpenOutcome::Superseded + ); + // The older caller letting go of its handle leaves the newer request + // parked, and the older id no longer answers anything. + assert_eq!(state.pending_for("main"), Some(newer_request.clone())); + assert!(!state.answer("main", &older_id, ProjectOpenAnswer::Opened)); + assert_eq!(state.pending_for("main"), Some(newer_request.clone())); + + assert!(state.answer("main", &newer_request.request_id, ProjectOpenAnswer::Opened)); + assert_eq!( + newer.wait(ANSWER_TIMEOUT).await, + ProjectOpenOutcome::Answered(ProjectOpenAnswer::Opened) + ); + } + + #[tokio::test] + async fn windows_hold_their_requests_independently() { + let state = ProjectOpenState::default(); + let main = state.request("main", "p1"); + let peer = state.request("win-2", "p2"); + let main_id = main.request().request_id.clone(); + let peer_request = peer.request().clone(); + + assert_eq!(state.pending_for("win-2"), Some(peer_request.clone())); + assert_eq!(state.pending_for("win-3"), None); + assert!( + !state.answer("win-2", &main_id, ProjectOpenAnswer::Opened), + "a window cannot answer another window's request" + ); + + assert!(state.answer("main", &main_id, ProjectOpenAnswer::Opened)); + assert_eq!( + main.wait(ANSWER_TIMEOUT).await, + ProjectOpenOutcome::Answered(ProjectOpenAnswer::Opened) + ); + assert_eq!( + state.pending_for("win-2"), + Some(peer_request), + "answering one window leaves the other's request parked" + ); + } + + #[tokio::test(start_paused = true)] + async fn an_unanswered_request_times_out_and_is_withdrawn() { + let state = ProjectOpenState::default(); + let pending = state.request("main", "p1"); + let id = pending.request().request_id.clone(); + let started = tokio::time::Instant::now(); + + assert_eq!( + pending.wait(ANSWER_TIMEOUT).await, + ProjectOpenOutcome::TimedOut + ); + + assert_eq!(started.elapsed(), ANSWER_TIMEOUT); + assert_eq!( + state.pending_for("main"), + None, + "a window loading later is not offered a request nobody waits on" + ); + assert!( + !state.answer("main", &id, ProjectOpenAnswer::Opened), + "a late answer is refused" + ); + } + + /// An answer that lands as the timeout fires has already been reported + /// to the window as recorded, so the caller must read it too. + #[tokio::test] + async fn giving_up_after_an_answer_landed_still_reads_the_answer() { + let state = ProjectOpenState::default(); + let mut pending = state.request("main", "p1"); + let id = pending.request().request_id.clone(); + assert!(state.answer("main", &id, ProjectOpenAnswer::Opened)); + + assert_eq!( + pending.give_up(), + ProjectOpenOutcome::Answered(ProjectOpenAnswer::Opened) + ); + } + + #[test] + fn dropping_the_handle_withdraws_the_request() { + let state = ProjectOpenState::default(); + let pending = state.request("main", "p1"); + let id = pending.request().request_id.clone(); + + drop(pending); + + assert_eq!(state.pending_for("main"), None); + assert!(!state.answer("main", &id, ProjectOpenAnswer::Opened)); + } + + #[test] + fn the_wire_shapes_are_camel_case() { + let request = ProjectOpenRequest { + request_id: "r1".to_string(), + project_id: "p1".to_string(), + }; + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::json!({ "requestId": "r1", "projectId": "p1" }) + ); + + for (wire, answer) in [ + ("opened", ProjectOpenAnswer::Opened), + ("dialogOpen", ProjectOpenAnswer::DialogOpen), + ("diffCommentUnsaved", ProjectOpenAnswer::DiffCommentUnsaved), + ("projectNotFound", ProjectOpenAnswer::ProjectNotFound), + ] { + assert_eq!( + serde_json::from_value::(serde_json::json!(wire)).unwrap(), + answer + ); + } + assert!(serde_json::from_value::(serde_json::json!("ignored")).is_err()); + } +} diff --git a/apps/staged/src-tauri/src/project_open/commands.rs b/apps/staged/src-tauri/src/project_open/commands.rs new file mode 100644 index 000000000..4a7386334 --- /dev/null +++ b/apps/staged/src-tauri/src/project_open/commands.rs @@ -0,0 +1,122 @@ +//! Tauri commands a window uses to take and answer its project-open request. +//! Both act on the calling window only, by its label, so one window can +//! neither see nor answer another's request. Desktop only: +//! `web_server::dispatch` refuses them, and they stay out of +//! `control_server::ALLOWED_COMMANDS`, so no stagedctl caller can answer the +//! request it made. +//! +//! Generic over the runtime so the tests below can call them with windows +//! from tauri's mock runtime; `generate_handler!` instantiates them with +//! `Wry`. + +use tauri::{Runtime, State, WebviewWindow}; + +use super::{ProjectOpenAnswer, ProjectOpenRequest, ProjectOpenState}; + +/// The request this window has yet to answer: none, or one. The frontend +/// takes this snapshot once its event listener is live, so a request that +/// arrived before the window loaded is still acted on. +#[tauri::command] +pub fn list_pending_project_opens( + window: WebviewWindow, + state: State<'_, ProjectOpenState>, +) -> Vec { + state.pending_for(window.label()).into_iter().collect() +} + +/// Report what this window did with request `request_id`. Fails when the +/// request is no longer this window's to answer — it was superseded, or its +/// caller stopped waiting — so the frontend can tell its answer went nowhere. +#[tauri::command] +pub fn answer_project_open( + window: WebviewWindow, + state: State<'_, ProjectOpenState>, + request_id: String, + outcome: ProjectOpenAnswer, +) -> Result<(), String> { + if state.answer(window.label(), &request_id, outcome) { + Ok(()) + } else { + Err("that project-open request is no longer pending".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::project_open::{ProjectOpenOutcome, ANSWER_TIMEOUT}; + use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; + use tauri::Manager; + + /// A mock app managing the state, with one window per label. + fn mock_app(labels: &[&str]) -> tauri::App { + let app = mock_builder() + .build(mock_context(noop_assets())) + .expect("failed to build mock app"); + app.manage(ProjectOpenState::default()); + for label in labels { + tauri::WebviewWindowBuilder::new(&app, *label, Default::default()) + .build() + .expect("failed to create mock window"); + } + app + } + + fn window(app: &tauri::App, label: &str) -> WebviewWindow { + app.get_webview_window(label).expect("window should exist") + } + + #[test] + fn a_window_lists_only_its_own_request() { + let app = mock_app(&["main", "win-2"]); + let state = app.state::(); + let pending = state.request("win-2", "p1"); + + assert_eq!( + list_pending_project_opens(window(&app, "win-2"), app.state()), + vec![pending.request().clone()] + ); + assert!(list_pending_project_opens(window(&app, "main"), app.state()).is_empty()); + } + + #[tokio::test] + async fn a_window_answers_only_its_own_request() { + let app = mock_app(&["main", "win-2"]); + let state = app.state::(); + let pending = state.request("win-2", "p1"); + let id = pending.request().request_id.clone(); + + let refused = answer_project_open( + window(&app, "main"), + app.state(), + id.clone(), + ProjectOpenAnswer::Opened, + ); + assert_eq!( + refused.unwrap_err(), + "that project-open request is no longer pending" + ); + + answer_project_open( + window(&app, "win-2"), + app.state(), + id.clone(), + ProjectOpenAnswer::DiffCommentUnsaved, + ) + .expect("the request's own window answers it"); + assert_eq!( + pending.wait(ANSWER_TIMEOUT).await, + ProjectOpenOutcome::Answered(ProjectOpenAnswer::DiffCommentUnsaved) + ); + + // Answered once: the request is gone, for its own window too. + assert!(list_pending_project_opens(window(&app, "win-2"), app.state()).is_empty()); + assert!(answer_project_open( + window(&app, "win-2"), + app.state(), + id, + ProjectOpenAnswer::Opened, + ) + .is_err()); + } +} diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 61b2afd83..da3c6a02e 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -1105,6 +1105,15 @@ pub(crate) async fn dispatch( "list_control_grants" => Err(format!("{command} is not available in web mode")), "revoke_control_grant" => Err(format!("{command} is not available in web mode")), + // ===================================================================== + // stagedctl project open (desktop only) + // ===================================================================== + // A request is addressed to one native window, which the backend + // raises and which answers for its own navigation. A browser client + // is not that window: it must neither read nor answer the request. + "list_pending_project_opens" => Err(format!("{command} is not available in web mode")), + "answer_project_open" => Err(format!("{command} is not available in web mode")), + // ===================================================================== // Projects // ===================================================================== From 83e25bd33556e7bdbc751f782223cf87decec6ce Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 14:49:17 +1100 Subject: [PATCH 03/14] feat(staged): add the open_project control command Step 3 of the `stagedctl project open` plan: the command the CLI verb will call. Steps 1 and 2 added the window pick and raise helpers and the pending-open state; this joins them behind the control server. `project_open::control::open_project` is the body of a new `open_project` dispatch arm. Given a `projectId` it: - checks the project exists before any window is touched, so a typo does not pull Staged to the front; - picks the window a Dock click would land on (`pick_window_to_show`); - parks a request for that window in `ProjectOpenState`, emits `project-open-requested` to that window alone, and raises it (`raise_window`); - waits up to `ANSWER_TIMEOUT` for the window's answer. It returns `{ "outcome", "window" }` whatever the window did. The outcome is `opened`, `dialogOpen`, `diffCommentUnsaved`, `projectNotFound`, `superseded` or `timedOut`. A refusal is a result rather than an error because the control server's plain errors carry no code, and the CLI needs to tell these apart to word `ui_busy` and `ui_timeout`. Only a request that could not be made, an unknown project or no window left, is an error. Who may call it, using the plan's recommended defaults: - Allowlisted in `ALLOWED_COMMANDS`, with a `CONTROL_SCOPE` row keyed on `projectId`, so a project-scoped grant opens only its own project. - Read-write: the window marks the project read and persists it as the last one shown, so a read-only grant is refused. - Session credentials are refused, even for the session's own project, so an agent cannot pull the user's window away. This is a new `ScopeKey`, `OpenProject`, and the first scope rule decided by the grant's actor; the `Actor` and module docs say so. - Web callers are refused in `web_caller_rejection`, ahead of the arm. No protocol version bump: an older app answers 404 for the unknown command, as it does for any other. Tested: the open flow on tauri's mock runtime (answer, each refusal, only the picked window told, unknown project, no window, timeout, supersede), the scoped, read-only and session refusals in the scope table, the web refusal, and the pinned allowlist. Nothing listens for the event yet, so until the frontend steps land a call raises the window and reads `timedOut`. No schema change and no new backend type. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/control_server.rs | 10 + .../src-tauri/src/control_server/grants.rs | 7 +- apps/staged/src-tauri/src/project_open.rs | 6 +- .../src-tauri/src/project_open/control.rs | 336 ++++++++++++++++++ apps/staged/src-tauri/src/web_server.rs | 27 +- .../src-tauri/src/web_server/control_scope.rs | 138 ++++++- 6 files changed, 510 insertions(+), 14 deletions(-) create mode 100644 apps/staged/src-tauri/src/project_open/control.rs diff --git a/apps/staged/src-tauri/src/control_server.rs b/apps/staged/src-tauri/src/control_server.rs index 214d43810..4ad01d342 100644 --- a/apps/staged/src-tauri/src/control_server.rs +++ b/apps/staged/src-tauri/src/control_server.rs @@ -70,6 +70,13 @@ //! destructive verb reopens the auth design and must come with a //! [`PROTOCOL_VERSION`] bump. //! +//! One verb reaches past the data into the user's view: `open_project` +//! raises a window and asks it to show a project (see +//! [`project_open`](crate::project_open)). It is the user's own gesture from +//! a terminal, so only a connected app's grant may make it — a session's +//! credential is refused in the scope table, and an agent cannot pull the +//! window away from what the user is looking at. +//! //! **Discovery.** Once the listener is bound — synchronously at app setup, //! before anything can start a session (see [`start`]) — the server //! atomically writes `~/.staged/control//discovery.json` (0600) @@ -160,6 +167,8 @@ pub const ALLOWED_COMMANDS: &[&str] = &[ "list_projects", "list_project_repos", "list_branches_for_project", + // project open + "open_project", // timeline show "get_branch_timeline", "get_project_timeline", @@ -1628,6 +1637,7 @@ mod tests { "list_project_repos", "list_projects", "move_tag", + "open_project", "rename_tag_group", "reorder_tag_groups", "set_icon_tag_group", diff --git a/apps/staged/src-tauri/src/control_server/grants.rs b/apps/staged/src-tauri/src/control_server/grants.rs index 70f818caa..53dd4a7f2 100644 --- a/apps/staged/src-tauri/src/control_server/grants.rs +++ b/apps/staged/src-tauri/src/control_server/grants.rs @@ -35,9 +35,10 @@ use sha2::{Digest, Sha256}; use super::enrol::Enrolment; use super::files::{self, ControlTransport}; -/// Who a grant was issued to. Recorded so mutations can name their actor; -/// never consulted for the authorization decision, which is the scope and -/// access alone. +/// Who a grant was issued to. Recorded so mutations can name their actor. +/// The authorization decision is the scope and access alone, with one +/// exception: `open_project` moves the user's window, so the scope table +/// refuses it to a [`Session`](Self::Session) whatever its scope. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Actor { /// A session this instance spawned. `epoch` is the registry diff --git a/apps/staged/src-tauri/src/project_open.rs b/apps/staged/src-tauri/src/project_open.rs index cfd80fb8e..0435e1c2b 100644 --- a/apps/staged/src-tauri/src/project_open.rs +++ b/apps/staged/src-tauri/src/project_open.rs @@ -4,8 +4,9 @@ //! `stagedctl project open` asks a running app to put a project on screen. //! The backend cannot do that on its own: every window has its own selection, //! and only the window knows whether a dialog is open or a diff comment still -//! needs saving. So the caller parks a [`ProjectOpenRequest`] here for the -//! window it picked, and that window reports what it did through +//! needs saving. So the caller ([`control::open_project`], the control +//! server's `open_project` command) parks a [`ProjectOpenRequest`] here for +//! the window it picked, and that window reports what it did through //! [`answer_project_open`](commands::answer_project_open). //! //! **Not lost before the window loads.** A request can arrive before the @@ -34,6 +35,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::oneshot; pub mod commands; +pub(crate) mod control; /// How long a caller waits for the window's answer. A window that is already /// up answers at once; this covers one that is still loading because the app diff --git a/apps/staged/src-tauri/src/project_open/control.rs b/apps/staged/src-tauri/src/project_open/control.rs new file mode 100644 index 000000000..0426dc130 --- /dev/null +++ b/apps/staged/src-tauri/src/project_open/control.rs @@ -0,0 +1,336 @@ +//! The app's side of `stagedctl project open`: the body of `dispatch`'s +//! `open_project` arm, reachable through the control server only. +//! +//! [`open_project`] checks the project exists, picks the window a Dock click +//! would land on, parks a [`ProjectOpenRequest`](super::ProjectOpenRequest) +//! for it, tells that window alone with [`PROJECT_OPEN_REQUESTED_EVENT`], +//! raises it, and waits for what the window did. A window that was not +//! listening yet finds the request in its +//! [`list_pending_project_opens`](super::commands::list_pending_project_opens) +//! snapshot instead. +//! +//! **A refusal is a result, not an error.** The answer goes back as +//! `{ "outcome", "window" }` whatever the window did, so the CLI can tell a +//! dialog in the way from a window that never answered and word each for the +//! user. Only a request that could not be made at all — an unknown project, +//! no window left — is an `Err`. +//! +//! Generic over the runtime so the tests below can drive it with windows +//! from tauri's mock runtime; `dispatch` calls it with `Wry`. + +use std::time::Duration; + +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter, Manager, Runtime}; + +use super::{ProjectOpenAnswer, ProjectOpenOutcome, ProjectOpenState, ANSWER_TIMEOUT}; +use crate::app_lifecycle::window_raise::{pick_window_to_show, raise_window}; +use crate::store::Store; + +/// Emitted to one window, by label, with the +/// [`ProjectOpenRequest`](super::ProjectOpenRequest) it is asked to act on. +pub const PROJECT_OPEN_REQUESTED_EVENT: &str = "project-open-requested"; + +/// Ask a window to show `project_id` and report what it did, waiting up to +/// [`ANSWER_TIMEOUT`]. +pub(crate) async fn open_project( + app: &AppHandle, + store: &Store, + project_id: &str, +) -> Result { + open_project_within(app, store, project_id, ANSWER_TIMEOUT).await +} + +/// [`open_project`] with the wait passed in. +async fn open_project_within( + app: &AppHandle, + store: &Store, + project_id: &str, + timeout: Duration, +) -> Result { + // Before any window is touched: a typo must not pull Staged to the front. + store + .get_project(project_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project not found: {project_id}"))?; + let state = app + .try_state::() + .ok_or("open_project is not available: its request state is not managed")?; + let window = pick_window_to_show(app) + .ok_or("Staged has no window to show the project in; restart Staged and try again")?; + let label = window.label().to_string(); + + // Parked before the event goes out, so the window can answer the moment + // it hears it, and before the raise, so a window still loading finds the + // request in its snapshot. + let pending = state.request(&label, project_id); + if let Err(e) = app.emit_to( + label.as_str(), + PROJECT_OPEN_REQUESTED_EVENT, + pending.request(), + ) { + // Not fatal: the snapshot still offers the request. + log::warn!("Failed to emit {PROJECT_OPEN_REQUESTED_EVENT} to {label}: {e}"); + } + // Raised whatever the answer turns out to be: a window that refuses is + // then showing the user the dialog or comment that is in the way. + raise_window(&window); + + let outcome = pending.wait(timeout).await; + Ok(json!({ "outcome": outcome_name(outcome), "window": label })) +} + +/// The `outcome` a control caller reads. The answers carry the names the +/// window gave them on the way in. +fn outcome_name(outcome: ProjectOpenOutcome) -> &'static str { + match outcome { + ProjectOpenOutcome::Answered(ProjectOpenAnswer::Opened) => "opened", + ProjectOpenOutcome::Answered(ProjectOpenAnswer::DialogOpen) => "dialogOpen", + ProjectOpenOutcome::Answered(ProjectOpenAnswer::DiffCommentUnsaved) => "diffCommentUnsaved", + ProjectOpenOutcome::Answered(ProjectOpenAnswer::ProjectNotFound) => "projectNotFound", + ProjectOpenOutcome::Superseded => "superseded", + ProjectOpenOutcome::TimedOut => "timedOut", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::project_open::ProjectOpenRequest; + use crate::store::Project; + use std::sync::{Arc, Mutex}; + use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; + use tauri::Listener; + + /// A mock app managing the state with one window per label, a store + /// holding one project, and that project's id. + fn fixture(labels: &[&str]) -> (tauri::App, Store, String) { + let app = mock_builder() + .build(mock_context(noop_assets())) + .expect("failed to build mock app"); + app.manage(ProjectOpenState::default()); + for label in labels { + tauri::WebviewWindowBuilder::new(&app, *label, Default::default()) + .build() + .expect("failed to create mock window"); + } + let store = Store::in_memory().unwrap(); + let project = Project::named("alpha"); + store.create_project(&project).unwrap(); + (app, store, project.id) + } + + /// The requests `label`'s window hears, in order. + fn heard_by(app: &tauri::App, label: &str) -> Arc>> { + let heard = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&heard); + app.get_webview_window(label) + .expect("window should exist") + .listen(PROJECT_OPEN_REQUESTED_EVENT, move |event| { + sink.lock() + .unwrap() + .push(serde_json::from_str(event.payload()).unwrap()); + }); + heard + } + + /// Answer the request parked for `label`, as its window would. + fn answer(app: &tauri::App, label: &str, answer: ProjectOpenAnswer) { + let state = app.state::(); + let request = state + .pending_for(label) + .expect("a request should be parked"); + assert!(state.answer(label, &request.request_id, answer)); + } + + fn as_wire(request: &ProjectOpenRequest) -> Value { + serde_json::to_value(request).unwrap() + } + + #[tokio::test] + async fn the_picked_window_is_told_and_its_answer_comes_back() { + let (app, store, project_id) = fixture(&["main"]); + let heard = heard_by(&app, "main"); + let state = app.state::(); + + // `join!` polls in order: the open runs up to its wait, with the + // request parked and the event out, before the window's side runs. + let (result, parked) = + tokio::join!(open_project(app.handle(), &store, &project_id), async { + let parked = state + .pending_for("main") + .expect("a request should be parked"); + assert!(state.answer("main", &parked.request_id, ProjectOpenAnswer::Opened)); + parked + }); + + assert_eq!( + result.unwrap(), + json!({ "outcome": "opened", "window": "main" }) + ); + assert_eq!(parked.project_id, project_id); + assert_eq!( + *heard.lock().unwrap(), + vec![as_wire(&parked)], + "the window hears the request it will find in its snapshot" + ); + assert_eq!(state.pending_for("main"), None); + } + + /// A window that refuses is not an error: the caller reads which refusal. + #[tokio::test] + async fn a_refusal_comes_back_as_its_outcome() { + let (app, store, project_id) = fixture(&["main"]); + for (given, outcome) in [ + (ProjectOpenAnswer::DialogOpen, "dialogOpen"), + (ProjectOpenAnswer::DiffCommentUnsaved, "diffCommentUnsaved"), + (ProjectOpenAnswer::ProjectNotFound, "projectNotFound"), + ] { + let (result, ()) = + tokio::join!(open_project(app.handle(), &store, &project_id), async { + answer(&app, "main", given) + }); + assert_eq!( + result.unwrap(), + json!({ "outcome": outcome, "window": "main" }) + ); + } + } + + /// Only the window the request is parked for hears about it. + #[tokio::test] + async fn no_other_window_is_told() { + let (app, store, project_id) = fixture(&["main", "win-2"]); + let heard = [heard_by(&app, "main"), heard_by(&app, "win-2")]; + let state = app.state::(); + + let (result, picked) = + tokio::join!(open_project(app.handle(), &store, &project_id), async { + // The mock runtime calls every window visible, so either may be + // picked; exactly one holds the request. + let picked: Vec<&str> = ["main", "win-2"] + .into_iter() + .filter(|label| state.pending_for(label).is_some()) + .collect(); + assert_eq!(picked.len(), 1, "one window holds the request: {picked:?}"); + answer(&app, picked[0], ProjectOpenAnswer::Opened); + picked[0] + }); + + assert_eq!( + result.unwrap(), + json!({ "outcome": "opened", "window": picked }) + ); + let (told, untold) = if picked == "main" { (0, 1) } else { (1, 0) }; + assert_eq!(heard[told].lock().unwrap().len(), 1); + assert!( + heard[untold].lock().unwrap().is_empty(), + "the other window must not navigate" + ); + } + + #[tokio::test] + async fn an_unknown_project_is_refused_before_any_window_is_told() { + let (app, store, _) = fixture(&["main"]); + let heard = heard_by(&app, "main"); + + let err = open_project(app.handle(), &store, "nope") + .await + .unwrap_err(); + + assert_eq!(err, "Project not found: nope"); + assert!(heard.lock().unwrap().is_empty()); + assert_eq!(app.state::().pending_for("main"), None); + } + + #[tokio::test] + async fn with_no_window_left_the_request_fails_without_waiting() { + let (app, store, project_id) = fixture(&[]); + + let err = open_project(app.handle(), &store, &project_id) + .await + .unwrap_err(); + + assert!(err.contains("no window"), "got: {err}"); + } + + #[tokio::test(start_paused = true)] + async fn a_window_that_never_answers_times_out_and_loses_the_request() { + let (app, store, project_id) = fixture(&["main"]); + let started = tokio::time::Instant::now(); + + let result = open_project(app.handle(), &store, &project_id).await; + + assert_eq!( + result.unwrap(), + json!({ "outcome": "timedOut", "window": "main" }) + ); + assert_eq!(started.elapsed(), ANSWER_TIMEOUT); + assert_eq!( + app.state::().pending_for("main"), + None, + "a window loading later is not offered the request" + ); + } + + /// A second call for the same window replaces the first, whose caller is + /// told so rather than left to time out. + #[tokio::test] + async fn a_newer_call_supersedes_the_one_still_waiting() { + let (app, store, project_id) = fixture(&["main"]); + let other = Project::named("beta"); + store.create_project(&other).unwrap(); + let heard = heard_by(&app, "main"); + + let (older, newer, ()) = tokio::join!( + open_project(app.handle(), &store, &project_id), + open_project(app.handle(), &store, &other.id), + async { answer(&app, "main", ProjectOpenAnswer::Opened) } + ); + + assert_eq!( + older.unwrap(), + json!({ "outcome": "superseded", "window": "main" }) + ); + assert_eq!( + newer.unwrap(), + json!({ "outcome": "opened", "window": "main" }) + ); + let heard = heard.lock().unwrap(); + assert_eq!(heard.len(), 2); + assert_eq!( + heard[1]["projectId"], + other.id.as_str(), + "the newer request is the one the window answered" + ); + } + + #[test] + fn every_outcome_has_its_own_name() { + let outcomes = [ + ProjectOpenOutcome::Answered(ProjectOpenAnswer::Opened), + ProjectOpenOutcome::Answered(ProjectOpenAnswer::DialogOpen), + ProjectOpenOutcome::Answered(ProjectOpenAnswer::DiffCommentUnsaved), + ProjectOpenOutcome::Answered(ProjectOpenAnswer::ProjectNotFound), + ProjectOpenOutcome::Superseded, + ProjectOpenOutcome::TimedOut, + ]; + let names: std::collections::BTreeSet<&str> = + outcomes.into_iter().map(outcome_name).collect(); + assert_eq!(names.len(), outcomes.len()); + + // An answer goes out under the name the window sent it in as. + for answer in [ + ProjectOpenAnswer::Opened, + ProjectOpenAnswer::DialogOpen, + ProjectOpenAnswer::DiffCommentUnsaved, + ProjectOpenAnswer::ProjectNotFound, + ] { + let name = outcome_name(ProjectOpenOutcome::Answered(answer)); + assert_eq!( + serde_json::from_value::(json!(name)).unwrap(), + answer + ); + } + } +} diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index da3c6a02e..d3760368c 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -933,10 +933,11 @@ impl DispatchCaller { /// Why a web (remote-browser) caller may not run `command`, or `None` if it /// may. From a remote browser, `create_image` would mean "read any file on -/// the app's machine into a viewable timeline" and `open_in_app` would mean -/// "open an arbitrary path in an arbitrary local app" — so both are rejected -/// before any dispatch arm runs, and restoring the web listener cannot -/// silently inherit them. +/// the app's machine into a viewable timeline", `open_in_app` would mean +/// "open an arbitrary path in an arbitrary local app" and `open_project` +/// would mean "pull a window on the user's desk to the front and change what +/// it shows" — so all three are rejected before any dispatch arm runs, and +/// restoring the web listener cannot silently inherit them. fn web_caller_rejection(cmd: &str, caller: &DispatchCaller) -> Option<&'static str> { if !matches!(caller, DispatchCaller::Web) { return None; @@ -953,6 +954,10 @@ fn web_caller_rejection(cmd: &str, caller: &DispatchCaller) -> Option<&'static s "open_in_app is not available in web mode: it launches an app on \ the app's machine", ), + "open_project" => Some( + "open_project is not available in web mode: it raises a Staged \ + window on the app's machine and switches the project it shows", + ), _ => None, } } @@ -1113,6 +1118,14 @@ pub(crate) async fn dispatch( // is not that window: it must neither read nor answer the request. "list_pending_project_opens" => Err(format!("{command} is not available in web mode")), "answer_project_open" => Err(format!("{command} is not available in web mode")), + // The request itself: control callers only (`web_caller_rejection` + // turns a browser away before this arm), and only a connected app's + // read-write grant covering the project (`control_scope`). + "open_project" => { + let store = get_store(store_mutex)?; + let project_id: String = arg(&args, "projectId")?; + crate::project_open::control::open_project(app_handle, &store, &project_id).await + } // ===================================================================== // Projects @@ -4572,11 +4585,11 @@ mod tests { fn web_callers_cannot_reach_same_host_only_commands() { let control = DispatchCaller::Control(crate::control_server::ControlGrant::test_all_projects()); - for command in ["create_image", "open_in_app"] { + for command in ["create_image", "open_in_app", "open_project"] { assert!( web_caller_rejection(command, &DispatchCaller::Web).is_some(), - "{command} takes local paths / launches local apps and must be \ - rejected for web callers" + "{command} takes local paths, launches local apps or raises a \ + native window and must be rejected for web callers" ); assert!( web_caller_rejection(command, &control).is_none(), diff --git a/apps/staged/src-tauri/src/web_server/control_scope.rs b/apps/staged/src-tauri/src/web_server/control_scope.rs index c34888d96..c0bb95a08 100644 --- a/apps/staged/src-tauri/src/web_server/control_scope.rs +++ b/apps/staged/src-tauri/src/web_server/control_scope.rs @@ -25,6 +25,11 @@ //! same request (`assignProjectId`), which is what the tag picker's "Create //! tag" row does. The two deletes are not allowlisted at all. //! +//! `open_project` is the one command decided by who holds the grant as well +//! as by its scope. It raises a window and switches the project it shows, +//! which is the user's to ask for: a connected app's grant covering the +//! project may, a session's credential may not, even for its own project. +//! //! This is a table iterated with `find`, not a `match`, on purpose: the //! dispatch parity tests locate `dispatch`'s arm table by its `match` over //! `command` and each arm by its `"" =>` spelling, so neither may @@ -35,7 +40,7 @@ use std::sync::{Arc, Mutex}; use serde_json::Value; use super::{arg, control_create_image_args, get_store, opt_arg, DispatchCaller}; -use crate::control_server::{Access, ControlGrant}; +use crate::control_server::{Access, Actor, ControlGrant}; use crate::store::Store; /// Prefix on a `dispatch` error that denotes a scope refusal, so the control @@ -65,6 +70,10 @@ pub(crate) enum ScopeKey { CreateTag, /// An edit to the shared tag catalog: only an unconfined grant. Unconfined, + /// `open_project`'s `projectId`, from a connected app only: a session's + /// credential is refused, so an agent cannot pull the user's window to + /// its project. + OpenProject, } /// One row per allowlisted control command: how to resolve its project, @@ -84,6 +93,9 @@ pub(crate) const CONTROL_SCOPE: &[(&str, ScopeKey, Access)] = &[ Access::ReadOnly, ), ("list_project_notes", ScopeKey::ProjectArg, Access::ReadOnly), + // A write: the window marks the project read and persists it as the + // last one shown. + ("open_project", ScopeKey::OpenProject, Access::ReadWrite), ( "start_branch_session", ScopeKey::BranchArg, @@ -246,6 +258,18 @@ pub(crate) fn check_with_store( ))), None => Ok(()), }, + ScopeKey::OpenProject => { + if let Actor::Session { session_id, .. } = &grant.actor { + return Err(denied(format!( + "{cmd} raises a Staged window and switches the project it shows, which a \ + spawned session may not do: this credential belongs to Staged session \ + {session_id}; `stagedctl project open` is for clients the user runs \ + outside a session" + ))); + } + let project_id: String = arg(args, "projectId")?; + require_in_scope(scope, &project_id, "project") + } } } @@ -323,7 +347,7 @@ fn session_project(store: &Store, session_id: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::control_server::{Actor, Scope}; + use crate::control_server::Scope; use crate::store::{Branch, Image, Project, Session}; use serde_json::json; @@ -789,6 +813,115 @@ mod tests { ); } + // ------------------------------------------------------------------- + // open_project + // ------------------------------------------------------------------- + + /// A connected app's grant, as `stagedctl connect` leaves the user with. + fn app_grant(scope: Scope, access: Access) -> ControlGrant { + ControlGrant { + actor: Actor::App { + grant_id: "g1".to_string(), + client: "terminal".to_string(), + }, + scope, + branch_id: None, + access, + } + } + + #[test] + fn open_project_follows_a_connected_apps_project_scope() { + let f = fixture(); + let confined = app_grant(Scope::Project(f.project_a.clone()), Access::ReadWrite); + check_with_store( + "open_project", + &json!({ "projectId": f.project_a }), + &confined, + &f.store, + ) + .unwrap(); + assert_denied( + check_with_store( + "open_project", + &json!({ "projectId": f.project_b }), + &confined, + &f.store, + ), + "another project", + ); + let all = app_grant(Scope::AllProjects, Access::ReadWrite); + for project_id in [&f.project_a, &f.project_b] { + check_with_store( + "open_project", + &json!({ "projectId": project_id }), + &all, + &f.store, + ) + .unwrap(); + } + let err = check_with_store("open_project", &json!({}), &all, &f.store).unwrap_err(); + assert!( + err.contains("missing argument: projectId") && !err.starts_with(SCOPE_DENIED_PREFIX), + "a missing argument is a plain command error: {err}" + ); + } + + /// Navigation marks the project read and persists it as the last shown, + /// so a read-only grant cannot open even a project it may read. + #[test] + fn open_project_is_refused_to_a_read_only_grant() { + let f = fixture(); + for scope in [Scope::Project(f.project_a.clone()), Scope::AllProjects] { + assert_denied( + check_with_store( + "open_project", + &json!({ "projectId": f.project_a }), + &app_grant(scope, Access::ReadOnly), + &f.store, + ), + "read-only", + ); + } + } + + /// An agent must not pull the user's window to its project, so a + /// session's credential is refused for its own project too, and before + /// the argument is looked at. + #[test] + fn open_project_is_refused_to_a_session_credential() { + let f = fixture(); + let session = scoped(&f.project_a); + for args in [ + json!({ "projectId": f.project_a }), + json!({ "projectId": f.project_b }), + json!({}), + ] { + assert_denied( + check_with_store("open_project", &args, &session, &f.store), + "spawned session may not", + ); + } + // Whatever its scope: no session grant is unconfined today, and one + // that was would still be an agent's. + assert_denied( + check_with_store( + "open_project", + &json!({ "projectId": f.project_a }), + &grant(Scope::AllProjects, Access::ReadWrite), + &f.store, + ), + "spawned session may not", + ); + // No other command is decided by the actor. + let actor_keyed: Vec<&str> = CONTROL_SCOPE + .iter() + .filter(|(_, key, _)| *key == ScopeKey::OpenProject) + .map(|(name, ..)| *name) + .collect(); + assert_eq!(actor_keyed, ["open_project"]); + } + // ------------------------------------------------------------------- // Access and scope breadth // ------------------------------------------------------------------- @@ -798,6 +931,7 @@ mod tests { let f = fixture(); let g = grant(Scope::Project(f.project_a.clone()), Access::ReadOnly); let mut writes = vec![ + ("open_project", json!({ "projectId": f.project_a })), ("start_branch_session", json!({ "branchId": f.branch_a })), ("start_project_session", json!({ "projectId": f.project_a })), ("cancel_session", json!({ "sessionId": f.session_a })), From b9bf4c190ceeecac651d48841e08372fbc10a0f5 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 15:19:56 +1100 Subject: [PATCH 04/14] feat(staged): add openProjectExternally navigation helper Step 4 of the `stagedctl project open` plan: what a window does with a request to show a project that came from outside it. Steps 1 to 3 added the backend half, which parks a request for one window and waits for that window's answer; this is the navigation the answer reports on. `openProjectExternally(projectId)` in `navigation.svelte.ts` resolves to a `ProjectOpenAnswer`: `opened`, `dialogOpen`, `diffCommentUnsaved` or `projectNotFound`. Those are the backend's `ProjectOpenAnswer` values as serde reads them, so step 5 can hand the result to `answer_project_open` unchanged. `selectProject` trusts its caller to have picked a safe moment. A request from outside can land at any time, so the helper makes the checks itself: - It waits for the first project list, since the app may have been launched for the request and the window still be loading. - It refuses with `dialogOpen` while a dialog or alert dialog is open. Navigating would unmount the view that owns the dialog along with whatever was typed into it. The command palette counts here. - Over the diff view it awaits `diffLeaveCheck`, as the palette does, and stays with `diffCommentUnsaved` when the save failed or threw. - It validates the id against the window's project list, treating a project with a delete in flight as not found, then calls `selectProject`. Three choices the plan did not spell out: - A project the window cannot show is `projectNotFound` ahead of the dialog and diff checks, and is checked again after a save. The caller is then never told to close a dialog and retry a request that can only fail. - A project that is already the view on screen is `opened` even under a dialog, and nothing is navigated or persisted again. This matches the plan's "already on screen: raise the window and exit 0". - A project list that failed to load refuses nothing, as for a new window's seed in `initNavigation`. The backend has already checked the id against the database. The dialog and the project are checked again after a diff save, because the save takes time. Tested in `navigation.test.ts`: each outcome, the already-on-screen and Settings cases, a dialog with no `data-state` and one animating out, a save that fails or throws, a dialog opening and a project deleted during the save, the wait for the first list, and a list that failed to load. Nothing calls the helper yet; the listener and the command bindings are step 5. Frontend only, with no backend change. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- .../lib/features/layout/navigation.svelte.ts | 92 ++++++ .../lib/features/layout/navigation.test.ts | 311 ++++++++++++++++++ 2 files changed, 403 insertions(+) diff --git a/apps/staged/src/lib/features/layout/navigation.svelte.ts b/apps/staged/src/lib/features/layout/navigation.svelte.ts index 52ded1a5d..8e68c72b9 100644 --- a/apps/staged/src/lib/features/layout/navigation.svelte.ts +++ b/apps/staged/src/lib/features/layout/navigation.svelte.ts @@ -724,3 +724,95 @@ export function registerDiffLeaveCheck(check: DiffLeaveCheck): () => void { export function diffLeaveCheck(): DiffLeaveCheck | null { return currentRoute().kind === 'diff' ? registeredDiffLeaveCheck : null; } + +// ── Opening a project for a caller outside the window ── +// +// `stagedctl project open` asks the backend to put a project on screen, and +// the backend hands the request to one window (project_open.rs), since only +// the window knows whether a dialog is open or a diff comment still needs +// saving. This is what that window does with it. + +/** + * What the window did with an outside request to show a project. The values + * are the backend's `ProjectOpenAnswer` (src-tauri/src/project_open.rs) as it + * deserializes them, so a result goes to `answer_project_open` unchanged. + * + * - `opened`: the project is on screen. The window switched to it, or was + * already showing it. + * - `dialogOpen`: a dialog is open, and the project is unchanged. + * - `diffCommentUnsaved`: the diff view could not save the comment being + * typed, so the window stayed on it. + * - `projectNotFound`: the window knows no project with that id. + */ +export type ProjectOpenAnswer = 'opened' | 'dialogOpen' | 'diffCommentUnsaved' | 'projectNotFound'; + +/** Whether a dialog or alert dialog is open (design-system or hand-rolled, + * they all carry the role), not counting one that is only playing its exit + * animation. Unlike the palette's otherModalOpen, the ⌘K palette counts. */ +function dialogOpen(): boolean { + for (const dialog of document.querySelectorAll('[role="dialog"], [role="alertdialog"]')) { + if (dialog.getAttribute('data-state') !== 'closed') return true; + } + return false; +} + +/** Whether the window can show this project: it is in the list and has no + * delete in flight, the ⌘K palette's rule for a row it will open. A list that + * failed to load refuses nothing, as for a new window's seed (initNavigation): + * the backend checked the id against the database before it asked. */ +function projectOpenable(projectId: string): boolean { + if (!projectsDataStore.loaded) return true; + return ( + projectsDataStore.projects.some((project) => project.id === projectId) && + !projectsDataStore.isProjectDeleting(projectId) + ); +} + +/** + * Show a project because something outside the window asked for it, and say + * what happened. Unlike selectProject, which trusts its caller to have picked + * a moment when navigating is safe, the request can land at any time, so this + * makes the checks itself: + * + * - It waits for the first project list. The app may have been launched for + * this request, and the window still be loading. + * - A project the window cannot show is `projectNotFound`, whatever else is + * going on, so the caller is not told to close a dialog and retry a request + * that can only fail. + * - A project that is already the view on screen is `opened`, even under a + * dialog: there is nothing to navigate, so nothing for the dialog to block. + * - Otherwise an open dialog refuses with `dialogOpen`. Navigating would + * unmount the view that owns it along with whatever was typed into it, and + * a pending confirmation would outlive the project it was asked about. + * - Over the diff view it waits for the comment being typed to save, like the + * palette and the view's own close, and stays with `diffCommentUnsaved` + * when the save failed so the text is still there to retry. + * + * A save takes time, so the dialog and the project are checked again after it + * and before navigating. From Settings or the diff view the project replaces + * the workspace route underneath, as selectProject describes. + */ +export async function openProjectExternally(projectId: string): Promise { + await projectsDataStore.whenLoaded(); + if (!projectOpenable(projectId)) return 'projectNotFound'; + + const route = currentRoute(); + if (route.kind === 'project' && route.projectId === projectId) return 'opened'; + if (dialogOpen()) return 'dialogOpen'; + + const diffSaved = diffLeaveCheck(); + if (diffSaved) { + let saved = false; + try { + saved = await diffSaved(); + } catch (e) { + console.error('[Navigation] Could not save the diff comment before leaving:', e); + } + if (!saved) return 'diffCommentUnsaved'; + if (!projectOpenable(projectId)) return 'projectNotFound'; + if (dialogOpen()) return 'dialogOpen'; + } + + selectProject(projectId); + return 'opened'; +} diff --git a/apps/staged/src/lib/features/layout/navigation.test.ts b/apps/staged/src/lib/features/layout/navigation.test.ts index f5e62c090..0d9087972 100644 --- a/apps/staged/src/lib/features/layout/navigation.test.ts +++ b/apps/staged/src/lib/features/layout/navigation.test.ts @@ -14,7 +14,9 @@ let projectsStoreMock: { loaded: boolean; ensureLoaded: ReturnType; whenLoaded: ReturnType; + isProjectDeleting: (projectId: string) => boolean; }; +let deletingProjectIds: Set; let takeWindowSeed: ReturnType; let pruneDeletedProjects: ReturnType; @@ -35,11 +37,13 @@ beforeEach(() => { writeSnapshot = vi.fn(); clearSnapshot = vi.fn(); snapshotProjectId = null; + deletingProjectIds = new Set(); projectsStoreMock = { projects: [], loaded: false, ensureLoaded: vi.fn().mockResolvedValue(undefined), whenLoaded: vi.fn().mockResolvedValue(undefined), + isProjectDeleting: (projectId) => deletingProjectIds.has(projectId), }; takeWindowSeed = vi.fn().mockResolvedValue(null); pruneDeletedProjects = vi.fn().mockResolvedValue(undefined); @@ -678,6 +682,313 @@ describe('diffLeaveCheck', () => { }); }); +describe('openProjectExternally', () => { + const home = { kind: 'projects' }; + const projectRoute = (projectId: string) => ({ kind: 'project', projectId }); + + /** Stand in for the DOM's dialogs: the helper only asks for the elements + * carrying a dialog role and reads their `data-state`. */ + let dialogs: Array<{ getAttribute: (name: string) => string | null }>; + function addDialog(state: string | null = 'open') { + dialogs.push({ getAttribute: (name) => (name === 'data-state' ? state : null) }); + } + let dispatchEvent: ReturnType; + + beforeEach(() => { + dialogs = []; + dispatchEvent = vi.fn(); + vi.stubGlobal('window', { dispatchEvent }); + vi.stubGlobal('document', { querySelectorAll: () => dialogs }); + projectsStoreMock.projects = [{ id: 'a' }, { id: 'b' }]; + projectsStoreMock.loaded = true; + }); + + it('opens a project from home and persists it like any other open', async () => { + const { openProjectExternally, navigation } = await importNavigation(); + + await expect(openProjectExternally('a')).resolves.toBe('opened'); + + expect(navigation.detailStack).toEqual([home, projectRoute('a')]); + expect(setStoreValue).toHaveBeenCalledWith('last-viewed-project', 'a'); + }); + + it('replaces the project on screen, and the one under Settings', async () => { + const { openProjectExternally, selectProject, openSettings, navigation } = + await importNavigation(); + selectProject('a'); + + await expect(openProjectExternally('b')).resolves.toBe('opened'); + expect(navigation.detailStack).toEqual([home, projectRoute('b')]); + + openSettings('general'); + await expect(openProjectExternally('a')).resolves.toBe('opened'); + expect(navigation.detailStack).toEqual([home, projectRoute('a')]); + }); + + it('answers opened for the project already on screen without navigating', async () => { + const { openProjectExternally, selectProject, navigation } = await importNavigation(); + selectProject('a'); + const stack = navigation.detailStack; + setStoreValue.mockClear(); + + await expect(openProjectExternally('a')).resolves.toBe('opened'); + + expect(navigation.detailStack).toBe(stack); + expect(setStoreValue).not.toHaveBeenCalled(); + }); + + it('answers opened for the project already on screen under a dialog', async () => { + const { openProjectExternally, selectProject, navigation } = await importNavigation(); + selectProject('a'); + addDialog(); + + await expect(openProjectExternally('a')).resolves.toBe('opened'); + + expect(navigation.detailStack).toEqual([home, projectRoute('a')]); + }); + + it('refuses while a dialog is open and leaves the route alone', async () => { + const { openProjectExternally, selectProject, navigation } = await importNavigation(); + selectProject('a'); + addDialog(); + + await expect(openProjectExternally('b')).resolves.toBe('dialogOpen'); + + expect(navigation.detailStack).toEqual([home, projectRoute('a')]); + }); + + it('counts a dialog with no data-state, as hand-rolled ones have none', async () => { + const { openProjectExternally, navigation } = await importNavigation(); + addDialog(null); + + await expect(openProjectExternally('a')).resolves.toBe('dialogOpen'); + + expect(navigation.detailStack).toEqual([home]); + }); + + it('ignores a dialog that is only animating out', async () => { + const { openProjectExternally, navigation } = await importNavigation(); + addDialog('closed'); + + await expect(openProjectExternally('a')).resolves.toBe('opened'); + + expect(navigation.detailStack).toEqual([home, projectRoute('a')]); + }); + + it('looks for dialogs and alert dialogs', async () => { + const querySelectorAll = vi.fn().mockReturnValue([]); + vi.stubGlobal('document', { querySelectorAll }); + const { openProjectExternally } = await importNavigation(); + + await openProjectExternally('a'); + + expect(querySelectorAll).toHaveBeenCalledWith('[role="dialog"], [role="alertdialog"]'); + }); + + it('leaves the diff view once the comment being typed is saved', async () => { + const { + openProjectExternally, + selectProject, + openDiffRoute, + registerDiffLeaveCheck, + navigation, + } = await importNavigation(); + selectProject('a'); + openDiffRoute({ branchId: 'b1', projectId: 'a' }); + const diffRoute = navigation.currentRoute; + let finishSave!: (saved: boolean) => void; + const check = vi.fn(() => new Promise((resolve) => (finishSave = resolve))); + registerDiffLeaveCheck(check); + + const opening = openProjectExternally('b'); + await vi.waitFor(() => expect(check).toHaveBeenCalledTimes(1)); + // Still on the diff while the save runs. + expect(navigation.currentRoute).toBe(diffRoute); + + finishSave(true); + await expect(opening).resolves.toBe('opened'); + + expect(navigation.detailStack).toEqual([home, projectRoute('b')]); + expect(dispatchEvent).toHaveBeenCalledTimes(1); + const event = dispatchEvent.mock.calls[0][0] as CustomEvent; + expect(event.type).toBe('staged:diff-route-replaced'); + expect(event.detail).toEqual({ route: diffRoute }); + }); + + it('leaves a diff over the requested project for the project itself', async () => { + const { + openProjectExternally, + selectProject, + openDiffRoute, + registerDiffLeaveCheck, + navigation, + } = await importNavigation(); + selectProject('a'); + openDiffRoute({ branchId: 'b1', projectId: 'a' }); + const check = vi.fn().mockResolvedValue(true); + registerDiffLeaveCheck(check); + + await expect(openProjectExternally('a')).resolves.toBe('opened'); + + expect(check).toHaveBeenCalledTimes(1); + expect(navigation.detailStack).toEqual([home, projectRoute('a')]); + }); + + it('stays on the diff when the comment could not be saved', async () => { + const { + openProjectExternally, + selectProject, + openDiffRoute, + registerDiffLeaveCheck, + navigation, + } = await importNavigation(); + selectProject('a'); + openDiffRoute({ branchId: 'b1', projectId: 'a' }); + const stack = navigation.detailStack; + registerDiffLeaveCheck(async () => false); + + await expect(openProjectExternally('b')).resolves.toBe('diffCommentUnsaved'); + + expect(navigation.detailStack).toBe(stack); + expect(dispatchEvent).not.toHaveBeenCalled(); + }); + + it('stays on the diff when the save throws', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { + openProjectExternally, + selectProject, + openDiffRoute, + registerDiffLeaveCheck, + navigation, + } = await importNavigation(); + selectProject('a'); + openDiffRoute({ branchId: 'b1', projectId: 'a' }); + const stack = navigation.detailStack; + registerDiffLeaveCheck(async () => { + throw new Error('save failed'); + }); + + await expect(openProjectExternally('b')).resolves.toBe('diffCommentUnsaved'); + + expect(navigation.detailStack).toBe(stack); + expect(error).toHaveBeenCalledTimes(1); + }); + + it('refuses for a dialog that opened while the comment was saving', async () => { + const { + openProjectExternally, + selectProject, + openDiffRoute, + registerDiffLeaveCheck, + navigation, + } = await importNavigation(); + selectProject('a'); + openDiffRoute({ branchId: 'b1', projectId: 'a' }); + const stack = navigation.detailStack; + registerDiffLeaveCheck(async () => { + addDialog(); + return true; + }); + + await expect(openProjectExternally('b')).resolves.toBe('dialogOpen'); + + expect(navigation.detailStack).toBe(stack); + }); + + it('answers not found for a project deleted while the comment was saving', async () => { + const { + openProjectExternally, + selectProject, + openDiffRoute, + registerDiffLeaveCheck, + navigation, + } = await importNavigation(); + selectProject('a'); + openDiffRoute({ branchId: 'b1', projectId: 'a' }); + const stack = navigation.detailStack; + registerDiffLeaveCheck(async () => { + projectsStoreMock.projects = [{ id: 'a' }]; + return true; + }); + + await expect(openProjectExternally('b')).resolves.toBe('projectNotFound'); + + expect(navigation.detailStack).toBe(stack); + }); + + it('answers not found for an id the window does not list', async () => { + const { openProjectExternally, navigation } = await importNavigation(); + + await expect(openProjectExternally('missing')).resolves.toBe('projectNotFound'); + + expect(navigation.detailStack).toEqual([home]); + expect(setStoreValue).not.toHaveBeenCalled(); + }); + + it('answers not found for a project with a delete in flight, even on screen', async () => { + const { openProjectExternally, selectProject, navigation } = await importNavigation(); + selectProject('a'); + deletingProjectIds.add('a'); + deletingProjectIds.add('b'); + + await expect(openProjectExternally('b')).resolves.toBe('projectNotFound'); + await expect(openProjectExternally('a')).resolves.toBe('projectNotFound'); + + expect(navigation.detailStack).toEqual([home, projectRoute('a')]); + }); + + it('answers not found ahead of an open dialog and without a diff save', async () => { + const { openProjectExternally, selectProject, openDiffRoute, registerDiffLeaveCheck } = + await importNavigation(); + selectProject('a'); + openDiffRoute({ branchId: 'b1', projectId: 'a' }); + const check = vi.fn().mockResolvedValue(true); + registerDiffLeaveCheck(check); + addDialog(); + + await expect(openProjectExternally('missing')).resolves.toBe('projectNotFound'); + + expect(check).not.toHaveBeenCalled(); + }); + + it('waits for the first project list before deciding', async () => { + projectsStoreMock.projects = []; + projectsStoreMock.loaded = false; + let finishLoad!: () => void; + projectsStoreMock.whenLoaded.mockReturnValue( + new Promise((resolve) => (finishLoad = resolve)) + ); + const { openProjectExternally, navigation } = await importNavigation(); + let answer: string | null = null; + + const opening = openProjectExternally('a').then((value) => (answer = value)); + await Promise.resolve(); + await Promise.resolve(); + expect(answer).toBeNull(); + expect(navigation.detailStack).toEqual([home]); + + projectsStoreMock.projects = [{ id: 'a' }]; + projectsStoreMock.loaded = true; + finishLoad(); + await opening; + + expect(answer).toBe('opened'); + expect(navigation.detailStack).toEqual([home, projectRoute('a')]); + }); + + it('opens the project when the list failed to load, as a window seed does', async () => { + projectsStoreMock.projects = []; + projectsStoreMock.loaded = false; + const { openProjectExternally, navigation } = await importNavigation(); + + await expect(openProjectExternally('a')).resolves.toBe('opened'); + + expect(projectsStoreMock.whenLoaded).toHaveBeenCalledTimes(1); + expect(navigation.detailStack).toEqual([home, projectRoute('a')]); + }); +}); + describe('workspace layers', () => { // The workspace is two stacked layers (App.svelte): the home grid, mounted // once `homeLayerRetained` and active while `homeActive`, and the project From 23048dfd647f089ed6d30c7a24910a69c8e7bc77 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 15:26:36 +1100 Subject: [PATCH 05/14] feat(staged): answer project-open requests from the window Step 5 of the `stagedctl project open` plan: the listener that joins the two halves already on the branch. The backend parks a request for one window, emits `project-open-requested` to it and waits for an answer; the frontend has `openProjectExternally`, which navigates and says what happened. Nothing connected them, so the `open_project` control command raised the window and then read `timedOut`. `listenForProjectOpens` in `listeners/projectOpenListener.ts` hands each request to `openProjectExternally` and reports the result through `answer_project_open`: - Window-scoped, like the menu listener. An any-target listener also matches emits addressed to other windows, so every window would navigate. - A request parked before the window was listening is still picked up. Once the registration lands, the listener takes `list_pending_project_opens`, the consent dialog's event-plus-snapshot pairing. - A request is never acted on or answered twice. The snapshot can hold a request the event also delivered, so each request id is taken once. - Requests run one at a time and the newest wins. One can wait on the project list or on a diff comment being saved; a later one waits its turn, so the window ends on the project asked for last. Only the newest waiting request is kept, since the backend holds one per window and has already told a replaced one's caller it was superseded. - An answer the backend refuses, because the caller timed out or was superseded meanwhile, is logged and dropped. If the open itself throws, no answer is sent and the caller reads a timeout. Supporting changes: - `listenToWindowEvent` takes the same `ListenOptions` as `listenToEvent` and calls `onEstablished` and `onRegistrationFailed` under the same rules. This is the established hook the snapshot needs. The menu listener passes none and is unchanged. - `commands.ts` gains `listPendingProjectOpens` and `answerProjectOpen`, and `types.ts` gains `ProjectOpenRequest`, mirroring the backend's `{ requestId, projectId }`. - `App.svelte` starts the listener in every desktop window, since the backend picks whichever one a Dock click would raise. Web mode is skipped: the web dispatch refuses both commands. One choice the plan did not spell out: the listener starts after the route restore (`initNavigation`) rather than beside the other listeners. A request handled during the restore could be overwritten by it, most plainly in a secondary window, which sets its seeded project unconditionally. Starting late loses nothing, because the snapshot offers any request parked earlier. Tested in `projectOpenListener.test.ts` on the `menuListener.test.ts` pattern: the event name and scope, the answer and each refusal, the snapshot taken only once the listener is live, the event and snapshot overlapping in either order, a repeated event, overlapping requests, a refused answer, a failed open, a failed snapshot, and teardown. `transport.test.ts` covers the new hooks on the window listener. Not run against a live app: the unit tests mock the transport, so the event and command round trip with the real backend is untested here. Frontend only, with no Rust change. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/src/App.svelte | 13 + apps/staged/src/lib/commands.ts | 20 ++ .../lib/listeners/projectOpenListener.test.ts | 308 ++++++++++++++++++ .../src/lib/listeners/projectOpenListener.ts | 109 +++++++ apps/staged/src/lib/transport.test.ts | 118 +++++++ apps/staged/src/lib/transport.ts | 21 +- apps/staged/src/lib/types.ts | 12 + 7 files changed, 597 insertions(+), 4 deletions(-) create mode 100644 apps/staged/src/lib/listeners/projectOpenListener.test.ts create mode 100644 apps/staged/src/lib/listeners/projectOpenListener.ts diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 2c73ff7a6..6fb1d31a6 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -82,6 +82,7 @@ import { listenForAcpToolsReconciled } from './lib/listeners/acpToolsListener'; import { listenForControlAuthorizations } from './lib/listeners/controlAuthorizationListener'; import { listenForMenuEvents } from './lib/listeners/menuListener'; + import { listenForProjectOpens } from './lib/listeners/projectOpenListener'; import { listenForReferenceCopy } from './lib/features/sessions/referenceClipboard'; import { darkMode } from './lib/stores/isDark.svelte'; import * as prPollingService from './lib/services/prPollingService'; @@ -98,6 +99,7 @@ let unlistenPageLifecycle: (() => void) | undefined; let unlistenAcpToolsReconciled: UnlistenFn | undefined; let unlistenControlAuthorizations: UnlistenFn | undefined; + let unlistenProjectOpens: UnlistenFn | undefined; let unlistenStoreReset: UnlistenFn | undefined; let unlistenUpdaterOwnerAvailable: UnlistenFn | undefined; let unregisterShortcuts: (() => void) | null = null; @@ -439,6 +441,16 @@ console.error('Failed to restore last viewed project:', e); } + // `stagedctl project open` requests for this window: every desktop + // window, since the backend picks whichever one a Dock click would raise. + // Started only now, unlike the listeners above, so a request never + // navigates underneath the route restore, which would then put the + // restored or seeded project back over it. Nothing is missed by waiting: + // the listener takes the backend's snapshot of a request parked earlier. + if (isTauri && !destroyed) { + unlistenProjectOpens = listenForProjectOpens(); + } + const sweep = () => void sweepCache(); if (typeof requestIdleCallback === 'function') { requestIdleCallback(sweep, { timeout: 10_000 }); @@ -669,6 +681,7 @@ unlistenPageLifecycle?.(); unlistenAcpToolsReconciled?.(); unlistenControlAuthorizations?.(); + unlistenProjectOpens?.(); unlistenStoreReset?.(); unlistenUpdaterOwnerAvailable?.(); projectsDataStore.stopListeners(); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index b48a3faf2..dfa09c48a 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -6,6 +6,7 @@ import { invokeCommand, isTauri } from './transport'; import type { ActionType, ProjectAction } from './features/actions/actions'; +import type { ProjectOpenAnswer } from './features/layout/navigation.svelte'; import { cachedCommand, cachedInvoke, @@ -45,6 +46,7 @@ import type { PollWorkspaceResult, PrDetectionResult, Project, + ProjectOpenRequest, ProjectRepo, ProjectTransferDirection, ProjectTransferPreflight, @@ -169,6 +171,24 @@ export function revokeControlGrant(grantId: string): Promise { return invokeCommand('revoke_control_grant', { grantId }); } +/** + * The request to show a project that this window has yet to answer: none, or + * one. Taken once the `project-open-requested` listener is live, so a request + * parked before the window loaded is still acted on. Desktop only. + */ +export function listPendingProjectOpens(): Promise { + return invokeCommand('list_pending_project_opens'); +} + +/** + * Tell the waiting `stagedctl project open` what this window did with request + * `requestId`. Rejects when the request is no longer this window's to answer: + * a newer one superseded it, or its caller stopped waiting. + */ +export function answerProjectOpen(requestId: string, outcome: ProjectOpenAnswer): Promise { + return invokeCommand('answer_project_open', { requestId, outcome }); +} + // ============================================================================= // Projects // ============================================================================= diff --git a/apps/staged/src/lib/listeners/projectOpenListener.test.ts b/apps/staged/src/lib/listeners/projectOpenListener.test.ts new file mode 100644 index 000000000..86738e08a --- /dev/null +++ b/apps/staged/src/lib/listeners/projectOpenListener.test.ts @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * The `stagedctl project open` listener. What it has to get right: the + * request reaches this window alone, a request parked before the listener was + * live is still picked up, no request is acted on or answered twice, and + * requests that overlap leave the window on the one asked for last. + */ + +type Request = { requestId: string; projectId: string }; +type Answer = 'opened' | 'dialogOpen' | 'diffCommentUnsaved' | 'projectNotFound'; +type ListenOptions = { onEstablished?: () => void }; + +/** A promise the test settles by hand. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +let windowEvent: string | undefined; +let windowCallback: ((request: Request) => void) | undefined; +let windowOptions: ListenOptions | undefined; +let transportUnlisten: ReturnType; +let listenToEvent: ReturnType; +let listPendingProjectOpens: ReturnType Promise>>; +let answerProjectOpen: ReturnType< + typeof vi.fn<(requestId: string, outcome: Answer) => Promise> +>; +let openProjectExternally: ReturnType Promise>>; + +async function startListening() { + const { listenForProjectOpens } = await import('./projectOpenListener'); + return listenForProjectOpens(); +} + +/** The backend's `emit_to` reaching this window. */ +function emit(request: Request): void { + if (!windowCallback) throw new Error('no window listener registered'); + windowCallback(request); +} + +/** The registration landing: the listener is live. */ +function establish(): void { + if (!windowOptions?.onEstablished) throw new Error('no established hook registered'); + windowOptions.onEstablished(); +} + +/** Let every already-settled promise run its continuations. */ +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +beforeEach(() => { + vi.resetModules(); + windowEvent = undefined; + windowCallback = undefined; + windowOptions = undefined; + transportUnlisten = vi.fn(); + listenToEvent = vi.fn(); + listPendingProjectOpens = vi.fn<() => Promise>().mockResolvedValue([]); + answerProjectOpen = vi + .fn<(requestId: string, outcome: Answer) => Promise>() + .mockResolvedValue(undefined); + openProjectExternally = vi + .fn<(projectId: string) => Promise>() + .mockResolvedValue('opened'); + + vi.doMock('../transport', () => ({ + listenToEvent, + listenToWindowEvent: ( + event: string, + callback: (request: Request) => void, + opts?: ListenOptions + ) => { + windowEvent = event; + windowCallback = callback; + windowOptions = opts; + return transportUnlisten; + }, + })); + vi.doMock('../commands', () => ({ listPendingProjectOpens, answerProjectOpen })); + vi.doMock('../features/layout/navigation.svelte', () => ({ openProjectExternally })); +}); + +afterEach(() => { + vi.doUnmock('../transport'); + vi.doUnmock('../commands'); + vi.doUnmock('../features/layout/navigation.svelte'); + vi.restoreAllMocks(); +}); + +describe('listenForProjectOpens', () => { + it('listens for the backend’s event, window-scoped', async () => { + await startListening(); + + // Mirrors `PROJECT_OPEN_REQUESTED_EVENT` in `project_open/control.rs`, + // hard-coded rather than read from the module. + expect(windowEvent).toBe('project-open-requested'); + // An any-target listener would navigate every window for one request. + expect(listenToEvent).not.toHaveBeenCalled(); + }); + + it('opens the project a request names and answers with what happened', async () => { + await startListening(); + + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + + expect(openProjectExternally).toHaveBeenCalledExactlyOnceWith('p1'); + expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r1', 'opened'); + }); + + it('passes each refusal to the backend unchanged', async () => { + await startListening(); + + const refusals: Answer[] = ['dialogOpen', 'diffCommentUnsaved', 'projectNotFound']; + for (const [index, refusal] of refusals.entries()) { + openProjectExternally.mockResolvedValueOnce(refusal); + emit({ requestId: `r${index}`, projectId: 'p1' }); + await settle(); + expect(answerProjectOpen).toHaveBeenLastCalledWith(`r${index}`, refusal); + } + expect(answerProjectOpen).toHaveBeenCalledTimes(refusals.length); + }); + + it('takes the snapshot only once the listener is live', async () => { + listPendingProjectOpens.mockResolvedValue([{ requestId: 'r1', projectId: 'p1' }]); + await startListening(); + await settle(); + + // Taken any earlier, a request parked between the snapshot and the + // registration landing would be in neither. + expect(listPendingProjectOpens).not.toHaveBeenCalled(); + + establish(); + await settle(); + + // Parked before this window was listening: the event never arrived. + expect(listPendingProjectOpens).toHaveBeenCalledTimes(1); + expect(openProjectExternally).toHaveBeenCalledExactlyOnceWith('p1'); + expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r1', 'opened'); + }); + + it('acts once on a request both the event and the snapshot deliver', async () => { + const snapshot = deferred(); + listPendingProjectOpens.mockReturnValue(snapshot.promise); + await startListening(); + + // The event lands while the snapshot is in flight, and is answered + // before the snapshot, read before that answer, comes back. + establish(); + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + expect(answerProjectOpen).toHaveBeenCalledTimes(1); + + snapshot.resolve([{ requestId: 'r1', projectId: 'p1' }]); + await settle(); + + expect(openProjectExternally).toHaveBeenCalledTimes(1); + expect(answerProjectOpen).toHaveBeenCalledTimes(1); + }); + + it('acts once on a request the snapshot delivers before the event', async () => { + listPendingProjectOpens.mockResolvedValue([{ requestId: 'r1', projectId: 'p1' }]); + await startListening(); + + establish(); + await settle(); + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + + expect(openProjectExternally).toHaveBeenCalledTimes(1); + expect(answerProjectOpen).toHaveBeenCalledTimes(1); + }); + + it('acts once on an event delivered twice', async () => { + await startListening(); + + emit({ requestId: 'r1', projectId: 'p1' }); + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + + expect(openProjectExternally).toHaveBeenCalledTimes(1); + expect(answerProjectOpen).toHaveBeenCalledTimes(1); + }); + + it('holds a request back until the one before it has been answered', async () => { + const first = deferred(); + openProjectExternally.mockReturnValueOnce(first.promise); + await startListening(); + + // The first is waiting on a diff comment being saved, say. + emit({ requestId: 'r1', projectId: 'p1' }); + emit({ requestId: 'r2', projectId: 'p2' }); + await settle(); + + // Navigating for the second now would be undone when the first lands. + expect(openProjectExternally).toHaveBeenCalledExactlyOnceWith('p1'); + + first.resolve('opened'); + await settle(); + + expect(openProjectExternally.mock.calls).toEqual([['p1'], ['p2']]); + expect(answerProjectOpen.mock.calls).toEqual([ + ['r1', 'opened'], + ['r2', 'opened'], + ]); + }); + + it('drops a waiting request that a newer one replaced', async () => { + const first = deferred(); + openProjectExternally.mockReturnValueOnce(first.promise); + await startListening(); + + emit({ requestId: 'r1', projectId: 'p1' }); + emit({ requestId: 'r2', projectId: 'p2' }); + emit({ requestId: 'r3', projectId: 'p3' }); + first.resolve('opened'); + await settle(); + + // The backend told r2's caller it was superseded when r3 was parked, so + // the window neither navigates for it nor answers it. + expect(openProjectExternally.mock.calls).toEqual([['p1'], ['p3']]); + expect(answerProjectOpen.mock.calls).toEqual([ + ['r1', 'opened'], + ['r3', 'opened'], + ]); + }); + + it('keeps going after an answer the backend refuses', async () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + answerProjectOpen.mockRejectedValueOnce( + new Error('that project-open request is no longer pending') + ); + await startListening(); + + // Its caller timed out, or a newer request superseded it, while the + // window was busy. Caught, so it can't escape as an unhandled rejection. + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + expect(consoleWarn).toHaveBeenCalledTimes(1); + + emit({ requestId: 'r2', projectId: 'p2' }); + await settle(); + expect(answerProjectOpen).toHaveBeenLastCalledWith('r2', 'opened'); + }); + + it('sends no answer when the open itself fails, and keeps going', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + openProjectExternally.mockRejectedValueOnce(new Error('store down')); + await startListening(); + + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + + // No answer would be true, so the caller is left to read a timeout. + expect(answerProjectOpen).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledTimes(1); + + emit({ requestId: 'r2', projectId: 'p2' }); + await settle(); + expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r2', 'opened'); + }); + + it('swallows a snapshot that fails and still hears the event', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + listPendingProjectOpens.mockRejectedValue(new Error('ipc down')); + await startListening(); + + establish(); + await settle(); + expect(consoleError).toHaveBeenCalledTimes(1); + + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r1', 'opened'); + }); + + it('tears the listener down and takes nothing afterwards', async () => { + const first = deferred(); + const snapshot = deferred(); + openProjectExternally.mockReturnValueOnce(first.promise); + listPendingProjectOpens.mockReturnValue(snapshot.promise); + const unlisten = await startListening(); + establish(); + emit({ requestId: 'r1', projectId: 'p1' }); + emit({ requestId: 'r2', projectId: 'p2' }); + await settle(); + + unlisten(); + expect(transportUnlisten).toHaveBeenCalledTimes(1); + + // A late event, a late snapshot and the request that was waiting its + // turn are all left alone. The one already under way still answers. + emit({ requestId: 'r3', projectId: 'p3' }); + snapshot.resolve([{ requestId: 'r4', projectId: 'p4' }]); + first.resolve('opened'); + await settle(); + + expect(openProjectExternally).toHaveBeenCalledExactlyOnceWith('p1'); + expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r1', 'opened'); + }); +}); diff --git a/apps/staged/src/lib/listeners/projectOpenListener.ts b/apps/staged/src/lib/listeners/projectOpenListener.ts new file mode 100644 index 000000000..d9288cdd3 --- /dev/null +++ b/apps/staged/src/lib/listeners/projectOpenListener.ts @@ -0,0 +1,109 @@ +/** + * Listener for `stagedctl project open` requests addressed to this window. + * + * The backend parks a request for the one window it picked, emits + * `project-open-requested` to that window alone (`emit_to` by label, see + * `project_open/control.rs`) and waits for the window's answer. This is the + * window's side: it hands each request to `openProjectExternally` and reports + * what happened through `answer_project_open`. + * + * **Window-scoped.** The listener has to be `listenToWindowEvent`: an + * any-target listener also matches emits addressed to other windows, so every + * window would navigate, and all but one would then be refused the answer. + * + * **Not lost before the window loads.** Registration is asynchronous, and the + * app may have been launched for the request. Once the listener is live the + * backend's snapshot of this window's pending request is taken as well. + * + * **Never answered twice.** The snapshot can hold a request the event also + * delivered, so each request id is taken once. The backend refuses a second + * answer in any case; taking the request once is what stops a second + * navigation. + * + * **One at a time, newest wins.** A request can wait on the project list or + * on a diff comment being saved. One that arrives meanwhile waits its turn + * instead of navigating underneath it, so the window ends on the project asked + * for last. The backend holds at most one request per window and has already + * told the caller of a replaced one that it was superseded, so only the newest + * waiting request is kept. + */ + +import { listenToWindowEvent, type UnlistenFn } from '../transport'; +import { answerProjectOpen, listPendingProjectOpens } from '../commands'; +import { + openProjectExternally, + type ProjectOpenAnswer, +} from '../features/layout/navigation.svelte'; +import type { ProjectOpenRequest } from '../types'; + +/** Mirrors `PROJECT_OPEN_REQUESTED_EVENT` in `project_open/control.rs`. */ +export const PROJECT_OPEN_REQUESTED_EVENT = 'project-open-requested'; + +export function listenForProjectOpens(): UnlistenFn { + /** Ids already taken from the event or the snapshot. One short string per + * CLI call for the life of the window, so it is never pruned. */ + const taken = new Set(); + /** The newest request still waiting its turn. */ + let waiting: ProjectOpenRequest | null = null; + let working = false; + let stopped = false; + + function take(request: ProjectOpenRequest): void { + if (stopped || taken.has(request.requestId)) return; + taken.add(request.requestId); + waiting = request; + if (!working) void work(); + } + + async function work(): Promise { + working = true; + try { + while (waiting && !stopped) { + const request = waiting; + waiting = null; + await act(request); + } + } finally { + working = false; + } + } + + async function loadPending(): Promise { + let pending: ProjectOpenRequest[]; + try { + pending = await listPendingProjectOpens(); + } catch (e) { + console.error('[projectOpen] Failed to load the pending project-open request:', e); + return; + } + for (const request of pending) take(request); + } + + const unlisten = listenToWindowEvent(PROJECT_OPEN_REQUESTED_EVENT, take, { + onEstablished: () => void loadPending(), + }); + + return () => { + stopped = true; + waiting = null; + unlisten(); + }; +} + +/** Act on one request and report the result. Never rejects. */ +async function act(request: ProjectOpenRequest): Promise { + let outcome: ProjectOpenAnswer; + try { + outcome = await openProjectExternally(request.projectId); + } catch (e) { + // No answer is true here, so none is sent: the caller reads a timeout. + console.error(`[projectOpen] Failed to open project ${request.projectId}:`, e); + return; + } + try { + await answerProjectOpen(request.requestId, outcome); + } catch (e) { + // Superseded, or the caller stopped waiting while the window was busy. + console.warn(`[projectOpen] The answer "${outcome}" was not recorded:`, e); + } +} diff --git a/apps/staged/src/lib/transport.test.ts b/apps/staged/src/lib/transport.test.ts index ad9d069d4..86e457115 100644 --- a/apps/staged/src/lib/transport.test.ts +++ b/apps/staged/src/lib/transport.test.ts @@ -588,6 +588,124 @@ describe('tauri listener establishment', () => { }); }); +describe('tauri window listener establishment', () => { + let resolveListen: ((unlisten: () => void) => void) | undefined; + let rejectListen: ((error: unknown) => void) | undefined; + let windowListen: ReturnType; + let anyTargetListen: ReturnType; + let tauriUnlisten: ReturnType void>>; + let consoleError: MockInstance; + + beforeEach(() => { + vi.resetModules(); + vi.stubGlobal('__TAURI__', {}); + resolveListen = undefined; + rejectListen = undefined; + tauriUnlisten = vi.fn<() => void>(); + windowListen = vi.fn( + () => + new Promise<() => void>((resolve, reject) => { + resolveListen = resolve; + rejectListen = reject; + }) + ); + anyTargetListen = vi.fn(); + vi.doMock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ listen: windowListen }), + })); + vi.doMock('@tauri-apps/api/event', () => ({ listen: anyTargetListen })); + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleError.mockRestore(); + vi.doUnmock('@tauri-apps/api/window'); + vi.doUnmock('@tauri-apps/api/event'); + vi.unstubAllGlobals(); + }); + + it('registers on the current window and delivers the payload', async () => { + const { listenToWindowEvent } = await import('./transport'); + const callback = vi.fn(); + const unlisten = listenToWindowEvent('project-open-requested', callback); + + await vi.waitFor(() => expect(windowListen).toHaveBeenCalledTimes(1)); + expect(windowListen.mock.calls[0][0]).toBe('project-open-requested'); + // An any-target listener would also match emits addressed to other windows. + expect(anyTargetListen).not.toHaveBeenCalled(); + + const handler = windowListen.mock.calls[0][1] as (event: { payload: unknown }) => void; + handler({ payload: { requestId: 'r1' } }); + expect(callback).toHaveBeenCalledExactlyOnceWith({ requestId: 'r1' }); + + resolveListen?.(tauriUnlisten); + await Promise.resolve(); + unlisten(); + await vi.waitFor(() => expect(tauriUnlisten).toHaveBeenCalledTimes(1)); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('announces establishment only once the window registration resolves', async () => { + const { listenToWindowEvent } = await import('./transport'); + const onEstablished = vi.fn(); + const unlisten = listenToWindowEvent('project-open-requested', vi.fn(), { onEstablished }); + + await vi.waitFor(() => expect(windowListen).toHaveBeenCalledTimes(1)); + // The gap the hook exists for: an event emitted to this window before the + // registration lands is never delivered, so the snapshot waits for it. + expect(onEstablished).not.toHaveBeenCalled(); + + resolveListen?.(tauriUnlisten); + await vi.waitFor(() => expect(onEstablished).toHaveBeenCalledTimes(1)); + + unlisten(); + expect(tauriUnlisten).toHaveBeenCalledTimes(1); + }); + + it('stays silent when the unlisten precedes registration', async () => { + const { listenToWindowEvent } = await import('./transport'); + const onEstablished = vi.fn(); + const unlisten = listenToWindowEvent('project-open-requested', vi.fn(), { onEstablished }); + + await vi.waitFor(() => expect(windowListen).toHaveBeenCalledTimes(1)); + unlisten(); + resolveListen?.(tauriUnlisten); + + await vi.waitFor(() => expect(tauriUnlisten).toHaveBeenCalledTimes(1)); + expect(onEstablished).not.toHaveBeenCalled(); + }); + + it('reports a registration that fails, unless the consumer walked away', async () => { + const { listenToWindowEvent } = await import('./transport'); + const onEstablished = vi.fn(); + const onRegistrationFailed = vi.fn(); + listenToWindowEvent('project-open-requested', vi.fn(), { + onEstablished, + onRegistrationFailed, + }); + + await vi.waitFor(() => expect(windowListen).toHaveBeenCalledTimes(1)); + const failure = new Error('ipc down'); + rejectListen?.(failure); + + await vi.waitFor(() => expect(onRegistrationFailed).toHaveBeenCalledWith(failure)); + expect(onEstablished).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledTimes(1); + + const abandoned = vi.fn(); + const unlisten = listenToWindowEvent('project-open-requested', vi.fn(), { + onRegistrationFailed: abandoned, + }); + await vi.waitFor(() => expect(windowListen).toHaveBeenCalledTimes(2)); + unlisten(); + rejectListen?.(new Error('ipc down')); + + // Still logged, but the hook stays silent after an early unlisten. + await vi.waitFor(() => expect(consoleError).toHaveBeenCalledTimes(2)); + expect(abandoned).not.toHaveBeenCalled(); + }); +}); + describe('tauri window label', () => { let consoleError: MockInstance; diff --git a/apps/staged/src/lib/transport.ts b/apps/staged/src/lib/transport.ts index ddef2b2b7..7e6528560 100644 --- a/apps/staged/src/lib/transport.ts +++ b/apps/staged/src/lib/transport.ts @@ -209,10 +209,18 @@ export function listenToEvent( * that must reach exactly one window need this window-scoped variant paired * with a backend `emit_to(label, ..)`. In web mode it falls back to the shared * WebSocket stream, where the page is the only "window". + * + * Registration is asynchronous here too, so `opts` carries the same hooks as + * `listenToEvent`, with the same guarantees: an event that must not be missed + * is paired with a snapshot taken in `onEstablished`. */ -export function listenToWindowEvent(event: string, callback: (payload: T) => void): UnlistenFn { +export function listenToWindowEvent( + event: string, + callback: (payload: T) => void, + opts?: ListenOptions +): UnlistenFn { if (!isTauri) { - return webSocketListen(event, callback, undefined); + return webSocketListen(event, callback, opts); } let cancelled = false; @@ -221,10 +229,15 @@ export function listenToWindowEvent(event: string, callback: (payload: T) => void (async () => { const { getCurrentWindow } = await import('@tauri-apps/api/window'); const u = await getCurrentWindow().listen(event, (e) => callback(e.payload)); - if (cancelled) u(); - else unlisten = u; + if (cancelled) { + u(); + return; + } + unlisten = u; + opts?.onEstablished?.(); })().catch((e) => { console.error(`[transport] Failed to register window listener for event "${event}":`, e); + if (!cancelled) opts?.onRegistrationFailed?.(e); }); return () => { diff --git a/apps/staged/src/lib/types.ts b/apps/staged/src/lib/types.ts index b662083ff..249c8e580 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -1379,6 +1379,18 @@ export interface ControlAuthorizationRequest { expiresAt: number; } +/** + * A request for this window to show a project, made from outside it by + * `stagedctl project open`. Mirrors `ProjectOpenRequest` in + * `src-tauri/src/project_open.rs`; carried by the `project-open-requested` + * event and `list_pending_project_opens`. + */ +export interface ProjectOpenRequest { + /** Names the request in `answer_project_open`. */ + requestId: string; + projectId: string; +} + /** * An approved client as the Settings "Connected apps" panel lists it. * Mirrors `ControlGrantSummary` in `control_server/consent.rs`. From e9efe3aaf5841512ce0ad8614d70ffec6bd2f2fd Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 15:34:18 +1100 Subject: [PATCH 06/14] feat(stagedctl): add the project open verb Step 6 of the `stagedctl project open` plan: the CLI verb. Steps 1 to 5 added the app's half, an `open_project` control command that raises the window a Dock click would land on, asks it to switch project, and answers `{ "outcome", "window" }` whatever the window did. Nothing on the command line called it. `stagedctl project open ` resolves the name or id with the existing rules (exact id, exact name, unique case-insensitive name), so `not_found` and `ambiguous` are decided before any window is raised, then sends the id alone to `open_project` and maps the outcome: - `opened` exits 0 and prints the project's name and id and the pid of the Staged instance. `--json` gives `{ outcome, project: { id, name }, window, pid }`. - `dialogOpen` and `diffCommentUnsaved` exit 1 with `ui_busy`, each with its own message. The dialog one is the plan's wording. - `timedOut` exits 1 with `ui_timeout`. - `projectNotFound`, a project the window lost between the CLI's lookup and the switch, exits 1 with `not_found`. - `superseded` exits 1 with `ui_busy` and a message of its own. The plan gives it no code. `ui_busy` is the closest existing one: the window was taken up with another request, nothing was shown for this caller, and running the command again is the remedy, as for a dialog. - An outcome this CLI does not know is reported as CLI/app drift (exit 2), like any other unexpected shape, never as success. The app's own refusals pass through as they already do: `scope_denied` and `authorization_required` exit 4, and a request the app could not make (no window left) stays `app_error`. The verb lives in a new `open` module rather than in `commands.rs`, which is already over two thousand lines. The mapping takes the `open_project` call as a closure, so it is tested without an app. `classify` appends an "update Staged" hint to a 404 on `open_project`, as it does for the tag commands: an app from before the command answers it like any unknown command, and no protocol bump tells the two apart. The project argument is required in this step. Resolving it from the current directory (step 7) and launching the app (step 8) are separate commits; the mapping already takes a resolved project, so both can feed it without reshaping the verb. A session's credential can list only its own project, so from inside a session another project's name reads `not_found` from the CLI's lookup, and the session's own project reads the app's `scope_denied`. Tested: parsing (required argument, the plural alias, one project only), the request body, the success output in both forms, each outcome's exit code and stderr line, the outcome names against the app's, an unknown outcome, pass-through of the app's refusals, and the 404 hint. The help fixture is refreshed for the new verb and the `project` noun's summary. Not run against a live app: `open_project` refuses a session credential, and this session has no other. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- .../src-tauri/crates/stagedctl/src/cli.rs | 58 +++- .../src-tauri/crates/stagedctl/src/client.rs | 35 ++- .../crates/stagedctl/src/commands.rs | 4 +- .../src-tauri/crates/stagedctl/src/lib.rs | 3 + .../src-tauri/crates/stagedctl/src/open.rs | 286 ++++++++++++++++++ .../crates/stagedctl/tests/fixtures/help.txt | 46 ++- 6 files changed, 426 insertions(+), 6 deletions(-) create mode 100644 apps/staged/src-tauri/crates/stagedctl/src/open.rs diff --git a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs index dc7f29c07..92e52d36f 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs @@ -70,7 +70,8 @@ pub struct Cli { #[derive(Subcommand, Debug)] pub enum Noun { - /// Inspect projects and the repos attached to them + /// Inspect projects and the repos attached to them, and show one in the + /// app #[command(alias = "projects", subcommand)] Project(ProjectVerb), @@ -190,6 +191,36 @@ the --repo flag of `timeline show`, `session start`, `session list`, and project: String, }, + /// Show a project in the Staged window and bring that window to the front + #[command(long_about = "\ +Show a project in the Staged app. The window a click on Staged's Dock +icon would raise is brought to the front and switched to the project. +Prints the project's name and id and the pid of the Staged instance that +showed it. A project that is already on screen is a success too. + +A that matches nothing (`not_found`), or several projects when +case is ignored (`ambiguous`), exits 1 before any window is touched. + +Otherwise the window is raised whether or not it could switch, so the +user sees what is in the way. When it could not, the command exits 1: + ui_busy a dialog is open in the window, or the diff comment being + written could not be saved: close or finish it and run the + command again. Also when another `project open` replaced + this one before the window acted on it. + ui_timeout the window did not answer in time + not_found the project was deleted meanwhile + +Needs a connected-app grant with write access that reaches the project +(see `stagedctl connect --help`): showing a project marks it read and +records it as the last one shown, so a read-only grant is refused (exit +4, `scope_denied`). Not for Staged-spawned sessions: the app refuses a +session's credential the same way, for the session's own project too, so +an agent cannot pull the user's window away.")] + Open { + /// Project name or id (see `stagedctl project list`) + project: String, + }, + /// Show one project's tags: its choice in each group, then its free tags #[command(long_about = "\ Show one project's tags. @@ -919,6 +950,31 @@ mod tests { assert!(matches!(cli.command, Noun::Disconnect { client: Some(c) } if c == "x")); } + #[test] + fn project_open_takes_one_project() { + for noun in ["project", "projects"] { + let cli = Cli::try_parse_from(["stagedctl", noun, "open", "Widgets"]).unwrap(); + assert!( + matches!(cli.command, Noun::Project(ProjectVerb::Open { project }) if project == "Widgets") + ); + } + // A name with spaces is one argument, and --json stays global. + let cli = + Cli::try_parse_from(["stagedctl", "project", "open", "My Project", "--json"]).unwrap(); + assert!(cli.json); + assert!( + matches!(cli.command, Noun::Project(ProjectVerb::Open { project }) if project == "My Project") + ); + assert!( + Cli::try_parse_from(["stagedctl", "project", "open"]).is_err(), + "the project is required" + ); + assert!( + Cli::try_parse_from(["stagedctl", "project", "open", "a", "b"]).is_err(), + "one project at a time" + ); + } + #[test] fn tag_verbs_parse_their_arguments() { let cli = Cli::try_parse_from(["stagedctl", "tag", "list"]).unwrap(); diff --git a/apps/staged/src-tauri/crates/stagedctl/src/client.rs b/apps/staged/src-tauri/crates/stagedctl/src/client.rs index d75e243d1..22cc57cc4 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/client.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/client.rs @@ -1031,11 +1031,16 @@ fn classify(what: &str, status: u16, body: Value) -> Result { 200 => Ok(body), 400 => Err(CtlError::command("app_error", error_message(&body))), 404 => { - // Not allowlisted. For a tag command the likely cause is a Staged - // from before tags reached the control server, not a typo. + // Not allowlisted. For a command a newer Staged added, the likely + // cause is a Staged from before it reached the control server, + // not a typo. let mut message = error_message(&body); if crate::tags::is_tag_command(what) { message.push_str(" (this Staged does not have tags yet; update it)"); + } else if what == crate::open::OPEN_PROJECT_COMMAND { + message.push_str( + " (this Staged cannot open a project from the command line yet; update it)", + ); } Err(CtlError::command("app_error", message)) } @@ -1172,6 +1177,32 @@ mod tests { assert_eq!(err.to_string(), "app_error: Tag group not found: g1"); } + #[test] + fn a_404_on_open_project_hints_at_updating_staged() { + // An app from before `project open` answers `open_project` as it + // answers any unknown command; no protocol bump tells the two apart. + let err = classify( + "open_project", + 404, + serde_json::json!({"error": "command not available through stagedctl: open_project"}), + ) + .unwrap_err(); + assert_eq!(err.exit_code(), 1); + assert_eq!( + err.to_string(), + "app_error: command not available through stagedctl: open_project \ + (this Staged cannot open a project from the command line yet; update it)" + ); + // A current app's own refusal is passed through as it is. + let err = classify( + "open_project", + 400, + serde_json::json!({"error": "Project not found: p1"}), + ) + .unwrap_err(); + assert_eq!(err.to_string(), "app_error: Project not found: p1"); + } + #[test] fn command_errors_render_as_code_colon_message() { let err = CtlError::command("app_error", "Branch not found: xyz"); diff --git a/apps/staged/src-tauri/crates/stagedctl/src/commands.rs b/apps/staged/src-tauri/crates/stagedctl/src/commands.rs index 7b1717749..eb0bb562c 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/commands.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/commands.rs @@ -21,6 +21,7 @@ use crate::client::{ Client, CtlError, STAGEDCTL_LOCK_ENV, }; use crate::grants::{self, GrantFile}; +use crate::open; use crate::tags; /// One command's result: the value `--json` prints, and the human rendering. @@ -100,6 +101,7 @@ pub fn run(cli: Cli) -> Result { match cli.command { Noun::Project(ProjectVerb::List { tag }) => project_list(&client, &tag), Noun::Project(ProjectVerb::Get { project }) => project_get(&client, &project), + Noun::Project(ProjectVerb::Open { project }) => open::project_open(&client, &project), Noun::Project(ProjectVerb::Tags { project }) => tags::project_tags(&client, &project), Noun::Project(ProjectVerb::Tag { project, @@ -746,7 +748,7 @@ pub(crate) fn find_project<'a>( } } -fn resolve_project(client: &Client, needle: &str) -> Result { +pub(crate) fn resolve_project(client: &Client, needle: &str) -> Result { let raw = client.invoke("list_projects", json!({}))?; let projects: Vec = parse(&raw, "project list")?; find_project(&projects, needle).cloned() diff --git a/apps/staged/src-tauri/crates/stagedctl/src/lib.rs b/apps/staged/src-tauri/crates/stagedctl/src/lib.rs index 7cf7be988..9fc15705c 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/lib.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/lib.rs @@ -4,6 +4,8 @@ //! - [`cli`]: the clap tree and all help prose (byte-pinned by tests) //! - [`client`]: discovery file, ping handshake, HTTP transport, exit codes //! - [`commands`]: verb implementations and output rendering +//! - [`open`]: `project open`: asks a window to show a project and words +//! what the window did //! - [`tags`]: the `tag` noun and `project tags`: tag wire shapes, `` //! and `` resolution, and their renderers //! @@ -16,4 +18,5 @@ pub mod cli; pub mod client; pub mod commands; pub mod grants; +pub mod open; pub mod tags; diff --git a/apps/staged/src-tauri/crates/stagedctl/src/open.rs b/apps/staged/src-tauri/crates/stagedctl/src/open.rs new file mode 100644 index 000000000..ae9ba78a5 --- /dev/null +++ b/apps/staged/src-tauri/crates/stagedctl/src/open.rs @@ -0,0 +1,286 @@ +//! `stagedctl project open`: ask a Staged window to show a project, and +//! word what the window did. +//! +//! The app's `open_project` command (`src-tauri/src/project_open/control.rs`) +//! picks the window a Dock click would raise, raises it, asks it to switch, +//! and answers `{ "outcome", "window" }` whatever the window did. A window +//! that refused is a result on the wire, not an error, because the control +//! server's plain errors carry no code. This module is where each outcome +//! becomes an exit code and a `code: message` line. +//! +//! The project is resolved here, before the call, so a name that matches +//! nothing never raises a window. + +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::client::{Client, CtlError}; +use crate::commands::{parse, resolve_project, CmdOutput, Project}; + +/// The dispatch command behind the verb. A Staged from before it answers +/// 404, as for any unknown command, and the transport appends an "update +/// Staged" hint to exactly this name. +pub const OPEN_PROJECT_COMMAND: &str = "open_project"; + +/// What the window did with the request, as `open_project` names it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Outcome { + /// The project is on screen: switched to, or already showing. + Opened, + /// A dialog is open in the window; switching would discard it. + DialogOpen, + /// The diff comment being written could not be saved first. + DiffCommentUnsaved, + /// The window does not have the project (a delete is in flight). + ProjectNotFound, + /// A newer request for the same window replaced this one. + Superseded, + /// The window did not answer within the app's wait. + TimedOut, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OpenAnswer { + outcome: Outcome, + /// The label of the window that was asked and raised. + window: String, +} + +/// `project open `: resolve the name or id, then ask the app. +pub fn project_open(client: &Client, needle: &str) -> Result { + let project = resolve_project(client, needle)?; + open_resolved(&project, client.pid, |args| { + client.invoke(OPEN_PROJECT_COMMAND, args) + }) +} + +/// Send the request for an already-resolved project and map the window's +/// answer. `invoke` is the `open_project` call, passed in so the mapping is +/// testable without an app; `pid` is the instance being asked. +fn open_resolved( + project: &Project, + pid: u32, + invoke: impl FnOnce(Value) -> Result, +) -> Result { + // Only the id crosses the socket; the app looks the project up itself. + let raw = invoke(json!({ "projectId": project.id }))?; + let answer: OpenAnswer = parse(&raw, "open_project answer")?; + let name = &project.name; + match answer.outcome { + Outcome::Opened => Ok(CmdOutput { + json: json!({ + "outcome": "opened", + "project": { "id": project.id, "name": name }, + "window": answer.window, + "pid": pid, + }), + human: format!("opened {name} id:{} pid:{pid}", project.id), + }), + Outcome::DialogOpen => Err(CtlError::command( + "ui_busy", + "Staged has a dialog open; close it and run the command again", + )), + Outcome::DiffCommentUnsaved => Err(CtlError::command( + "ui_busy", + "Staged could not save the diff comment being written; save or discard it in the \ + window and run the command again", + )), + // Not a dialog in the way, but the same class of failure: the + // window was taken up with something else, so nothing was shown + // for this caller, and running the command again is the remedy. + Outcome::Superseded => Err(CtlError::command( + "ui_busy", + format!( + "another `stagedctl project open` reached the same Staged window and replaced \ + this one before the window acted on it, so {name:?} was not shown; run the \ + command again if it still should be" + ), + )), + Outcome::ProjectNotFound => Err(CtlError::command( + "not_found", + format!( + "the Staged window no longer has project {name:?} (id {}); it was probably \ + just deleted (see `stagedctl project list`)", + project.id + ), + )), + Outcome::TimedOut => Err(CtlError::command( + "ui_timeout", + "the Staged window was brought to the front but did not answer in time, so the \ + project it shows may not have changed; run the command again", + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn widgets() -> Project { + Project { + id: "p1".to_string(), + name: "Widgets".to_string(), + github_repo: None, + subpath: None, + location: None, + } + } + + /// `open_resolved` against an app that answers `outcome` from `main`. + fn open_with(outcome: &str) -> Result { + let answer = json!({ "outcome": outcome, "window": "main" }); + open_resolved(&widgets(), 4242, move |_| Ok(answer)) + } + + /// The exit code and stderr line of a refused open. + fn refusal(outcome: &str) -> (i32, String) { + let err = open_with(outcome).expect_err("the outcome is a failure"); + (err.exit_code(), err.to_string()) + } + + #[test] + fn the_request_carries_the_project_id_and_nothing_else() { + let mut sent = None; + open_resolved(&widgets(), 4242, |args| { + sent = Some(args); + Ok(json!({ "outcome": "opened", "window": "main" })) + }) + .unwrap(); + assert_eq!(sent, Some(json!({ "projectId": "p1" }))); + } + + #[test] + fn opened_prints_the_name_the_id_and_the_instance_pid() { + let output = open_with("opened").unwrap(); + assert_eq!(output.human, "opened Widgets id:p1 pid:4242"); + assert_eq!( + output.json, + json!({ + "outcome": "opened", + "project": { "id": "p1", "name": "Widgets" }, + "window": "main", + "pid": 4242, + }) + ); + } + + #[test] + fn a_dialog_or_an_unsaved_diff_comment_is_ui_busy() { + assert_eq!( + refusal("dialogOpen"), + ( + 1, + "ui_busy: Staged has a dialog open; close it and run the command again".to_string() + ) + ); + assert_eq!( + refusal("diffCommentUnsaved"), + ( + 1, + "ui_busy: Staged could not save the diff comment being written; save or \ + discard it in the window and run the command again" + .to_string() + ) + ); + } + + #[test] + fn a_superseded_request_is_ui_busy_with_its_own_message() { + let (exit, line) = refusal("superseded"); + assert_eq!(exit, 1); + assert_eq!( + line, + "ui_busy: another `stagedctl project open` reached the same Staged window and \ + replaced this one before the window acted on it, so \"Widgets\" was not shown; \ + run the command again if it still should be" + ); + assert_ne!(line, refusal("dialogOpen").1); + } + + #[test] + fn a_project_the_window_lost_is_not_found() { + assert_eq!( + refusal("projectNotFound"), + ( + 1, + "not_found: the Staged window no longer has project \"Widgets\" (id p1); it \ + was probably just deleted (see `stagedctl project list`)" + .to_string() + ) + ); + } + + #[test] + fn a_window_that_never_answered_is_ui_timeout() { + assert_eq!( + refusal("timedOut"), + ( + 1, + "ui_timeout: the Staged window was brought to the front but did not answer \ + in time, so the project it shows may not have changed; run the command again" + .to_string() + ) + ); + } + + #[test] + fn every_outcome_the_app_names_is_understood() { + // The names `project_open::control::outcome_name` returns app-side. + for (name, outcome) in [ + ("opened", Outcome::Opened), + ("dialogOpen", Outcome::DialogOpen), + ("diffCommentUnsaved", Outcome::DiffCommentUnsaved), + ("projectNotFound", Outcome::ProjectNotFound), + ("superseded", Outcome::Superseded), + ("timedOut", Outcome::TimedOut), + ] { + let answer: OpenAnswer = + serde_json::from_value(json!({ "outcome": name, "window": "w2" })).unwrap(); + assert_eq!(answer.outcome, outcome, "{name}"); + assert_eq!(answer.window, "w2"); + } + } + + #[test] + fn an_outcome_this_cli_does_not_know_is_drift_not_success() { + let err = open_with("minimized").unwrap_err(); + assert_eq!(err.exit_code(), 2); + assert!( + err.to_string() + .contains("unexpected open_project answer shape"), + "{err}" + ); + // Nor is an answer without an outcome. + let err = open_resolved(&widgets(), 4242, |_| Ok(json!({ "window": "main" }))).unwrap_err(); + assert_eq!(err.exit_code(), 2); + } + + #[test] + fn the_apps_own_refusals_pass_through_untouched() { + // Scope and credential refusals are exit 4 with the app's code. + let err = open_resolved(&widgets(), 4242, |_| { + Err(CtlError::Authorization { + code: "scope_denied".to_string(), + message: "open_project changes the app and this credential is read-only".into(), + }) + }) + .unwrap_err(); + assert_eq!(err.exit_code(), 4); + assert_eq!( + err.to_string(), + "scope_denied: open_project changes the app and this credential is read-only" + ); + // A request the app could not make at all stays the app's error. + let err = open_resolved(&widgets(), 4242, |_| { + Err(CtlError::command( + "app_error", + "Staged has no window to show the project in; restart Staged and try again", + )) + }) + .unwrap_err(); + assert_eq!(err.exit_code(), 1); + assert_eq!(err.code(), "app_error"); + } +} diff --git a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt index 31175de22..a6a81efa2 100644 --- a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt +++ b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt @@ -43,7 +43,7 @@ Exit codes: Usage: stagedctl [OPTIONS] Commands: - project Inspect projects and the repos attached to them + project Inspect projects and the repos attached to them, and show one in the app timeline Read project and repo timelines (commits, notes, reviews, images) session Start, observe, wait for, and cancel agent sessions image Attach images to a repo's timeline @@ -66,13 +66,14 @@ Options: Print version === stagedctl project --help === -Inspect projects and the repos attached to them +Inspect projects and the repos attached to them, and show one in the app Usage: stagedctl project [OPTIONS] Commands: list List every project in the app get Show one project's repos and branches + open Show a project in the Staged window and bring that window to the front tags Show one project's tags: its choice in each group, then its free tags tag Tag a project: pick its tag in a group, or add free tags untag Untag a project: remove the named tags, or clear its choice in a group @@ -124,6 +125,47 @@ the --repo flag of `timeline show`, `session start`, `session list`, and Usage: stagedctl project get [OPTIONS] +Arguments: + + Project name or id (see `stagedctl project list`) + +Options: + --lock-path + Path to the app's control file (defaults to $STAGEDCTL_LOCK, then live ~/.staged/control//discovery.json files, newest first) + + --json + Print machine-readable JSON on stdout instead of human-readable text + + -h, --help + Print help (see a summary with '-h') + +=== stagedctl project open --help === +Show a project in the Staged app. The window a click on Staged's Dock +icon would raise is brought to the front and switched to the project. +Prints the project's name and id and the pid of the Staged instance that +showed it. A project that is already on screen is a success too. + +A that matches nothing (`not_found`), or several projects when +case is ignored (`ambiguous`), exits 1 before any window is touched. + +Otherwise the window is raised whether or not it could switch, so the +user sees what is in the way. When it could not, the command exits 1: + ui_busy a dialog is open in the window, or the diff comment being + written could not be saved: close or finish it and run the + command again. Also when another `project open` replaced + this one before the window acted on it. + ui_timeout the window did not answer in time + not_found the project was deleted meanwhile + +Needs a connected-app grant with write access that reaches the project +(see `stagedctl connect --help`): showing a project marks it read and +records it as the last one shown, so a read-only grant is refused (exit +4, `scope_denied`). Not for Staged-spawned sessions: the app refuses a +session's credential the same way, for the session's own project too, so +an agent cannot pull the user's window away. + +Usage: stagedctl project open [OPTIONS] + Arguments: Project name or id (see `stagedctl project list`) From 37f46369639a4c15cbc0605f80a5ab97bafb0474 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 15:41:11 +1100 Subject: [PATCH 07/14] feat(stagedctl): resolve project open from the current directory Step 7 of the `stagedctl project open` plan: directory resolution. Step 6 added the verb with a required project name or id. From a terminal that is already inside a project's worktree, the user still had to look the project up to open it. `stagedctl project open` with no argument now opens the project whose workspace the current directory is in. Staged keeps a project's worktrees under `~/.staged/workspaces//projects//`, so the id is a segment of the path. In `open.rs`: - `workspace_project_id(path, staged_home)` is the path-to-id mapping. It is purely lexical: it reads nothing from disk or the environment, and takes the Staged home as an input. It matches the project's own directory and anything beneath it, for both locations. A path that climbs with `..` is not matched, since its text does not say where it lands. - `directory_project_id` handles symlinks. The OS reports the current directory by its physical path, while the Staged home comes from `$HOME`, which may reach the same place through a link. When the paths do not match as written, both are canonicalized and compared again. - `target` decides what the verb was pointed at, a name or a directory's id, without the app. A directory outside every project workspace, or a current directory that cannot be read, is `not_in_workspace` (exit 1), in the plan's wording. - `open_target` then confirms the target against the app's project list before `open_project` is sent. An id from a directory is matched on the id alone, never as a name. A directory whose project the app does not have, such as the worktree of a deleted project, is `not_found` and no window is raised. Only an id from the app's list crosses the socket. `commands.rs` settles the target before discovery, as `validate` does for flag rules, so `not_in_workspace` reads the same whether or not Staged is running. Step 8 will launch the app when it is not running, and a directory that names no project must not do that. `list_projects` is split out of `resolve_project` so both kinds of target share one lookup. The project argument becomes optional in `cli.rs`. The help text describes the no-argument form and the help fixture is refreshed. Choices the plan left open: - The older worktree layout, `workspaces/local///`, is not resolved, per the plan's default. It names no project in its path and reads `not_in_workspace`. - A current directory that cannot be read is `not_in_workspace` rather than an environment error, because the remedy is the same: pass a project. - A workspace directory that is itself a symlink to somewhere outside the Staged home is not resolved. The OS reports the link's target, which carries no id, and `$PWD` is not consulted. Tested in `open.rs`: nested directories, both locations, non-workspace paths, the older layout, `..` paths, a non-UTF-8 segment, a symlinked home, a symlinked `~/.staged`, a directory reached through a symlink, each `target` outcome, and unknown ids. The unknown-id and unknown-name tests panic if `open_project` is called. `cli.rs` covers the optional argument. Run by hand with the built binary: a directory outside any workspace gave `not_in_workspace`; a made-up id under a temporary home gave `not_found` listing the real projects; this worktree resolved to its project and reached `open_project`. The running app predates that command and answered with the update hint, so the open itself was not exercised against a live app. No app-side change, no new backend type and no schema change. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- .../src-tauri/crates/stagedctl/src/cli.rs | 36 +- .../crates/stagedctl/src/commands.rs | 35 +- .../src-tauri/crates/stagedctl/src/lib.rs | 4 +- .../src-tauri/crates/stagedctl/src/open.rs | 634 +++++++++++++++++- .../crates/stagedctl/tests/fixtures/help.txt | 16 +- 5 files changed, 694 insertions(+), 31 deletions(-) diff --git a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs index 92e52d36f..40d17c7f4 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs @@ -198,8 +198,16 @@ icon would raise is brought to the front and switched to the project. Prints the project's name and id and the pid of the Staged instance that showed it. A project that is already on screen is a success too. +With no , the project is the one whose workspace the current +directory is in: any directory under +~/.staged/workspaces//projects//, where Staged +keeps a project's worktrees. From any other directory the command exits 1 +(`not_in_workspace`) without looking for the app; pass a there. + A that matches nothing (`not_found`), or several projects when -case is ignored (`ambiguous`), exits 1 before any window is touched. +case is ignored (`ambiguous`), exits 1 before any window is touched. So +does a directory whose project the app does not have (`not_found`), such +as the worktree of a project that was deleted. Otherwise the window is raised whether or not it could switch, so the user sees what is in the way. When it could not, the command exits 1: @@ -217,8 +225,9 @@ records it as the last one shown, so a read-only grant is refused (exit session's credential the same way, for the session's own project too, so an agent cannot pull the user's window away.")] Open { - /// Project name or id (see `stagedctl project list`) - project: String, + /// Project name or id (see `stagedctl project list`); omit it to + /// open the project the current directory belongs to + project: Option, }, /// Show one project's tags: its choice in each group, then its free tags @@ -951,11 +960,11 @@ mod tests { } #[test] - fn project_open_takes_one_project() { + fn project_open_takes_at_most_one_project() { for noun in ["project", "projects"] { let cli = Cli::try_parse_from(["stagedctl", noun, "open", "Widgets"]).unwrap(); assert!( - matches!(cli.command, Noun::Project(ProjectVerb::Open { project }) if project == "Widgets") + matches!(cli.command, Noun::Project(ProjectVerb::Open { project }) if project.as_deref() == Some("Widgets")) ); } // A name with spaces is one argument, and --json stays global. @@ -963,12 +972,19 @@ mod tests { Cli::try_parse_from(["stagedctl", "project", "open", "My Project", "--json"]).unwrap(); assert!(cli.json); assert!( - matches!(cli.command, Noun::Project(ProjectVerb::Open { project }) if project == "My Project") - ); - assert!( - Cli::try_parse_from(["stagedctl", "project", "open"]).is_err(), - "the project is required" + matches!(cli.command, Noun::Project(ProjectVerb::Open { project }) if project.as_deref() == Some("My Project")) ); + // No project means the current directory's, with or without flags. + for args in [ + &["stagedctl", "project", "open"][..], + &["stagedctl", "project", "open", "--json"][..], + ] { + let cli = Cli::try_parse_from(args).unwrap(); + assert!(matches!( + cli.command, + Noun::Project(ProjectVerb::Open { project: None }) + )); + } assert!( Cli::try_parse_from(["stagedctl", "project", "open", "a", "b"]).is_err(), "one project at a time" diff --git a/apps/staged/src-tauri/crates/stagedctl/src/commands.rs b/apps/staged/src-tauri/crates/stagedctl/src/commands.rs index eb0bb562c..e761b3a06 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/commands.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/commands.rs @@ -92,6 +92,24 @@ pub fn run(cli: Cli) -> Result { _ => {} } + // `project open` with no argument takes its project from the current + // directory. Like the flag rules in `validate`, that needs no app, so it + // is settled before discovery: a directory outside every workspace fails + // the same way (exit 1) whether or not a Staged app is running. + if let Noun::Project(ProjectVerb::Open { project }) = &cli.command { + let target = open::target( + project.as_deref(), + std::env::current_dir, + data_dir.as_deref(), + )?; + let client = discover_and_connect( + cli.lock_path.as_deref(), + std::env::var_os(STAGEDCTL_LOCK_ENV), + data_dir.as_deref(), + )?; + return open::project_open(&client, &target); + } + let client = discover_and_connect( cli.lock_path.as_deref(), std::env::var_os(STAGEDCTL_LOCK_ENV), @@ -101,7 +119,6 @@ pub fn run(cli: Cli) -> Result { match cli.command { Noun::Project(ProjectVerb::List { tag }) => project_list(&client, &tag), Noun::Project(ProjectVerb::Get { project }) => project_get(&client, &project), - Noun::Project(ProjectVerb::Open { project }) => open::project_open(&client, &project), Noun::Project(ProjectVerb::Tags { project }) => tags::project_tags(&client, &project), Noun::Project(ProjectVerb::Tag { project, @@ -192,7 +209,11 @@ pub fn run(cli: Cli) -> Result { branch, file, }) => image_add(&client, &project, &repo, branch.as_deref(), &file), - Noun::Connect { .. } | Noun::Disconnect { .. } => unreachable!("handled above"), + Noun::Project(ProjectVerb::Open { .. }) + | Noun::Connect { .. } + | Noun::Disconnect { .. } => { + unreachable!("handled above") + } } } @@ -748,10 +769,14 @@ pub(crate) fn find_project<'a>( } } -pub(crate) fn resolve_project(client: &Client, needle: &str) -> Result { +/// Every project the caller's credential can see. +pub(crate) fn list_projects(client: &Client) -> Result, CtlError> { let raw = client.invoke("list_projects", json!({}))?; - let projects: Vec = parse(&raw, "project list")?; - find_project(&projects, needle).cloned() + parse(&raw, "project list") +} + +pub(crate) fn resolve_project(client: &Client, needle: &str) -> Result { + find_project(&list_projects(client)?, needle).cloned() } struct ResolvedBranch { diff --git a/apps/staged/src-tauri/crates/stagedctl/src/lib.rs b/apps/staged/src-tauri/crates/stagedctl/src/lib.rs index 9fc15705c..efbf647a7 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/lib.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/lib.rs @@ -4,8 +4,8 @@ //! - [`cli`]: the clap tree and all help prose (byte-pinned by tests) //! - [`client`]: discovery file, ping handshake, HTTP transport, exit codes //! - [`commands`]: verb implementations and output rendering -//! - [`open`]: `project open`: asks a window to show a project and words -//! what the window did +//! - [`open`]: `project open`: finds the project, by name, id or the current +//! directory, asks a window to show it and words what the window did //! - [`tags`]: the `tag` noun and `project tags`: tag wire shapes, `` //! and `` resolution, and their renderers //! diff --git a/apps/staged/src-tauri/crates/stagedctl/src/open.rs b/apps/staged/src-tauri/crates/stagedctl/src/open.rs index ae9ba78a5..1b930f394 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/open.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/open.rs @@ -9,13 +9,24 @@ //! becomes an exit code and a `code: message` line. //! //! The project is resolved here, before the call, so a name that matches -//! nothing never raises a window. +//! nothing never raises a window. It comes from the `` argument or, +//! without one, from the current directory: Staged keeps a project's +//! worktrees under `~/.staged/workspaces//projects//`, so +//! the id is a segment of the path ([`workspace_project_id`]). Either way +//! the project is looked up in the app's list first, and only an id from +//! that list crosses the socket, never a path. +//! +//! The older worktree layout, `workspaces/local///`, names no +//! project in its path and is not resolved; a directory there reads +//! `not_in_workspace`. + +use std::path::{Component, Path, PathBuf}; use serde::Deserialize; use serde_json::{json, Value}; use crate::client::{Client, CtlError}; -use crate::commands::{parse, resolve_project, CmdOutput, Project}; +use crate::commands::{find_project, list_projects, parse, CmdOutput, Project}; /// The dispatch command behind the verb. A Staged from before it answers /// 404, as for any unknown command, and the transport appends an "update @@ -48,14 +59,169 @@ struct OpenAnswer { window: String, } -/// `project open `: resolve the name or id, then ask the app. -pub fn project_open(client: &Client, needle: &str) -> Result { - let project = resolve_project(client, needle)?; - open_resolved(&project, client.pid, |args| { - client.invoke(OPEN_PROJECT_COMMAND, args) +/// What `project open` was pointed at. Decided without the app, so a +/// directory that names no project fails before Staged is looked for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Target { + /// The `` argument: a name or an id, matched by the usual + /// rules. + Named(String), + /// No argument: the project id in the current directory's path. + Directory { + /// The id segment as it appears on disk. Not yet known to be a + /// project; [`project_open`] confirms it with the app. + project_id: String, + /// The directory the id was read from, for error messages. + dir: PathBuf, + }, +} + +/// The project id in `path`, when `path` is a project's workspace directory +/// or anything beneath it: `/workspaces// +/// projects//…`. `staged_home` is the app's data directory, `~/.staged`. +/// +/// Purely lexical: nothing is read from disk, so a symlink on either side +/// is the caller's to resolve ([`directory_project_id`]). A path that +/// climbs with `..` after the Staged home is not matched, since where it +/// lands cannot be told from its text. +/// +/// The segment is returned as written. Whether a project has that id is +/// for the app to say. +pub(crate) fn workspace_project_id<'a>(path: &'a Path, staged_home: &Path) -> Option<&'a str> { + let rest = path.strip_prefix(staged_home).ok()?; + if rest + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return None; + } + let mut segments = rest.components().map(Component::as_os_str); + if segments.next()? != "workspaces" { + return None; + } + let location = segments.next()?; + if location != "local" && location != "remote" { + return None; + } + if segments.next()? != "projects" { + return None; + } + segments.next()?.to_str() +} + +/// [`workspace_project_id`] for a directory on disk. The directory is +/// usually the physical path the OS reports for the current directory, +/// while the Staged home comes from `$HOME`, which may reach the same place +/// through a symlink (a linked home, or `~/.staged` kept on another +/// volume). So when the paths do not match as written, both are resolved +/// and compared again. +fn directory_project_id(dir: &Path, staged_home: &Path) -> Option { + if let Some(id) = workspace_project_id(dir, staged_home) { + return Some(id.to_owned()); + } + let dir = dir.canonicalize().ok()?; + let staged_home = staged_home.canonicalize().ok()?; + workspace_project_id(&dir, &staged_home).map(str::to_owned) +} + +/// Decide the [`Target`] from the optional `` argument. Without +/// one, `current_dir` is asked for the directory and its project id is read +/// from the path under `staged_home`. +/// +/// Needs no app. A directory outside every project workspace, or one that +/// cannot be determined, is `not_in_workspace` (exit 1). +pub fn target( + project: Option<&str>, + current_dir: impl FnOnce() -> std::io::Result, + staged_home: Option<&Path>, +) -> Result { + if let Some(needle) = project { + return Ok(Target::Named(needle.to_owned())); + } + let dir = current_dir().map_err(|e| { + CtlError::command( + "not_in_workspace", + format!( + "cannot determine the current directory ({e}); pass a project name or id (see \ + `stagedctl project list`)" + ), + ) + })?; + let staged_home = staged_home + .ok_or_else(|| CtlError::Environment("cannot determine the home directory".into()))?; + match directory_project_id(&dir, staged_home) { + Some(project_id) => Ok(Target::Directory { project_id, dir }), + None => Err(CtlError::command( + "not_in_workspace", + format!( + "{} is not inside a Staged project workspace; pass a project name or id (see \ + `stagedctl project list`)", + dir.display() + ), + )), + } +} + +/// The project whose workspace `dir` is in, from the app's list. Matched on +/// the id alone: the segment is an id by construction, so a project that +/// merely has it as a name is not the one. +fn project_for_directory<'a>( + projects: &'a [Project], + project_id: &str, + dir: &Path, +) -> Result<&'a Project, CtlError> { + projects.iter().find(|p| p.id == project_id).ok_or_else(|| { + let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect(); + let dir = dir.display(); + CtlError::command( + "not_found", + if names.is_empty() { + format!( + "{dir} is in the workspace of project id {project_id}, which Staged \ + does not have; the project was probably deleted, and the app lists no \ + projects" + ) + } else { + format!( + "{dir} is in the workspace of project id {project_id}, which Staged \ + does not have; the project was probably deleted. Projects: {}", + names.join(", ") + ) + }, + ) }) } +/// `project open []`: find the project in the app's list, then ask +/// the app to show it. +pub fn project_open(client: &Client, target: &Target) -> Result { + open_target( + target, + client.pid, + || list_projects(client), + |args| client.invoke(OPEN_PROJECT_COMMAND, args), + ) +} + +/// Resolve `target` against the app's project list, then send the request. +/// `list` is the `list_projects` call and `invoke` the `open_project` one, +/// both passed in so the order is testable without an app: a target that +/// matches no project fails after `list` and never reaches `invoke`, which +/// is what raises a window. +fn open_target( + target: &Target, + pid: u32, + list: impl FnOnce() -> Result, CtlError>, + invoke: impl FnOnce(Value) -> Result, +) -> Result { + let projects = list()?; + let project = match target { + Target::Named(needle) => find_project(&projects, needle)?, + Target::Directory { project_id, dir } => project_for_directory(&projects, project_id, dir)?, + }; + open_resolved(project, pid, invoke) +} + /// Send the request for an already-resolved project and map the window's /// answer. `invoke` is the `open_project` call, passed in so the mapping is /// testable without an app; `pid` is the instance being asked. @@ -118,16 +284,464 @@ fn open_resolved( mod tests { use super::*; - fn widgets() -> Project { + fn project(id: &str, name: &str) -> Project { Project { - id: "p1".to_string(), - name: "Widgets".to_string(), + id: id.to_string(), + name: name.to_string(), github_repo: None, subpath: None, location: None, } } + fn widgets() -> Project { + project("p1", "Widgets") + } + + /// A project id as the app mints them. + const ID: &str = "7b23e070-efd3-4568-8671-bd532a5bbf9d"; + + /// The Staged home the lexical tests use. Nothing is read from disk, so + /// it need not exist. + fn home() -> PathBuf { + PathBuf::from("/Users/me/.staged") + } + + fn id_at(path: &str) -> Option { + workspace_project_id(Path::new(path), &home()).map(str::to_owned) + } + + /// An `open_project` call that must not happen. + fn no_open(_: Value) -> Result { + panic!("open_project was called, which raises a window") + } + + fn opened(_: Value) -> Result { + Ok(json!({ "outcome": "opened", "window": "main" })) + } + + // ------------------------------------------------------------------- + // Path to project id + // ------------------------------------------------------------------- + + #[test] + fn a_project_workspace_and_everything_beneath_it_names_the_project() { + for path in [ + // The project's own directory, with and without a trailing slash. + "/Users/me/.staged/workspaces/local/projects/7b23e070-efd3-4568-8671-bd532a5bbf9d", + "/Users/me/.staged/workspaces/local/projects/7b23e070-efd3-4568-8671-bd532a5bbf9d/", + // A worktree root. + "/Users/me/.staged/workspaces/local/projects/7b23e070-efd3-4568-8671-bd532a5bbf9d/block-builderbot--my-branch", + // Nested directories inside a worktree, however deep. + "/Users/me/.staged/workspaces/local/projects/7b23e070-efd3-4568-8671-bd532a5bbf9d/block-builderbot--my-branch/apps/staged", + "/Users/me/.staged/workspaces/local/projects/7b23e070-efd3-4568-8671-bd532a5bbf9d/block-builderbot--my-branch/apps/staged/src-tauri/crates/stagedctl/src", + // A directory that happens to repeat the layout further down + // still belongs to the outer project. + "/Users/me/.staged/workspaces/local/projects/7b23e070-efd3-4568-8671-bd532a5bbf9d/wt/workspaces/local/projects/other", + ] { + assert_eq!(id_at(path).as_deref(), Some(ID), "{path}"); + } + } + + #[test] + fn local_and_remote_workspaces_both_resolve() { + assert_eq!( + id_at("/Users/me/.staged/workspaces/local/projects/p1/wt").as_deref(), + Some("p1") + ); + assert_eq!( + id_at("/Users/me/.staged/workspaces/remote/projects/p2").as_deref(), + Some("p2") + ); + assert_eq!( + id_at("/Users/me/.staged/workspaces/cloud/projects/p3"), + None + ); + } + + #[test] + fn a_trailing_slash_on_the_staged_home_changes_nothing() { + let path = Path::new("/Users/me/.staged/workspaces/local/projects/p1/wt"); + assert_eq!( + workspace_project_id(path, Path::new("/Users/me/.staged/")), + Some("p1") + ); + } + + #[test] + fn paths_outside_a_project_workspace_name_no_project() { + for path in [ + // Nowhere near Staged. + "/Users/me/code/builderbot", + "/", + // The Staged home and the levels above a project id. + "/Users/me/.staged", + "/Users/me/.staged/workspaces", + "/Users/me/.staged/workspaces/local", + "/Users/me/.staged/workspaces/local/projects", + "/Users/me/.staged/workspaces/local/projects/", + // Other things Staged keeps in its home. + "/Users/me/.staged/repos/block/builderbot", + "/Users/me/.staged/control/4242/sessions", + "/Users/me/.staged/projects/p1", + // The right segments in the wrong place. + "/Users/me/.staged/local/projects/p1", + "/Users/me/.staged/workspaces/projects/p1", + "/Users/me/.staged/workspaces/local/local/projects/p1", + // Segment names are matched exactly. + "/Users/me/.staged/Workspaces/local/projects/p1", + "/Users/me/.staged/workspaces/local/project/p1", + // A sibling whose name merely starts with the home's. + "/Users/me/.staged-backup/workspaces/local/projects/p1", + // Someone else's Staged home. + "/Users/you/.staged/workspaces/local/projects/p1", + // Relative paths are not placed anywhere. + "workspaces/local/projects/p1", + ".staged/workspaces/local/projects/p1", + ] { + assert_eq!(id_at(path), None, "{path}"); + } + } + + #[test] + fn the_older_worktree_layout_names_no_project() { + // `workspaces/local///` carries no id; resolving + // it is deferred, so it must not be mistaken for the new layout, + // even for a repo that is itself called `projects`. + assert_eq!( + id_at("/Users/me/.staged/workspaces/local/builderbot/my-branch"), + None + ); + assert_eq!( + id_at("/Users/me/.staged/workspaces/local/builderbot/my-branch/apps/staged"), + None + ); + // Not told apart from the new layout by its text: the segment is + // returned, and the app's project list is what rejects it. + assert_eq!( + id_at("/Users/me/.staged/workspaces/local/projects/my-branch/src").as_deref(), + Some("my-branch") + ); + } + + #[test] + fn a_path_that_climbs_with_dot_dot_is_not_matched_lexically() { + // Really `/Users/me/.staged/repos`; its text must not name `p1`. + assert_eq!( + id_at("/Users/me/.staged/workspaces/local/projects/p1/../../../../repos"), + None + ); + assert_eq!( + id_at("/Users/me/.staged/workspaces/local/projects/.."), + None + ); + // `.` segments change nothing. + assert_eq!( + id_at("/Users/me/.staged/./workspaces/local/./projects/p1/.").as_deref(), + Some("p1") + ); + } + + #[cfg(unix)] + #[test] + fn an_id_segment_that_is_not_utf8_names_no_project() { + use std::os::unix::ffi::OsStrExt; + let path = home() + .join("workspaces/local/projects") + .join(std::ffi::OsStr::from_bytes(b"p\xff1")) + .join("wt"); + assert_eq!(workspace_project_id(&path, &home()), None); + } + + // ------------------------------------------------------------------- + // Symlinks between the directory and the Staged home + // ------------------------------------------------------------------- + + /// A temp root as the OS reports it (macOS reaches its temp directory + /// through a symlink), so only the links a test makes are in play. + #[cfg(unix)] + fn real_tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().canonicalize().unwrap(); + (dir, real) + } + + #[cfg(unix)] + #[test] + fn a_symlinked_home_still_resolves() { + // `$HOME` is a link (`/home/me` -> `/data/home/me`), while the OS + // reports the current directory by its physical path. + let (_guard, root) = real_tempdir(); + let worktree = root.join("data/me/.staged/workspaces/local/projects/p1/repo--branch/src"); + std::fs::create_dir_all(&worktree).unwrap(); + std::os::unix::fs::symlink(root.join("data/me"), root.join("home")).unwrap(); + let staged_home = root.join("home/.staged"); + + assert_eq!(workspace_project_id(&worktree, &staged_home), None); + assert_eq!( + directory_project_id(&worktree, &staged_home).as_deref(), + Some("p1") + ); + } + + #[cfg(unix)] + #[test] + fn a_symlinked_staged_home_still_resolves() { + // `~/.staged` itself kept on another volume. + let (_guard, root) = real_tempdir(); + let worktree = root.join("volume/staged/workspaces/remote/projects/p2"); + std::fs::create_dir_all(&worktree).unwrap(); + std::fs::create_dir_all(root.join("home")).unwrap(); + std::os::unix::fs::symlink(root.join("volume/staged"), root.join("home/.staged")).unwrap(); + + assert_eq!( + directory_project_id(&worktree, &root.join("home/.staged")).as_deref(), + Some("p2") + ); + } + + #[cfg(unix)] + #[test] + fn a_directory_reached_through_a_symlink_resolves_to_where_it_is() { + let (_guard, root) = real_tempdir(); + let staged_home = root.join("home/.staged"); + let worktree = staged_home.join("workspaces/local/projects/p1/repo--branch"); + std::fs::create_dir_all(&worktree).unwrap(); + std::fs::create_dir_all(root.join("code")).unwrap(); + std::os::unix::fs::symlink(&worktree, root.join("code/link")).unwrap(); + + assert_eq!( + directory_project_id(&root.join("code/link"), &staged_home).as_deref(), + Some("p1") + ); + // A `..` is followed on disk rather than read as text. + std::fs::create_dir_all(staged_home.join("repos")).unwrap(); + assert_eq!( + directory_project_id(&worktree.join("../../../../../repos"), &staged_home), + None + ); + assert_eq!( + directory_project_id(&worktree.join("../../p1/repo--branch"), &staged_home).as_deref(), + Some("p1") + ); + } + + #[cfg(unix)] + #[test] + fn resolving_symlinks_does_not_invent_a_workspace() { + let (_guard, root) = real_tempdir(); + let staged_home = root.join("home/.staged"); + std::fs::create_dir_all(staged_home.join("workspaces/local/projects")).unwrap(); + std::fs::create_dir_all(root.join("code/app")).unwrap(); + + assert_eq!( + directory_project_id(&root.join("code/app"), &staged_home), + None + ); + assert_eq!( + directory_project_id(&staged_home.join("workspaces/local/projects"), &staged_home), + None + ); + // A Staged home that does not exist contains nothing. + assert_eq!( + directory_project_id(&root.join("code/app"), &root.join("nobody/.staged")), + None + ); + } + + // ------------------------------------------------------------------- + // Choosing the target + // ------------------------------------------------------------------- + + #[test] + fn a_project_argument_is_the_target_and_the_directory_is_never_read() { + let target = target( + Some("Widgets"), + || panic!("the current directory is not needed"), + None, + ) + .unwrap(); + assert_eq!(target, Target::Named("Widgets".to_string())); + } + + #[test] + fn no_argument_takes_the_project_from_the_current_directory() { + let dir = home() + .join("workspaces/local/projects") + .join(ID) + .join("wt/src"); + let target = target(None, || Ok(dir.clone()), Some(&home())).unwrap(); + assert_eq!( + target, + Target::Directory { + project_id: ID.to_string(), + dir, + } + ); + } + + #[test] + fn a_directory_outside_every_workspace_is_not_in_workspace() { + let err = target(None, || Ok(PathBuf::from("/Users/me/code")), Some(&home())).unwrap_err(); + assert_eq!(err.exit_code(), 1); + assert_eq!( + err.to_string(), + "not_in_workspace: /Users/me/code is not inside a Staged project workspace; pass a \ + project name or id (see `stagedctl project list`)" + ); + // So is a worktree in the older layout. + let old = home().join("workspaces/local/builderbot/my-branch"); + let err = target(None, || Ok(old), Some(&home())).unwrap_err(); + assert_eq!(err.code(), "not_in_workspace"); + } + + #[test] + fn a_current_directory_that_cannot_be_read_is_not_in_workspace() { + let err = target( + None, + || Err(std::io::Error::from(std::io::ErrorKind::NotFound)), + Some(&home()), + ) + .unwrap_err(); + assert_eq!(err.exit_code(), 1); + assert_eq!(err.code(), "not_in_workspace"); + assert!( + err.to_string() + .starts_with("not_in_workspace: cannot determine the current directory ("), + "{err}" + ); + assert!( + err.to_string() + .ends_with("pass a project name or id (see `stagedctl project list`)"), + "{err}" + ); + } + + #[test] + fn no_argument_and_no_home_directory_is_an_environment_error() { + let err = target(None, || Ok(PathBuf::from("/Users/me/code")), None).unwrap_err(); + assert_eq!(err.exit_code(), 3); + assert_eq!(err.to_string(), "cannot determine the home directory"); + } + + // ------------------------------------------------------------------- + // Confirming the target with the app + // ------------------------------------------------------------------- + + fn directory(project_id: &str) -> Target { + Target::Directory { + project_id: project_id.to_string(), + dir: home() + .join("workspaces/local/projects") + .join(project_id) + .join("wt"), + } + } + + #[test] + fn a_directory_target_opens_the_project_with_that_id() { + let projects = vec![project("p0", "Gadgets"), widgets()]; + let mut sent = None; + let output = open_target( + &directory("p1"), + 4242, + || Ok(projects), + |args| { + sent = Some(args); + opened(Value::Null) + }, + ) + .unwrap(); + assert_eq!(sent, Some(json!({ "projectId": "p1" }))); + // The same output as when the project was named. + assert_eq!(output.human, "opened Widgets id:p1 pid:4242"); + } + + #[test] + fn a_directory_whose_project_the_app_does_not_have_is_not_found_and_raises_nothing() { + let projects = vec![project("p0", "Gadgets"), widgets()]; + let err = open_target(&directory("gone"), 4242, || Ok(projects), no_open).unwrap_err(); + assert_eq!(err.exit_code(), 1); + assert_eq!( + err.to_string(), + "not_found: /Users/me/.staged/workspaces/local/projects/gone/wt is in the workspace \ + of project id gone, which Staged does not have; the project was probably deleted. \ + Projects: Gadgets, Widgets" + ); + + let err = open_target(&directory("gone"), 4242, || Ok(vec![]), no_open).unwrap_err(); + assert_eq!(err.code(), "not_found"); + assert!( + err.to_string().ends_with("and the app lists no projects"), + "{err}" + ); + } + + #[test] + fn a_directory_id_is_never_matched_against_project_names() { + // A directory called `Widgets` under `projects/` is not project p1, + // whatever case it is written in. + for segment in ["Widgets", "widgets"] { + let err = open_target(&directory(segment), 4242, || Ok(vec![widgets()]), no_open) + .unwrap_err(); + assert_eq!(err.code(), "not_found", "{segment}"); + } + } + + #[test] + fn a_named_target_keeps_the_name_rules_and_fails_before_any_window() { + let projects = || { + Ok(vec![ + widgets(), + project("p2", "widgets"), + project("p3", "Gears"), + ]) + }; + for needle in ["p3", "Gears", "gears"] { + let mut sent = None; + open_target(&Target::Named(needle.to_string()), 4242, projects, |args| { + sent = Some(args); + opened(Value::Null) + }) + .unwrap(); + assert_eq!(sent, Some(json!({ "projectId": "p3" })), "{needle}"); + } + let err = + open_target(&Target::Named("nope".to_string()), 4242, projects, no_open).unwrap_err(); + assert_eq!((err.exit_code(), err.code()), (1, "not_found")); + let err = open_target( + &Target::Named("WIDGETS".to_string()), + 4242, + projects, + no_open, + ) + .unwrap_err(); + assert_eq!((err.exit_code(), err.code()), (1, "ambiguous")); + } + + #[test] + fn a_project_list_that_fails_is_the_error_and_raises_nothing() { + for target in [Target::Named("Widgets".to_string()), directory("p1")] { + let err = open_target( + &target, + 4242, + || { + Err(CtlError::Authorization { + code: "authorization_required".to_string(), + message: "run `stagedctl connect --client ` once, then retry".into(), + }) + }, + no_open, + ) + .unwrap_err(); + assert_eq!((err.exit_code(), err.code()), (4, "authorization_required")); + } + } + + // ------------------------------------------------------------------- + // Wording the window's answer + // ------------------------------------------------------------------- + /// `open_resolved` against an app that answers `outcome` from `main`. fn open_with(outcome: &str) -> Result { let answer = json!({ "outcome": outcome, "window": "main" }); diff --git a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt index a6a81efa2..270e5a167 100644 --- a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt +++ b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt @@ -145,8 +145,16 @@ icon would raise is brought to the front and switched to the project. Prints the project's name and id and the pid of the Staged instance that showed it. A project that is already on screen is a success too. +With no , the project is the one whose workspace the current +directory is in: any directory under +~/.staged/workspaces//projects//, where Staged +keeps a project's worktrees. From any other directory the command exits 1 +(`not_in_workspace`) without looking for the app; pass a there. + A that matches nothing (`not_found`), or several projects when -case is ignored (`ambiguous`), exits 1 before any window is touched. +case is ignored (`ambiguous`), exits 1 before any window is touched. So +does a directory whose project the app does not have (`not_found`), such +as the worktree of a project that was deleted. Otherwise the window is raised whether or not it could switch, so the user sees what is in the way. When it could not, the command exits 1: @@ -164,11 +172,11 @@ records it as the last one shown, so a read-only grant is refused (exit session's credential the same way, for the session's own project too, so an agent cannot pull the user's window away. -Usage: stagedctl project open [OPTIONS] +Usage: stagedctl project open [OPTIONS] [PROJECT] Arguments: - - Project name or id (see `stagedctl project list`) + [PROJECT] + Project name or id (see `stagedctl project list`); omit it to open the project the current directory belongs to Options: --lock-path From 96ece1d7236cf08b85a70eba5f8329beabd8c6ef Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 15:56:04 +1100 Subject: [PATCH 08/14] feat(stagedctl): start Staged from project open when it is not running Step 8 of the `stagedctl project open` plan: launch when not running. Steps 6 and 7 added the verb, by name, id or the current directory, but it needed an app that was already up. With Staged closed it failed with the same "not running under a Staged app" error as every other verb, so the one command a user runs to get to a project could not get there. `stagedctl project open` now starts Staged when no instance is running, waits for the new instance's control server to answer the ping, and then sends the request as before. The app already holds the request until its window's listener is live. `--no-launch` exits 3 instead. The new `launch` module keeps four things apart, so the tests never start the real app: - `launch_target` is the decision. In order: `--no-launch` (exit 3), no credential (exit 4, `authorization_required`), no bundle to start (exit 3). Otherwise it names the bundle. - `bundle_for_executable` reads the bundle off the CLI's own path: `.app/Contents/MacOS/`. It is purely lexical. `LaunchEnv::real` resolves symlinks first, because `/usr/local/bin/stagedctl` is a link into the bundle. - `wait_for_instance` polls for a discovery file that was not there before the launch and passes the handshake, for up to 30 seconds. - `Launcher` is the one spawn. The real one runs `/usr/bin/open ` and nothing else, on macOS only. `connect_or_launch` joins them, and `open::run` is now the verb from the top: target first, then find or start Staged, then open. What may be started, per the plan's "Security" and "Behaviour by state": - Only the bundle this executable is in. Nothing from the arguments or the environment names it, and `open` is run by absolute path. - Never for a caller with no credential. A grant can only be proven to a running app, so the precheck is that an unexpired grant file exists on this machine. Per the plan's default, the consent dialog is not triggered; the message points at `stagedctl connect`. - Never a development build, whose executable is not in a bundle and whose app needs its dev server. The message says to run `just dev`. - Never for a directory outside every project workspace. Step 7's order is kept: `open::target` runs before the app is looked for, so `not_in_workspace` reads the same with or without `--no-launch`. Choices the plan left open: - A named control file never starts an app. `--lock-path` and `$STAGEDCTL_LOCK` point at one instance, and a fresh app cannot make that file valid. So a spawned session never launches one. - "Not running" needs to be told from "running but unusable", such as an app that speaks another protocol version. Starting a second Staged beside that one is not the remedy. `CtlError` gains `NotRunning` for the three failures that mean no app is there: no live instance directory, a control file that is gone, and an endpoint nothing answers. It exits 3 and prints exactly as `Environment` did, so no other verb's output changes. Staged is started only when every candidate failed that way, which also covers a crashed instance's leftover directory. - The wait skips the candidates that were on disk before the launch, by path and mtime, so a leftover directory costs nothing per poll and a rewritten file at the same path still counts as new. A new instance that is not answering yet is retried; one that answers but cannot be used fails at once. - The precheck does not read a grant's access or scope. Which grant the app accepts is decided at reconnect, so a prediction here could disagree with it. A read-only grant therefore still starts Staged and then reads the app's `scope_denied`. - A name is looked up in the app's list, so a name that matches nothing is `not_found` only after Staged has started. - Instance choice stays newest-first and the pid is still printed. A line on stderr says Staged is being started; stdout is unchanged. `client.rs` also gains `live_discovery_candidates`, the existing live-pid listing with each file's mtime, and its test module's fake servers and fixtures become `pub(crate)` for the launch tests. The help text for `project open` describes the launch and each case in which nothing is started, and the help fixture is refreshed. The guide and the `staged` launcher script are step 9. Tested in `launch.rs`: bundle paths (installed, renamed, nested, development builds, unresolved links, relative and `..` paths, a real symlink), the `open` invocation without running it, each decision and its order, the precheck, which candidates count as running, the wait (a file that appears later, stale entries never probed, a rewritten file, a retry, a fast failure, a timeout), and `connect_or_launch` with a running app, none, a leftover directory, `--no-launch`, no grant, a development build, an unusable app, a named control file, a failed launch and a launch that never answers. `open.rs` covers `run`: a start followed by the two app calls, a workspace directory, an unknown name, `--no-launch`, and a directory outside every workspace with a launcher that panics if reached. `client.rs` pins which failures are `NotRunning`, and `cli.rs` the flag. Run by hand with the debug binary under a temporary home, with `$STAGEDCTL_LOCK` unset: no grant gave `authorization_required` (exit 4), `--no-launch` gave exit 3, a grant file gave the `just dev` refusal (exit 3), a missing `--lock-path` gave the usual control-file error, and a directory outside any workspace gave `not_in_workspace`. Not run against a real app. Every successful launch above is against a fake launcher and a fake control server: the real `open` call, the wait on a real cold start, and the request reaching a window that is still loading were not exercised. This session runs inside Staged, so the app was not started, quit or restarted. CLI only: no app-side change, no schema change and no backend type. The new types (`Launcher`, `LaunchEnv`, `DiscoveryCandidate`, the `NotRunning` variant) are in the stagedctl crate. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- .../src-tauri/crates/stagedctl/src/cli.rs | 56 +- .../src-tauri/crates/stagedctl/src/client.rs | 125 +- .../crates/stagedctl/src/commands.rs | 22 +- .../src-tauri/crates/stagedctl/src/launch.rs | 1173 +++++++++++++++++ .../src-tauri/crates/stagedctl/src/lib.rs | 3 + .../src-tauri/crates/stagedctl/src/open.rs | 264 +++- .../crates/stagedctl/tests/fixtures/help.txt | 24 + 7 files changed, 1629 insertions(+), 38 deletions(-) create mode 100644 apps/staged/src-tauri/crates/stagedctl/src/launch.rs diff --git a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs index 40d17c7f4..40bb433d4 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs @@ -223,11 +223,36 @@ Needs a connected-app grant with write access that reaches the project records it as the last one shown, so a read-only grant is refused (exit 4, `scope_denied`). Not for Staged-spawned sessions: the app refuses a session's credential the same way, for the session's own project too, so -an agent cannot pull the user's window away.")] +an agent cannot pull the user's window away. + +When Staged is not running, the command starts it, waits for it to come +up, and then asks as above; the app holds the request until its window +has loaded. What it starts is the Staged.app this stagedctl is bundled +in, and nothing else. A line on stderr says so; stdout is unchanged. + +Staged is never started: + - with --no-launch: the command exits 3 instead; + - when --lock-path or $STAGEDCTL_LOCK names a control file. That + points at one instance, and when it is gone the command exits 3, so + a spawned session never starts an app; + - when this machine holds no unexpired connected-app grant (exit 4, + `authorization_required`). Start Staged and run `stagedctl connect` + once; the consent dialog is not raised for you; + - from a development build, whose app needs its dev server (exit 3). + Run `just dev`; + - beside a Staged that is running but cannot be used, such as one + that was updated underneath this CLI (exit 3). +A name is looked up only once the app is up, so a name that +matches nothing still exits 1 (`not_found`) after Staged has started. A +directory outside every project workspace never starts it.")] Open { /// Project name or id (see `stagedctl project list`); omit it to /// open the project the current directory belongs to project: Option, + + /// Exit 3 instead of starting Staged when it is not running + #[arg(long)] + no_launch: bool, }, /// Show one project's tags: its choice in each group, then its free tags @@ -964,7 +989,7 @@ mod tests { for noun in ["project", "projects"] { let cli = Cli::try_parse_from(["stagedctl", noun, "open", "Widgets"]).unwrap(); assert!( - matches!(cli.command, Noun::Project(ProjectVerb::Open { project }) if project.as_deref() == Some("Widgets")) + matches!(cli.command, Noun::Project(ProjectVerb::Open { project, no_launch: false }) if project.as_deref() == Some("Widgets")) ); } // A name with spaces is one argument, and --json stays global. @@ -972,7 +997,7 @@ mod tests { Cli::try_parse_from(["stagedctl", "project", "open", "My Project", "--json"]).unwrap(); assert!(cli.json); assert!( - matches!(cli.command, Noun::Project(ProjectVerb::Open { project }) if project.as_deref() == Some("My Project")) + matches!(cli.command, Noun::Project(ProjectVerb::Open { project, no_launch: false }) if project.as_deref() == Some("My Project")) ); // No project means the current directory's, with or without flags. for args in [ @@ -982,9 +1007,32 @@ mod tests { let cli = Cli::try_parse_from(args).unwrap(); assert!(matches!( cli.command, - Noun::Project(ProjectVerb::Open { project: None }) + Noun::Project(ProjectVerb::Open { + project: None, + no_launch: false + }) )); } + // --no-launch goes either side of the project, or with none. + for args in [ + &["stagedctl", "project", "open", "--no-launch", "Widgets"][..], + &["stagedctl", "project", "open", "Widgets", "--no-launch"][..], + ] { + let cli = Cli::try_parse_from(args).unwrap(); + assert!( + matches!(cli.command, Noun::Project(ProjectVerb::Open { project, no_launch: true }) if project.as_deref() == Some("Widgets")) + ); + } + let cli = Cli::try_parse_from(["stagedctl", "project", "open", "--no-launch"]).unwrap(); + assert!(matches!( + cli.command, + Noun::Project(ProjectVerb::Open { + project: None, + no_launch: true + }) + )); + // It is `project open`'s alone: no other verb starts the app. + assert!(Cli::try_parse_from(["stagedctl", "project", "list", "--no-launch"]).is_err()); assert!( Cli::try_parse_from(["stagedctl", "project", "open", "a", "b"]).is_err(), "one project at a time" diff --git a/apps/staged/src-tauri/crates/stagedctl/src/client.rs b/apps/staged/src-tauri/crates/stagedctl/src/client.rs index 22cc57cc4..c7b41de42 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/client.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/client.rs @@ -74,9 +74,16 @@ pub enum CtlError { Command { code: &'static str, message: String }, /// The app was reachable at handshake time but a call later failed. Transport(String), - /// No usable app: not spawned by Staged, the app exited or restarted, - /// or the app speaks a different protocol version. + /// No usable app: not spawned by Staged, the app restarted, or the app + /// speaks a different protocol version. Environment(String), + /// No app at all, as far as one control file (or the lack of any) can + /// say: there is no live instance directory, the file is gone, or + /// nothing answers at the endpoint it names. Reported exactly like + /// [`Environment`](Self::Environment) (exit 3). It is its own variant + /// because `project open` starts Staged on it, and must not on an app + /// that is running but unusable (see [`crate::launch`]). + NotRunning(String), /// The app answered but refused the credential: none was sent, it was /// revoked or expired, the user denied it, or the command addresses /// something outside the credential's scope. `code` is one of @@ -110,7 +117,7 @@ impl CtlError { Self::Command { code, .. } => code, Self::Authorization { code, .. } => code, Self::Transport(_) => "transport", - Self::Environment(_) => "environment", + Self::Environment(_) | Self::NotRunning(_) => "environment", Self::Partial { error, .. } => error.code(), } } @@ -119,7 +126,9 @@ impl CtlError { fn message(&self) -> String { match self { Self::Command { message, .. } | Self::Authorization { message, .. } => message.clone(), - Self::Transport(message) | Self::Environment(message) => message.clone(), + Self::Transport(message) | Self::Environment(message) | Self::NotRunning(message) => { + message.clone() + } Self::Partial { error, .. } => error.message(), } } @@ -153,7 +162,7 @@ impl CtlError { match self { Self::Command { .. } => 1, Self::Transport(_) => 2, - Self::Environment(_) => 3, + Self::Environment(_) | Self::NotRunning(_) => 3, Self::Authorization { .. } => 4, Self::Partial { error, .. } => error.exit_code(), } @@ -165,7 +174,7 @@ impl std::fmt::Display for CtlError { match self { Self::Command { code, message } => write!(f, "{code}: {message}"), Self::Transport(message) => write!(f, "transport: {message}"), - Self::Environment(message) => write!(f, "{message}"), + Self::Environment(message) | Self::NotRunning(message) => write!(f, "{message}"), Self::Authorization { code, message } => write!(f, "{code}: {message}"), Self::Partial { error, .. } => write!(f, "{error}"), } @@ -250,7 +259,7 @@ pub fn resolve_lock_candidates( } let candidates = data_dir.map(live_discovery_files).unwrap_or_default(); if candidates.is_empty() { - return Err(CtlError::Environment( + return Err(CtlError::NotRunning( "not running under a Staged app: $STAGEDCTL_LOCK is not set and no running \ Staged instance was found (no live control//discovery.json under \ ~/.staged). Start the Staged app, or pass --lock-path." @@ -337,6 +346,23 @@ pub fn session_credential_in_use( /// candidate — they try each in order until the ping handshake picks out a /// real control server. fn live_discovery_files(data_dir: &Path) -> Vec { + live_discovery_candidates(data_dir) + .into_iter() + .map(|candidate| candidate.path) + .collect() +} + +/// One live-pid `control//discovery.json` and when it was last +/// written. The pair tells a file the app has since rewritten from the one +/// seen earlier at the same path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DiscoveryCandidate { + pub(crate) path: PathBuf, + pub(crate) modified: std::time::SystemTime, +} + +/// [`live_discovery_files`] with each file's mtime, in the same order. +pub(crate) fn live_discovery_candidates(data_dir: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(data_dir.join("control")) else { return Vec::new(); }; @@ -360,7 +386,10 @@ fn live_discovery_files(data_dir: &Path) -> Vec { candidates.push((mtime, path)); } candidates.sort_by(|(a, _), (b, _)| b.cmp(a)); - candidates.into_iter().map(|(_, path)| path).collect() + candidates + .into_iter() + .map(|(modified, path)| DiscoveryCandidate { path, modified }) + .collect() } #[cfg(unix)] @@ -400,7 +429,7 @@ pub fn read_discovery(path: &Path) -> Result { Err(e) => last_err = format!("cannot parse {}: {e}", path.display()), }, Err(ReadError::Missing(e)) => { - return Err(CtlError::Environment(format!( + return Err(CtlError::NotRunning(format!( "cannot read control file {}: {e} — the Staged app that wrote it has \ probably exited; restart Staged or re-run from a fresh session", path.display() @@ -636,7 +665,7 @@ impl Client { } Err(e) => { if attempt > 0 { - return Err(CtlError::Environment(format!( + return Err(CtlError::NotRunning(format!( "cannot reach the Staged app at {} ({e}) — the app has probably \ exited; restart Staged or re-run from a fresh session", discovery.endpoint() @@ -1077,8 +1106,10 @@ fn authorization_code(body: &Value) -> Option<&str> { .filter(|code| AUTHORIZATION_CODES.contains(code)) } +// `pub(crate)` so the launch tests can stand up the same fake control +// servers and on-disk fixtures. #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use std::io::{Read, Write}; use std::net::TcpListener; @@ -1135,6 +1166,11 @@ mod tests { assert_eq!(CtlError::command("app_error", "boom").exit_code(), 1); assert_eq!(CtlError::Transport("boom".into()).exit_code(), 2); assert_eq!(CtlError::Environment("boom".into()).exit_code(), 3); + // "Not running" is the environment exit and reads the same way. + let not_running = CtlError::NotRunning("boom".into()); + assert_eq!(not_running.exit_code(), 3); + assert_eq!(not_running.code(), "environment"); + assert_eq!(not_running.to_string(), "boom"); assert_eq!( CtlError::Authorization { code: "scope_denied".into(), @@ -1238,7 +1274,7 @@ mod tests { /// A data dir the hardened reader accepts: `tempfile` creates /// directories with the default (umask) mode, not `0700`. - fn private_tempdir() -> tempfile::TempDir { + pub(crate) fn private_tempdir() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); make_private_dir(dir.path()); dir @@ -1286,7 +1322,12 @@ mod tests { /// A TCP-fallback discovery file (no token, as a v1-shaped server fake /// would answer) under the v2 layout. - fn write_tcp_discovery(data_dir: &Path, pid: u32, port: u16, generation: u64) -> PathBuf { + pub(crate) fn write_tcp_discovery( + data_dir: &Path, + pid: u32, + port: u16, + generation: u64, + ) -> PathBuf { let path = instance_dir(data_dir, pid).join("discovery.json"); write_private( &path, @@ -1417,7 +1458,7 @@ mod tests { /// Backdate a file's mtime by `age` so candidate ordering is /// deterministic regardless of filesystem timestamp granularity. - fn set_mtime(path: &Path, age: Duration) { + pub(crate) fn set_mtime(path: &Path, age: Duration) { std::fs::OpenOptions::new() .write(true) .open(path) @@ -1611,7 +1652,7 @@ mod tests { } /// Loopback-TCP fake (the app's fallback transport). - fn fake_server(mut handler: F) -> (u16, std::thread::JoinHandle<()>) + pub(crate) fn fake_server(mut handler: F) -> (u16, std::thread::JoinHandle<()>) where F: FnMut(&str) -> (u16, String) + Send + 'static, { @@ -1635,7 +1676,7 @@ mod tests { /// Loopback-TCP fake whose handler also sees the request body (the /// credential routes carry their arguments there). - fn fake_server_with_body(mut handler: F) -> (u16, std::thread::JoinHandle<()>) + pub(crate) fn fake_server_with_body(mut handler: F) -> (u16, std::thread::JoinHandle<()>) where F: FnMut(&str, &str) -> (u16, String) + Send + 'static, { @@ -1669,7 +1710,7 @@ mod tests { // Connected-app grants // ----------------------------------------------------------------- - fn grant_file(id: &str, secret: &str, expires_at: i64) -> crate::grants::GrantFile { + pub(crate) fn grant_file(id: &str, secret: &str, expires_at: i64) -> crate::grants::GrantFile { crate::grants::GrantFile { grant_id: id.to_string(), reconnect_secret: secret.to_string(), @@ -1694,7 +1735,7 @@ mod tests { /// one fixed nonce per challenge, checking the proof on reconnect /// exactly as `control_server::enrol` does. Records every /// `Authorization` header it sees. - fn grant_aware_server( + pub(crate) fn grant_aware_server( pid: u32, known: Vec<(String, String)>, renew_to: i64, @@ -2391,7 +2432,7 @@ mod tests { ); } - fn ping_body(pid: u32, generation: u64, protocol_version: u32) -> String { + pub(crate) fn ping_body(pid: u32, generation: u64, protocol_version: u32) -> String { serde_json::json!({ "ok": true, "pid": pid, @@ -2401,7 +2442,7 @@ mod tests { .to_string() } - fn header_value<'a>(head: &'a str, name: &str) -> Option<&'a str> { + pub(crate) fn header_value<'a>(head: &'a str, name: &str) -> Option<&'a str> { head.lines().find_map(|l| { let (n, v) = l.split_once(':')?; n.eq_ignore_ascii_case(name).then(|| v.trim()) @@ -2611,6 +2652,50 @@ mod tests { ); } + #[test] + fn only_an_app_that_is_not_there_is_not_running() { + // `project open` starts Staged on `NotRunning` and on nothing + // else, so which failures carry it is pinned here. + let not_running = |err: &CtlError| matches!(err, CtlError::NotRunning(_)); + let pid = std::process::id(); + let dir = private_tempdir(); + + // Not there: no instance directory at all, a control file that is + // gone, and one whose endpoint nothing listens on. + let err = resolve_lock_candidates(None, None, Some(dir.path())).unwrap_err(); + assert!(not_running(&err), "no candidates: {err:?}"); + let err = Client::connect(&dir.path().join("control/1/discovery.json")).unwrap_err(); + assert!(not_running(&err), "missing file: {err:?}"); + let closed_port = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let dead = write_tcp_discovery(dir.path(), pid, closed_port, 1); + let err = Client::connect(&dead).unwrap_err(); + assert!(not_running(&err), "dead endpoint: {err:?}"); + assert_eq!(err.exit_code(), 3); + + // There, but not usable: another protocol version, another + // generation, a file that is not safe to read, an empty + // `$STAGEDCTL_LOCK`. + let (port, _handle) = fake_server(move |_| (200, ping_body(pid, 1, PROTOCOL_VERSION + 1))); + let updated = write_tcp_discovery(dir.path(), pid, port, 1); + let err = Client::connect(&updated).unwrap_err(); + assert!(!not_running(&err), "protocol mismatch: {err:?}"); + let (port, _handle) = fake_server(move |_| (200, ping_body(pid, 99, PROTOCOL_VERSION))); + let restarted = write_tcp_discovery(dir.path(), pid, port, 1); + let err = Client::connect(&restarted).unwrap_err(); + assert!(!not_running(&err), "stale generation: {err:?}"); + let corrupt = dir.path().join("control").join("corrupt.json"); + write_private(&corrupt, "{"); + let err = Client::connect(&corrupt).unwrap_err(); + assert!(!not_running(&err), "corrupt file: {err:?}"); + let err = resolve_lock_candidates(None, Some(std::ffi::OsString::new()), Some(dir.path())) + .unwrap_err(); + assert!(!not_running(&err), "empty $STAGEDCTL_LOCK: {err:?}"); + } + #[test] fn persistent_generation_mismatch_reads_as_stale_control_file() { let pid = std::process::id(); diff --git a/apps/staged/src-tauri/crates/stagedctl/src/commands.rs b/apps/staged/src-tauri/crates/stagedctl/src/commands.rs index e761b3a06..a34b4c8aa 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/commands.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/commands.rs @@ -21,6 +21,7 @@ use crate::client::{ Client, CtlError, STAGEDCTL_LOCK_ENV, }; use crate::grants::{self, GrantFile}; +use crate::launch; use crate::open; use crate::tags; @@ -92,22 +93,19 @@ pub fn run(cli: Cli) -> Result { _ => {} } - // `project open` with no argument takes its project from the current - // directory. Like the flag rules in `validate`, that needs no app, so it - // is settled before discovery: a directory outside every workspace fails - // the same way (exit 1) whether or not a Staged app is running. - if let Noun::Project(ProjectVerb::Open { project }) = &cli.command { - let target = open::target( + // `project open` finds its own app: it is the one verb that starts + // Staged when none is running (see `launch`), and it settles what it was + // pointed at before looking for one. + if let Noun::Project(ProjectVerb::Open { project, no_launch }) = &cli.command { + return open::run( project.as_deref(), - std::env::current_dir, - data_dir.as_deref(), - )?; - let client = discover_and_connect( + *no_launch, cli.lock_path.as_deref(), std::env::var_os(STAGEDCTL_LOCK_ENV), + std::env::current_dir, data_dir.as_deref(), - )?; - return open::project_open(&client, &target); + &launch::LaunchEnv::real(), + ); } let client = discover_and_connect( diff --git a/apps/staged/src-tauri/crates/stagedctl/src/launch.rs b/apps/staged/src-tauri/crates/stagedctl/src/launch.rs new file mode 100644 index 000000000..9195895b5 --- /dev/null +++ b/apps/staged/src-tauri/crates/stagedctl/src/launch.rs @@ -0,0 +1,1173 @@ +//! Starting Staged for `stagedctl project open` when no instance is running. +//! +//! Every other verb needs an app that is already up. `project open` is the +//! one a user runs to get to Staged, so when nothing answers it starts the +//! app itself, waits for the new instance's control server, and hands back +//! a client for the request to go out on. The app parks that request until +//! its window has loaded (`src-tauri/src/project_open.rs`). +//! +//! What may be started, and for whom: +//! +//! - **Only the bundle this executable is in.** `stagedctl` ships as a +//! sidecar at `Staged.app/Contents/MacOS/stagedctl`, so the bundle is read +//! off the executable's own resolved path ([`bundle_for_executable`]). +//! Nothing from the arguments or the environment names what is started, +//! and `open` is run by absolute path. +//! - **Never a development build.** Its executable is not inside a bundle, +//! and a dev binary needs its Vite server; the user is told to run +//! `just dev` (exit 3). +//! - **Never for a caller with no credential.** A grant can only be proven +//! to a running app, so the check before launching is that an unexpired +//! grant file exists on this machine. Without one the app would start and +//! then refuse the command; instead nothing starts and the caller is told +//! to run `stagedctl connect` (exit 4). The consent dialog is not raised +//! on the caller's behalf. +//! - **Never when a control file was named.** `--lock-path` and +//! `$STAGEDCTL_LOCK` point at one instance; a fresh app cannot make that +//! file valid. So a spawned session never starts an app. +//! - **Never over an app that is there but unusable.** A running Staged +//! that speaks another protocol version, say, is reported as it always +//! was. Only [`CtlError::NotRunning`] from every candidate counts as "not +//! running". +//! +//! The pieces are kept apart so the tests never start the real app: the +//! decision ([`launch_target`]), the bundle path ([`bundle_for_executable`]) +//! and the wait ([`wait_for_instance`]) take what they need as arguments, +//! and the one function that spawns a process sits behind [`Launcher`]. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use crate::client::{ + discover_and_connect, live_discovery_candidates, Client, CtlError, DiscoveryCandidate, +}; +use crate::grants::{self, GrantFile}; + +/// How long a started Staged is given to bring its control server up. The +/// server starts during app setup, ahead of the window's first paint, so +/// this covers a cold start with room to spare. +pub const LAUNCH_TIMEOUT: Duration = Duration::from_secs(30); + +/// The pause between looks for the new instance's discovery file. +const LAUNCH_POLL_PAUSE: Duration = Duration::from_millis(200); + +/// The step that starts a process. Everything else in this module is +/// reachable without one. +pub trait Launcher { + /// Start the app bundle at `bundle` and return once the request to + /// start it has been made, not once the app is up. An `Err` is the + /// reason, for the user. + fn launch(&self, bundle: &Path) -> Result<(), String>; +} + +/// The real [`Launcher`]: macOS `open`, by absolute path so `$PATH` cannot +/// substitute another program. LaunchServices starts the app, so it does +/// not inherit this process's environment. +pub struct OpenCommand; + +/// Where macOS keeps `open`. +#[cfg(any(target_os = "macos", test))] +const OPEN_PROGRAM: &str = "/usr/bin/open"; + +/// The `open` invocation for `bundle`: the bundle path and nothing else. +#[cfg(any(target_os = "macos", test))] +fn open_command(bundle: &Path) -> std::process::Command { + let mut command = std::process::Command::new(OPEN_PROGRAM); + command + .arg(bundle) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()); + command +} + +impl Launcher for OpenCommand { + #[cfg(target_os = "macos")] + fn launch(&self, bundle: &Path) -> Result<(), String> { + let output = open_command(bundle) + .output() + .map_err(|e| format!("cannot run {OPEN_PROGRAM}: {e}"))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + Err(if stderr.is_empty() { + format!("{OPEN_PROGRAM} failed ({})", output.status) + } else { + format!("{OPEN_PROGRAM} failed ({}): {stderr}", output.status) + }) + } + + #[cfg(not(target_os = "macos"))] + fn launch(&self, _bundle: &Path) -> Result<(), String> { + Err("starting Staged from the command line is only supported on macOS".to_string()) + } +} + +/// What the launch path takes from the machine it runs on. Tests supply +/// their own, so no real app is started and no wait runs to its full +/// length. +pub struct LaunchEnv<'a> { + /// This executable's path with symlinks resolved (`/usr/local/bin/ + /// stagedctl` is a link into the bundle), or `None` when the OS could + /// not say. + pub executable: Option, + pub launcher: &'a dyn Launcher, + /// How long to wait for the started app's control server. + pub timeout: Duration, + /// The pause between looks for it. + pub poll_pause: Duration, +} + +impl LaunchEnv<'static> { + /// The running process's own: its executable, `open`, and the default + /// wait. + pub fn real() -> Self { + Self { + executable: std::env::current_exe() + .and_then(|path| path.canonicalize()) + .ok(), + launcher: &OpenCommand, + timeout: LAUNCH_TIMEOUT, + poll_pause: LAUNCH_POLL_PAUSE, + } + } +} + +/// The app bundle `executable` is the sidecar of: +/// `.app/Contents/MacOS/` gives `.app`. +/// +/// Purely lexical; the caller resolves symlinks first. Anything else names +/// no bundle: a development build under `target/`, a loose copy of the +/// binary, a relative path, or a path that climbs with `..`. +pub(crate) fn bundle_for_executable(executable: &Path) -> Option<&Path> { + if !executable.is_absolute() { + return None; + } + executable.file_name()?; + let macos = executable.parent()?; + if macos.file_name()? != "MacOS" { + return None; + } + let contents = macos.parent()?; + if contents.file_name()? != "Contents" { + return None; + } + let bundle = contents.parent()?; + (bundle.extension()? == "app").then_some(bundle) +} + +/// Whether a grant on this machine could still authenticate. This is all +/// that can be checked with no app to prove a grant to: the file exists and +/// has not expired. Whether the app still honours it, and whether it +/// reaches the project, is the app's to say once it is up. +pub(crate) fn has_unexpired_grant(grants: &[GrantFile], now_ms: i64) -> bool { + grants.iter().any(|grant| !grant.is_expired_at(now_ms)) +} + +/// Decide what to do about a Staged that is not running: the bundle to +/// start, or the reason nothing is started. +/// +/// In order: `--no-launch` (exit 3), then no credential (exit 4, +/// `authorization_required`), then no bundle to start (exit 3). `grants` is +/// only read when `--no-launch` did not already settle it. +pub(crate) fn launch_target( + no_launch: bool, + grants: impl FnOnce() -> Vec, + now_ms: i64, + executable: Option<&Path>, +) -> Result { + if no_launch { + return Err(CtlError::NotRunning( + "Staged is not running, and --no-launch says not to start it; start Staged and run \ + the command again" + .to_string(), + )); + } + if !has_unexpired_grant(&grants(), now_ms) { + return Err(CtlError::Authorization { + code: "authorization_required".to_string(), + message: "Staged is not running, and this machine holds no unexpired connected-app \ + grant to open a project with, so Staged was not started; start Staged, \ + run `stagedctl connect --client ` once, then retry" + .to_string(), + }); + } + let Some(executable) = executable else { + return Err(CtlError::Environment( + "Staged is not running, and stagedctl could not determine its own location to \ + find the app it belongs to; start Staged and run the command again" + .to_string(), + )); + }; + match bundle_for_executable(executable) { + Some(bundle) => Ok(bundle.to_path_buf()), + None => Err(CtlError::Environment(format!( + "Staged is not running, and this stagedctl ({}) is not inside a Staged.app bundle, \ + so it has no app to start; a development build needs its dev server, so run \ + `just dev`, or start Staged, and run the command again", + executable.display() + ))), + } +} + +/// Connect to the first of `candidates` that passes the handshake. +/// +/// `Ok(None)` means no Staged is running: there are no candidates, or every +/// one failed as [`CtlError::NotRunning`] (a crashed instance's leftover +/// directory, say). A candidate that failed any other way is an app that is +/// there but unusable; the newest such failure is the error, and nothing is +/// started over it. +fn find_running( + candidates: &[DiscoveryCandidate], + mut connect: impl FnMut(&Path) -> Result, +) -> Result, CtlError> { + let mut unusable: Option = None; + for candidate in candidates { + match connect(&candidate.path) { + Ok(client) => return Ok(Some(client)), + Err(CtlError::NotRunning(_)) => {} + Err(e) => { + unusable.get_or_insert(e); + } + } + } + match unusable { + Some(e) => Err(e), + None => Ok(None), + } +} + +/// Wait for a started Staged: poll `candidates` until one that is not in +/// `stale` passes the handshake, or `timeout` runs out (exit 3). +/// +/// `stale` is what was on disk before the launch, every entry already found +/// dead. Those are skipped, so a leftover directory costs nothing per poll; +/// a file the app has rewritten since (same path, new mtime) is new again. +/// A new candidate that is not answering yet is tried again next round. One +/// that fails any other way will not get better by waiting, so its error is +/// returned at once. +fn wait_for_instance( + stale: &[DiscoveryCandidate], + timeout: Duration, + poll_pause: Duration, + mut candidates: impl FnMut() -> Vec, + mut connect: impl FnMut(&Path) -> Result, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + for candidate in candidates() { + if stale.contains(&candidate) { + continue; + } + match connect(&candidate.path) { + Ok(client) => return Ok(client), + Err(CtlError::NotRunning(_)) => {} + Err(e) => return Err(e), + } + } + if Instant::now() >= deadline { + return Err(launch_timed_out(timeout)); + } + std::thread::sleep(poll_pause); + } +} + +/// The error for a started Staged whose control server never answered. Not +/// [`CtlError::NotRunning`]: the app was started, so starting it again is +/// not the remedy. +fn launch_timed_out(timeout: Duration) -> CtlError { + CtlError::Environment(format!( + "Staged was started but its control server did not answer within {} seconds; run the \ + command again once its window is up", + timeout.as_secs() + )) +} + +/// A verified, authenticated client for `project open`, starting Staged +/// first when none is running. `lock_flag` and `lock_env` are `--lock-path` +/// and `$STAGEDCTL_LOCK`; `data_dir` is `~/.staged`. +/// +/// With an explicit control file this is [`discover_and_connect`] and +/// nothing is ever started. Otherwise each live instance is tried, newest +/// first, as for every other verb, and only when none is running does +/// [`launch_target`] decide whether to start one. +pub fn connect_or_launch( + lock_flag: Option<&Path>, + lock_env: Option, + data_dir: Option<&Path>, + no_launch: bool, + env: &LaunchEnv<'_>, +) -> Result { + let data_dir = match data_dir { + Some(data_dir) if lock_flag.is_none() && lock_env.is_none() => data_dir, + _ => return discover_and_connect(lock_flag, lock_env, data_dir), + }; + let before = live_discovery_candidates(data_dir); + let mut client = match find_running(&before, Client::connect)? { + Some(client) => client, + None => { + let bundle = launch_target( + no_launch, + || { + grants::read_grant_files(data_dir) + .into_iter() + .map(|(_, grant)| grant) + .collect() + }, + grants::now_ms(), + env.executable.as_deref(), + )?; + eprintln!( + "stagedctl: Staged is not running; starting {}", + bundle.display() + ); + env.launcher.launch(&bundle).map_err(|reason| { + CtlError::Environment(format!( + "could not start Staged at {}: {reason}; start it and run the command again", + bundle.display() + )) + })?; + wait_for_instance( + &before, + env.timeout, + env.poll_pause, + || live_discovery_candidates(data_dir), + Client::connect, + )? + } + }; + // Every candidate is an instance's `discovery.json`, which carries no + // session credential, so the grant lookup applies. + client.attach_grant_credential(data_dir)?; + Ok(client) +} + +// `pub(crate)` so the `project open` tests can reuse the fake launchers. +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::client::tests::{ + fake_server, grant_aware_server, grant_file, ping_body, private_tempdir, set_mtime, + write_tcp_discovery, + }; + use crate::client::PROTOCOL_VERSION; + use std::net::TcpListener; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + // ------------------------------------------------------------------- + // Fixtures. Nothing here starts a process: the launchers are fakes, and + // an "app" is a fake control server plus the discovery file a real one + // would write. + // ------------------------------------------------------------------- + + /// Where an installed `stagedctl` resolves to. Only ever read as text. + pub(crate) const BUNDLED_EXECUTABLE: &str = "/Applications/Staged.app/Contents/MacOS/stagedctl"; + pub(crate) const BUNDLE: &str = "/Applications/Staged.app"; + /// Where a development build's `stagedctl` is. + const DEV_EXECUTABLE: &str = + "/Users/me/builderbot/apps/staged/src-tauri/target/debug/stagedctl"; + + /// A launcher that must not be reached. + pub(crate) struct NeverLaunch; + + impl Launcher for NeverLaunch { + fn launch(&self, bundle: &Path) -> Result<(), String> { + panic!( + "Staged was started ({}), which must not happen", + bundle.display() + ) + } + } + + /// A launcher that records what it was asked to start and then runs + /// `on_launch` in place of the app coming up. + pub(crate) struct FakeLauncher { + pub(crate) launched: Mutex>, + on_launch: F, + } + + impl Result<(), String>> FakeLauncher { + pub(crate) fn new(on_launch: F) -> Self { + Self { + launched: Mutex::new(Vec::new()), + on_launch, + } + } + + pub(crate) fn launched(&self) -> Vec { + self.launched.lock().unwrap().clone() + } + } + + impl Result<(), String>> Launcher for FakeLauncher { + fn launch(&self, bundle: &Path) -> Result<(), String> { + self.launched.lock().unwrap().push(bundle.to_path_buf()); + (self.on_launch)() + } + } + + /// A launch environment for tests: a wait long enough for a loaded + /// machine, polled often. + pub(crate) fn test_env<'a>( + executable: Option<&str>, + launcher: &'a dyn Launcher, + ) -> LaunchEnv<'a> { + LaunchEnv { + executable: executable.map(PathBuf::from), + launcher, + timeout: Duration::from_secs(20), + poll_pause: Duration::from_millis(20), + } + } + + /// An unexpired grant id the fake app knows, and its secret. + pub(crate) const GRANT_ID: &str = "4e77e4"; + const GRANT_SECRET: &str = "s3cret"; + + /// Store an unexpired grant under `data_dir`, as `stagedctl connect` + /// would have. + pub(crate) fn store_grant(data_dir: &Path) { + let expires_at = grants::now_ms() + 60 * 60 * 1000; + grants::write_grant_file(data_dir, &grant_file(GRANT_ID, GRANT_SECRET, expires_at)) + .unwrap(); + } + + /// A fake Staged that knows [`GRANT_ID`], listening but not yet + /// discoverable: its port, for the discovery file to name. + fn grant_aware_app() -> u16 { + let renew_to = grants::now_ms() + grants::GRANT_LIFETIME_MS - 1000; + let (port, _handle) = grant_aware_server( + std::process::id(), + vec![(GRANT_ID.to_string(), GRANT_SECRET.to_string())], + renew_to, + Arc::new(Mutex::new(Vec::new())), + ); + port + } + + /// A port nothing listens on. + fn closed_port() -> u16 { + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() + } + + /// A crashed instance's leftover: pid 1 is alive, so the directory + /// looks live, but nothing answers at its port. + fn write_zombie(data_dir: &Path) -> PathBuf { + write_tcp_discovery(data_dir, 1, closed_port(), 1) + } + + fn candidate(path: &str) -> DiscoveryCandidate { + DiscoveryCandidate { + path: PathBuf::from(path), + modified: std::time::UNIX_EPOCH, + } + } + + fn not_running() -> CtlError { + CtlError::NotRunning("nothing there".to_string()) + } + + // ------------------------------------------------------------------- + // Bundle path + // ------------------------------------------------------------------- + + fn bundle_of(executable: &str) -> Option<&str> { + bundle_for_executable(Path::new(executable)).and_then(Path::to_str) + } + + #[test] + fn a_sidecar_names_the_bundle_it_is_in() { + for (executable, bundle) in [ + (BUNDLED_EXECUTABLE, BUNDLE), + ( + "/Users/me/Applications/Staged.app/Contents/MacOS/stagedctl", + "/Users/me/Applications/Staged.app", + ), + // A bundle built in the repo. + ( + "/Users/me/builderbot/apps/staged/src-tauri/target/release/bundle/macos/Staged.app/Contents/MacOS/stagedctl", + "/Users/me/builderbot/apps/staged/src-tauri/target/release/bundle/macos/Staged.app", + ), + // Renamed by the user, with a space. + ( + "/Applications/Staged Beta.app/Contents/MacOS/stagedctl", + "/Applications/Staged Beta.app", + ), + // The bundle is found by where the executable is, not by what + // the executable is called. + ( + "/Applications/Staged.app/Contents/MacOS/stagedctl-aarch64-apple-darwin", + BUNDLE, + ), + // A bundle inside another bundle is the inner one. + ( + "/Applications/Outer.app/Contents/Resources/Staged.app/Contents/MacOS/stagedctl", + "/Applications/Outer.app/Contents/Resources/Staged.app", + ), + ] { + assert_eq!(bundle_of(executable), Some(bundle), "{executable}"); + } + } + + #[test] + fn an_executable_outside_a_bundle_names_none() { + for executable in [ + // Development builds. + DEV_EXECUTABLE, + "/Users/me/.cache/cargo-target/release/stagedctl", + // An install link that was not resolved, or a loose copy. + "/usr/local/bin/stagedctl", + "/Users/me/.staged/control/4242/bin/stagedctl", + // The right segments in the wrong place. + "/Applications/Staged.app/Contents/Resources/stagedctl", + "/Applications/Staged.app/Contents/MacOS/helpers/stagedctl", + "/Applications/Staged.app/MacOS/stagedctl", + "/Applications/Staged.app/stagedctl", + "/Applications/Staged/Contents/MacOS/stagedctl", + // Segment names are matched exactly. + "/Applications/Staged.app/Contents/macos/stagedctl", + "/Applications/Staged.app/contents/MacOS/stagedctl", + "/Applications/Staged.APP/Contents/MacOS/stagedctl", + "/Applications/Staged.application/Contents/MacOS/stagedctl", + // A directory called `.app` has no extension. + "/Applications/.app/Contents/MacOS/stagedctl", + // The directory itself is not an executable in it. + "/Applications/Staged.app/Contents/MacOS", + "/Applications/Staged.app/Contents/MacOS/", + // Where a path that climbs lands cannot be told from its text. + "/Applications/Staged.app/Contents/MacOS/../stagedctl", + "/Applications/Staged.app/Contents/MacOS/..", + // A relative path is never handed to `open`. + "Staged.app/Contents/MacOS/stagedctl", + "./Staged.app/Contents/MacOS/stagedctl", + "stagedctl", + "", + "/", + ] { + assert_eq!(bundle_of(executable), None, "{executable}"); + } + } + + #[cfg(unix)] + #[test] + fn an_install_link_resolves_to_the_bundle_it_points_into() { + // `/usr/local/bin/stagedctl` is a symlink to the sidecar. The OS + // may report the link as the executable, so `LaunchEnv::real` + // resolves it before the bundle is read off the path. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let macos = root.join("Applications/Staged.app/Contents/MacOS"); + std::fs::create_dir_all(&macos).unwrap(); + std::fs::write(macos.join("stagedctl"), "").unwrap(); + std::fs::create_dir_all(root.join("bin")).unwrap(); + let link = root.join("bin/stagedctl"); + std::os::unix::fs::symlink(macos.join("stagedctl"), &link).unwrap(); + + assert_eq!(bundle_for_executable(&link), None, "the link as written"); + let resolved = link.canonicalize().unwrap(); + assert_eq!( + bundle_for_executable(&resolved), + Some(root.join("Applications/Staged.app").as_path()) + ); + } + + // ------------------------------------------------------------------- + // The spawn, without spawning + // ------------------------------------------------------------------- + + #[test] + fn open_is_run_by_absolute_path_with_the_bundle_and_nothing_else() { + let bundle = Path::new("/Applications/Staged Beta.app"); + let command = open_command(bundle); + assert_eq!(command.get_program(), "/usr/bin/open"); + // One argument, spaces and all: no shell, no flags, no project. + let args: Vec<_> = command.get_args().collect(); + assert_eq!(args, vec![bundle.as_os_str()]); + assert_eq!(command.get_envs().count(), 0, "no environment is added"); + } + + // ------------------------------------------------------------------- + // The decision: start Staged, or say why not + // ------------------------------------------------------------------- + + const NOW: i64 = 1_800_000_000_000; + + fn unexpired() -> Vec { + vec![grant_file(GRANT_ID, GRANT_SECRET, NOW + 1)] + } + + fn no_grants_read() -> Vec { + panic!("the grants were read, though --no-launch had already settled it") + } + + #[test] + fn a_grant_and_a_bundle_start_that_bundle() { + let bundle = + launch_target(false, unexpired, NOW, Some(Path::new(BUNDLED_EXECUTABLE))).unwrap(); + assert_eq!(bundle, Path::new(BUNDLE)); + } + + #[test] + fn no_launch_is_exit_3_whatever_else_is_true() { + for executable in [Some(BUNDLED_EXECUTABLE), Some(DEV_EXECUTABLE), None] { + let err = + launch_target(true, no_grants_read, NOW, executable.map(Path::new)).unwrap_err(); + assert_eq!(err.exit_code(), 3, "{err}"); + assert!(matches!(err, CtlError::NotRunning(_)), "{err:?}"); + assert_eq!( + err.to_string(), + "Staged is not running, and --no-launch says not to start it; start Staged and \ + run the command again" + ); + } + } + + #[test] + fn the_credential_precheck_wants_one_unexpired_grant() { + assert!(!has_unexpired_grant(&[], NOW)); + // A grant expires at its `expires_at`, not after it. + assert!(!has_unexpired_grant(&[grant_file("a1", "s", NOW)], NOW)); + assert!(!has_unexpired_grant(&[grant_file("a1", "s", NOW - 1)], NOW)); + assert!(has_unexpired_grant(&[grant_file("a1", "s", NOW + 1)], NOW)); + // One live grant among dead ones is enough. + assert!(has_unexpired_grant( + &[ + grant_file("a1", "s", NOW - 1), + grant_file("b2", "s", NOW + 1), + grant_file("c3", "s", 0), + ], + NOW + )); + } + + #[test] + fn no_credential_is_exit_4_and_points_at_connect() { + let expired = grant_file(GRANT_ID, GRANT_SECRET, NOW - 1); + for (case, grants) in [ + ("no grant files", Vec::new()), + ("only an expired grant", vec![expired]), + ] { + let grants = move || grants; + // Even from a bundle: the caller could not use the app it + // would start. + let err = + launch_target(false, grants, NOW, Some(Path::new(BUNDLED_EXECUTABLE))).unwrap_err(); + assert_eq!(err.exit_code(), 4, "{case}: {err}"); + assert_eq!(err.code(), "authorization_required", "{case}"); + assert_eq!( + err.to_string(), + "authorization_required: Staged is not running, and this machine holds no \ + unexpired connected-app grant to open a project with, so Staged was not \ + started; start Staged, run `stagedctl connect --client ` once, then retry", + "{case}" + ); + } + } + + #[test] + fn the_credential_is_checked_before_the_bundle() { + // No grant and no bundle: the answer is the credential (exit 4), + // so an unauthorized caller learns nothing about the install. + for executable in [Some(DEV_EXECUTABLE), None] { + let err = launch_target(false, Vec::new, NOW, executable.map(Path::new)).unwrap_err(); + assert_eq!(err.exit_code(), 4, "{err}"); + } + } + + #[test] + fn a_development_build_is_exit_3_with_the_just_dev_hint() { + let err = + launch_target(false, unexpired, NOW, Some(Path::new(DEV_EXECUTABLE))).unwrap_err(); + assert_eq!(err.exit_code(), 3, "{err}"); + let message = err.to_string(); + assert!(message.contains("`just dev`"), "got: {message}"); + assert!(message.contains(DEV_EXECUTABLE), "got: {message}"); + assert!( + message.contains("not inside a Staged.app bundle"), + "got: {message}" + ); + } + + #[test] + fn an_executable_the_os_could_not_name_is_exit_3() { + let err = launch_target(false, unexpired, NOW, None).unwrap_err(); + assert_eq!(err.exit_code(), 3, "{err}"); + assert!( + err.to_string() + .contains("could not determine its own location"), + "got: {err}" + ); + } + + // ------------------------------------------------------------------- + // Is a Staged running? + // ------------------------------------------------------------------- + + #[test] + fn no_candidates_or_only_dead_ones_mean_not_running() { + let none = find_running(&[], |path| panic!("probed {}", path.display())).unwrap(); + assert!(none.is_none()); + + let probed = AtomicUsize::new(0); + let dead = find_running(&[candidate("/a"), candidate("/b")], |_| { + probed.fetch_add(1, Ordering::SeqCst); + Err(not_running()) + }) + .unwrap(); + assert!(dead.is_none()); + assert_eq!(probed.load(Ordering::SeqCst), 2, "every candidate is tried"); + } + + #[test] + fn an_app_that_is_there_but_unusable_is_an_error_not_an_absence() { + // The newest candidate is a leftover; an older one answers but + // cannot be used. That is not "not running", so nothing may be + // started over it, and its error is the one reported. + let err = find_running( + &[candidate("/zombie"), candidate("/updated"), candidate("/c")], + |path| { + Err(match path.to_str().unwrap() { + "/zombie" => not_running(), + "/updated" => CtlError::Environment("app updated".to_string()), + _ => CtlError::Environment("a later failure".to_string()), + }) + }, + ) + .unwrap_err(); + assert_eq!(err.to_string(), "app updated"); + assert_eq!(err.exit_code(), 3); + } + + #[test] + fn the_first_instance_that_answers_is_the_one_and_later_ones_are_not_tried() { + let pid = std::process::id(); + let (port, _handle) = fake_server(move |_| (200, ping_body(pid, 1, PROTOCOL_VERSION))); + let dir = private_tempdir(); + let live = write_tcp_discovery(dir.path(), pid, port, 1); + let candidates = [ + DiscoveryCandidate { + path: live.clone(), + modified: std::time::UNIX_EPOCH, + }, + candidate("/never"), + ]; + + let client = find_running(&candidates, |path| { + assert_eq!(path, live, "only the first candidate is probed"); + Client::connect(path) + }) + .unwrap() + .expect("the fake app answers"); + assert_eq!(client.pid, pid); + } + + // ------------------------------------------------------------------- + // The wait + // ------------------------------------------------------------------- + + #[test] + fn the_wait_finds_an_app_whose_discovery_file_appears_later() { + let pid = std::process::id(); + let (port, _handle) = fake_server(move |_| (200, ping_body(pid, 7, PROTOCOL_VERSION))); + let dir = private_tempdir(); + let data_dir = dir.path().to_path_buf(); + let polls = AtomicUsize::new(0); + + // The app "comes up" after a few empty polls. + let writer = { + let data_dir = data_dir.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(150)); + write_tcp_discovery(&data_dir, pid, port, 7); + }) + }; + let client = wait_for_instance( + &[], + Duration::from_secs(20), + Duration::from_millis(20), + || { + polls.fetch_add(1, Ordering::SeqCst); + live_discovery_candidates(&data_dir) + }, + Client::connect, + ) + .unwrap(); + writer.join().unwrap(); + + assert_eq!((client.pid, client.generation), (pid, 7)); + assert!( + polls.load(Ordering::SeqCst) > 1, + "the wait polled before the file was there" + ); + } + + #[test] + fn the_wait_never_probes_what_was_already_there_and_times_out_as_exit_3() { + let dir = private_tempdir(); + let zombie = write_zombie(dir.path()); + let stale = live_discovery_candidates(dir.path()); + assert_eq!(stale.len(), 1); + + let started = Instant::now(); + let err = wait_for_instance( + &stale, + Duration::from_millis(200), + Duration::from_millis(20), + || live_discovery_candidates(dir.path()), + |path| -> Result { + panic!("probed the stale {} (zombie is {zombie:?})", path.display()) + }, + ) + .unwrap_err(); + + assert!(started.elapsed() >= Duration::from_millis(200)); + assert_eq!(err.exit_code(), 3); + assert!( + matches!(err, CtlError::Environment(_)), + "a started app that never answered is not `NotRunning`: {err:?}" + ); + assert_eq!( + err.to_string(), + launch_timed_out(Duration::from_millis(200)).to_string() + ); + } + + #[test] + fn the_timeout_message_names_the_wait_and_the_remedy() { + assert_eq!( + launch_timed_out(LAUNCH_TIMEOUT).to_string(), + "Staged was started but its control server did not answer within 30 seconds; run \ + the command again once its window is up" + ); + } + + #[test] + fn the_wait_treats_a_rewritten_file_at_a_stale_path_as_new() { + // The started app was handed a pid a leftover directory already + // had, and rewrote the file there: same path, new mtime. + let pid = std::process::id(); + let (port, _handle) = fake_server(move |_| (200, ping_body(pid, 2, PROTOCOL_VERSION))); + let dir = private_tempdir(); + let path = write_tcp_discovery(dir.path(), pid, closed_port(), 1); + set_mtime(&path, Duration::from_secs(600)); + let stale = live_discovery_candidates(dir.path()); + + write_tcp_discovery(dir.path(), pid, port, 2); + let client = wait_for_instance( + &stale, + Duration::from_secs(20), + Duration::from_millis(20), + || live_discovery_candidates(dir.path()), + Client::connect, + ) + .unwrap(); + assert_eq!(client.generation, 2); + } + + #[test] + fn the_wait_retries_an_app_that_is_not_answering_yet() { + // The discovery file is on disk before the server accepts. + let pid = std::process::id(); + let (port, _handle) = fake_server(move |_| (200, ping_body(pid, 1, PROTOCOL_VERSION))); + let dir = private_tempdir(); + write_tcp_discovery(dir.path(), pid, port, 1); + let attempts = AtomicUsize::new(0); + + let client = wait_for_instance( + &[], + Duration::from_secs(20), + Duration::from_millis(5), + || live_discovery_candidates(dir.path()), + |path| { + if attempts.fetch_add(1, Ordering::SeqCst) < 3 { + return Err(not_running()); + } + Client::connect(path) + }, + ) + .unwrap(); + assert_eq!(client.pid, pid); + assert_eq!(attempts.load(Ordering::SeqCst), 4); + } + + #[test] + fn the_wait_gives_up_at_once_on_an_app_that_answers_but_cannot_be_used() { + let dir = private_tempdir(); + write_tcp_discovery(dir.path(), std::process::id(), 1, 1); + let attempts = AtomicUsize::new(0); + + let err = wait_for_instance( + &[], + // Long enough that a retry loop would hang the test. + Duration::from_secs(600), + Duration::from_millis(5), + || live_discovery_candidates(dir.path()), + |_| -> Result { + attempts.fetch_add(1, Ordering::SeqCst); + Err(CtlError::Environment("app updated".to_string())) + }, + ) + .unwrap_err(); + assert_eq!(err.to_string(), "app updated"); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + } + + // ------------------------------------------------------------------- + // Connect, starting Staged when none is running + // ------------------------------------------------------------------- + + #[test] + fn a_running_staged_is_used_and_nothing_is_started() { + let dir = private_tempdir(); + store_grant(dir.path()); + write_tcp_discovery(dir.path(), std::process::id(), grant_aware_app(), 1); + + for no_launch in [false, true] { + let client = connect_or_launch( + None, + None, + Some(dir.path()), + no_launch, + &test_env(Some(BUNDLED_EXECUTABLE), &NeverLaunch), + ) + .unwrap(); + assert_eq!(client.pid, std::process::id()); + assert_eq!( + client.bearer(), + Some(format!("live-{GRANT_ID}").as_str()), + "the grant is attached as for every other verb" + ); + } + } + + #[test] + fn with_none_running_the_bundle_is_started_and_the_new_instance_is_used() { + let dir = private_tempdir(); + store_grant(dir.path()); + let port = grant_aware_app(); + let data_dir = dir.path().to_path_buf(); + // The app coming up: its discovery file lands a little after the + // launch returns, as a real start's does. + let launcher = FakeLauncher::new(move || { + let data_dir = data_dir.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + write_tcp_discovery(&data_dir, std::process::id(), port, 1); + }); + Ok(()) + }); + + let client = connect_or_launch( + None, + None, + Some(dir.path()), + false, + &test_env(Some(BUNDLED_EXECUTABLE), &launcher), + ) + .unwrap(); + + assert_eq!( + launcher.launched(), + vec![PathBuf::from(BUNDLE)], + "started once, and only the CLI's own bundle" + ); + assert_eq!(client.pid, std::process::id()); + assert_eq!(client.bearer(), Some(format!("live-{GRANT_ID}").as_str())); + } + + #[test] + fn a_leftover_instance_directory_does_not_count_as_running() { + // Only a zombie on disk: Staged is started, and the zombie is + // neither mistaken for the new instance nor in its way. + let dir = private_tempdir(); + store_grant(dir.path()); + write_zombie(dir.path()); + let port = grant_aware_app(); + let data_dir = dir.path().to_path_buf(); + let launcher = FakeLauncher::new(move || { + write_tcp_discovery(&data_dir, std::process::id(), port, 1); + Ok(()) + }); + + let client = connect_or_launch( + None, + None, + Some(dir.path()), + false, + &test_env(Some(BUNDLED_EXECUTABLE), &launcher), + ) + .unwrap(); + assert_eq!(launcher.launched(), vec![PathBuf::from(BUNDLE)]); + assert_eq!(client.pid, std::process::id()); + } + + /// `connect_or_launch` with nothing running and a launcher that must + /// not be reached. + fn refused(data_dir: &Path, no_launch: bool, executable: Option<&str>) -> CtlError { + connect_or_launch( + None, + None, + Some(data_dir), + no_launch, + &test_env(executable, &NeverLaunch), + ) + .unwrap_err() + } + + #[test] + fn no_launch_exits_3_without_starting_anything() { + let dir = private_tempdir(); + store_grant(dir.path()); + let err = refused(dir.path(), true, Some(BUNDLED_EXECUTABLE)); + assert_eq!(err.exit_code(), 3, "{err}"); + assert!(err.to_string().contains("--no-launch"), "got: {err}"); + + // A leftover directory changes nothing. + write_zombie(dir.path()); + let err = refused(dir.path(), true, Some(BUNDLED_EXECUTABLE)); + assert_eq!(err.exit_code(), 3, "{err}"); + assert!(err.to_string().contains("--no-launch"), "got: {err}"); + } + + #[test] + fn a_caller_with_no_grant_never_starts_staged() { + let dir = private_tempdir(); + let err = refused(dir.path(), false, Some(BUNDLED_EXECUTABLE)); + assert_eq!(err.exit_code(), 4, "{err}"); + assert_eq!(err.code(), "authorization_required"); + assert!( + err.to_string().contains("stagedctl connect --client"), + "got: {err}" + ); + + // Nor with only a grant that has run out. + let expired = grant_file(GRANT_ID, GRANT_SECRET, grants::now_ms() - 1); + grants::write_grant_file(dir.path(), &expired).unwrap(); + let err = refused(dir.path(), false, Some(BUNDLED_EXECUTABLE)); + assert_eq!(err.code(), "authorization_required", "{err}"); + } + + #[test] + fn a_development_build_never_starts_staged() { + let dir = private_tempdir(); + store_grant(dir.path()); + let err = refused(dir.path(), false, Some(DEV_EXECUTABLE)); + assert_eq!(err.exit_code(), 3, "{err}"); + assert!(err.to_string().contains("`just dev`"), "got: {err}"); + } + + #[test] + fn a_staged_that_is_running_but_unusable_is_reported_and_none_is_started() { + // An app from another version answers the ping. Starting a second + // Staged beside it is not the remedy; the usual message is. + let pid = std::process::id(); + let (port, _handle) = fake_server(move |_| (200, ping_body(pid, 1, PROTOCOL_VERSION + 1))); + let dir = private_tempdir(); + store_grant(dir.path()); + write_tcp_discovery(dir.path(), pid, port, 1); + + let err = refused(dir.path(), false, Some(BUNDLED_EXECUTABLE)); + assert_eq!(err.exit_code(), 3); + assert!( + err.to_string().contains("app updated — restart Staged"), + "got: {err}" + ); + } + + #[test] + fn a_named_control_file_never_starts_staged() { + // `--lock-path` and `$STAGEDCTL_LOCK` point at one instance. When + // it is gone the error is the one every verb gives, even though a + // grant and a bundle are both to hand. + let dir = private_tempdir(); + store_grant(dir.path()); + let gone = dir.path().join("control/4242/sessions/s1.json"); + let env = test_env(Some(BUNDLED_EXECUTABLE), &NeverLaunch); + + let by_flag = + connect_or_launch(Some(&gone), None, Some(dir.path()), false, &env).unwrap_err(); + assert_eq!(by_flag.exit_code(), 3, "{by_flag}"); + assert!( + by_flag.to_string().contains("cannot read control file"), + "got: {by_flag}" + ); + + let by_env = connect_or_launch( + None, + Some(gone.clone().into_os_string()), + Some(dir.path()), + false, + &env, + ) + .unwrap_err(); + assert_eq!(by_env.to_string(), by_flag.to_string()); + + // An empty `$STAGEDCTL_LOCK` is still an error, not a fallback + // that could reach the launch. + let empty = connect_or_launch(None, Some(OsString::new()), Some(dir.path()), false, &env) + .unwrap_err(); + assert_eq!(empty.exit_code(), 3); + assert!(empty.to_string().contains("set but empty"), "got: {empty}"); + } + + #[test] + fn no_home_directory_never_starts_staged() { + let err = connect_or_launch( + None, + None, + None, + false, + &test_env(Some(BUNDLED_EXECUTABLE), &NeverLaunch), + ) + .unwrap_err(); + assert_eq!(err.exit_code(), 3, "{err}"); + } + + #[test] + fn a_launch_that_fails_is_exit_3_with_the_reason_and_the_bundle() { + let dir = private_tempdir(); + store_grant(dir.path()); + let launcher = FakeLauncher::new(|| Err("the application cannot be opened".to_string())); + + let err = connect_or_launch( + None, + None, + Some(dir.path()), + false, + &test_env(Some(BUNDLED_EXECUTABLE), &launcher), + ) + .unwrap_err(); + assert_eq!(launcher.launched(), vec![PathBuf::from(BUNDLE)]); + assert_eq!(err.exit_code(), 3); + assert_eq!( + err.to_string(), + "could not start Staged at /Applications/Staged.app: the application cannot be \ + opened; start it and run the command again" + ); + } + + #[test] + fn a_started_staged_that_never_answers_is_exit_3_and_is_started_only_once() { + let dir = private_tempdir(); + store_grant(dir.path()); + let launcher = FakeLauncher::new(|| Ok(())); + let mut env = test_env(Some(BUNDLED_EXECUTABLE), &launcher); + env.timeout = Duration::from_millis(200); + + let err = connect_or_launch(None, None, Some(dir.path()), false, &env).unwrap_err(); + assert_eq!(launcher.launched().len(), 1, "no second attempt"); + assert_eq!(err.exit_code(), 3); + assert!( + err.to_string().contains("did not answer within"), + "got: {err}" + ); + } +} diff --git a/apps/staged/src-tauri/crates/stagedctl/src/lib.rs b/apps/staged/src-tauri/crates/stagedctl/src/lib.rs index efbf647a7..779cd5e2b 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/lib.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/lib.rs @@ -4,6 +4,8 @@ //! - [`cli`]: the clap tree and all help prose (byte-pinned by tests) //! - [`client`]: discovery file, ping handshake, HTTP transport, exit codes //! - [`commands`]: verb implementations and output rendering +//! - [`launch`]: starts Staged for `project open` when no instance is +//! running: the decision, the bundle path, the wait, and the one spawn //! - [`open`]: `project open`: finds the project, by name, id or the current //! directory, asks a window to show it and words what the window did //! - [`tags`]: the `tag` noun and `project tags`: tag wire shapes, `` @@ -18,5 +20,6 @@ pub mod cli; pub mod client; pub mod commands; pub mod grants; +pub mod launch; pub mod open; pub mod tags; diff --git a/apps/staged/src-tauri/crates/stagedctl/src/open.rs b/apps/staged/src-tauri/crates/stagedctl/src/open.rs index 1b930f394..b2800a693 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/open.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/open.rs @@ -19,7 +19,13 @@ //! The older worktree layout, `workspaces/local///`, names no //! project in its path and is not resolved; a directory there reads //! `not_in_workspace`. +//! +//! [`run`] is the verb from the top. What it was pointed at is settled +//! first and needs no app, so a directory that names no project fails +//! before Staged is looked for, and so before it could be started: this is +//! the one verb that starts Staged when none is running ([`crate::launch`]). +use std::ffi::OsString; use std::path::{Component, Path, PathBuf}; use serde::Deserialize; @@ -27,6 +33,7 @@ use serde_json::{json, Value}; use crate::client::{Client, CtlError}; use crate::commands::{find_project, list_projects, parse, CmdOutput, Project}; +use crate::launch::{connect_or_launch, LaunchEnv}; /// The dispatch command behind the verb. A Staged from before it answers /// 404, as for any unknown command, and the transport appends an "update @@ -192,8 +199,31 @@ fn project_for_directory<'a>( }) } -/// `project open []`: find the project in the app's list, then ask -/// the app to show it. +/// `project open [] [--no-launch]` from the top: decide the +/// [`Target`], find a Staged to ask (starting one when none is running, +/// unless `no_launch`), then [`project_open`]. +/// +/// The order is the point. [`target`] runs first and needs no app, so +/// `not_in_workspace` reads the same whether or not Staged is running, and a +/// directory that names no project never starts it. `lock_flag` and +/// `lock_env` are `--lock-path` and `$STAGEDCTL_LOCK`; `data_dir` is +/// `~/.staged`; `launch_env` is what a launch would use. +pub fn run( + project: Option<&str>, + no_launch: bool, + lock_flag: Option<&Path>, + lock_env: Option, + current_dir: impl FnOnce() -> std::io::Result, + data_dir: Option<&Path>, + launch_env: &LaunchEnv<'_>, +) -> Result { + let target = target(project, current_dir, data_dir)?; + let client = connect_or_launch(lock_flag, lock_env, data_dir, no_launch, launch_env)?; + project_open(&client, &target) +} + +/// `project open []` against a connected app: find the project in +/// the app's list, then ask the app to show it. pub fn project_open(client: &Client, target: &Target) -> Result { open_target( target, @@ -897,4 +927,234 @@ mod tests { assert_eq!(err.exit_code(), 1); assert_eq!(err.code(), "app_error"); } + + // ------------------------------------------------------------------- + // The verb from the top: target first, then find or start Staged + // + // Nothing here starts a process. The launchers are fakes, and the + // "app" is a fake control server plus the discovery file a real one + // would write. + // ------------------------------------------------------------------- + + use crate::client::tests::{ + fake_server_with_body, header_value, ping_body, private_tempdir, write_tcp_discovery, + }; + use crate::client::PROTOCOL_VERSION; + use crate::launch::tests::{ + store_grant, test_env, FakeLauncher, NeverLaunch, BUNDLE, BUNDLED_EXECUTABLE, + }; + use std::sync::{Arc, Mutex}; + + /// The `invoke` calls a fake app received: the command and its body. + type Invoked = Arc>>; + + /// A fake Staged holding one project, `Widgets` (`p1`). It accepts any + /// grant's reconnect and then answers `list_projects` and + /// `open_project`, each only with the bearer the reconnect issued. + /// Listening, but not discoverable until a discovery file names its + /// port. + fn fake_app(invoked: Invoked) -> u16 { + let pid = std::process::id(); + let renew_to = crate::grants::now_ms() + crate::grants::GRANT_LIFETIME_MS - 1000; + let (port, _handle) = fake_server_with_body(move |head, body| { + let route = head.split_whitespace().nth(1).unwrap_or(""); + if route == "/api/ping" { + return (200, ping_body(pid, 1, PROTOCOL_VERSION)); + } + if route == "/api/reconnect/challenge" { + return (200, json!({ "nonce": "n1", "expiresAt": 0 }).to_string()); + } + if route == "/api/reconnect" { + return ( + 200, + json!({ "token": "live", "expiresAt": renew_to }).to_string(), + ); + } + let Some(command) = route.strip_prefix("/api/invoke/") else { + return (404, json!({ "error": "unexpected route" }).to_string()); + }; + if header_value(head, "authorization") != Some("Bearer live") { + return ( + 401, + json!({ "error": "no credential", "code": "authorization_required" }) + .to_string(), + ); + } + let args = serde_json::from_str(body).unwrap_or(Value::Null); + invoked.lock().unwrap().push((command.to_string(), args)); + match command { + "list_projects" => (200, json!([{ "id": "p1", "name": "Widgets" }]).to_string()), + "open_project" => ( + 200, + json!({ "outcome": "opened", "window": "main" }).to_string(), + ), + _ => (404, json!({ "error": "not allowlisted" }).to_string()), + } + }); + port + } + + /// What [`run_starting_staged`] saw. + struct Started { + result: Result, + /// The bundles the launcher was asked to start. + launched: Vec, + /// What the app was asked once it was up. + invoked: Vec<(String, Value)>, + } + + /// Run the verb on a machine with a grant, an installed `stagedctl` + /// and no Staged running, where starting Staged brings the fake app + /// up. + fn run_starting_staged(project: Option<&str>, dir_in_data_dir: &str) -> Started { + let dir = private_tempdir(); + store_grant(dir.path()); + let invoked: Invoked = Arc::default(); + let port = fake_app(Arc::clone(&invoked)); + let data_dir = dir.path().to_path_buf(); + let launcher = FakeLauncher::new(move || { + write_tcp_discovery(&data_dir, std::process::id(), port, 1); + Ok(()) + }); + let cwd = dir.path().join(dir_in_data_dir); + + let result = run( + project, + false, + None, + None, + || Ok(cwd), + Some(dir.path()), + &test_env(Some(BUNDLED_EXECUTABLE), &launcher), + ); + let invoked = invoked.lock().unwrap().clone(); + Started { + result, + launched: launcher.launched(), + invoked, + } + } + + #[test] + fn with_no_staged_running_it_is_started_and_then_asked_for_the_project() { + let Started { + result, + launched, + invoked, + } = run_starting_staged(Some("widgets"), "elsewhere"); + + let out = result.unwrap(); + let pid = std::process::id(); + assert_eq!(out.human, format!("opened Widgets id:p1 pid:{pid}")); + assert_eq!(out.json["pid"], pid, "the instance that was started"); + assert_eq!(launched, vec![PathBuf::from(BUNDLE)]); + assert_eq!( + invoked, + vec![ + ("list_projects".to_string(), json!({})), + ("open_project".to_string(), json!({ "projectId": "p1" })), + ], + "the same two calls, with the grant's bearer, as against a running app" + ); + } + + #[test] + fn with_no_staged_running_a_workspace_directory_starts_it_too() { + let Started { + result, + launched, + invoked, + } = run_starting_staged( + None, + "workspaces/local/projects/p1/block-builderbot--wt/apps", + ); + + assert_eq!(result.unwrap().json["project"]["id"], "p1"); + assert_eq!(launched, vec![PathBuf::from(BUNDLE)]); + assert_eq!( + invoked.last().unwrap(), + &("open_project".to_string(), json!({ "projectId": "p1" })) + ); + } + + #[test] + fn a_name_that_matches_nothing_is_only_known_once_staged_is_up() { + // Names live in the app, so this one costs a start. No window is + // asked for anything, as against a running app. + let Started { + result, + launched, + invoked, + } = run_starting_staged(Some("Gadgets"), "elsewhere"); + + let err = result.unwrap_err(); + assert_eq!(err.exit_code(), 1); + assert_eq!(err.code(), "not_found"); + assert_eq!(launched.len(), 1); + assert_eq!(invoked, vec![("list_projects".to_string(), json!({}))]); + } + + #[test] + fn a_directory_outside_every_workspace_never_starts_staged() { + // Everything a launch needs is to hand: a grant, an installed + // `stagedctl`, and no Staged running. The directory names no + // project, so the verb fails before any of it is looked at. + let dir = private_tempdir(); + store_grant(dir.path()); + let env = test_env(Some(BUNDLED_EXECUTABLE), &NeverLaunch); + let old_layout = dir.path().join("workspaces/local/builderbot/my-branch"); + + for cwd in [PathBuf::from("/Users/me/code"), old_layout] { + for no_launch in [false, true] { + let err = run( + None, + no_launch, + None, + None, + || Ok(cwd.clone()), + Some(dir.path()), + &env, + ) + .unwrap_err(); + assert_eq!(err.exit_code(), 1, "{err}"); + assert_eq!(err.code(), "not_in_workspace", "{err}"); + } + } + + // Nor does a current directory that cannot be read. + let err = run( + None, + false, + None, + None, + || Err(std::io::Error::from(std::io::ErrorKind::NotFound)), + Some(dir.path()), + &env, + ) + .unwrap_err(); + assert_eq!(err.code(), "not_in_workspace", "{err}"); + } + + #[test] + fn no_launch_with_no_staged_running_is_exit_3_for_a_name_or_a_directory() { + let dir = private_tempdir(); + store_grant(dir.path()); + let env = test_env(Some(BUNDLED_EXECUTABLE), &NeverLaunch); + let workspace = dir.path().join("workspaces/local/projects/p1/wt"); + + for project in [Some("Widgets"), None] { + let err = run( + project, + true, + None, + None, + || Ok(workspace.clone()), + Some(dir.path()), + &env, + ) + .unwrap_err(); + assert_eq!(err.exit_code(), 3, "{err}"); + assert!(err.to_string().contains("--no-launch"), "got: {err}"); + } + } } diff --git a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt index 270e5a167..244eab750 100644 --- a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt +++ b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt @@ -172,6 +172,27 @@ records it as the last one shown, so a read-only grant is refused (exit session's credential the same way, for the session's own project too, so an agent cannot pull the user's window away. +When Staged is not running, the command starts it, waits for it to come +up, and then asks as above; the app holds the request until its window +has loaded. What it starts is the Staged.app this stagedctl is bundled +in, and nothing else. A line on stderr says so; stdout is unchanged. + +Staged is never started: + - with --no-launch: the command exits 3 instead; + - when --lock-path or $STAGEDCTL_LOCK names a control file. That + points at one instance, and when it is gone the command exits 3, so + a spawned session never starts an app; + - when this machine holds no unexpired connected-app grant (exit 4, + `authorization_required`). Start Staged and run `stagedctl connect` + once; the consent dialog is not raised for you; + - from a development build, whose app needs its dev server (exit 3). + Run `just dev`; + - beside a Staged that is running but cannot be used, such as one + that was updated underneath this CLI (exit 3). +A name is looked up only once the app is up, so a name that +matches nothing still exits 1 (`not_found`) after Staged has started. A +directory outside every project workspace never starts it. + Usage: stagedctl project open [OPTIONS] [PROJECT] Arguments: @@ -182,6 +203,9 @@ Options: --lock-path Path to the app's control file (defaults to $STAGEDCTL_LOCK, then live ~/.staged/control//discovery.json files, newest first) + --no-launch + Exit 3 instead of starting Staged when it is not running + --json Print machine-readable JSON on stdout instead of human-readable text From bb3fe998b499ced78e9ba5a90d0a33c625095099 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 16:03:31 +1100 Subject: [PATCH 09/14] docs(stagedctl): document project open and make staged a wrapper for it Step 9 of the `stagedctl project open` plan: docs and launcher. Steps 1 to 8 built the command, but nothing outside its own help described it, and the two places a user would look were wrong. The README said `staged [path]` opens a directory and that each run opens a new window. The app never read that argument, and the script started a second Staged process on every run. `scripts/staged` is now a thin wrapper: - Every argument goes unchanged to `stagedctl project open`, by `exec`, so the output and exit codes are stagedctl's. - `stagedctl` is taken from PATH, then from `/Applications/Staged.app` and `~/Applications/Staged.app`. A release DMG install puts nothing on PATH today, so the fallback keeps `staged` working there. - The script never starts the app itself. When `stagedctl` is not found it says so on stderr, prints nothing on stdout and exits 127. - The lookup uses `type -P`, so an exported shell function named `stagedctl` is not mistaken for the binary. The README's "Command Line Usage" section is rewritten from `open.rs`, `launch.rs` and the verb's help text rather than from the plan: - opening by name or id, and with no argument from inside a project workspace; - `--no-launch`, what happens when Staged is not running, and each case in which it is never started; - the first-run `stagedctl connect`, and that read-only grants and agent session credentials are refused; - the `staged` shorthand, and a table of exit codes and error codes; - how `stagedctl` gets on PATH today: the source install links it, and a release DMG install needs a manual link into the bundle. The claims that `staged` opens a path and that each run opens a new window are gone. The install bullet now names both CLIs. `guide.md` gains a "Showing a project in the app" section with the same facts, written for the agent sessions that read it. It leads with the refusal: a spawned session cannot use the verb, even for its own project, and should name the project for the user instead. The safety invariant section says how the verb fits it. `install.sh` printed "Run `staged` to launch Staged from your current directory" when it finished. It now prints the connect step and the two forms of `staged`. Choices the plan left open: - `staged` takes no directory argument. The old usage line offered one; a path is now read as a project name and fails with `not_found`. Mapping a path to a directory change was left out to keep the wrapper thin: `cd && staged` does the same. - 127 for a missing `stagedctl` is the shell's "command not found", and sits outside stagedctl's own 0 to 4. - The old lookup of a bundle built inside the repository is dropped. A build there is reached by putting its `stagedctl` on PATH. - The Settings action that will put `stagedctl` on PATH is step 10 and is not mentioned. No test pins the guide, the README or the launcher, and the help text is unchanged, so no fixture moved. No Rust or frontend source changed. Checked: - `just fmt-check` passed. It covers Rust and `src/`, which this commit does not touch. - `bash -n` passed on `scripts/staged` and `install.sh`. shellcheck is not configured in the repository and is not installed here, so it was not run. - The launcher was run by hand against a fake `stagedctl` that echoes its arguments: no argument, a name with spaces, flags either side of the name, `--`, empty and quoted arguments, `--help`, exit codes 1 to 4 passed through, a symlinked script, `/bin/bash` 3.2, the bundle fallback, PATH taking precedence over the bundle, a non-executable bundle file, an exported function, and `stagedctl` missing with and without `$HOME`. - It was also run over this branch's debug `stagedctl` under a temporary home with `$STAGEDCTL_LOCK` unset: `--help` printed the verb's help, `--no-launch` exited 3, a directory outside any workspace exited 1 with `not_in_workspace`, no grant exited 4 with `authorization_required`, and two projects exited 1 as a usage error. Not run against a real app. No project was opened through the wrapper, and the running Staged was not started, quit or restarted; its process was the same before and after. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/README.md | 86 +++++++++++++++-- apps/staged/install.sh | 5 +- .../src-tauri/resources/stagedctl/guide.md | 95 +++++++++++++++++++ scripts/staged | 85 +++++++++-------- 4 files changed, 223 insertions(+), 48 deletions(-) diff --git a/apps/staged/README.md b/apps/staged/README.md index 2c87703f7..bd5559336 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -46,22 +46,96 @@ The installer will: - Install dependencies - Build the application - Install to `/Applications/Staged.app` -- Install the `staged` CLI to `/usr/local/bin` +- Link the `stagedctl` control CLI and install the `staged` launcher in `/usr/local/bin` **Note**: This builds from source, which takes a few minutes. It is meant for development and does not use the signed auto-updating release channel. ### Command Line Usage -After installation, launch Staged from the terminal: +Staged ships a control CLI, `stagedctl`, inside the app bundle. Its `project open` verb shows a project in the app from a terminal: ```bash -staged # Open in current directory -staged /path/to/repo # Open in specified directory +stagedctl project open "My Project" # by name or id (see `stagedctl project list`) +stagedctl project open # the project whose workspace the current directory is in +stagedctl project open --no-launch # fail instead of starting Staged when it is not running ``` -Each invocation opens a new window, so you can have multiple repos open simultaneously. +The window a click on Staged's Dock icon would raise comes to the front and switches to the project. On success the command prints the project's name and id and the pid of the Staged instance that showed it. A project that is already on screen is a success too. No new window is opened, and a Staged that is already running is never joined by a second instance. -If you installed manually (not via the install script), copy `scripts/staged` to somewhere in your PATH (e.g., `/usr/local/bin`). +- **By name or id.** `` is matched as an exact id, then an exact name, then a name that matches one project when case is ignored. A name that matches nothing, or several projects, fails before any window is touched. +- **From a project workspace.** With no argument, the project is the one whose workspace the current directory is in: any directory under `~/.staged/workspaces//projects//`, where Staged keeps a project's worktrees. From any other directory the command fails with `not_in_workspace` without looking for the app, so pass a project there. It does not open arbitrary directories or repositories. +- **A busy window.** The window is raised whether or not it could switch, so you can see what is in the way. A dialog that is open, or a diff comment that could not be saved, is left alone and reported as `ui_busy`: close or finish it and run the command again. +- **When Staged is not running.** The command starts the `Staged.app` that `stagedctl` is bundled in, waits up to thirty seconds for it to come up, and then opens the project. A line on stderr says Staged is being started. This is macOS only. +- **`--no-launch`.** The command exits 3 instead of starting Staged. + +Staged is also never started when you are not yet authorized (see below), when the directory names no project, when `--lock-path` or `$STAGEDCTL_LOCK` points at one particular instance, or from a development build, whose app needs its dev server (run `just dev` instead). + +#### First run: `stagedctl connect` + +Outside a Staged-spawned agent session, `stagedctl` needs your approval once per machine. With Staged running: + +```bash +stagedctl connect --client "my terminal" +``` + +Staged shows a consent dialog naming the client. Approving it stores a grant under `~/.staged/control/grants/` that later commands use without asking again. It lasts thirty days from its last use and can be revoked under **Settings -> Connected apps** or with `stagedctl disconnect`. + +Until then `stagedctl project open` exits 4 with `authorization_required`. It does not raise the consent dialog for you, and it does not start Staged to ask. + +Two kinds of credential are refused with `scope_denied` (exit 4): + +- **Read-only grants** (`stagedctl connect --read-only`). Showing a project marks it read and records it as the last one shown, which is a write. +- **Agent session credentials.** A session spawned by Staged cannot open a project, not even its own, so an agent cannot pull your window away. Run the command from your own terminal. + +A grant confined to one project (`stagedctl connect --project`) opens that project only. Any other project reads `not_found`, because that grant does not list it. + +#### The `staged` shorthand + +`staged` is a small wrapper script around the same command: + +```bash +staged "My Project" # stagedctl project open "My Project" +staged # stagedctl project open +staged --no-launch # stagedctl project open --no-launch +``` + +It passes its arguments to `stagedctl project open` unchanged, so the output and exit codes are the ones described here. It does not take a directory to open: a path is read as a project name. The script never starts the app itself. If it cannot find `stagedctl` on your PATH, in `/Applications/Staged.app` or in `~/Applications/Staged.app`, it says so and exits 127 without starting anything. + +#### Getting `stagedctl` on your PATH + +- **Development install from source.** The install script links `/usr/local/bin/stagedctl` to the copy inside `/Applications/Staged.app` and installs `staged` beside it. +- **Release DMG.** Nothing is put on your PATH. Link the bundled CLI yourself, with `sudo` if `/usr/local/bin` is not writable: + + ```bash + mkdir -p /usr/local/bin + ln -sfn /Applications/Staged.app/Contents/MacOS/stagedctl /usr/local/bin/stagedctl + ``` + + Use a link, not a copy. `stagedctl` finds the app to start from its own location inside the bundle, and a link stays in step with app updates. + +- **The `staged` script.** If you installed manually, copy `scripts/staged` from the repository root to a directory on your PATH. +- **Agent sessions** spawned by Staged already have `stagedctl` on their PATH. + +#### Exit codes + +On failure, stderr carries one `code: message` line. + +| Exit | Code | Meaning | +| ---- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | | The project is on screen. | +| 1 | `not_found` | No project matches; the message lists the project names. Also a workspace whose project was deleted. | +| 1 | `ambiguous` | The name matches several projects when case is ignored. | +| 1 | `not_in_workspace` | No argument, and the current directory is not inside a Staged project workspace. | +| 1 | `ui_busy` | A dialog is open, a diff comment could not be saved, or another `project open` replaced this one. The window was raised but not switched. | +| 1 | `ui_timeout` | The window was raised but did not answer in time. | +| 1 | `app_error` | The app could not make the request, for example a running Staged from before this command existed (update it). | +| 2 | `transport` | A fault talking to the app. | +| 3 | message only | Staged is not running and was not started, a started Staged did not come up in time, or the running Staged cannot be used. | +| 4 | `authorization_required` | No grant yet. Run `stagedctl connect`. | +| 4 | `scope_denied` | A read-only grant, or an agent session's credential. | +| 127 | | `staged` only: `stagedctl` was not found. | + +`stagedctl project open --help` is the full reference, and `stagedctl --help` lists everything else the CLI can do. ### AI Agent Setup (ACP) diff --git a/apps/staged/install.sh b/apps/staged/install.sh index 080209ab9..44e3a497b 100755 --- a/apps/staged/install.sh +++ b/apps/staged/install.sh @@ -168,5 +168,6 @@ log "linking control CLI to $STAGEDCTL_DEST" link_cli "$APP_DEST/Contents/MacOS/stagedctl" "$STAGEDCTL_DEST" log "installation complete" -printf 'Run `%s` to launch Staged from your current directory.\n' "$(basename "$CLI_DEST")" -printf 'Run `%s --help` to control the running app from the command line.\n' "$(basename "$STAGEDCTL_DEST")" +printf 'Run `%s connect --client ` once with Staged running, to approve command-line access.\n' "$(basename "$STAGEDCTL_DEST")" +printf 'Run `%s ` to show a project in Staged, or `%s` from inside a project workspace.\n' "$(basename "$CLI_DEST")" "$(basename "$CLI_DEST")" +printf 'Run `%s --help` to control the app from the command line.\n' "$(basename "$STAGEDCTL_DEST")" diff --git a/apps/staged/src-tauri/resources/stagedctl/guide.md b/apps/staged/src-tauri/resources/stagedctl/guide.md index 41a00b18d..72f444c37 100644 --- a/apps/staged/src-tauri/resources/stagedctl/guide.md +++ b/apps/staged/src-tauri/resources/stagedctl/guide.md @@ -69,6 +69,12 @@ Verbs that start sessions spawn real agents, which cost real money. They appear immediately in the UI, where the user can watch and cancel them — start sessions only when the task genuinely calls for it. +Showing a project (`stagedctl project open`) fits the invariant too: the +window switches in front of the user, who can switch back, and all it +records is the project's read mark and which project was shown last. It +is not available to a spawned session; see "Showing a project in the +app" below. + Tag assignments (`stagedctl project tag`, `stagedctl project untag`) fit the invariant: they show at once as the pills and chips in the project header, where the user can undo them with a group pill's None or a chip's ×. A tag @@ -92,6 +98,95 @@ the moving tag); the command refuses without `--yes` when that would happen, and the user can re-tag those projects. Deleting tags and groups is deliberately not available. +## Showing a project in the app + +`stagedctl project open` brings a Staged window to the front and switches +it to a project. It is the one verb that moves the user's window, and the +one verb that starts Staged when it is not running, so it is for clients +the user runs themselves: a terminal, a script, an agent they started. + +**Not from a spawned session.** The app refuses a session's credential +(exit 4, `scope_denied`), for the session's own project too, so an agent +cannot pull the user's window away. Another project's name reads +`not_found` (exit 1) instead, because a session's credential lists only +its own project. Neither is a fault to retry or work around, and a +session never starts an app: with `$STAGEDCTL_LOCK` set, nothing is +launched. If the user should look at a project, name it in your output +and let them open it. + +Outside a session: + +- `stagedctl project open ` takes a project name or id, as + printed by `stagedctl project list`. A name that matches nothing + (`not_found`), or several projects when case is ignored (`ambiguous`), + exits 1 before any window is touched. +- `stagedctl project open` with no argument opens the project whose + workspace the current directory is in: any directory under + `~/.staged/workspaces//projects//`, where + Staged keeps a project's worktrees. From any other directory it exits 1 + (`not_in_workspace`) without looking for the app, and never starts it. + A workspace whose project was deleted exits 1 (`not_found`). +- `--no-launch` exits 3 instead of starting Staged when it is not + running. +- `staged [] [--no-launch]` is the same command, where the + `staged` launcher script is installed. It hands its arguments to + `stagedctl project open` unchanged and exits 127, starting nothing, + when it cannot find `stagedctl`. + +On success it prints `opened id: pid:`, the pid being +the Staged instance that showed the project (`--json`: `{ outcome, +project: { id, name }, window, pid }`). A project that is already on +screen is a success too. + +The window raised is the one a click on Staged's Dock icon would raise, +and it is raised whether or not it could switch, so the user sees what is +in the way. When it could not switch, the command exits 1: + +- `ui_busy`: a dialog is open in the window, or the diff comment being + written could not be saved. Close or finish it and run the command + again. Also when another `project open` reached the same window and + replaced this one first. +- `ui_timeout`: the window did not answer in time, so the project it + shows may not have changed. +- `not_found`: the project was deleted meanwhile. + +**Credentials.** The caller needs a connected-app grant with write access +that reaches the project. On a machine that has none, run `stagedctl +connect --client ` once while Staged is running and approve the +consent dialog; until then the command exits 4 (`authorization_required`). +A read-only grant (`connect --read-only`) is refused (exit 4, +`scope_denied`), because showing a project marks it read and records it +as the last one shown. A grant confined to one project (`connect +--project`) opens that project only; any other reads `not_found`, because +that grant does not list it. + +**When Staged is not running**, the command starts it, waits up to thirty +seconds for it to come up, and then asks as above; the app holds the +request until its window has loaded. What it starts is the `Staged.app` +this `stagedctl` is bundled in and nothing else, on macOS only. A line on +stderr says so; stdout is unchanged. A project name is looked up only +once the app is up, so a name that matches nothing still exits 1 +(`not_found`) after Staged has started. + +Staged is never started: + +- with `--no-launch` (exit 3); +- when `--lock-path` or `$STAGEDCTL_LOCK` names a control file: that + points at one instance, and when it is gone the command exits 3; +- when this machine holds no unexpired connected-app grant (exit 4, + `authorization_required`). The consent dialog is not raised on the + caller's behalf: start Staged and run `stagedctl connect` first; +- from a development build, whose `stagedctl` is not inside a + `Staged.app` and whose app needs its dev server (exit 3; run + `just dev`); +- beside a Staged that is running but cannot be used, such as one that + was updated underneath this CLI (exit 3); +- for a directory outside every project workspace (exit 1, + `not_in_workspace`). + +`stagedctl project open --help` is the authoritative reference for all of +the above. + ## When stagedctl is unavailable The CLI needs the spawning app instance to be alive and reachable over diff --git a/scripts/staged b/scripts/staged index bf75bf015..857faf138 100755 --- a/scripts/staged +++ b/scripts/staged @@ -1,47 +1,52 @@ #!/bin/bash -# CLI launcher for Staged app -# Usage: staged [path] -# path - Optional directory to open (defaults to current directory) +# `staged`: show a project in the Staged app. +# +# Usage: staged [] [--no-launch] +# Project name or id. Omit it inside a project's workspace +# (~/.staged/workspaces//projects//...) to +# open that project. +# --no-launch Fail instead of starting Staged when it is not running. +# +# A thin wrapper: every argument goes unchanged to `stagedctl project open`, +# which also decides the output and the exit code. See +# `stagedctl project open --help`. +# +# This script never starts the app itself. stagedctl asks a running Staged +# to show the project, and starts one only when none is running, the caller +# is already authorized, and it sits inside the Staged.app to start. When +# stagedctl cannot be found, nothing is opened or started and the exit code +# is 127. -set -e +set -eu -# Resolve the target path -if [ -n "$1" ]; then - TARGET_PATH="$1" -else - TARGET_PATH="." -fi - -# Convert to absolute path -TARGET_PATH=$(cd "$TARGET_PATH" 2>/dev/null && pwd) || { - echo "Error: '$1' is not a valid directory" >&2 - exit 1 +# stagedctl ships inside the app bundle. Prefer the one on PATH, which is +# the one typing `stagedctl` would run, then the usual install locations. +find_stagedctl() { + # `type -P` searches PATH only, so a shell function or alias of the same + # name is never mistaken for the binary. + if type -P stagedctl; then + return 0 + fi + local candidate + for candidate in \ + "/Applications/Staged.app/Contents/MacOS/stagedctl" \ + "${HOME:+$HOME/Applications/Staged.app/Contents/MacOS/stagedctl}"; do + if [ -n "$candidate" ] && [ -f "$candidate" ] && [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 } -# Find the Staged app -# Priority: /Applications > ~/Applications > built app in repo -if [ -d "/Applications/Staged.app" ]; then - APP_PATH="/Applications/Staged.app" -elif [ -d "$HOME/Applications/Staged.app" ]; then - APP_PATH="$HOME/Applications/Staged.app" -else - # Try to find it relative to this script (for development) - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - REPO_ROOT="$(dirname "$SCRIPT_DIR")" - STAGED_APP="$REPO_ROOT/apps/staged" - - if [ -d "$STAGED_APP/src-tauri/target/release/bundle/macos/Staged.app" ]; then - APP_PATH="$STAGED_APP/src-tauri/target/release/bundle/macos/Staged.app" - elif [ -d "$STAGED_APP/src-tauri/target/debug/bundle/macos/Staged.app" ]; then - APP_PATH="$STAGED_APP/src-tauri/target/debug/bundle/macos/Staged.app" - else - echo "Error: Could not find Staged.app" >&2 - echo "Install it to /Applications or build with 'just app staged build'" >&2 - exit 1 - fi +if ! STAGEDCTL="$(find_stagedctl)"; then + cat >&2 <<'MESSAGE' +staged: stagedctl was not found, so no project was opened and Staged was not started. +It was looked for on PATH, then in /Applications/Staged.app and ~/Applications/Staged.app. +stagedctl ships inside Staged.app. Install Staged, or link its stagedctl onto PATH: + ln -s /path/to/Staged.app/Contents/MacOS/stagedctl /usr/local/bin/stagedctl +MESSAGE + exit 127 fi -# Launch the app with the target path as argument -# Run in background and detach so CLI returns immediately -"$APP_PATH/Contents/MacOS/staged" "$TARGET_PATH" &>/dev/null & -disown +exec "$STAGEDCTL" project open "$@" From d1656c294216e85375ac41236512dc0921510a93 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 16:28:55 +1100 Subject: [PATCH 10/14] feat(staged): add a Settings action that puts stagedctl on PATH Step 10 of the `stagedctl project open` plan, and its last: PATH for release installs. Steps 1 to 9 built the command and documented it, but a release DMG install puts nothing on PATH. The CLI sits inside the bundle, only agent sessions get a shim for it, and the README told everyone else to make a symlink by hand. Settings -> Connected apps now has a "Command line" section with an "Add to PATH" button. It calls one new Tauri command, `link_stagedctl_on_path`, which links the app's bundled sidecar to `/usr/local/bin/stagedctl`, or to `~/.local/bin/stagedctl` when `/usr/local/bin` cannot be written. It never asks for a password. The backend is a new `stagedctl_link` module in three parts, so that no test writes outside a temporary directory: - `stagedctl_link.rs` holds the decisions and touches no filesystem. `link_source` reads the link's target off the app executable's path, or refuses. `link_candidates` lists where the link may go, in order. `decide` picks one step from what is already at each path. `dir_on_path` says whether the chosen directory is on PATH. - `stagedctl_link/fs.rs` looks at the disk, runs the step `decide` chose and looks again if the disk changed underneath. Every path is handed to it. - `stagedctl_link/commands.rs` is the command. It is the only place that reads the real executable, home directory and PATH. What the action does, following the plan's "Getting onto PATH" and "Security" sections: - The link's target is this app's own sidecar, `.app/Contents/MacOS/stagedctl`. The command takes no path, and nothing from the frontend or the environment names the target. - It refuses when the app runs from an App Translocation path. macOS runs a quarantined app from a randomized read-only mount, and a link into it would dangle. The message says to move Staged to Applications and reopen it. - It refuses a development build, which has no bundle and whose sidecar goes when the build directory does. - It never overwrites what it has not shown. A path that already holds something else comes back as a `conflict` outcome and is left alone. A link that points elsewhere is shown with its target and a "Replace link" button. That press sends back the link and target that were on screen, and the link is replaced only if it is still that one. A file or directory is never replaced. - A link that already reaches the sidecar, however its target is spelled, is reported as already linked and not rewritten. - The fallback is only for a directory that cannot be made or written. A conflict in `/usr/local/bin` is never skipped for `~/.local/bin`: the earlier `stagedctl` could shadow the new link, and the user should see it. - After linking, the answer says whether the directory is on the user's PATH, read from the cached login-shell environment. The panel names the directory to add when it is not, and says so when PATH could not be read. Who may call it: - Registered in `generate_handler!` beside the other stagedctl commands. - Refused by the web dispatch, with the other desktop-only arms. A browser client must not change what the user's shell runs. - Left out of `ALLOWED_COMMANDS`, so no stagedctl caller, an agent session included, can reach it. Choices the plan left open: - The action lives in the Connected apps panel, which is already desktop-only and about stagedctl, as a small `StagedctlPathSetting` component. - One command, with no separate status query. The panel shows a result only after a press. Pressing again is safe: a link that is already right is reported, not rewritten. - A replace swaps the link with a rename, so the name on PATH is never missing. The check and the rename are two calls, the closest the filesystem allows to a compare-and-swap. - A replace that the directory refuses is an error, not a reason to link elsewhere. - An app run straight from a mounted disk image that macOS did not translocate is not refused. Its path looks like any external volume. Docs: - `README.md` points release installs at the Settings action and keeps the manual link as the "By hand" fallback. - `guide.md` did not describe a manual link before. It now says how `stagedctl` gets on PATH outside a session, that the Settings action is the user's and not a verb, and gives the manual link as the fallback. - The `staged` launcher's "stagedctl was not found" message names the Settings action ahead of the manual link. This adds backend types, which AGENTS.md asks a human to review: `LinkOutcome`, `ExistingEntry` and `ShownLink` cross to the frontend, and `Refusal`, `Entry`, `Candidate`, `Step` and `Installed` are internal. Nothing is persisted and there is no schema change. Tested: - `stagedctl_link.rs`, 23 tests: the link target for several install locations, the translocation refusal and its order against the bundle check, a path that only mentions the word, executables outside a bundle, the candidate order, every branch of `decide`, the PATH check and the wire shape. - `stagedctl_link/fs.rs`, 16 tests in a temporary directory: create, second run, a link spelled another way, both fallbacks, no usable directory, a link elsewhere, a dangling link, replace, a link that changed after it was shown, a file, a directory, a replace the directory refuses, a missing sidecar and a link already in the fallback. - `StagedctlPathSetting.component.test.ts`, 11 tests: each outcome's copy, the PATH hints, replace only on request and with what was shown, no replace for a file, errors, and presses while a request is in flight. `ConnectedAppsPanel.component.test.ts` gains one test. Checked: - `cargo fmt --check` and `prettier --check` on `src/` passed. - `cargo clippy --workspace -- -D warnings` passed. - `cargo test --lib -- stagedctl_link web_server control_server` passed, 165 tests. That includes `web_dispatch_covers_tauri_commands`, the registration and web-mode parity check, and the pinned allowlist tests. - `pnpm -s check` passed with no errors. - `vitest run` on the two component test files passed, 17 tests. - `bash -n scripts/staged` passed. - The panel was rendered in a Vite harness under /tmp with the command's answers faked, and each state was screenshotted at two widths. Not exercised in a real app. The command was never invoked through Tauri, so the real executable path, the real `/usr/local/bin` and `~/.local/bin`, the login-shell PATH lookup and the translocation refusal on a translocated app are untested outside unit tests. No link was created or changed in a real PATH directory, and the running Staged was not started, quit or restarted. The full Rust and frontend suites were not run. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/README.md | 20 +- .../src-tauri/resources/stagedctl/guide.md | 22 + apps/staged/src-tauri/src/lib.rs | 3 + apps/staged/src-tauri/src/stagedctl_link.rs | 763 ++++++++++++++++++ .../src-tauri/src/stagedctl_link/commands.rs | 56 ++ .../staged/src-tauri/src/stagedctl_link/fs.rs | 559 +++++++++++++ apps/staged/src-tauri/src/web_server.rs | 9 + apps/staged/src/lib/commands.ts | 17 + .../ConnectedAppsPanel.component.test.ts | 15 + .../settings/ConnectedAppsPanel.svelte | 6 + .../StagedctlPathSetting.component.test.ts | 259 ++++++ .../settings/StagedctlPathSetting.svelte | 198 +++++ apps/staged/src/lib/types.ts | 33 + scripts/staged | 3 +- 14 files changed, 1958 insertions(+), 5 deletions(-) create mode 100644 apps/staged/src-tauri/src/stagedctl_link.rs create mode 100644 apps/staged/src-tauri/src/stagedctl_link/commands.rs create mode 100644 apps/staged/src-tauri/src/stagedctl_link/fs.rs create mode 100644 apps/staged/src/lib/features/settings/StagedctlPathSetting.component.test.ts create mode 100644 apps/staged/src/lib/features/settings/StagedctlPathSetting.svelte diff --git a/apps/staged/README.md b/apps/staged/README.md index bd5559336..35d6d26d9 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -104,14 +104,26 @@ It passes its arguments to `stagedctl project open` unchanged, so the output and #### Getting `stagedctl` on your PATH - **Development install from source.** The install script links `/usr/local/bin/stagedctl` to the copy inside `/Applications/Staged.app` and installs `staged` beside it. -- **Release DMG.** Nothing is put on your PATH. Link the bundled CLI yourself, with `sudo` if `/usr/local/bin` is not writable: +- **Release DMG.** Installing puts nothing on your PATH. In Staged, open **Settings -> Connected apps** and press **Add to PATH** under **Command line**. + - **Where the link goes.** Staged links `/usr/local/bin/stagedctl` to the copy inside the app you are running. When `/usr/local/bin` cannot be written without an administrator password, it links `~/.local/bin/stagedctl` instead. Staged never asks for a password. + - **If the directory is not on your PATH.** Staged says so after linking. For zsh, add this line to `~/.zshrc` and open a new terminal: + + ```bash + export PATH="$HOME/.local/bin:$PATH" + ``` + + - **Nothing is overwritten unseen.** If a `stagedctl` is already at that path and points at another copy of Staged, the app shows where it points and replaces it only when you press **Replace link**. A file that is not a link is left alone: move or delete it, then press **Add to PATH** again. + - **When it is refused.** When a downloaded app is opened before it has been moved, macOS runs it from a temporary, read-only location (App Translocation). A link to that location would stop working, so Staged refuses. Move Staged to your Applications folder, open it from there, and try again. A development build is refused too, because it has no app bundle to link to. + - **Pressing it again is safe.** A link that is already right is reported and left as it is. + +- **By hand.** The Settings action is the supported route. To make the link yourself instead, for example to put it in `/usr/local/bin` on a Mac where that needs `sudo`: ```bash - mkdir -p /usr/local/bin - ln -sfn /Applications/Staged.app/Contents/MacOS/stagedctl /usr/local/bin/stagedctl + sudo mkdir -p /usr/local/bin + sudo ln -sfn /Applications/Staged.app/Contents/MacOS/stagedctl /usr/local/bin/stagedctl ``` - Use a link, not a copy. `stagedctl` finds the app to start from its own location inside the bundle, and a link stays in step with app updates. + Drop `sudo` where `/usr/local/bin` is yours to write. `ln -sfn` replaces whatever link is already at that path without showing it. Use a link, not a copy. `stagedctl` finds the app to start from its own location inside the bundle, and a link stays in step with app updates. - **The `staged` script.** If you installed manually, copy `scripts/staged` from the repository root to a directory on your PATH. - **Agent sessions** spawned by Staged already have `stagedctl` on their PATH. diff --git a/apps/staged/src-tauri/resources/stagedctl/guide.md b/apps/staged/src-tauri/resources/stagedctl/guide.md index 72f444c37..65df26557 100644 --- a/apps/staged/src-tauri/resources/stagedctl/guide.md +++ b/apps/staged/src-tauri/resources/stagedctl/guide.md @@ -19,6 +19,28 @@ without asking again. It lasts thirty days from its last use; the user can revoke it any time under Settings ▸ Connected apps, and `stagedctl disconnect` forgets and revokes it from this side. +Outside a session, `stagedctl` is on `PATH` only where the user put it: + +- A source install (`install.sh`) links `/usr/local/bin/stagedctl` to the + copy inside `/Applications/Staged.app`. +- A release install links nothing. The user presses **Add to PATH** under + Settings ▸ Connected apps ▸ Command line, and Staged links the copy + inside the running app into `/usr/local/bin`, or into `~/.local/bin` + when `/usr/local/bin` cannot be written. It reports anything already at + that path instead of overwriting it, tells the user when the directory + is not on their `PATH`, and refuses while macOS runs the app from a + temporary App Translocation path or from a development build. +- The fallback is a link made by hand: + `ln -s /Applications/Staged.app/Contents/MacOS/stagedctl /stagedctl`. + A link, never a copy: `stagedctl` finds the app to start from its own + location inside the bundle. + +Add to PATH is the user's action in the app's Settings. There is no +stagedctl verb for it and a session cannot run it; a session does not +need it, because its own `stagedctl` is already on `PATH`. If the user +reports `stagedctl: command not found` in their own terminal, point them +at that Settings action. + ## The boundary for spawned sessions A spawned session is issued exactly one credential: its own, scoped to its diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 8c07bac92..c23758caf 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -51,6 +51,7 @@ pub mod session_completion; pub mod session_queue; pub mod session_runner; pub mod shell_env; +pub mod stagedctl_link; pub mod store; pub mod store_events; mod tag_commands; @@ -2584,6 +2585,8 @@ pub fn run() { // stagedctl project open (the window's side of a request) project_open::commands::list_pending_project_opens, project_open::commands::answer_project_open, + // stagedctl on PATH (Settings action) + stagedctl_link::commands::link_stagedctl_on_path, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/apps/staged/src-tauri/src/stagedctl_link.rs b/apps/staged/src-tauri/src/stagedctl_link.rs new file mode 100644 index 000000000..a87a7391c --- /dev/null +++ b/apps/staged/src-tauri/src/stagedctl_link.rs @@ -0,0 +1,763 @@ +//! Putting `stagedctl` on the user's PATH from Settings. +//! +//! A release install puts nothing on PATH: the CLI ships inside the bundle, +//! at `Staged.app/Contents/MacOS/stagedctl`, and only agent sessions get a +//! shim for it. The Settings action behind +//! [`commands::link_stagedctl_on_path`] links that file into +//! `/usr/local/bin`, or into `~/.local/bin` when `/usr/local/bin` cannot be +//! written. It never asks for an administrator password. +//! +//! This file holds the decisions and touches no filesystem: +//! +//! - [`link_source`]: which file to link, or why this copy of Staged is not +//! linked at all ([`Refusal`]). +//! - [`link_candidates`]: where the link may go, in order. +//! - [`decide`]: what to do given what is already at each of those paths. +//! - [`dir_on_path`]: whether the directory that got the link is on PATH. +//! +//! [`fs`] looks at the disk, runs the one step `decide` chose, and is handed +//! every path it touches, so its tests stay inside a temporary directory. +//! +//! What the action will and will not do: +//! +//! - **The link's target is this app's own sidecar.** It is read off the +//! running executable's path. Nothing from the frontend, the arguments or +//! the environment names it. +//! - **A link is made, never a copy.** `stagedctl` finds the app to start +//! from its own location in the bundle, and a link stays in step with app +//! updates. +//! - **Nothing is overwritten unseen.** A path that already holds something +//! else is reported as [`LinkOutcome::Conflict`] and left alone. A link +//! that points elsewhere is replaced only on a second call that repeats +//! the path and target the user was shown, and only if that is still what +//! is on disk. A file or directory is never replaced. +//! - **Desktop only.** `web_server::dispatch` refuses the command, and it +//! stays out of `control_server::ALLOWED_COMMANDS`, so neither a browser +//! client nor a stagedctl caller can change what the user's shell runs. + +// The symlink work is unix-only; elsewhere the command answers that it is +// unsupported and the decisions below have no caller. +#![cfg_attr(not(unix), allow(dead_code))] + +pub mod commands; +mod fs; + +use std::ffi::OsStr; +use std::fmt; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// The CLI's file name, in the bundle and on PATH. +const CLI_NAME: &str = "stagedctl"; + +/// The directory tried first. It is on the default macOS PATH +/// (`/etc/paths`), and it is where `install.sh` links the CLI. +const SYSTEM_BIN_DIR: &str = "/usr/local/bin"; + +/// The fallback, under the user's home, for when [`SYSTEM_BIN_DIR`] cannot +/// be written. A stock Mac leaves `/usr/local` owned by root. +const USER_BIN_DIR: &str = ".local/bin"; + +/// The path component macOS puts in every App Translocation mount: +/// `/private/var/folders/…/T/AppTranslocation//d/Staged.app`. +const TRANSLOCATION_COMPONENT: &str = "AppTranslocation"; + +/// Why this copy of Staged is not linked onto PATH. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Refusal { + /// macOS is running a quarantined app from a randomized, read-only mount + /// (App Translocation). The mount is gone after a restart, so a link + /// into it would dangle. + Translocated, + /// The executable is not inside a `.app` bundle: a development build + /// under `target/`, whose sidecar goes when the build directory does. + NotBundled, +} + +impl fmt::Display for Refusal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Refusal::Translocated => { + "macOS is running Staged from a temporary, read-only copy, so a link to it \ + would stop working. Move Staged to your Applications folder, open it from \ + there, and try again." + } + Refusal::NotBundled => { + "This is a development build of Staged, which has no app bundle to link to. \ + Open an installed Staged.app and try again." + } + }) + } +} + +/// The file the link points at: the `stagedctl` beside `app_executable`, +/// which tauri bundles at `.app/Contents/MacOS/stagedctl`. +/// +/// Purely lexical. The translocation check comes first, because a +/// translocated app is inside a bundle too. +pub(crate) fn link_source(app_executable: &Path) -> Result { + if app_executable + .components() + .any(|component| component.as_os_str() == TRANSLOCATION_COMPONENT) + { + return Err(Refusal::Translocated); + } + let macos = bundle_executable_dir(app_executable).ok_or(Refusal::NotBundled)?; + Ok(macos.join(CLI_NAME)) +} + +/// `.app/Contents/MacOS` when `executable` is an absolute path to a +/// file directly inside it. The same shape `stagedctl` reads its own bundle +/// from (`bundle_for_executable` in its `launch` module). +fn bundle_executable_dir(executable: &Path) -> Option<&Path> { + if !executable.is_absolute() { + return None; + } + executable.file_name()?; + let macos = executable.parent()?; + if macos.file_name()? != "MacOS" { + return None; + } + let contents = macos.parent()?; + if contents.file_name()? != "Contents" { + return None; + } + (contents.parent()?.extension()? == "app").then_some(macos) +} + +/// Where the link may go, in the order tried: `/usr/local/bin/stagedctl`, +/// then `/.local/bin/stagedctl`. With no home directory there is no +/// fallback. +pub(crate) fn link_candidates(home: Option<&Path>) -> Vec { + let mut links = vec![Path::new(SYSTEM_BIN_DIR).join(CLI_NAME)]; + if let Some(home) = home { + links.push(home.join(USER_BIN_DIR).join(CLI_NAME)); + } + links +} + +/// What is at a candidate link path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Entry { + /// Nothing. + Absent, + /// A symlink that reaches this app's sidecar. + LinkToSource, + /// A symlink that points somewhere else, with its target as written. It + /// may dangle. + LinkElsewhere { target: PathBuf }, + /// A file, a directory, or anything else that is not a symlink. + NotALink, +} + +/// One place the link may go, as [`fs`] found it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Candidate { + pub link: PathBuf, + pub entry: Entry, + /// False once creating the link in this directory has failed. + pub usable: bool, +} + +/// A link the user was shown and asked to replace. The frontend sends back +/// the `link` and `target` of the [`LinkOutcome::Conflict`] it displayed. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShownLink { + pub link: String, + pub target: String, +} + +impl ShownLink { + /// Whether this is the link at `link`, still pointing at `target`. + fn shows(&self, link: &Path, target: &Path) -> bool { + Path::new(&self.link) == link && Path::new(&self.target) == target + } +} + +/// The single step to take, or the reason none is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Step { + /// A link to the sidecar is already there. + AlreadyLinked { link: PathBuf }, + /// Nothing is at `link`: create it. + Create { link: PathBuf }, + /// The user saw that `link` points at `shown_target` and asked for it to + /// be replaced. + Replace { + link: PathBuf, + shown_target: PathBuf, + }, + /// Something else is at `link`, and the user has not seen it. + Conflict { link: PathBuf, entry: Entry }, + /// No candidate directory took a link. + NowhereUsable, +} + +/// Decide what to do, given what is at each candidate path. +/// +/// The candidates are read in order and the first one that is not an empty +/// spot in an unusable directory settles it: +/// +/// - a link to the sidecar: nothing to do; +/// - a link elsewhere: replace it if `replace` names exactly this link and +/// target, otherwise report it; +/// - anything that is not a link: report it, whatever `replace` says; +/// - nothing there: create the link, or move on to the next candidate when +/// this directory has already refused one. +/// +/// A conflict is never skipped in favour of the fallback. A `stagedctl` +/// earlier on PATH would shadow the new link, and the user should see it. +pub(crate) fn decide(candidates: &[Candidate], replace: Option<&ShownLink>) -> Step { + for candidate in candidates { + let link = candidate.link.clone(); + match &candidate.entry { + Entry::LinkToSource => return Step::AlreadyLinked { link }, + Entry::LinkElsewhere { target } + if replace.is_some_and(|shown| shown.shows(&link, target)) => + { + return Step::Replace { + link, + shown_target: target.clone(), + }; + } + Entry::LinkElsewhere { .. } | Entry::NotALink => { + return Step::Conflict { + link, + entry: candidate.entry.clone(), + }; + } + Entry::Absent if candidate.usable => return Step::Create { link }, + Entry::Absent => {} + } + } + Step::NowhereUsable +} + +/// Whether `dir` is an entry of the PATH value `path_var`. `None` when the +/// user's PATH is not known. +pub(crate) fn dir_on_path(dir: &Path, path_var: Option<&OsStr>) -> Option { + let path_var = path_var?; + Some(std::env::split_paths(path_var).any(|entry| entry == dir)) +} + +/// What [`fs::install`] did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Installed { + /// The link at this path was created, or replaced at the user's request. + Linked(PathBuf), + /// The link at this path already reached the sidecar. + AlreadyLinked(PathBuf), + /// Something else is at `link`. Nothing was changed. + Conflict { link: PathBuf, entry: Entry }, +} + +/// What is in the way of the link, as the user is shown it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ExistingEntry { + /// A link to `target`, which the user may ask to have replaced. + Link { target: String }, + /// A file or directory. Staged never replaces one. + Other, +} + +/// The command's answer. `link` is the full path of the `stagedctl` on PATH. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "outcome", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum LinkOutcome { + /// The link was created, or replaced at the user's request. `on_path` + /// says whether its directory is on the user's PATH; `null` when their + /// PATH could not be read. + Linked { link: String, on_path: Option }, + /// The link was already there. + AlreadyLinked { link: String, on_path: Option }, + /// Something else is at `link`. Nothing was changed. + Conflict { + link: String, + existing: ExistingEntry, + }, +} + +impl LinkOutcome { + /// `installed` as the frontend reads it. `path_var` is the user's PATH, + /// when it is known. + pub(crate) fn new(installed: Installed, path_var: Option<&OsStr>) -> Self { + let on_path = |link: &Path| link.parent().and_then(|dir| dir_on_path(dir, path_var)); + let shown = |path: &Path| path.to_string_lossy().into_owned(); + match installed { + Installed::Linked(link) => LinkOutcome::Linked { + on_path: on_path(&link), + link: shown(&link), + }, + Installed::AlreadyLinked(link) => LinkOutcome::AlreadyLinked { + on_path: on_path(&link), + link: shown(&link), + }, + Installed::Conflict { link, entry } => LinkOutcome::Conflict { + link: shown(&link), + existing: match entry { + Entry::LinkElsewhere { target } => ExistingEntry::Link { + target: shown(&target), + }, + _ => ExistingEntry::Other, + }, + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const INSTALLED: &str = "/Applications/Staged.app/Contents/MacOS/Staged"; + + // ------------------------------------------------------------------- + // The link target + // ------------------------------------------------------------------- + + #[test] + fn the_link_points_at_the_sidecar_beside_the_app_executable() { + assert_eq!( + link_source(Path::new(INSTALLED)), + Ok(PathBuf::from( + "/Applications/Staged.app/Contents/MacOS/stagedctl" + )) + ); + } + + #[test] + fn the_link_follows_the_bundle_wherever_it_is_installed() { + for (executable, sidecar) in [ + ( + "/Users/me/Applications/Staged.app/Contents/MacOS/Staged", + "/Users/me/Applications/Staged.app/Contents/MacOS/stagedctl", + ), + // A renamed bundle, and one with a space in its path. + ( + "/Applications/Staged Beta.app/Contents/MacOS/Staged", + "/Applications/Staged Beta.app/Contents/MacOS/stagedctl", + ), + ( + "/Volumes/Work Disk/Apps/Staged.app/Contents/MacOS/Staged", + "/Volumes/Work Disk/Apps/Staged.app/Contents/MacOS/stagedctl", + ), + ] { + assert_eq!( + link_source(Path::new(executable)), + Ok(PathBuf::from(sidecar)), + "{executable}" + ); + } + } + + #[test] + fn a_translocated_app_is_refused() { + for executable in [ + "/private/var/folders/yc/abc123/T/AppTranslocation/\ + 6F1C0C2E-93B4-4E55-9F0B-0A7D4B0D2A11/d/Staged.app/Contents/MacOS/Staged", + "/var/folders/yc/abc123/T/AppTranslocation/\ + 6F1C0C2E-93B4-4E55-9F0B-0A7D4B0D2A11/d/Staged.app/Contents/MacOS/Staged", + ] { + assert_eq!( + link_source(Path::new(executable)), + Err(Refusal::Translocated), + "{executable}" + ); + } + } + + /// The component is matched whole: a directory that merely has the word + /// in its name is an ordinary install location. + #[test] + fn a_path_that_only_mentions_translocation_is_not_refused() { + assert_eq!( + link_source(Path::new( + "/Users/me/AppTranslocation-notes/Staged.app/Contents/MacOS/Staged" + )), + Ok(PathBuf::from( + "/Users/me/AppTranslocation-notes/Staged.app/Contents/MacOS/stagedctl" + )) + ); + } + + /// Translocation is the reason given even where the path would also fail + /// the bundle check, since moving the app is the remedy. + #[test] + fn translocation_is_checked_before_the_bundle_shape() { + assert_eq!( + link_source(Path::new( + "/private/var/folders/yc/abc/T/AppTranslocation/ID/d/Staged" + )), + Err(Refusal::Translocated) + ); + } + + #[test] + fn an_executable_outside_a_bundle_is_refused() { + for executable in [ + // Development builds, with and without a target triple. + "/Users/me/.cache/cargo-target/debug/Staged", + "/repo/apps/staged/src-tauri/target/release/Staged", + // Bundle-like paths that are not `.app/Contents/MacOS/`. + "/Applications/Staged.app/Contents/Resources/Staged", + "/Applications/Staged.app/MacOS/Staged", + "/Applications/Staged/Contents/MacOS/Staged", + "/Applications/Staged.app/Contents/MacOS/nested/Staged", + // Not absolute, so it names no place a link could point at. + "Staged.app/Contents/MacOS/Staged", + "/", + ] { + assert_eq!( + link_source(Path::new(executable)), + Err(Refusal::NotBundled), + "{executable}" + ); + } + } + + #[test] + fn each_refusal_says_what_to_do_next() { + let translocated = Refusal::Translocated.to_string(); + assert!(translocated.contains("Move Staged to your Applications folder")); + let not_bundled = Refusal::NotBundled.to_string(); + assert!(not_bundled.contains("development build")); + assert!(not_bundled.contains("installed Staged.app")); + } + + // ------------------------------------------------------------------- + // Where the link goes + // ------------------------------------------------------------------- + + #[test] + fn the_system_directory_comes_before_the_one_under_home() { + assert_eq!( + link_candidates(Some(Path::new("/Users/me"))), + vec![ + PathBuf::from("/usr/local/bin/stagedctl"), + PathBuf::from("/Users/me/.local/bin/stagedctl"), + ] + ); + } + + #[test] + fn without_a_home_directory_there_is_no_fallback() { + assert_eq!( + link_candidates(None), + vec![PathBuf::from("/usr/local/bin/stagedctl")] + ); + } + + // ------------------------------------------------------------------- + // The decision + // ------------------------------------------------------------------- + + const SYSTEM: &str = "/usr/local/bin/stagedctl"; + const USER: &str = "/Users/me/.local/bin/stagedctl"; + const OTHER_BUILD: &str = "/Users/me/old/Staged.app/Contents/MacOS/stagedctl"; + + fn candidate(link: &str, entry: Entry) -> Candidate { + Candidate { + link: PathBuf::from(link), + entry, + usable: true, + } + } + + fn unusable(link: &str) -> Candidate { + Candidate { + link: PathBuf::from(link), + entry: Entry::Absent, + usable: false, + } + } + + fn elsewhere() -> Entry { + Entry::LinkElsewhere { + target: PathBuf::from(OTHER_BUILD), + } + } + + fn shown(link: &str, target: &str) -> ShownLink { + ShownLink { + link: link.to_string(), + target: target.to_string(), + } + } + + #[test] + fn an_empty_spot_in_the_first_directory_gets_the_link() { + let candidates = [ + candidate(SYSTEM, Entry::Absent), + candidate(USER, Entry::Absent), + ]; + assert_eq!( + decide(&candidates, None), + Step::Create { + link: PathBuf::from(SYSTEM) + } + ); + } + + #[test] + fn a_directory_that_refused_the_link_falls_back_to_the_next() { + let candidates = [unusable(SYSTEM), candidate(USER, Entry::Absent)]; + assert_eq!( + decide(&candidates, None), + Step::Create { + link: PathBuf::from(USER) + } + ); + } + + #[test] + fn no_usable_directory_is_reported() { + assert_eq!( + decide(&[unusable(SYSTEM), unusable(USER)], None), + Step::NowhereUsable + ); + assert_eq!(decide(&[], None), Step::NowhereUsable); + } + + #[test] + fn an_existing_link_to_the_sidecar_needs_nothing() { + let candidates = [ + candidate(SYSTEM, Entry::LinkToSource), + candidate(USER, Entry::Absent), + ]; + assert_eq!( + decide(&candidates, None), + Step::AlreadyLinked { + link: PathBuf::from(SYSTEM) + } + ); + + // Found in the fallback too, when the first directory has none and + // cannot take one. It does not need to be writable to count. + let mut fallback = candidate(USER, Entry::LinkToSource); + fallback.usable = false; + assert_eq!( + decide(&[unusable(SYSTEM), fallback], None), + Step::AlreadyLinked { + link: PathBuf::from(USER) + } + ); + } + + #[test] + fn a_link_elsewhere_is_reported_not_replaced() { + let candidates = [ + candidate(SYSTEM, elsewhere()), + candidate(USER, Entry::Absent), + ]; + assert_eq!( + decide(&candidates, None), + Step::Conflict { + link: PathBuf::from(SYSTEM), + entry: elsewhere(), + } + ); + } + + /// The fallback is for a directory that cannot be written, never a way + /// round something the user has not seen. + #[test] + fn a_conflict_is_not_skipped_for_the_fallback() { + for entry in [elsewhere(), Entry::NotALink] { + let mut first = candidate(SYSTEM, entry.clone()); + first.usable = false; + assert_eq!( + decide(&[first, candidate(USER, Entry::Absent)], None), + Step::Conflict { + link: PathBuf::from(SYSTEM), + entry, + } + ); + } + } + + #[test] + fn a_link_the_user_was_shown_is_replaced() { + let candidates = [ + candidate(SYSTEM, elsewhere()), + candidate(USER, Entry::Absent), + ]; + assert_eq!( + decide(&candidates, Some(&shown(SYSTEM, OTHER_BUILD))), + Step::Replace { + link: PathBuf::from(SYSTEM), + shown_target: PathBuf::from(OTHER_BUILD), + } + ); + } + + #[test] + fn a_link_that_is_not_the_one_shown_is_reported_again() { + let candidates = [ + candidate(SYSTEM, elsewhere()), + candidate(USER, Entry::Absent), + ]; + let conflict = Step::Conflict { + link: PathBuf::from(SYSTEM), + entry: elsewhere(), + }; + // The link changed after the user saw it. + assert_eq!( + decide(&candidates, Some(&shown(SYSTEM, "/somewhere/else"))), + conflict + ); + // The user was shown the other candidate's link. + assert_eq!( + decide(&candidates, Some(&shown(USER, OTHER_BUILD))), + conflict + ); + // A path that is no candidate at all selects nothing. + assert_eq!( + decide(&candidates, Some(&shown("/etc/hosts", OTHER_BUILD))), + conflict + ); + } + + #[test] + fn a_file_is_never_replaced() { + let candidates = [ + candidate(SYSTEM, Entry::NotALink), + candidate(USER, Entry::Absent), + ]; + for replace in [None, Some(shown(SYSTEM, "")), Some(shown(SYSTEM, SYSTEM))] { + assert_eq!( + decide(&candidates, replace.as_ref()), + Step::Conflict { + link: PathBuf::from(SYSTEM), + entry: Entry::NotALink, + } + ); + } + } + + /// A replace request is only ever a confirmation. With nothing in the + /// way it changes nothing about a create, and it never turns a link that + /// is already right into a write. + #[test] + fn a_replace_request_with_nothing_to_replace_is_ignored() { + let replace = shown(SYSTEM, OTHER_BUILD); + assert_eq!( + decide(&[candidate(SYSTEM, Entry::Absent)], Some(&replace)), + Step::Create { + link: PathBuf::from(SYSTEM) + } + ); + assert_eq!( + decide(&[candidate(SYSTEM, Entry::LinkToSource)], Some(&replace)), + Step::AlreadyLinked { + link: PathBuf::from(SYSTEM) + } + ); + } + + // ------------------------------------------------------------------- + // PATH + // ------------------------------------------------------------------- + + #[test] + fn a_directory_is_found_on_path_by_whole_entry() { + let path = OsStr::new("/usr/bin:/bin:/usr/local/bin/:/Users/me/.local/bin"); + assert_eq!( + dir_on_path(Path::new("/usr/local/bin"), Some(path)), + Some(true) + ); + assert_eq!( + dir_on_path(Path::new("/Users/me/.local/bin"), Some(path)), + Some(true) + ); + assert_eq!( + dir_on_path(Path::new("/usr/local"), Some(path)), + Some(false) + ); + assert_eq!(dir_on_path(Path::new("/opt/bin"), Some(path)), Some(false)); + assert_eq!( + dir_on_path(Path::new("/usr/bin"), Some(OsStr::new(""))), + Some(false) + ); + } + + #[test] + fn an_unknown_path_gives_no_answer() { + assert_eq!(dir_on_path(Path::new("/usr/local/bin"), None), None); + } + + // ------------------------------------------------------------------- + // The wire shape + // ------------------------------------------------------------------- + + #[test] + fn outcomes_serialize_as_the_frontend_reads_them() { + let path = OsStr::new("/usr/bin:/usr/local/bin"); + assert_eq!( + serde_json::to_value(LinkOutcome::new( + Installed::Linked(PathBuf::from(SYSTEM)), + Some(path) + )) + .unwrap(), + json!({ "outcome": "linked", "link": SYSTEM, "onPath": true }) + ); + assert_eq!( + serde_json::to_value(LinkOutcome::new( + Installed::AlreadyLinked(PathBuf::from(USER)), + Some(path) + )) + .unwrap(), + json!({ "outcome": "alreadyLinked", "link": USER, "onPath": false }) + ); + assert_eq!( + serde_json::to_value(LinkOutcome::new( + Installed::Linked(PathBuf::from(USER)), + None + )) + .unwrap(), + json!({ "outcome": "linked", "link": USER, "onPath": null }) + ); + assert_eq!( + serde_json::to_value(LinkOutcome::new( + Installed::Conflict { + link: PathBuf::from(SYSTEM), + entry: elsewhere(), + }, + Some(path) + )) + .unwrap(), + json!({ + "outcome": "conflict", + "link": SYSTEM, + "existing": { "kind": "link", "target": OTHER_BUILD }, + }) + ); + assert_eq!( + serde_json::to_value(LinkOutcome::new( + Installed::Conflict { + link: PathBuf::from(SYSTEM), + entry: Entry::NotALink, + }, + Some(path) + )) + .unwrap(), + json!({ "outcome": "conflict", "link": SYSTEM, "existing": { "kind": "other" } }) + ); + } + + /// The replace confirmation is the conflict's own `link` and `target` + /// sent back, so it must read the same field names. + #[test] + fn a_shown_link_deserializes_from_the_conflict_fields() { + let replace: ShownLink = + serde_json::from_value(json!({ "link": SYSTEM, "target": OTHER_BUILD })).unwrap(); + assert_eq!(replace, shown(SYSTEM, OTHER_BUILD)); + } +} diff --git a/apps/staged/src-tauri/src/stagedctl_link/commands.rs b/apps/staged/src-tauri/src/stagedctl_link/commands.rs new file mode 100644 index 000000000..fc019e988 --- /dev/null +++ b/apps/staged/src-tauri/src/stagedctl_link/commands.rs @@ -0,0 +1,56 @@ +//! The Tauri command behind the Settings action that puts `stagedctl` on +//! PATH. Desktop only: `web_server::dispatch` refuses it, and it stays out +//! of `control_server::ALLOWED_COMMANDS`, so no browser client and no +//! stagedctl caller, an agent session included, can change what the user's +//! shell runs. +//! +//! This is the one place the real inputs are read: the running executable, +//! the home directory and the user's PATH. Everything it decides with is in +//! [`super`], and everything it writes goes through [`super::fs`], which are +//! tested there with paths of their own. + +use std::ffi::OsString; +use std::time::Duration; + +use super::{fs, link_candidates, link_source, Installed, LinkOutcome, ShownLink}; + +/// How long the answer waits for the user's shell environment. It is +/// captured at startup and cached, so this only bites when the cache has +/// lapsed and the login shell is slow. The link is already made by then; +/// the answer just goes without the PATH check. +const PATH_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5); + +/// Link this app's bundled `stagedctl` into `/usr/local/bin`, or into +/// `~/.local/bin` when that cannot be written. +/// +/// Takes no path. `replace` is only a confirmation: the `link` and `target` +/// of a conflict the user was shown, repeated back. A link is replaced only +/// when it is still that one, and a file never is. +/// +/// An `Err` is a sentence for the user. Nothing was linked. +#[tauri::command] +pub async fn link_stagedctl_on_path(replace: Option) -> Result { + let executable = std::env::current_exe() + .map_err(|e| format!("Staged could not find its own executable: {e}."))?; + let source = link_source(&executable).map_err(|refusal| refusal.to_string())?; + let links = link_candidates(dirs::home_dir().as_deref()); + + let installed = fs::install(&source, &links, replace.as_ref())?; + + let path_var = match installed { + Installed::Conflict { .. } => None, + Installed::Linked(_) | Installed::AlreadyLinked(_) => user_path().await, + }; + Ok(LinkOutcome::new(installed, path_var.as_deref())) +} + +/// The PATH of the user's login shell, which is what a new terminal gets. +/// The app's own PATH is launchd's and says nothing about it. +async fn user_path() -> Option { + let cache = crate::session_runner::shell_env_cache(); + let capture = crate::shell_env::capture_home_interactive_env(cache.as_ref()); + let mut env = tokio::time::timeout(PATH_LOOKUP_TIMEOUT, capture) + .await + .ok()?; + env.remove("PATH").map(OsString::from) +} diff --git a/apps/staged/src-tauri/src/stagedctl_link/fs.rs b/apps/staged/src-tauri/src/stagedctl_link/fs.rs new file mode 100644 index 000000000..6abccd1d8 --- /dev/null +++ b/apps/staged/src-tauri/src/stagedctl_link/fs.rs @@ -0,0 +1,559 @@ +//! The filesystem half of linking `stagedctl` onto PATH: look at what is at +//! each candidate path, run the one step [`decide`] chose, and look again +//! when the disk changed underneath. +//! +//! Every path comes from the caller. Nothing here knows `/usr/local/bin` or +//! the user's home, so the tests below run the whole flow inside a temporary +//! directory. + +use std::path::{Path, PathBuf}; + +use super::{Installed, ShownLink}; + +/// Link `source` at the first of `links` that can take it. +/// +/// `replace` is the link the user was shown and asked to have replaced, if +/// any. An `Err` is a sentence for the user: nothing was linked, and it says +/// why. +#[cfg(unix)] +pub(crate) fn install( + source: &Path, + links: &[PathBuf], + replace: Option<&ShownLink>, +) -> Result { + unix::install(source, links, replace) +} + +#[cfg(not(unix))] +pub(crate) fn install( + _source: &Path, + _links: &[PathBuf], + _replace: Option<&ShownLink>, +) -> Result { + Err("Linking stagedctl onto your PATH is only supported on macOS.".to_string()) +} + +#[cfg(unix)] +mod unix { + use std::io::{self, ErrorKind}; + use std::os::unix::fs::symlink; + use std::path::{Path, PathBuf}; + + use super::super::{decide, Candidate, Entry, Installed, ShownLink, Step, CLI_NAME}; + + pub(super) fn install( + source: &Path, + links: &[PathBuf], + replace: Option<&ShownLink>, + ) -> Result { + if !source.is_file() { + return Err(format!( + "This copy of Staged has no stagedctl inside it (looked for {}). Reinstall \ + Staged and try again.", + source.display() + )); + } + + // The directories that would not take a link, each with the reason. + let mut refused: Vec<(PathBuf, String)> = Vec::new(); + // Each pass ends the call, rules one directory out, or looks again + // because the disk changed between the look and the step. The last + // can only repeat if something else keeps rewriting the same path, + // so the passes are bounded rather than trusted to stop. + for _ in 0..links.len() * 2 + 2 { + let candidates = links + .iter() + .map(|link| { + Ok(Candidate { + entry: observe(link, source)?, + usable: !refused.iter().any(|(refused, _)| refused == link), + link: link.clone(), + }) + }) + .collect::, String>>()?; + + match decide(&candidates, replace) { + Step::AlreadyLinked { link } => return Ok(Installed::AlreadyLinked(link)), + Step::Conflict { link, entry } => return Ok(Installed::Conflict { link, entry }), + Step::Create { link } => match create_link(source, &link) { + Ok(()) => return Ok(Installed::Linked(link)), + Err(CreateError::Appeared) => {} + Err(CreateError::Unusable(why)) => refused.push((link, why)), + }, + Step::Replace { link, shown_target } => { + match replace_link(source, &link, &shown_target) { + Ok(true) => return Ok(Installed::Linked(link)), + // No longer the link the user saw: look again, and + // report what is there now. + Ok(false) => {} + Err(e) => { + return Err(format!( + "Staged could not replace {}: {e}. The link is unchanged.", + link.display() + )); + } + } + } + Step::NowhereUsable => return Err(nowhere_usable(&refused)), + } + } + Err( + "The stagedctl link kept changing while Staged was setting it up. Nothing more was \ + changed; try again." + .to_string(), + ) + } + + /// What is at `link`, without following it. + fn observe(link: &Path, source: &Path) -> Result { + let metadata = match std::fs::symlink_metadata(link) { + Ok(metadata) => metadata, + // `NotADirectory`: the directory itself is missing and a file + // sits where one of its parents should be. Creating the link + // fails there, which is what moves on to the fallback. + Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory) => { + return Ok(Entry::Absent); + } + Err(e) => return Err(could_not_read(link, &e)), + }; + if !metadata.file_type().is_symlink() { + return Ok(Entry::NotALink); + } + let target = std::fs::read_link(link).map_err(|e| could_not_read(link, &e))?; + if target == source || same_file(link, source) { + Ok(Entry::LinkToSource) + } else { + Ok(Entry::LinkElsewhere { target }) + } + } + + /// Whether `link` resolves to `source`, however its target is spelled: a + /// relative link, or one through a symlinked directory. A link that + /// dangles resolves to nothing. + fn same_file(link: &Path, source: &Path) -> bool { + match (std::fs::canonicalize(link), std::fs::canonicalize(source)) { + (Ok(link), Ok(source)) => link == source, + _ => false, + } + } + + fn could_not_read(link: &Path, error: &io::Error) -> String { + format!("Staged could not read {}: {error}.", link.display()) + } + + enum CreateError { + /// Something took the path after it was seen empty. + Appeared, + /// The directory cannot be made or written, with the OS's reason. + Unusable(String), + } + + /// Create the link, and its directory if needed. `symlink` fails when + /// anything is at the path, a dangling link included, so this never + /// overwrites. + fn create_link(source: &Path, link: &Path) -> Result<(), CreateError> { + if let Some(dir) = link.parent() { + std::fs::create_dir_all(dir).map_err(|e| CreateError::Unusable(e.to_string()))?; + } + match symlink(source, link) { + Ok(()) => Ok(()), + Err(e) if e.kind() == ErrorKind::AlreadyExists => Err(CreateError::Appeared), + Err(e) => Err(CreateError::Unusable(e.to_string())), + } + } + + /// Point `link` at `source`, provided it is still a link to + /// `shown_target`. `Ok(false)` means it is not, and nothing was changed. + /// + /// The new link is made beside the old one and renamed over it, so the + /// name on PATH is never missing and a failure leaves the old link in + /// place. The check and the rename are two calls, the closest the + /// filesystem allows to a compare-and-swap. + fn replace_link(source: &Path, link: &Path, shown_target: &Path) -> io::Result { + match std::fs::read_link(link) { + Ok(target) if target == shown_target => {} + _ => return Ok(false), + } + let staging = link.with_file_name(format!(".{CLI_NAME}.link-{}", std::process::id())); + symlink(source, &staging)?; + if let Err(e) = std::fs::rename(&staging, link) { + let _ = std::fs::remove_file(&staging); + return Err(e); + } + Ok(true) + } + + fn nowhere_usable(refused: &[(PathBuf, String)]) -> String { + let mut message = + "Staged could not link stagedctl into a directory on your PATH.".to_string(); + for (link, why) in refused { + let dir = link.parent().unwrap_or(link); + message.push_str(&format!(" {}: {why}.", dir.display())); + } + message + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::super::Entry; + use super::*; + use std::os::unix::fs::{symlink, PermissionsExt}; + + /// A bundle with a sidecar, and two candidate directories, all under one + /// temporary directory. Neither candidate directory exists yet. + struct Fixture { + _dir: tempfile::TempDir, + root: PathBuf, + source: PathBuf, + system: PathBuf, + user: PathBuf, + } + + impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + // Canonical, so `/var` against `/private/var` never decides a test. + let root = dir.path().canonicalize().unwrap(); + let macos = root.join("Applications/Staged.app/Contents/MacOS"); + std::fs::create_dir_all(&macos).unwrap(); + let source = macos.join("stagedctl"); + std::fs::write(&source, "#!/bin/sh\n").unwrap(); + Self { + system: root.join("usr/local/bin/stagedctl"), + user: root.join("home/.local/bin/stagedctl"), + source, + root, + _dir: dir, + } + } + + fn links(&self) -> Vec { + vec![self.system.clone(), self.user.clone()] + } + + fn install(&self, replace: Option<&ShownLink>) -> Result { + install(&self.source, &self.links(), replace) + } + + /// Another build's sidecar, for links that point elsewhere. + fn other_build(&self) -> PathBuf { + let macos = self.root.join("old/Staged.app/Contents/MacOS"); + std::fs::create_dir_all(&macos).unwrap(); + let other = macos.join("stagedctl"); + std::fs::write(&other, "old").unwrap(); + other + } + + fn place_link(&self, link: &Path, target: &Path) { + std::fs::create_dir_all(link.parent().unwrap()).unwrap(); + symlink(target, link).unwrap(); + } + } + + fn shown(link: &Path, target: &Path) -> ShownLink { + ShownLink { + link: link.to_string_lossy().into_owned(), + target: target.to_string_lossy().into_owned(), + } + } + + fn names_in(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names + } + + fn set_mode(path: &Path, mode: u32) { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap(); + } + + /// Whether the directory really refuses writes. It does not for root, + /// which ignores the mode bits, so those tests have nothing to check. + fn read_only(dir: &Path) -> bool { + let probe = dir.join(".probe"); + match std::fs::write(&probe, "") { + Ok(()) => { + std::fs::remove_file(&probe).unwrap(); + false + } + Err(_) => true, + } + } + + #[test] + fn the_link_is_created_in_the_first_directory() { + let fx = Fixture::new(); + + assert_eq!(fx.install(None), Ok(Installed::Linked(fx.system.clone()))); + + assert_eq!(std::fs::read_link(&fx.system).unwrap(), fx.source); + assert_eq!(std::fs::read_to_string(&fx.system).unwrap(), "#!/bin/sh\n"); + // The fallback was not needed, so nothing was made for it. + assert!(!fx.root.join("home").exists()); + } + + #[test] + fn a_second_run_finds_the_link_and_changes_nothing() { + let fx = Fixture::new(); + fx.install(None).unwrap(); + + assert_eq!( + fx.install(None), + Ok(Installed::AlreadyLinked(fx.system.clone())) + ); + assert_eq!(names_in(fx.system.parent().unwrap()), ["stagedctl"]); + assert!(!fx.root.join("home").exists()); + } + + /// A link that reaches the sidecar by another spelling is the same link: + /// here, through a directory that is itself a symlink to the bundle's. + #[test] + fn a_link_that_resolves_to_the_sidecar_counts_however_it_is_written() { + let fx = Fixture::new(); + let alias = fx.root.join("alias"); + symlink(fx.source.parent().unwrap(), &alias).unwrap(); + fx.place_link(&fx.system, &alias.join("stagedctl")); + + assert_eq!( + fx.install(None), + Ok(Installed::AlreadyLinked(fx.system.clone())) + ); + assert_eq!( + std::fs::read_link(&fx.system).unwrap(), + alias.join("stagedctl") + ); + } + + #[test] + fn a_directory_that_cannot_be_made_falls_back_to_the_next() { + let fx = Fixture::new(); + // A file where the first directory's parent should be. + std::fs::create_dir_all(fx.root.join("usr")).unwrap(); + std::fs::write(fx.root.join("usr/local"), "").unwrap(); + + assert_eq!(fx.install(None), Ok(Installed::Linked(fx.user.clone()))); + assert_eq!(std::fs::read_link(&fx.user).unwrap(), fx.source); + assert_eq!( + std::fs::read_to_string(fx.root.join("usr/local")).unwrap(), + "" + ); + } + + #[test] + fn a_directory_that_cannot_be_written_falls_back_to_the_next() { + let fx = Fixture::new(); + let system_dir = fx.system.parent().unwrap(); + std::fs::create_dir_all(system_dir).unwrap(); + set_mode(system_dir, 0o555); + if !read_only(system_dir) { + return; + } + + let installed = fx.install(None); + set_mode(system_dir, 0o755); + + assert_eq!(installed, Ok(Installed::Linked(fx.user.clone()))); + assert!(names_in(system_dir).is_empty()); + assert_eq!(std::fs::read_link(&fx.user).unwrap(), fx.source); + } + + #[test] + fn no_usable_directory_names_each_one_and_why() { + let fx = Fixture::new(); + for blocked in ["usr", "home"] { + std::fs::write(fx.root.join(blocked), "").unwrap(); + } + + let error = fx.install(None).unwrap_err(); + + assert!(error.starts_with("Staged could not link stagedctl into a directory")); + for link in fx.links() { + let dir = link.parent().unwrap().display().to_string(); + assert!(error.contains(&dir), "{error} should name {dir}"); + } + assert_eq!(names_in(&fx.root), ["Applications", "home", "usr"]); + } + + #[test] + fn a_link_elsewhere_is_reported_and_left_alone() { + let fx = Fixture::new(); + let other = fx.other_build(); + fx.place_link(&fx.system, &other); + + assert_eq!( + fx.install(None), + Ok(Installed::Conflict { + link: fx.system.clone(), + entry: Entry::LinkElsewhere { + target: other.clone() + }, + }) + ); + assert_eq!(std::fs::read_link(&fx.system).unwrap(), other); + // The fallback is not used as a way round it. + assert!(!fx.root.join("home").exists()); + } + + #[test] + fn a_dangling_link_is_reported_with_its_target() { + let fx = Fixture::new(); + let gone = fx.root.join("deleted/Staged.app/Contents/MacOS/stagedctl"); + fx.place_link(&fx.system, &gone); + + assert_eq!( + fx.install(None), + Ok(Installed::Conflict { + link: fx.system.clone(), + entry: Entry::LinkElsewhere { + target: gone.clone() + }, + }) + ); + assert_eq!(std::fs::read_link(&fx.system).unwrap(), gone); + } + + #[test] + fn a_link_the_user_was_shown_is_replaced() { + let fx = Fixture::new(); + let other = fx.other_build(); + fx.place_link(&fx.system, &other); + + assert_eq!( + fx.install(Some(&shown(&fx.system, &other))), + Ok(Installed::Linked(fx.system.clone())) + ); + + assert_eq!(std::fs::read_link(&fx.system).unwrap(), fx.source); + // The old target is untouched, and no staging link is left behind. + assert_eq!(std::fs::read_to_string(&other).unwrap(), "old"); + assert_eq!(names_in(fx.system.parent().unwrap()), ["stagedctl"]); + } + + #[test] + fn a_dangling_link_the_user_was_shown_is_replaced() { + let fx = Fixture::new(); + let gone = fx.root.join("deleted/stagedctl"); + fx.place_link(&fx.system, &gone); + + assert_eq!( + fx.install(Some(&shown(&fx.system, &gone))), + Ok(Installed::Linked(fx.system.clone())) + ); + assert_eq!(std::fs::read_link(&fx.system).unwrap(), fx.source); + } + + /// The link changed between the user seeing it and confirming: what is + /// there now is reported, and it is not replaced. + #[test] + fn a_link_that_changed_since_it_was_shown_is_not_replaced() { + let fx = Fixture::new(); + let other = fx.other_build(); + let newer = fx.root.join("newer/stagedctl"); + fx.place_link(&fx.system, &newer); + + assert_eq!( + fx.install(Some(&shown(&fx.system, &other))), + Ok(Installed::Conflict { + link: fx.system.clone(), + entry: Entry::LinkElsewhere { + target: newer.clone() + }, + }) + ); + assert_eq!(std::fs::read_link(&fx.system).unwrap(), newer); + } + + #[test] + fn a_file_is_reported_and_never_replaced() { + let fx = Fixture::new(); + std::fs::create_dir_all(fx.system.parent().unwrap()).unwrap(); + std::fs::write(&fx.system, "someone's copy").unwrap(); + let conflict = Ok(Installed::Conflict { + link: fx.system.clone(), + entry: Entry::NotALink, + }); + + assert_eq!(fx.install(None), conflict); + assert_eq!(fx.install(Some(&shown(&fx.system, &fx.system))), conflict); + assert_eq!( + fx.install(Some(&shown(&fx.system, Path::new("")))), + conflict + ); + + assert_eq!( + std::fs::read_to_string(&fx.system).unwrap(), + "someone's copy" + ); + assert!(!fx.root.join("home").exists()); + } + + #[test] + fn a_directory_at_the_link_path_is_reported() { + let fx = Fixture::new(); + std::fs::create_dir_all(&fx.system).unwrap(); + + assert_eq!( + fx.install(None), + Ok(Installed::Conflict { + link: fx.system.clone(), + entry: Entry::NotALink, + }) + ); + assert!(fx.system.is_dir()); + } + + /// A replace that the directory refuses is an error, not a reason to + /// link somewhere else: the old link would still be the one in the way. + #[test] + fn a_replace_that_fails_leaves_the_old_link_and_says_so() { + let fx = Fixture::new(); + let other = fx.other_build(); + fx.place_link(&fx.system, &other); + let system_dir = fx.system.parent().unwrap(); + set_mode(system_dir, 0o555); + if !read_only(system_dir) { + return; + } + + let replaced = fx.install(Some(&shown(&fx.system, &other))); + set_mode(system_dir, 0o755); + + let error = replaced.unwrap_err(); + assert!(error.starts_with(&format!("Staged could not replace {}", fx.system.display()))); + assert!(error.ends_with("The link is unchanged.")); + assert_eq!(std::fs::read_link(&fx.system).unwrap(), other); + assert_eq!(names_in(system_dir), ["stagedctl"]); + assert!(!fx.root.join("home").exists()); + } + + #[test] + fn a_bundle_with_no_sidecar_links_nothing() { + let fx = Fixture::new(); + std::fs::remove_file(&fx.source).unwrap(); + + let error = fx.install(None).unwrap_err(); + + assert!(error.contains("has no stagedctl inside it")); + assert!(error.contains(&fx.source.display().to_string())); + assert!(!fx.root.join("usr").exists()); + assert!(!fx.root.join("home").exists()); + } + + /// An existing link to the sidecar in the fallback is found when the + /// first directory has nothing and will not take a link. + #[test] + fn a_link_already_in_the_fallback_is_found() { + let fx = Fixture::new(); + std::fs::write(fx.root.join("usr"), "").unwrap(); + fx.place_link(&fx.user, &fx.source); + + assert_eq!( + fx.install(None), + Ok(Installed::AlreadyLinked(fx.user.clone())) + ); + } +} diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index d3760368c..6c34f5bfa 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -1127,6 +1127,15 @@ pub(crate) async fn dispatch( crate::project_open::control::open_project(app_handle, &store, &project_id).await } + // ===================================================================== + // stagedctl on PATH (desktop only) + // ===================================================================== + // Links the app's bundled CLI into a directory on the user's PATH. It + // is the user's own gesture in Settings: a browser client, which may + // be on another machine, must not change what their shell runs. It is + // outside `ALLOWED_COMMANDS` too, so no stagedctl caller reaches it. + "link_stagedctl_on_path" => Err(format!("{command} is not available in web mode")), + // ===================================================================== // Projects // ===================================================================== diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index dfa09c48a..496bd4c2f 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -65,6 +65,8 @@ import type { SessionPoll, SessionPollRequest, SessionReconcileEvent, + StagedctlLinkOutcome, + StagedctlShownLink, StoreIncompatibility, SuggestedRepo, ToolCallBody, @@ -189,6 +191,21 @@ export function answerProjectOpen(requestId: string, outcome: ProjectOpenAnswer) return invokeCommand('answer_project_open', { requestId, outcome }); } +/** + * Link the app's bundled `stagedctl` into `/usr/local/bin`, or into + * `~/.local/bin` when that cannot be written, so a terminal can run it. + * Desktop only. + * + * Nothing already at the path is overwritten: a `conflict` outcome says what + * is there. Pass that conflict's `link` and `target` back as `replace` once + * the user has seen them and asked for the link to be replaced. Rejects with + * a sentence for the user when nothing could be linked, for one when Staged + * is running from a location a link should not point at. + */ +export function linkStagedctlOnPath(replace?: StagedctlShownLink): Promise { + return invokeCommand('link_stagedctl_on_path', { replace: replace ?? null }); +} + // ============================================================================= // Projects // ============================================================================= diff --git a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts index 7eeeca86e..ce86eafcd 100644 --- a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts +++ b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.component.test.ts @@ -10,6 +10,8 @@ const { commands, transport } = vi.hoisted(() => ({ commands: { listControlGrants: vi.fn<() => Promise>(), revokeControlGrant: vi.fn<(id: string) => Promise>(), + // The "Command line" setting above the list; it calls nothing on mount. + linkStagedctlOnPath: vi.fn(), }, transport: { listeners: new Map void>(), @@ -183,6 +185,19 @@ describe('ConnectedAppsPanel', () => { expect(rows()).toHaveLength(1); }); + it('offers the command line setting above the list, and runs nothing for it on load', async () => { + commands.listControlGrants.mockResolvedValue([]); + await render(); + expect(buttonsNamed('Add to PATH')).toHaveLength(1); + const setting = host.querySelector('section[aria-labelledby="stagedctl-path-heading"]'); + const empty = host.querySelector('[data-testid="connected-apps-empty"]'); + expect(setting).not.toBeNull(); + expect( + setting!.compareDocumentPosition(empty!) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect(commands.linkStagedctlOnPath).not.toHaveBeenCalled(); + }); + it('surfaces a failed load and tears down its listener on unmount', async () => { commands.listControlGrants.mockRejectedValue(new Error('Database not initialized')); await render(); diff --git a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte index b11009046..dfc5c7e60 100644 --- a/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte +++ b/apps/staged/src/lib/features/settings/ConnectedAppsPanel.svelte @@ -7,6 +7,10 @@ live credentials at once and refuses its next reconnect. The list follows the backend's `control-grants-changed` event (approvals, renewals, revocations from either side), so it needs no manual refresh. + + Above the list sits the action that puts stagedctl on the user's PATH + (`StagedctlPathSetting`), since a client has to be able to run the CLI + before it can ask to connect. --> + +
+
+
+

Command line

+

+ Add stagedctl to your PATH so you can run it from a terminal. Staged links to the + copy inside this app, so it stays in step with updates. +

+
+ +
+ + {#if error} + + {/if} + + +
+ {#if outcome?.outcome === 'conflict'} + {#if outcome.existing.kind === 'link'} + {@const shown = { link: outcome.link, target: outcome.existing.target }} +

+ {shown.link} already exists and points to {shown.target}. + Nothing was changed. Replace it to make stagedctl run this copy of Staged. +

+ + {:else} +

+ {outcome.link} already exists and is not a link, so Staged left it alone. Move + or delete it, then try again. +

+ {/if} + {:else if outcome} + {@const directory = directoryOf(outcome.link)} +

+ {#if outcome.outcome === 'linked'} + Linked {outcome.link} to this copy of Staged. + {:else} + {outcome.link} already points to this copy of Staged. + {/if} +

+ {#if outcome.onPath === false} +

+ {directory} is not on your PATH. Add it in your shell profile, then open a new + terminal. +

+ {:else if outcome.onPath === null} +

+ Staged could not read your PATH. If a new terminal cannot find stagedctl, add + {directory} to your PATH. +

+ {/if} + {/if} +
+
+ + diff --git a/apps/staged/src/lib/types.ts b/apps/staged/src/lib/types.ts index 249c8e580..bffa8b46e 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -1391,6 +1391,39 @@ export interface ProjectOpenRequest { projectId: string; } +/** + * A `stagedctl` link the user was shown and asked to replace: the `link` and + * `target` of a `conflict` outcome, sent back unchanged. Mirrors `ShownLink` + * in `src-tauri/src/stagedctl_link.rs`. + */ +export interface StagedctlShownLink { + link: string; + target: string; +} + +/** + * What the Settings action that puts `stagedctl` on PATH did. `link` is the + * full path of the `stagedctl` on PATH. Mirrors `LinkOutcome` in + * `src-tauri/src/stagedctl_link.rs`. + * + * - `linked`: the link was created, or replaced at the user's request. + * - `alreadyLinked`: it was already there. + * - `conflict`: something else is at `link` and nothing was changed. A link + * elsewhere can be replaced by sending it back; a file or directory + * (`other`) never is. + * + * `onPath` says whether the link's directory is on the user's PATH, and is + * `null` when their PATH could not be read. + */ +export type StagedctlLinkOutcome = + | { outcome: 'linked'; link: string; onPath: boolean | null } + | { outcome: 'alreadyLinked'; link: string; onPath: boolean | null } + | { + outcome: 'conflict'; + link: string; + existing: { kind: 'link'; target: string } | { kind: 'other' }; + }; + /** * An approved client as the Settings "Connected apps" panel lists it. * Mirrors `ControlGrantSummary` in `control_server/consent.rs`. diff --git a/scripts/staged b/scripts/staged index 857faf138..681cde45c 100755 --- a/scripts/staged +++ b/scripts/staged @@ -43,7 +43,8 @@ if ! STAGEDCTL="$(find_stagedctl)"; then cat >&2 <<'MESSAGE' staged: stagedctl was not found, so no project was opened and Staged was not started. It was looked for on PATH, then in /Applications/Staged.app and ~/Applications/Staged.app. -stagedctl ships inside Staged.app. Install Staged, or link its stagedctl onto PATH: +stagedctl ships inside Staged.app. Install Staged, then press "Add to PATH" under +Settings > Connected apps > Command line. Or link it onto PATH by hand: ln -s /path/to/Staged.app/Contents/MacOS/stagedctl /usr/local/bin/stagedctl MESSAGE exit 127 From a17993ecbaf059bd6a1290a2f8e67420a3198dfd Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 16:55:23 +1100 Subject: [PATCH 11/14] fix(staged): pick the last-used window and wait for its reveal Two warnings from the review of `stagedctl project open`, both in how `open_project` chooses and raises a window. Both were checked against the code first and hold. Which window. `pick_window_to_show` took the focused window, then "any visible" one from `webview_windows()`. That map is built fresh on every call, so with two or more visible windows and none focused the pick could differ between two runs. That is the normal state for this command: the terminal is frontmost, so no Staged window holds focus. The help text promised "the window a click on Staged's Dock icon would raise". Cold start. Every window is created hidden (`visible: false`, and the window-state plugin leaves visibility out) and its frontend calls `show()` at the end of mount, after the theme is applied. `open_project` arrives as soon as the control server answers, and `raise_window` showed the window itself, bringing back the unpainted flash the hidden start is there to avoid. The pick now goes: focused, then the window the user last used, then a visible one, then one that needs unhiding. The last two tiers go in a fixed order: `main`, then the `win-N` peers by number. - A new `app_lifecycle::window_use` module holds `UsedWindows`, an in-memory list of window labels in the order they were last used. - A window goes on it when it gains focus (`WindowEvent::Focused(true)`), when the last window is closed to hide, and when the backend raises it. It comes off when it is destroyed. - When the last-used window is gone, the one used before it is tried. The raise for `open_project` is now `raise_window_once_revealed`: - A window its frontend has revealed is raised on the spot, as before. That is one on screen (visible or minimized), or one on the list, which covers a window hidden since, such as the last window closed to hide. - Any other window is still loading and is left alone. The request is still parked for it and it still answers from its snapshot. The raise follows once the window comes on screen, so the frontend's own reveal is the first and the window still ends up in front and focused. - Nothing reports a window going on screen, so a spawned task polls for it every 100 ms. It stops when the window is destroyed or after 60 seconds. The reveal can come after the answer, so the task outlives the call. Choices worth a look: - The backend is not told when a frontend calls `show()`, so "revealed" is inferred: on screen now, or used before. Telling it would need a new command, or a window permission the frontend does not have. - On macOS the frontend's `show()` is `makeKeyAndOrderFront`, which already fronts the window when Staged is the active app. The deferred raise is for when it is not: the user went back to the terminal while Staged loaded, or Staged was started some other way and is still loading. - The Dock reopen and `Window > Staged` still raise unconditionally. Their usual cases pick as before: one window closed to hide, or a focused window. One case changes: several windows, none focused and none visible (all minimized, or the app hidden). That used to land on `main` and now lands on the window used last. - Where it was arbitrary before, among several visible or several hidden windows that were never used, it is now the first in the fixed order. The help text, README, guide and two comments now say the window is the one the user was last in, and that a window still loading comes to the front once it is ready, which can be after the command has exited. The help fixture is refreshed. This adds a backend type, which AGENTS.md asks a human to review: `UsedWindows`, managed app state. Nothing is persisted and there is no schema change. Tested on the mock runtime: - `window_raise.rs`: each tier of the pick, the last-used window with several visible, the fallback when it is gone, the fixed order run repeatedly, the list read through the app-level pick, a raise recorded on the list, an immediate raise for a window on screen and for a used window that is hidden, a loading window raised only once shown, and a window never shown given up on. - `control.rs`: the last-used window told and no other across repeated calls, `main` told when none was used, a window on screen raised before it answers, a loading window parked, answered and raised only after its reveal, and a loading window whose caller timed out still raised. - `window_use.rs`: order, repeat, forget and an unknown label. Checked: - `cargo fmt --check` passed. - `cargo clippy --workspace -- -D warnings` passed. - `cargo test --lib -- app_lifecycle project_open window_commands web_server control_server` passed, 199 tests. - `cargo test -p stagedctl` passed, 175 unit tests and the help fixture. - `pnpm -s check` passed with no errors. - `vitest run` on `projectOpenListener.test.ts` and `navigation.test.ts` passed, 86 tests. The only frontend change is a comment. Not exercised in a real app, and the full Rust and frontend suites were not run. The mock runtime reports every window as visible, sends no focus events and never removes a destroyed window, so these are untested: - the focus event reaching the list, and the list being cleared on destroy; - `is_visible` and `is_minimized` on a real hidden, minimized or app-hidden window; - the cold start end to end: no flash, and the window fronted after its reveal; - the poll stopping early for a destroyed window. The running Staged was not started, quit or restarted. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/README.md | 4 +- .../src-tauri/crates/stagedctl/src/cli.rs | 11 +- .../src-tauri/crates/stagedctl/src/open.rs | 7 +- .../crates/stagedctl/tests/fixtures/help.txt | 11 +- .../src-tauri/resources/stagedctl/guide.md | 11 +- apps/staged/src-tauri/src/app_lifecycle.rs | 21 +- .../src/app_lifecycle/window_raise.rs | 357 ++++++++++++++++-- .../src-tauri/src/app_lifecycle/window_use.rs | 122 ++++++ apps/staged/src-tauri/src/lib.rs | 1 + .../src-tauri/src/project_open/control.rs | 205 ++++++++-- apps/staged/src/App.svelte | 2 +- 11 files changed, 669 insertions(+), 83 deletions(-) create mode 100644 apps/staged/src-tauri/src/app_lifecycle/window_use.rs diff --git a/apps/staged/README.md b/apps/staged/README.md index 35d6d26d9..36d586db6 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -60,12 +60,12 @@ stagedctl project open # the project whose workspace the current stagedctl project open --no-launch # fail instead of starting Staged when it is not running ``` -The window a click on Staged's Dock icon would raise comes to the front and switches to the project. On success the command prints the project's name and id and the pid of the Staged instance that showed it. A project that is already on screen is a success too. No new window is opened, and a Staged that is already running is never joined by a second instance. +The Staged window you were last in comes to the front and switches to the project: the window that holds focus, or else the one that held it most recently and is still open. On success the command prints the project's name and id and the pid of the Staged instance that showed it. A project that is already on screen is a success too. No new window is opened, and a Staged that is already running is never joined by a second instance. - **By name or id.** `` is matched as an exact id, then an exact name, then a name that matches one project when case is ignored. A name that matches nothing, or several projects, fails before any window is touched. - **From a project workspace.** With no argument, the project is the one whose workspace the current directory is in: any directory under `~/.staged/workspaces//projects//`, where Staged keeps a project's worktrees. From any other directory the command fails with `not_in_workspace` without looking for the app, so pass a project there. It does not open arbitrary directories or repositories. - **A busy window.** The window is raised whether or not it could switch, so you can see what is in the way. A dialog that is open, or a diff comment that could not be saved, is left alone and reported as `ui_busy`: close or finish it and run the command again. -- **When Staged is not running.** The command starts the `Staged.app` that `stagedctl` is bundled in, waits up to thirty seconds for it to come up, and then opens the project. A line on stderr says Staged is being started. This is macOS only. +- **When Staged is not running.** The command starts the `Staged.app` that `stagedctl` is bundled in, waits up to thirty seconds for it to come up, and then opens the project. The new window is not shown early: it comes to the front once it has finished loading, which can be shortly after the command has exited. A line on stderr says Staged is being started. This is macOS only. - **`--no-launch`.** The command exits 3 instead of starting Staged. Staged is also never started when you are not yet authorized (see below), when the directory names no project, when `--lock-path` or `$STAGEDCTL_LOCK` points at one particular instance, or from a development build, whose app needs its dev server (run `just dev` instead). diff --git a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs index 40bb433d4..0a3aabeca 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs @@ -193,8 +193,9 @@ the --repo flag of `timeline show`, `session start`, `session list`, and /// Show a project in the Staged window and bring that window to the front #[command(long_about = "\ -Show a project in the Staged app. The window a click on Staged's Dock -icon would raise is brought to the front and switched to the project. +Show a project in the Staged app. The Staged window you were last in is +brought to the front and switched to the project: the window that holds +focus, or else the one that held it most recently and is still open. Prints the project's name and id and the pid of the Staged instance that showed it. A project that is already on screen is a success too. @@ -227,8 +228,10 @@ an agent cannot pull the user's window away. When Staged is not running, the command starts it, waits for it to come up, and then asks as above; the app holds the request until its window -has loaded. What it starts is the Staged.app this stagedctl is bundled -in, and nothing else. A line on stderr says so; stdout is unchanged. +has loaded. A window that is still loading is not shown early: it comes +to the front once it is ready, which can be after the command has +exited. What it starts is the Staged.app this stagedctl is bundled in, +and nothing else. A line on stderr says so; stdout is unchanged. Staged is never started: - with --no-launch: the command exits 3 instead; diff --git a/apps/staged/src-tauri/crates/stagedctl/src/open.rs b/apps/staged/src-tauri/crates/stagedctl/src/open.rs index b2800a693..342a99e76 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/open.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/open.rs @@ -2,8 +2,8 @@ //! word what the window did. //! //! The app's `open_project` command (`src-tauri/src/project_open/control.rs`) -//! picks the window a Dock click would raise, raises it, asks it to switch, -//! and answers `{ "outcome", "window" }` whatever the window did. A window +//! picks the window the user was last in, raises it, asks it to switch, and +//! answers `{ "outcome", "window" }` whatever the window did. A window //! that refused is a result on the wire, not an error, because the control //! server's plain errors carry no code. This module is where each outcome //! becomes an exit code and a `code: message` line. @@ -62,7 +62,8 @@ enum Outcome { #[serde(rename_all = "camelCase")] struct OpenAnswer { outcome: Outcome, - /// The label of the window that was asked and raised. + /// The label of the window that was asked, and raised unless it was + /// still loading: that one is raised once its frontend has shown it. window: String, } diff --git a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt index 244eab750..7a1878f6d 100644 --- a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt +++ b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt @@ -140,8 +140,9 @@ Options: Print help (see a summary with '-h') === stagedctl project open --help === -Show a project in the Staged app. The window a click on Staged's Dock -icon would raise is brought to the front and switched to the project. +Show a project in the Staged app. The Staged window you were last in is +brought to the front and switched to the project: the window that holds +focus, or else the one that held it most recently and is still open. Prints the project's name and id and the pid of the Staged instance that showed it. A project that is already on screen is a success too. @@ -174,8 +175,10 @@ an agent cannot pull the user's window away. When Staged is not running, the command starts it, waits for it to come up, and then asks as above; the app holds the request until its window -has loaded. What it starts is the Staged.app this stagedctl is bundled -in, and nothing else. A line on stderr says so; stdout is unchanged. +has loaded. A window that is still loading is not shown early: it comes +to the front once it is ready, which can be after the command has +exited. What it starts is the Staged.app this stagedctl is bundled in, +and nothing else. A line on stderr says so; stdout is unchanged. Staged is never started: - with --no-launch: the command exits 3 instead; diff --git a/apps/staged/src-tauri/resources/stagedctl/guide.md b/apps/staged/src-tauri/resources/stagedctl/guide.md index 65df26557..8e6ed53a7 100644 --- a/apps/staged/src-tauri/resources/stagedctl/guide.md +++ b/apps/staged/src-tauri/resources/stagedctl/guide.md @@ -160,9 +160,10 @@ the Staged instance that showed the project (`--json`: `{ outcome, project: { id, name }, window, pid }`). A project that is already on screen is a success too. -The window raised is the one a click on Staged's Dock icon would raise, -and it is raised whether or not it could switch, so the user sees what is -in the way. When it could not switch, the command exits 1: +The window raised is the one the user was last in: the window that holds +focus, or else the one that held it most recently and is still open. It +is raised whether or not it could switch, so the user sees what is in the +way. When it could not switch, the command exits 1: - `ui_busy`: a dialog is open in the window, or the diff comment being written could not be saved. Close or finish it and run the command @@ -184,7 +185,9 @@ that grant does not list it. **When Staged is not running**, the command starts it, waits up to thirty seconds for it to come up, and then asks as above; the app holds the -request until its window has loaded. What it starts is the `Staged.app` +request until its window has loaded. A window that is still loading is +not shown early: it comes to the front once it is ready, which can be +after the command has exited. What it starts is the `Staged.app` this `stagedctl` is bundled in and nothing else, on macOS only. A line on stderr says so; stdout is unchanged. A project name is looked up only once the app is up, so a name that matches nothing still exits 1 diff --git a/apps/staged/src-tauri/src/app_lifecycle.rs b/apps/staged/src-tauri/src/app_lifecycle.rs index 0e81f6299..7ffad52ed 100644 --- a/apps/staged/src-tauri/src/app_lifecycle.rs +++ b/apps/staged/src-tauri/src/app_lifecycle.rs @@ -68,8 +68,10 @@ use crate::session_runner::SessionRegistry; use crate::store::{CompletionReason, Session, SessionStatus, Store}; pub(crate) mod window_raise; +pub(crate) mod window_use; pub use window_raise::show_a_window; +pub use window_use::UsedWindows; /// Title of the quit confirmation alert. const QUIT_PROMPT_TITLE: &str = "Quit Staged?"; @@ -220,9 +222,18 @@ fn should_prompt(blockers: &QuitBlockers) -> bool { /// `Builder::on_window_event` hook — see the module docs for why closing the /// last window doesn't end the process. +/// +/// Also keeps the record of used windows ([`UsedWindows`]) in step with focus, +/// which is how a reveal finds the window the user was last in once Staged is +/// no longer the frontmost app. pub fn on_window_event(window: &Window, event: &WindowEvent) { - if let WindowEvent::CloseRequested { api, .. } = event { - on_close_requested(window, api); + match event { + WindowEvent::CloseRequested { api, .. } => on_close_requested(window, api), + WindowEvent::Focused(true) => { + window_use::note_window_used(window.app_handle(), window.label()); + } + WindowEvent::Destroyed => window_use::forget_window(window.app_handle(), window.label()), + _ => {} } } @@ -273,6 +284,12 @@ fn hide_window(window: &Window) { return; } set_native_focus(window.app_handle(), window.label(), false); + // The user closed it, so it has been on screen. Normally it held focus + // first and is on the record already, but a window's close button works + // without the window taking focus. Without this, a window closed that way + // would read as one still loading, which nothing but its own frontend may + // show (`window_raise::raise_window_once_revealed`). + window_use::note_window_used(window.app_handle(), window.label()); } /// Mirror a native window's visibility onto its PR-poll client's focus hint. diff --git a/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs b/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs index 2f59341bc..16ed79b03 100644 --- a/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs +++ b/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs @@ -4,13 +4,26 @@ //! [`show_a_window`] is the two run back to back, for callers that only want //! *a* window on screen. A caller that has something to say to the window it //! lands on picks first, so it knows which window that is before raising it. +//! One that may arrive while the window is still loading raises with +//! [`raise_window_once_revealed`], which leaves the first reveal to the +//! window's own frontend. use std::collections::HashMap; +use std::time::Duration; use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; +use super::window_use::{note_window_used, UsedWindows}; use super::{set_native_focus, MAIN_WINDOW_LABEL}; +/// How often a window that was still loading is checked for having been +/// revealed. Nothing reports a window going on screen, so it is polled. +const REVEAL_POLL: Duration = Duration::from_millis(100); + +/// How long a window that was still loading is watched before the raise it is +/// owed is dropped. Far longer than a cold start takes to reach its reveal. +const REVEAL_WAIT: Duration = Duration::from_secs(60); + /// Bring a window back on screen: the Dock-icon click and `Window ▸ Staged` /// both funnel here. Deliberately *not* on the quit path — see the /// [`app_lifecycle`](super) module docs. @@ -28,13 +41,22 @@ pub fn show_a_window(app: &AppHandle) { /// The window a reveal should land on, or `None` when every window has been /// destroyed. /// -/// Prefers where the user already is (focused, then visible), then falls back to -/// one that needs unhiding: `main` for its restored geometry, else any surviving -/// `win-N` peer. +/// Prefers where the user already is: the focused window, then the one they +/// used most recently that is still open (see [`UsedWindows`]). Staged is +/// rarely frontmost when a request arrives from a terminal, so that second +/// tier is the usual one for `stagedctl project open`. With no window used +/// yet it falls back to a visible one, then to one that needs unhiding. +/// Those two tiers go in a fixed order: `main` first, for its restored +/// geometry, then the `win-N` peers, oldest first. pub(crate) fn pick_window_to_show(app: &AppHandle) -> Option> { let windows = app.webview_windows(); + let recently_used = app + .try_state::() + .map(|used| used.most_recent_first()) + .unwrap_or_default(); pick_window( &windows, + &recently_used, |window| window.is_focused().unwrap_or(false), |window| window.is_visible().unwrap_or(false), ) @@ -45,22 +67,44 @@ pub(crate) fn pick_window_to_show(app: &AppHandle) -> Option( - windows: &HashMap, +fn pick_window<'a, W>( + windows: &'a HashMap, + recently_used: &[String], is_focused: impl Fn(&W) -> bool, is_visible: impl Fn(&W) -> bool, -) -> Option<&W> { - windows - .values() +) -> Option<&'a W> { + // `webview_windows()` is a fresh `HashMap` per call, so its own iteration + // order differs from one call to the next. + let mut labels: Vec<&String> = windows.keys().collect(); + labels.sort_by(|a, b| fixed_rank(a).cmp(&fixed_rank(b))); + let in_fixed_order = || labels.iter().map(|label| &windows[*label]); + + in_fixed_order() .find(|window| is_focused(window)) - .or_else(|| windows.values().find(|window| is_visible(window))) - .or_else(|| windows.get(MAIN_WINDOW_LABEL)) - .or_else(|| windows.values().next()) + .or_else(|| recently_used.iter().find_map(|label| windows.get(label))) + .or_else(|| in_fixed_order().find(|window| is_visible(window))) + .or_else(|| in_fixed_order().next()) +} + +/// Sort key for the tiers that have no better order: `main`, then `win-N` by +/// `N` (so `win-10` follows `win-9`), then anything else by name. +fn fixed_rank(label: &str) -> (u8, usize, &str) { + if label == MAIN_WINDOW_LABEL { + return (0, 0, label); + } + match label + .strip_prefix("win-") + .and_then(|index| index.parse().ok()) + { + Some(index) => (1, index, label), + None => (2, 0, label), + } } -/// Show, unminimize and focus `window`, and put its PR-poll client back on the -/// focused tier. +/// Show, unminimize and focus `window`, put its PR-poll client back on the +/// focused tier, and record it as the window used most recently. /// /// Best effort: each step that fails is logged and the rest still run, so a /// window that can't take focus is at least left on screen. @@ -75,18 +119,86 @@ pub(crate) fn raise_window(window: &WebviewWindow) { log::warn!("Failed to focus window: {e}"); } set_native_focus(window.app_handle(), window.label(), true); + // Recorded here as well as on the focus event, which does not come when + // the window could not take focus. + note_window_used(window.app_handle(), window.label()); +} + +/// Whether `window` is on screen: visible, or minimized to the Dock. A window +/// its frontend has not revealed yet is neither. +pub(crate) fn is_on_screen(window: &WebviewWindow) -> bool { + window.is_visible().unwrap_or(false) || window.is_minimized().unwrap_or(false) +} + +/// [`raise_window`] for a caller that may be ahead of the window's frontend. +/// +/// Every window is created hidden, and its frontend shows it once the theme +/// is applied; showing it sooner flashes an unpainted webview. A request that +/// arrives during a cold start must not do that. So: +/// +/// - A window its frontend has revealed is raised now. That is one on screen +/// (`is_on_screen`), or one the user has used before ([`UsedWindows`]), +/// which covers a window hidden since: the last window closed to hide, or +/// a window of an app hidden with `Cmd+H`. +/// - Any other window is still loading. It is left alone and raised once it +/// comes on screen, so the frontend's own reveal is the first, and the +/// window still ends up in front and focused however long that took. The +/// raise is dropped if the window is destroyed first or has not appeared +/// within [`REVEAL_WAIT`]. +/// +/// `is_on_screen` is [`is_on_screen`] outside tests. The mock runtime calls +/// every window visible, so the tests pass their own. +/// +/// Call it from within a tokio runtime: the wait for a loading window is a +/// spawned task. +pub(crate) fn raise_window_once_revealed( + window: &WebviewWindow, + is_on_screen: impl Fn(&WebviewWindow) -> bool + Send + 'static, +) { + let used = window + .app_handle() + .try_state::() + .is_some_and(|used| used.contains(window.label())); + if used || is_on_screen(window) { + raise_window(window); + return; + } + + let window = window.clone(); + tokio::spawn(async move { + let label = window.label(); + let give_up_at = tokio::time::Instant::now() + REVEAL_WAIT; + loop { + tokio::time::sleep(REVEAL_POLL).await; + if window.app_handle().get_webview_window(label).is_none() { + return; + } + if is_on_screen(&window) { + raise_window(&window); + return; + } + if tokio::time::Instant::now() >= give_up_at { + log::warn!("Window {label} was not revealed in time; leaving it unraised"); + return; + } + } + }); } #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; - /// A mock app with one window per label. + /// A mock app with one window per label and an empty record of used + /// windows. fn mock_app(labels: &[&str]) -> tauri::App { let app = mock_builder() .build(mock_context(noop_assets())) .expect("failed to build mock app"); + app.manage(UsedWindows::default()); for label in labels { tauri::WebviewWindowBuilder::new(&app, *label, Default::default()) .build() @@ -96,53 +208,136 @@ mod tests { } /// Label [`pick_window`] lands on when exactly the `focused` and `visible` - /// windows report themselves as such. - fn picked(app: &tauri::App, focused: &[&str], visible: &[&str]) -> Option { + /// windows report themselves as such, and `used` (most recent first) is + /// the record of used windows. + fn picked( + app: &tauri::App, + focused: &[&str], + used: &[&str], + visible: &[&str], + ) -> Option { let windows = app.webview_windows(); + let used: Vec = used.iter().map(|label| label.to_string()).collect(); pick_window( &windows, + &used, |window| focused.contains(&window.label()), |window| visible.contains(&window.label()), ) .map(|window| window.label().to_string()) } + /// The app's record of used windows, most recent first. + fn used(app: &tauri::App) -> Vec { + app.state::().most_recent_first() + } + + /// An `is_on_screen` probe the test flips, standing in for the frontend + /// showing its window. + fn on_screen_when( + shown: &Arc, + ) -> impl Fn(&WebviewWindow) -> bool + Send + 'static { + let shown = Arc::clone(shown); + move |_| shown.load(Ordering::SeqCst) + } + + #[test] + fn the_focused_window_wins_over_the_last_used_and_visible_ones() { + let app = mock_app(&["main", "win-2", "win-3"]); + + assert_eq!( + picked(&app, &["win-3"], &["win-2"], &["main", "win-2", "win-3"]).as_deref(), + Some("win-3") + ); + } + + /// The state `stagedctl project open` arrives in: a terminal is frontmost, + /// so several windows are visible and none is focused. + #[test] + fn the_last_used_window_wins_when_none_is_focused() { + let app = mock_app(&["main", "win-2", "win-3"]); + + assert_eq!( + picked(&app, &[], &["win-2", "main"], &["main", "win-2", "win-3"]).as_deref(), + Some("win-2") + ); + } + #[test] - fn the_focused_window_wins_over_visible_ones_and_main() { + fn the_window_used_before_it_wins_once_the_last_used_is_gone() { let app = mock_app(&["main", "win-2", "win-3"]); assert_eq!( - picked(&app, &["win-3"], &["main", "win-2", "win-3"]).as_deref(), + picked( + &app, + &[], + &["win-9", "win-3", "main"], + &["main", "win-2", "win-3"] + ) + .as_deref(), Some("win-3") ); } + /// Where the user was beats what happens to be showing: every window of + /// an app hidden with `Cmd+H` reports itself as not visible. + #[test] + fn the_last_used_window_wins_even_when_no_window_is_visible() { + let app = mock_app(&["main", "win-2"]); + + assert_eq!(picked(&app, &[], &["win-2"], &[]).as_deref(), Some("win-2")); + } + + /// With no window used, the pick among visible ones is still the same one + /// every time: `main`, then the `win-N` peers by number, not by name. + #[test] + fn visible_windows_are_tried_in_a_fixed_order() { + let app = mock_app(&["win-10", "win-3", "main", "win-2"]); + + for _ in 0..20 { + assert_eq!( + picked(&app, &[], &[], &["main", "win-2", "win-3", "win-10"]).as_deref(), + Some("main") + ); + assert_eq!( + picked(&app, &[], &[], &["win-2", "win-3", "win-10"]).as_deref(), + Some("win-2") + ); + assert_eq!( + picked(&app, &[], &[], &["win-3", "win-10"]).as_deref(), + Some("win-3") + ); + } + } + #[test] fn a_visible_window_wins_over_a_hidden_main() { let app = mock_app(&["main", "win-2", "win-3"]); - assert_eq!(picked(&app, &[], &["win-2"]).as_deref(), Some("win-2")); + assert_eq!(picked(&app, &[], &[], &["win-2"]).as_deref(), Some("win-2")); } #[test] fn main_wins_when_every_window_is_hidden() { let app = mock_app(&["win-2", "main", "win-3"]); - assert_eq!(picked(&app, &[], &[]).as_deref(), Some("main")); + assert_eq!(picked(&app, &[], &[], &[]).as_deref(), Some("main")); } #[test] - fn a_hidden_peer_is_picked_once_main_is_gone() { - let app = mock_app(&["win-2"]); + fn the_oldest_hidden_peer_is_picked_once_main_is_gone() { + let app = mock_app(&["win-10", "win-3", "win-2"]); - assert_eq!(picked(&app, &[], &[]).as_deref(), Some("win-2")); + for _ in 0..20 { + assert_eq!(picked(&app, &[], &[], &[]).as_deref(), Some("win-2")); + } } #[test] fn nothing_is_picked_with_no_windows() { let app = mock_app(&[]); - assert_eq!(picked(&app, &[], &[]), None); + assert_eq!(picked(&app, &[], &["main"], &[]), None); assert!(pick_window_to_show(app.handle()).is_none()); } @@ -160,14 +355,120 @@ mod tests { assert!(!window.is_focused().unwrap()); } + /// The record is wired in too: every mock window is visible and `main` + /// leads the fixed order, so only the record can land the pick on `win-3`. + #[test] + fn the_app_level_pick_reads_the_record_of_used_windows() { + let app = mock_app(&["main", "win-2", "win-3"]); + let record = app.state::(); + record.note("win-2"); + record.note("win-3"); + + let window = pick_window_to_show(app.handle()).expect("expected a window"); + assert_eq!(window.label(), "win-3"); + + // A destroyed window comes off the record, and the one before it wins. + record.forget("win-3"); + let window = pick_window_to_show(app.handle()).expect("expected a window"); + assert_eq!(window.label(), "win-2"); + } + /// Smoke test for the raise half on the mock runtime. Raising needs no /// managed state: with no PR-poll scheduler on the app there is simply no - /// focus hint to update. + /// focus hint to update, and with no record nothing is noted. #[test] - fn raising_a_window_needs_no_scheduler() { - let app = mock_app(&["main"]); - let window = pick_window_to_show(app.handle()).expect("expected a window"); + fn raising_a_window_needs_no_managed_state() { + let app = mock_builder() + .build(mock_context(noop_assets())) + .expect("failed to build mock app"); + let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .expect("failed to create mock window"); raise_window(&window); } + + #[test] + fn a_raised_window_becomes_the_last_used() { + let app = mock_app(&["main", "win-2"]); + app.state::().note("win-2"); + + raise_window(&app.get_webview_window("main").unwrap()); + + assert_eq!(used(&app), ["main", "win-2"]); + } + + /// No tokio runtime here: the raise is made on the spot, with no wait + /// spawned. + #[test] + fn a_window_on_screen_is_raised_at_once() { + let app = mock_app(&["main"]); + let window = app.get_webview_window("main").unwrap(); + + raise_window_once_revealed(&window, |_| true); + + assert_eq!(used(&app), ["main"]); + } + + /// The mock runtime's windows are visible, so the real probe takes the + /// same path. + #[test] + fn the_real_probe_reads_the_windows_own_state() { + let app = mock_app(&["main"]); + let window = app.get_webview_window("main").unwrap(); + assert!(is_on_screen(&window)); + + raise_window_once_revealed(&window, is_on_screen); + + assert_eq!(used(&app), ["main"]); + } + + /// A window the user has used was revealed long ago. Off screen now, as + /// the last window is once closed to hide, it is waiting for exactly this + /// raise, and nothing else would show it. + #[test] + fn a_used_window_that_is_hidden_is_raised_at_once() { + let app = mock_app(&["main", "win-2"]); + let record = app.state::(); + record.note("main"); + record.note("win-2"); + + raise_window_once_revealed(&app.get_webview_window("main").unwrap(), |_| false); + + assert_eq!(used(&app), ["main", "win-2"]); + } + + /// The cold start: the window is hidden until its frontend shows it, and + /// the backend must not get there first. + #[tokio::test(start_paused = true)] + async fn a_window_still_loading_is_raised_only_once_its_frontend_shows_it() { + let app = mock_app(&["main"]); + let window = app.get_webview_window("main").unwrap(); + let shown = Arc::new(AtomicBool::new(false)); + + raise_window_once_revealed(&window, on_screen_when(&shown)); + assert!(used(&app).is_empty(), "not raised while it is loading"); + + tokio::time::sleep(REVEAL_POLL * 20).await; + assert!(used(&app).is_empty(), "still loading, still not raised"); + + shown.store(true, Ordering::SeqCst); + tokio::time::sleep(REVEAL_POLL * 2).await; + assert_eq!(used(&app), ["main"], "raised once its frontend showed it"); + } + + #[tokio::test(start_paused = true)] + async fn a_window_that_is_never_revealed_is_given_up_on() { + let app = mock_app(&["main"]); + let window = app.get_webview_window("main").unwrap(); + let shown = Arc::new(AtomicBool::new(false)); + + raise_window_once_revealed(&window, on_screen_when(&shown)); + tokio::time::sleep(REVEAL_WAIT + REVEAL_POLL * 2).await; + + // Too late: the wait is over, so nothing raises it now. + shown.store(true, Ordering::SeqCst); + tokio::time::sleep(REVEAL_POLL * 20).await; + assert!(used(&app).is_empty()); + } } diff --git a/apps/staged/src-tauri/src/app_lifecycle/window_use.rs b/apps/staged/src-tauri/src/app_lifecycle/window_use.rs new file mode 100644 index 000000000..0a8882846 --- /dev/null +++ b/apps/staged/src-tauri/src/app_lifecycle/window_use.rs @@ -0,0 +1,122 @@ +//! Which windows the user has used, and in what order. +//! +//! Nothing in tauri reports "the window the user was last in" once Staged is +//! not the frontmost app, and nothing reports a window going on screen. Both +//! are read off this record instead: +//! +//! - [`pick_window_to_show`](super::window_raise::pick_window_to_show) lands +//! on the most recently used window when none holds focus, which is the +//! normal state for a request that comes from a terminal. +//! - [`raise_window_once_revealed`](super::window_raise::raise_window_once_revealed) +//! takes a window that is on the record as one its frontend has already +//! revealed. Every window is created hidden and shown by its own frontend. +//! It cannot be focused or closed before that, and one the backend raises +//! is on screen from then on. +//! +//! A window goes on the record when it gains focus, when the last window is +//! closed to hide, and when the backend raises it. It comes off when it is +//! destroyed. + +use std::sync::Mutex; + +use tauri::{AppHandle, Manager, Runtime}; + +/// Labels of the windows the user has used, least recent first. Managed as +/// Tauri state, in memory only: window labels do not outlive the process. +#[derive(Default)] +pub struct UsedWindows { + order: Mutex>, +} + +impl UsedWindows { + /// Record `label` as the window used most recently. + pub(crate) fn note(&self, label: &str) { + let mut order = self.order.lock().unwrap(); + order.retain(|known| known != label); + order.push(label.to_string()); + } + + /// Drop a destroyed window from the record. + pub(crate) fn forget(&self, label: &str) { + self.order.lock().unwrap().retain(|known| known != label); + } + + /// Whether `label` has been used, and so has been on screen. + pub(crate) fn contains(&self, label: &str) -> bool { + self.order + .lock() + .unwrap() + .iter() + .any(|known| known == label) + } + + /// The used windows' labels, most recent first. + pub(crate) fn most_recent_first(&self) -> Vec { + self.order.lock().unwrap().iter().rev().cloned().collect() + } +} + +/// [`UsedWindows::note`] on the app's record. A no-op on an app that manages +/// none, as the mock apps in most tests do not. +pub(crate) fn note_window_used(app: &AppHandle, label: &str) { + if let Some(used) = app.try_state::() { + used.note(label); + } +} + +/// [`UsedWindows::forget`] on the app's record. +pub(crate) fn forget_window(app: &AppHandle, label: &str) { + if let Some(used) = app.try_state::() { + used.forget(label); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_window_noted_last_comes_first() { + let used = UsedWindows::default(); + used.note("main"); + used.note("win-2"); + used.note("win-3"); + + assert_eq!(used.most_recent_first(), ["win-3", "win-2", "main"]); + } + + /// Going back to a window moves it to the front rather than listing it + /// twice. + #[test] + fn noting_a_window_again_moves_it_to_the_front() { + let used = UsedWindows::default(); + used.note("main"); + used.note("win-2"); + used.note("main"); + + assert_eq!(used.most_recent_first(), ["main", "win-2"]); + } + + #[test] + fn a_forgotten_window_is_off_the_record() { + let used = UsedWindows::default(); + used.note("main"); + used.note("win-2"); + + used.forget("win-2"); + + assert!(used.contains("main")); + assert!(!used.contains("win-2")); + assert_eq!(used.most_recent_first(), ["main"]); + } + + #[test] + fn a_window_never_noted_is_not_on_the_record() { + let used = UsedWindows::default(); + + assert!(!used.contains("main")); + assert!(used.most_recent_first().is_empty()); + // Forgetting it is harmless. + used.forget("main"); + } +} diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index c23758caf..6878b4bd6 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2149,6 +2149,7 @@ pub fn run() { app.manage(action_executor); app.manage(action_registry); app.manage(app_lifecycle::QuitState::default()); + app.manage(app_lifecycle::UsedWindows::default()); app.manage(launch_resume::LaunchResumeState::default()); app.manage(DbState { db_path, diff --git a/apps/staged/src-tauri/src/project_open/control.rs b/apps/staged/src-tauri/src/project_open/control.rs index 0426dc130..0a5789c3d 100644 --- a/apps/staged/src-tauri/src/project_open/control.rs +++ b/apps/staged/src-tauri/src/project_open/control.rs @@ -1,14 +1,21 @@ //! The app's side of `stagedctl project open`: the body of `dispatch`'s //! `open_project` arm, reachable through the control server only. //! -//! [`open_project`] checks the project exists, picks the window a Dock click -//! would land on, parks a [`ProjectOpenRequest`](super::ProjectOpenRequest) -//! for it, tells that window alone with [`PROJECT_OPEN_REQUESTED_EVENT`], -//! raises it, and waits for what the window did. A window that was not -//! listening yet finds the request in its +//! [`open_project`] checks the project exists, picks the window the user was +//! last in, parks a [`ProjectOpenRequest`](super::ProjectOpenRequest) for it, +//! tells that window alone with [`PROJECT_OPEN_REQUESTED_EVENT`], raises it, +//! and waits for what the window did. A window that was not listening yet +//! finds the request in its //! [`list_pending_project_opens`](super::commands::list_pending_project_opens) //! snapshot instead. //! +//! **A window still loading is not shown early.** On a cold start the request +//! arrives while the window is hidden, waiting for its frontend to apply the +//! theme and show it. The request is parked for it all the same, but the +//! raise waits for that reveal +//! ([`raise_window_once_revealed`](crate::app_lifecycle::window_raise::raise_window_once_revealed)), +//! which may come after the window has answered. +//! //! **A refusal is a result, not an error.** The answer goes back as //! `{ "outcome", "window" }` whatever the window did, so the CLI can tell a //! dialog in the way from a window that never answered and word each for the @@ -21,10 +28,12 @@ use std::time::Duration; use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Manager, Runtime}; +use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow}; use super::{ProjectOpenAnswer, ProjectOpenOutcome, ProjectOpenState, ANSWER_TIMEOUT}; -use crate::app_lifecycle::window_raise::{pick_window_to_show, raise_window}; +use crate::app_lifecycle::window_raise::{ + is_on_screen, pick_window_to_show, raise_window_once_revealed, +}; use crate::store::Store; /// Emitted to one window, by label, with the @@ -38,15 +47,18 @@ pub(crate) async fn open_project( store: &Store, project_id: &str, ) -> Result { - open_project_within(app, store, project_id, ANSWER_TIMEOUT).await + open_project_within(app, store, project_id, ANSWER_TIMEOUT, is_on_screen).await } -/// [`open_project`] with the wait passed in. +/// [`open_project`] with the wait passed in, and the probe that says whether +/// a window is on screen: the mock runtime calls every window visible, so the +/// tests pass their own to stand in for one that is still loading. async fn open_project_within( app: &AppHandle, store: &Store, project_id: &str, timeout: Duration, + is_on_screen: impl Fn(&WebviewWindow) -> bool + Send + 'static, ) -> Result { // Before any window is touched: a typo must not pull Staged to the front. store @@ -61,7 +73,7 @@ async fn open_project_within( let label = window.label().to_string(); // Parked before the event goes out, so the window can answer the moment - // it hears it, and before the raise, so a window still loading finds the + // it hears it. A window still loading hears nothing, and finds the // request in its snapshot. let pending = state.request(&label, project_id); if let Err(e) = app.emit_to( @@ -73,8 +85,10 @@ async fn open_project_within( log::warn!("Failed to emit {PROJECT_OPEN_REQUESTED_EVENT} to {label}: {e}"); } // Raised whatever the answer turns out to be: a window that refuses is - // then showing the user the dialog or comment that is in the way. - raise_window(&window); + // then showing the user the dialog or comment that is in the way. A + // window its frontend has not revealed yet is raised when it has been, + // not now, so this never shows an unpainted webview. + raise_window_once_revealed(&window, is_on_screen); let outcome = pending.wait(timeout).await; Ok(json!({ "outcome": outcome_name(outcome), "window": label })) @@ -96,19 +110,23 @@ fn outcome_name(outcome: ProjectOpenOutcome) -> &'static str { #[cfg(test)] mod tests { use super::*; + use crate::app_lifecycle::UsedWindows; use crate::project_open::ProjectOpenRequest; use crate::store::Project; + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; use tauri::Listener; - /// A mock app managing the state with one window per label, a store - /// holding one project, and that project's id. + /// A mock app managing the state and an empty record of used windows, + /// with one window per label, a store holding one project, and that + /// project's id. fn fixture(labels: &[&str]) -> (tauri::App, Store, String) { let app = mock_builder() .build(mock_context(noop_assets())) .expect("failed to build mock app"); app.manage(ProjectOpenState::default()); + app.manage(UsedWindows::default()); for label in labels { tauri::WebviewWindowBuilder::new(&app, *label, Default::default()) .build() @@ -197,36 +215,153 @@ mod tests { } } - /// Only the window the request is parked for hears about it. + /// Only the window the request is parked for hears about it. With no + /// window focused, as when the caller's terminal is frontmost, that is the + /// window the user was last in, however many others are visible. #[tokio::test] - async fn no_other_window_is_told() { - let (app, store, project_id) = fixture(&["main", "win-2"]); - let heard = [heard_by(&app, "main"), heard_by(&app, "win-2")]; + async fn the_last_used_window_is_told_and_no_other() { + let (app, store, project_id) = fixture(&["main", "win-2", "win-3"]); + let heard = [ + heard_by(&app, "main"), + heard_by(&app, "win-2"), + heard_by(&app, "win-3"), + ]; let state = app.state::(); + let used = app.state::(); + used.note("win-3"); + used.note("win-2"); - let (result, picked) = - tokio::join!(open_project(app.handle(), &store, &project_id), async { - // The mock runtime calls every window visible, so either may be - // picked; exactly one holds the request. - let picked: Vec<&str> = ["main", "win-2"] - .into_iter() - .filter(|label| state.pending_for(label).is_some()) - .collect(); - assert_eq!(picked.len(), 1, "one window holds the request: {picked:?}"); - answer(&app, picked[0], ProjectOpenAnswer::Opened); - picked[0] - }); + // Run more than once: the pick must not vary between calls. + for round in 1..=5 { + let (result, ()) = + tokio::join!(open_project(app.handle(), &store, &project_id), async { + assert_eq!(state.pending_for("main"), None); + assert_eq!(state.pending_for("win-3"), None); + answer(&app, "win-2", ProjectOpenAnswer::Opened); + }); + + assert_eq!( + result.unwrap(), + json!({ "outcome": "opened", "window": "win-2" }) + ); + assert!( + heard[0].lock().unwrap().is_empty(), + "main must not navigate" + ); + assert_eq!(heard[1].lock().unwrap().len(), round); + assert!( + heard[2].lock().unwrap().is_empty(), + "win-3 must not navigate" + ); + } + } + + /// With no window used yet the pick is still the same every time: the + /// mock runtime calls every window visible, and `main` leads that tier. + #[tokio::test] + async fn with_no_window_used_yet_main_is_told() { + let (app, store, project_id) = fixture(&["win-3", "main", "win-2"]); + + for _ in 0..5 { + let (result, ()) = + tokio::join!(open_project(app.handle(), &store, &project_id), async { + answer(&app, "main", ProjectOpenAnswer::Opened) + }); + assert_eq!( + result.unwrap(), + json!({ "outcome": "opened", "window": "main" }) + ); + } + } + + /// The window is raised as it is asked, and so becomes the last used. + #[tokio::test] + async fn a_window_on_screen_is_raised_before_it_answers() { + let (app, store, project_id) = fixture(&["main"]); + let used = app.state::(); + + let (result, ()) = tokio::join!(open_project(app.handle(), &store, &project_id), async { + assert!(used.contains("main"), "raised before any answer"); + answer(&app, "main", ProjectOpenAnswer::DialogOpen) + }); assert_eq!( result.unwrap(), - json!({ "outcome": "opened", "window": picked }) + json!({ "outcome": "dialogOpen", "window": "main" }) ); - let (told, untold) = if picked == "main" { (0, 1) } else { (1, 0) }; - assert_eq!(heard[told].lock().unwrap().len(), 1); + } + + /// The cold start. The window is hidden until its frontend shows it, so + /// the request is parked and answered as usual, but nothing is raised + /// until that reveal, which here comes after the answer. + #[tokio::test(start_paused = true)] + async fn a_window_still_loading_is_asked_at_once_and_raised_only_when_revealed() { + let (app, store, project_id) = fixture(&["main"]); + let heard = heard_by(&app, "main"); + let state = app.state::(); + let used = app.state::(); + let shown = Arc::new(AtomicBool::new(false)); + let is_shown = { + let shown = Arc::clone(&shown); + move |_: &WebviewWindow| shown.load(Ordering::SeqCst) + }; + + let (result, parked) = tokio::join!( + open_project_within(app.handle(), &store, &project_id, ANSWER_TIMEOUT, is_shown), + async { + // What the loading window's snapshot will offer it. + let parked = state + .pending_for("main") + .expect("the request should be parked for the loading window"); + assert!(!used.contains("main"), "not raised while it is loading"); + // The window loads for a while before its listener is live. + tokio::time::sleep(Duration::from_secs(2)).await; + assert!(!used.contains("main"), "still not raised"); + assert!(state.answer("main", &parked.request_id, ProjectOpenAnswer::Opened)); + parked + } + ); + + assert_eq!( + result.unwrap(), + json!({ "outcome": "opened", "window": "main" }) + ); + assert_eq!(*heard.lock().unwrap(), vec![as_wire(&parked)]); assert!( - heard[untold].lock().unwrap().is_empty(), - "the other window must not navigate" + !used.contains("main"), + "answering is not the reveal: the frontend has not shown the window" + ); + + // The frontend shows its window; the raise that was owed follows. + shown.store(true, Ordering::SeqCst); + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(used.contains("main"), "raised once revealed"); + } + + /// A window that does not answer in time is still owed its raise: the caller reads + /// the timeout, and the window comes to the front when it does appear. + #[tokio::test(start_paused = true)] + async fn a_loading_window_whose_caller_timed_out_is_still_raised_when_revealed() { + let (app, store, project_id) = fixture(&["main"]); + let used = app.state::(); + let shown = Arc::new(AtomicBool::new(false)); + let is_shown = { + let shown = Arc::clone(&shown); + move |_: &WebviewWindow| shown.load(Ordering::SeqCst) + }; + + let result = + open_project_within(app.handle(), &store, &project_id, ANSWER_TIMEOUT, is_shown).await; + + assert_eq!( + result.unwrap(), + json!({ "outcome": "timedOut", "window": "main" }) ); + assert!(!used.contains("main")); + + shown.store(true, Ordering::SeqCst); + tokio::time::sleep(Duration::from_secs(1)).await; + assert!(used.contains("main")); } #[tokio::test] diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 6fb1d31a6..4dedf0bba 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -442,7 +442,7 @@ } // `stagedctl project open` requests for this window: every desktop - // window, since the backend picks whichever one a Dock click would raise. + // window, since the backend picks whichever one the user was last in. // Started only now, unlike the listeners above, so a request never // navigates underneath the route restore, which would then put the // restored or seeded project back over it. Nothing is missed by waiting: From 34aebf93f960d51926e3d254aee1b2db1c1d8fb0 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 17:09:08 +1100 Subject: [PATCH 12/14] fix(staged): open before the resume offer and skip withdrawn requests Two warnings from the review of `stagedctl project open`, both in what the window does with a request. Both were checked against the code first and hold. The two window-pick and cold-start-raise warnings were fixed in 1637a005; the review's suggestions are left for a separate commit. Cold start. The window refuses a request with `dialogOpen` while a dialog is open. On a cold start two chains of round trips ran side by side in the main window: - the listener: register, read the snapshot, wait for the project list, then look for a dialog; - the mount: restore unread state, load the key bindings, read the store status, then `resumeOnLaunch.load()`, which opens the "Resume interrupted sessions?" dialog when the last quit interrupted sessions. Nothing ordered them, so the same command could end `opened` with the dialog over the new project, or `ui_busy` telling the user to close a dialog in an app they had only just started. A request found at startup is now acted on before the offer is loaded: - `listenForProjectOpens` returns `{ stop, startupHandled }` instead of the bare unlisten function. `startupHandled` settles once the listener is live, the snapshot has been read, and every request taken by then has been opened and answered. - `App.svelte` chains `resumeOnLaunch.load()` on it, in the place it was called before. The project opens and the offer then appears over it. - With nothing parked the wait is two round trips that start well before the offer's turn, so the offer is not held up in practice. - The offer cannot be lost. `startupHandled` also settles when the registration or the snapshot fails, when the listener is stopped, and after 15 seconds at the latest. That mirrors the backend's `ANSWER_TIMEOUT`: by then the caller is being told it timed out whatever a dialog does. This is the ordering option rather than exempting the dialog. It is safe here: the offer is mounted at the app root, so no project view owns it, and the backend holds the interrupted set until it is answered, so loading it later loses nothing. It covers the request the window finds waiting, which is the cold start the review describes: the CLI sends the request as soon as the control server answers, well before the frontend runs. A request that reaches the window after its startup snapshot, while the offer is already open, is still refused like any other request with a dialog in the way. Stale navigation. A request the listener had taken could wait on the project list, on a diff comment being saved, or behind an earlier request. If the backend's wait ran out, or a newer request superseded it, the caller was told so, but `selectProject` still ran and only the answer was refused. The window now checks that the request is still pending right before it navigates: - `openProjectExternally` takes an optional `stillWanted` check. It is awaited last, after the wait for the project list and after a diff save. When it resolves false nothing is navigated and the result is `withdrawn`. - The project and the dialogs are looked at again after the check, in the same tick as the navigation, because the check is a round trip. - The listener passes a check that reads `list_pending_project_opens` and matches on the request id. A `withdrawn` result sends no answer. - A check that fails is treated as not wanted: the helper rejects, nothing is navigated, and the caller reads a timeout. - The check is not made when nothing would be navigated: a project that is already on screen, and each refusal. One gap is left and is now documented rather than denied. The timeout or the newer request can land between the snapshot's reply and the navigation it allows, one round trip to the window. The window then switches and its answer is refused. Closing it would need the backend to hold its lock across the window's navigation. Wording brought in line with that: - The module doc in `project_open.rs` no longer says a window "never navigates for a caller that has already been told it failed". It says what the state does, what the window's check adds, and what gap is left, and that `TimedOut` and `Superseded` mean the window did not report showing the project. - The CLI's superseded message said the project "was not shown". It now says the request was replaced "before the window answered it" and that the project "may not be showing". - The verb's help text and the README's `ui_busy` row say "before the window answered it" in place of "before the window acted on it", and no longer tie "not switched" to a superseded request. The help fixture is refreshed. No new backend type, command or state, and no schema change. The Rust changes are doc comments and one message string. Tested: - `projectOpenListener.test.ts`, 16 new tests. For stale requests: the check asks the backend afresh and matches on the request id, a `withdrawn` result is not answered, a request withdrawn while the helper waits is not navigated, one still pending is, one superseded while the helper waits gives way to the newer one, one withdrawn while it waited its turn is not navigated, and a failed check navigates nowhere. For startup: `startupHandled` settles only after the parked request is opened and answered, after the snapshot when nothing was parked, after a request the event delivered first, after one that arrives during the parked one, and on a withdrawn request, a failed snapshot, a failed registration, a stop, and the 15 second cap. - `navigation.test.ts`, 7 new tests on the real helper: a request withdrawn during the wait for the list, and during a diff save, is not navigated; the check runs after the save; it is skipped when nothing would be navigated; the dialogs and the project are read again after it; and a check that rejects navigates nowhere. - Each fix was removed in turn and the new tests failed: the listener passing no check, `startupHandled` settling before the queue drained, the helper ignoring the check, and the helper asking before its waits. Checked: - `prettier --check` on `src/` passed. - `pnpm -s check` passed with no errors or warnings. - `vitest run` on `projectOpenListener.test.ts` (29 tests) and `navigation.test.ts` (80 tests) passed, as did the other listener tests and `transport.test.ts` alongside them, 227 in all. - `cargo fmt --check` passed. - `cargo test -p stagedctl` passed: 175 unit tests and the help fixture. - `cargo test --lib -- project_open` in the app crate passed, 22 tests. - `cargo clippy --workspace -- -D warnings` passed. Not exercised in a real app, and the full Rust and frontend suites were not run. Unverified without a live app: - `App.svelte`'s chaining of the offer on `startupHandled`. No unit test mounts `App.svelte`; the tests cover the promise it waits on. - The cold start end to end: a request parked before the window loads, with sessions interrupted by the last quit, ending `opened` every time with the offer over the project. - That the request really is parked before the window's startup snapshot on a real cold start, which the ordering relies on. - The check against the real backend: a caller timing out, or being superseded, while a real window waits on its list or on a diff save. The running Staged was not started, quit or restarted. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/README.md | 2 +- .../src-tauri/crates/stagedctl/src/cli.rs | 2 +- .../src-tauri/crates/stagedctl/src/open.rs | 15 +- .../crates/stagedctl/tests/fixtures/help.txt | 2 +- apps/staged/src-tauri/src/project_open.rs | 28 +- .../src-tauri/src/project_open/commands.rs | 4 +- apps/staged/src/App.svelte | 16 +- apps/staged/src/lib/commands.ts | 4 +- .../lib/features/layout/navigation.svelte.ts | 48 ++- .../lib/features/layout/navigation.test.ts | 150 ++++++++ .../lib/listeners/projectOpenListener.test.ts | 361 +++++++++++++++++- .../src/lib/listeners/projectOpenListener.ts | 101 ++++- 12 files changed, 678 insertions(+), 55 deletions(-) diff --git a/apps/staged/README.md b/apps/staged/README.md index 36d586db6..a10271d0d 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -138,7 +138,7 @@ On failure, stderr carries one `code: message` line. | 1 | `not_found` | No project matches; the message lists the project names. Also a workspace whose project was deleted. | | 1 | `ambiguous` | The name matches several projects when case is ignored. | | 1 | `not_in_workspace` | No argument, and the current directory is not inside a Staged project workspace. | -| 1 | `ui_busy` | A dialog is open, a diff comment could not be saved, or another `project open` replaced this one. The window was raised but not switched. | +| 1 | `ui_busy` | The window was raised but did not switch: a dialog is open, or a diff comment was not saved. Or another `project open` replaced this one. | | 1 | `ui_timeout` | The window was raised but did not answer in time. | | 1 | `app_error` | The app could not make the request, for example a running Staged from before this command existed (update it). | | 2 | `transport` | A fault talking to the app. | diff --git a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs index 0a3aabeca..f5da7aa29 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs @@ -215,7 +215,7 @@ user sees what is in the way. When it could not, the command exits 1: ui_busy a dialog is open in the window, or the diff comment being written could not be saved: close or finish it and run the command again. Also when another `project open` replaced - this one before the window acted on it. + this one before the window answered it. ui_timeout the window did not answer in time not_found the project was deleted meanwhile diff --git a/apps/staged/src-tauri/crates/stagedctl/src/open.rs b/apps/staged/src-tauri/crates/stagedctl/src/open.rs index 342a99e76..0a8b1c8d0 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/open.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/open.rs @@ -285,14 +285,17 @@ fn open_resolved( window and run the command again", )), // Not a dialog in the way, but the same class of failure: the - // window was taken up with something else, so nothing was shown - // for this caller, and running the command again is the remedy. + // window was taken up with something else and never reported + // showing this project, and running the command again is the + // remedy. The message stops short of "was not shown": the window + // checks that a request is still pending right before it switches, + // but a replacement can land between that check and the switch. Outcome::Superseded => Err(CtlError::command( "ui_busy", format!( "another `stagedctl project open` reached the same Staged window and replaced \ - this one before the window acted on it, so {name:?} was not shown; run the \ - command again if it still should be" + this one before the window answered it, so {name:?} may not be showing; run \ + the command again if it still should be" ), )), Outcome::ProjectNotFound => Err(CtlError::command( @@ -838,8 +841,8 @@ mod tests { assert_eq!( line, "ui_busy: another `stagedctl project open` reached the same Staged window and \ - replaced this one before the window acted on it, so \"Widgets\" was not shown; \ - run the command again if it still should be" + replaced this one before the window answered it, so \"Widgets\" may not be \ + showing; run the command again if it still should be" ); assert_ne!(line, refusal("dialogOpen").1); } diff --git a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt index 7a1878f6d..93a0a8874 100644 --- a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt +++ b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt @@ -162,7 +162,7 @@ user sees what is in the way. When it could not, the command exits 1: ui_busy a dialog is open in the window, or the diff comment being written could not be saved: close or finish it and run the command again. Also when another `project open` replaced - this one before the window acted on it. + this one before the window answered it. ui_timeout the window did not answer in time not_found the project was deleted meanwhile diff --git a/apps/staged/src-tauri/src/project_open.rs b/apps/staged/src-tauri/src/project_open.rs index 0435e1c2b..42785993e 100644 --- a/apps/staged/src-tauri/src/project_open.rs +++ b/apps/staged/src-tauri/src/project_open.rs @@ -21,9 +21,31 @@ //! //! **Only while someone waits.** A request lives exactly as long as the //! [`PendingProjectOpen`] its caller holds. When the wait times out, or the -//! caller goes away without waiting it out, the request is withdrawn — a -//! window that comes up later never navigates for a caller that has already -//! been told it failed, and there is nothing to expire or prune. +//! caller goes away without waiting it out, the request is withdrawn, so +//! there is nothing to expire or prune. From then on the snapshot no longer +//! lists it and an answer to it is refused. The same holds for a request a +//! newer one superseded. +//! +//! **What a failed caller can rely on.** This state decides what the caller +//! is told. It cannot stop a window that has already taken the request. The +//! window (`projectOpenListener.ts`) therefore reads the snapshot again right +//! before it navigates, after any wait on its project list, on a diff comment +//! being saved, or behind an earlier request. It navigates only if the +//! request is still listed. So: +//! +//! - A window that comes up after a request was withdrawn or superseded is +//! never offered it. +//! - A window that had taken the request, and was waiting when that +//! happened, does not navigate for it. +//! - One gap remains. The timeout or the newer request can land between the +//! snapshot's reply and the navigation it allows, which is one round trip +//! to the window. The window then switches project and its answer is +//! refused. Closing the gap would mean holding this lock across the +//! window's navigation, which the backend cannot do. +//! +//! [`ProjectOpenOutcome::TimedOut`] and [`ProjectOpenOutcome::Superseded`] +//! therefore mean the window did not report showing the project. They do not +//! promise that it is not on screen. //! //! In memory only: nothing here is persisted. diff --git a/apps/staged/src-tauri/src/project_open/commands.rs b/apps/staged/src-tauri/src/project_open/commands.rs index 4a7386334..faceae6eb 100644 --- a/apps/staged/src-tauri/src/project_open/commands.rs +++ b/apps/staged/src-tauri/src/project_open/commands.rs @@ -15,7 +15,9 @@ use super::{ProjectOpenAnswer, ProjectOpenRequest, ProjectOpenState}; /// The request this window has yet to answer: none, or one. The frontend /// takes this snapshot once its event listener is live, so a request that -/// arrived before the window loaded is still acted on. +/// arrived before the window loaded is still acted on. It reads it again +/// right before it navigates for a request, and leaves the window alone when +/// that request is no longer listed. #[tauri::command] pub fn list_pending_project_opens( window: WebviewWindow, diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 4dedf0bba..20d4c4756 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -447,8 +447,11 @@ // navigates underneath the route restore, which would then put the // restored or seeded project back over it. Nothing is missed by waiting: // the listener takes the backend's snapshot of a request parked earlier. + let projectOpenStartupHandled: Promise = Promise.resolve(); if (isTauri && !destroyed) { - unlistenProjectOpens = listenForProjectOpens(); + const projectOpens = listenForProjectOpens(); + unlistenProjectOpens = projectOpens.stop; + projectOpenStartupHandled = projectOpens.startupHandled; } const sweep = () => void sweepCache(); @@ -645,8 +648,17 @@ // window only: the backend holds the set until it is answered, and the // store-reset prompt wins when there is no store to resume against. Not awaited — the // dialog appears over the shell once the window shows. + // + // Loaded only once a `stagedctl project open` request parked before this + // window was listening has been acted on. That request refuses while a + // dialog is open, so loading the offer alongside it let the same command + // end `opened` or `ui_busy` by whichever chain of round trips finished + // first. In this order the project opens and the offer appears over it. + // With nothing parked the wait is two round trips, started well before + // this line, and it is capped (see `startupHandled`), so the offer is + // never lost. if (isTauri && getWindowLabel() === 'main' && !storeIncompat && !storeError) { - void resumeOnLaunch.load(); + void projectOpenStartupHandled.then(() => resumeOnLaunch.load()); } // Discover available agents in the background. diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 496bd4c2f..81b78a2b2 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -176,7 +176,9 @@ export function revokeControlGrant(grantId: string): Promise { /** * The request to show a project that this window has yet to answer: none, or * one. Taken once the `project-open-requested` listener is live, so a request - * parked before the window loaded is still acted on. Desktop only. + * parked before the window loaded is still acted on, and again right before + * the window navigates for a request, to see that its caller has not timed out + * or been superseded meanwhile. Desktop only. */ export function listPendingProjectOpens(): Promise { return invokeCommand('list_pending_project_opens'); diff --git a/apps/staged/src/lib/features/layout/navigation.svelte.ts b/apps/staged/src/lib/features/layout/navigation.svelte.ts index 8e68c72b9..0767fbaaf 100644 --- a/apps/staged/src/lib/features/layout/navigation.svelte.ts +++ b/apps/staged/src/lib/features/layout/navigation.svelte.ts @@ -768,6 +768,17 @@ function projectOpenable(projectId: string): boolean { ); } +/** The answer that needs no navigation, as things stand in this tick: the + * project cannot be shown, is already the view on screen, or is blocked by a + * dialog. Null when the project should be navigated to. */ +function answerWithoutNavigating(projectId: string): ProjectOpenAnswer | null { + if (!projectOpenable(projectId)) return 'projectNotFound'; + const route = currentRoute(); + if (route.kind === 'project' && route.projectId === projectId) return 'opened'; + if (dialogOpen()) return 'dialogOpen'; + return null; +} + /** * Show a project because something outside the window asked for it, and say * what happened. Unlike selectProject, which trusts its caller to have picked @@ -787,18 +798,27 @@ function projectOpenable(projectId: string): boolean { * - Over the diff view it waits for the comment being typed to save, like the * palette and the view's own close, and stays with `diffCommentUnsaved` * when the save failed so the text is still there to retry. + * - Last, it asks `stillWanted`, when one is given. The waits above take + * time, and whoever asked may have given up or been replaced meanwhile. + * When it resolves false nothing is navigated and the result is + * `withdrawn`, which is not an answer: there is nobody left to tell. + * + * Each wait is followed by a fresh look at the project and the dialogs, so + * the navigation runs in the same tick as the checks it depends on. The + * `stillWanted` round trip is the last wait: nothing is awaited between it + * resolving true and the navigation. When it rejects, so does this, and + * nothing is navigated. * - * A save takes time, so the dialog and the project are checked again after it - * and before navigating. From Settings or the diff view the project replaces - * the workspace route underneath, as selectProject describes. + * From Settings or the diff view the project replaces the workspace route + * underneath, as selectProject describes. */ -export async function openProjectExternally(projectId: string): Promise { +export async function openProjectExternally( + projectId: string, + stillWanted?: () => Promise +): Promise { await projectsDataStore.whenLoaded(); - if (!projectOpenable(projectId)) return 'projectNotFound'; - - const route = currentRoute(); - if (route.kind === 'project' && route.projectId === projectId) return 'opened'; - if (dialogOpen()) return 'dialogOpen'; + let answer = answerWithoutNavigating(projectId); + if (answer) return answer; const diffSaved = diffLeaveCheck(); if (diffSaved) { @@ -809,8 +829,14 @@ export async function openProjectExternally(projectId: string): Promise { expect(projectsStoreMock.whenLoaded).toHaveBeenCalledTimes(1); expect(navigation.detailStack).toEqual([home, projectRoute('a')]); }); + + describe('with a stillWanted check', () => { + it('navigates once the check says the request is still wanted', async () => { + const { openProjectExternally, navigation } = await importNavigation(); + const stillWanted = vi.fn().mockResolvedValue(true); + + await expect(openProjectExternally('a', stillWanted)).resolves.toBe('opened'); + + expect(stillWanted).toHaveBeenCalledTimes(1); + expect(navigation.detailStack).toEqual([home, projectRoute('a')]); + }); + + it('leaves the window alone for a request withdrawn during the wait for the list', async () => { + projectsStoreMock.projects = []; + projectsStoreMock.loaded = false; + let finishLoad!: () => void; + projectsStoreMock.whenLoaded.mockReturnValue( + new Promise((resolve) => (finishLoad = resolve)) + ); + const { openProjectExternally, navigation } = await importNavigation(); + // The caller is waiting when the request is taken, and has timed out + // by the time the list lands. + let pending = true; + const stillWanted = vi.fn(async () => pending); + + const opening = openProjectExternally('a', stillWanted); + await Promise.resolve(); + await Promise.resolve(); + // Asked only at the end: an early yes would be stale by now. + expect(stillWanted).not.toHaveBeenCalled(); + + pending = false; + projectsStoreMock.projects = [{ id: 'a' }]; + projectsStoreMock.loaded = true; + finishLoad(); + + await expect(opening).resolves.toBe('withdrawn'); + expect(stillWanted).toHaveBeenCalledTimes(1); + expect(navigation.detailStack).toEqual([home]); + expect(setStoreValue).not.toHaveBeenCalled(); + }); + + it('leaves the window on the diff for a request withdrawn during the save', async () => { + const { + openProjectExternally, + selectProject, + openDiffRoute, + registerDiffLeaveCheck, + navigation, + } = await importNavigation(); + selectProject('a'); + openDiffRoute({ branchId: 'b1', projectId: 'a' }); + const stack = navigation.detailStack; + let finishSave!: (saved: boolean) => void; + const check = vi.fn(() => new Promise((resolve) => (finishSave = resolve))); + registerDiffLeaveCheck(check); + let pending = true; + const stillWanted = vi.fn(async () => pending); + + const opening = openProjectExternally('b', stillWanted); + await vi.waitFor(() => expect(check).toHaveBeenCalledTimes(1)); + expect(stillWanted).not.toHaveBeenCalled(); + + // Superseded, or timed out, while the comment was being saved. + pending = false; + finishSave(true); + + await expect(opening).resolves.toBe('withdrawn'); + expect(stillWanted).toHaveBeenCalledTimes(1); + // The comment is saved all the same; only the navigation is dropped. + expect(navigation.detailStack).toBe(stack); + expect(dispatchEvent).not.toHaveBeenCalled(); + }); + + it('asks only after the save, and navigates when still wanted', async () => { + const { + openProjectExternally, + selectProject, + openDiffRoute, + registerDiffLeaveCheck, + navigation, + } = await importNavigation(); + selectProject('a'); + openDiffRoute({ branchId: 'b1', projectId: 'a' }); + const order: string[] = []; + registerDiffLeaveCheck(async () => { + order.push('save'); + return true; + }); + const stillWanted = vi.fn(async () => { + order.push('stillWanted'); + return true; + }); + + await expect(openProjectExternally('b', stillWanted)).resolves.toBe('opened'); + + expect(order).toEqual(['save', 'stillWanted']); + expect(navigation.detailStack).toEqual([home, projectRoute('b')]); + }); + + it('does not ask when there is nothing to navigate', async () => { + const { openProjectExternally, selectProject, openDiffRoute, registerDiffLeaveCheck } = + await importNavigation(); + const stillWanted = vi.fn().mockResolvedValue(true); + selectProject('a'); + + // Already on screen, unknown to the window, and behind a dialog. + await expect(openProjectExternally('a', stillWanted)).resolves.toBe('opened'); + await expect(openProjectExternally('missing', stillWanted)).resolves.toBe('projectNotFound'); + addDialog(); + await expect(openProjectExternally('b', stillWanted)).resolves.toBe('dialogOpen'); + dialogs = []; + + // And a comment that could not be saved. + openDiffRoute({ branchId: 'b1', projectId: 'a' }); + registerDiffLeaveCheck(async () => false); + await expect(openProjectExternally('b', stillWanted)).resolves.toBe('diffCommentUnsaved'); + + expect(stillWanted).not.toHaveBeenCalled(); + }); + + it('looks at the dialogs and the project again after the check', async () => { + const { openProjectExternally, navigation } = await importNavigation(); + + // The check is a round trip, and the window does not stand still for it. + const opensDialog = vi.fn(async () => { + addDialog(); + return true; + }); + await expect(openProjectExternally('a', opensDialog)).resolves.toBe('dialogOpen'); + dialogs = []; + + const losesProject = vi.fn(async () => { + projectsStoreMock.projects = [{ id: 'b' }]; + return true; + }); + await expect(openProjectExternally('a', losesProject)).resolves.toBe('projectNotFound'); + + expect(navigation.detailStack).toEqual([home]); + }); + + it('rejects, and navigates nowhere, when the check itself fails', async () => { + const { openProjectExternally, navigation } = await importNavigation(); + const stillWanted = vi.fn().mockRejectedValue(new Error('ipc down')); + + await expect(openProjectExternally('a', stillWanted)).rejects.toThrow('ipc down'); + + expect(navigation.detailStack).toEqual([home]); + }); + }); }); describe('workspace layers', () => { diff --git a/apps/staged/src/lib/listeners/projectOpenListener.test.ts b/apps/staged/src/lib/listeners/projectOpenListener.test.ts index 86738e08a..edb907e40 100644 --- a/apps/staged/src/lib/listeners/projectOpenListener.test.ts +++ b/apps/staged/src/lib/listeners/projectOpenListener.test.ts @@ -3,13 +3,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; /** * The `stagedctl project open` listener. What it has to get right: the * request reaches this window alone, a request parked before the listener was - * live is still picked up, no request is acted on or answered twice, and - * requests that overlap leave the window on the one asked for last. + * live is still picked up, no request is acted on or answered twice, + * requests that overlap leave the window on the one asked for last, the + * window is left alone for a request whose caller was told it failed, and a + * request found at startup is dealt with before `startupHandled` settles. */ type Request = { requestId: string; projectId: string }; type Answer = 'opened' | 'dialogOpen' | 'diffCommentUnsaved' | 'projectNotFound'; -type ListenOptions = { onEstablished?: () => void }; +/** What the navigation helper resolves to: an answer, or nothing to answer. */ +type Result = Answer | 'withdrawn'; +/** The helper's second argument: asked right before it navigates. */ +type StillWanted = () => Promise; +type OpenExternally = (projectId: string, stillWanted?: StillWanted) => Promise; +type ListenOptions = { + onEstablished?: () => void; + onRegistrationFailed?: (error: unknown) => void; +}; /** A promise the test settles by hand. */ function deferred() { @@ -31,13 +41,42 @@ let listPendingProjectOpens: ReturnType Promise>>; let answerProjectOpen: ReturnType< typeof vi.fn<(requestId: string, outcome: Answer) => Promise> >; -let openProjectExternally: ReturnType Promise>>; +let openProjectExternally: ReturnType>; +/** Stands in for `selectProject` when a test supplies its own helper. */ +let navigate: ReturnType void>>; async function startListening() { const { listenForProjectOpens } = await import('./projectOpenListener'); return listenForProjectOpens(); } +/** The project ids the helper was asked to open, in order. */ +function opened(): string[] { + return openProjectExternally.mock.calls.map(([projectId]) => projectId); +} + +/** + * A helper shaped like the real one: it waits (on the project list or a diff + * comment being saved, say), then asks whether the request is still wanted, + * and navigates only if it is. + */ +function helperThatWaits(wait: Promise): OpenExternally { + return async (projectId, stillWanted) => { + await wait; + if (stillWanted && !(await stillWanted())) return 'withdrawn'; + navigate(projectId); + return 'opened'; + }; +} + +/** Whether `promise` has settled by the time the queued work has run. */ +async function hasSettled(promise: Promise): Promise { + let settled = false; + void promise.then(() => (settled = true)); + await settle(); + return settled; +} + /** The backend's `emit_to` reaching this window. */ function emit(request: Request): void { if (!windowCallback) throw new Error('no window listener registered'); @@ -66,9 +105,8 @@ beforeEach(() => { answerProjectOpen = vi .fn<(requestId: string, outcome: Answer) => Promise>() .mockResolvedValue(undefined); - openProjectExternally = vi - .fn<(projectId: string) => Promise>() - .mockResolvedValue('opened'); + openProjectExternally = vi.fn().mockResolvedValue('opened'); + navigate = vi.fn<(projectId: string) => void>(); vi.doMock('../transport', () => ({ listenToEvent, @@ -88,6 +126,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); vi.doUnmock('../transport'); vi.doUnmock('../commands'); vi.doUnmock('../features/layout/navigation.svelte'); @@ -111,7 +150,7 @@ describe('listenForProjectOpens', () => { emit({ requestId: 'r1', projectId: 'p1' }); await settle(); - expect(openProjectExternally).toHaveBeenCalledExactlyOnceWith('p1'); + expect(opened()).toEqual(['p1']); expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r1', 'opened'); }); @@ -142,7 +181,7 @@ describe('listenForProjectOpens', () => { // Parked before this window was listening: the event never arrived. expect(listPendingProjectOpens).toHaveBeenCalledTimes(1); - expect(openProjectExternally).toHaveBeenCalledExactlyOnceWith('p1'); + expect(opened()).toEqual(['p1']); expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r1', 'opened'); }); @@ -200,12 +239,12 @@ describe('listenForProjectOpens', () => { await settle(); // Navigating for the second now would be undone when the first lands. - expect(openProjectExternally).toHaveBeenCalledExactlyOnceWith('p1'); + expect(opened()).toEqual(['p1']); first.resolve('opened'); await settle(); - expect(openProjectExternally.mock.calls).toEqual([['p1'], ['p2']]); + expect(opened()).toEqual(['p1', 'p2']); expect(answerProjectOpen.mock.calls).toEqual([ ['r1', 'opened'], ['r2', 'opened'], @@ -225,7 +264,7 @@ describe('listenForProjectOpens', () => { // The backend told r2's caller it was superseded when r3 was parked, so // the window neither navigates for it nor answers it. - expect(openProjectExternally.mock.calls).toEqual([['p1'], ['p3']]); + expect(opened()).toEqual(['p1', 'p3']); expect(answerProjectOpen.mock.calls).toEqual([ ['r1', 'opened'], ['r3', 'opened'], @@ -286,13 +325,13 @@ describe('listenForProjectOpens', () => { const snapshot = deferred(); openProjectExternally.mockReturnValueOnce(first.promise); listPendingProjectOpens.mockReturnValue(snapshot.promise); - const unlisten = await startListening(); + const { stop } = await startListening(); establish(); emit({ requestId: 'r1', projectId: 'p1' }); emit({ requestId: 'r2', projectId: 'p2' }); await settle(); - unlisten(); + stop(); expect(transportUnlisten).toHaveBeenCalledTimes(1); // A late event, a late snapshot and the request that was waiting its @@ -302,7 +341,299 @@ describe('listenForProjectOpens', () => { first.resolve('opened'); await settle(); - expect(openProjectExternally).toHaveBeenCalledExactlyOnceWith('p1'); + expect(opened()).toEqual(['p1']); expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r1', 'opened'); }); + + describe('a request whose caller was told it failed', () => { + it('gives the helper a check that asks the backend for this request', async () => { + await startListening(); + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + const stillWanted = openProjectExternally.mock.calls[0][1]; + if (!stillWanted) throw new Error('the helper was given no check'); + listPendingProjectOpens.mockClear(); + + // Still the request the backend holds for this window. + listPendingProjectOpens.mockResolvedValueOnce([{ requestId: 'r1', projectId: 'p1' }]); + await expect(stillWanted()).resolves.toBe(true); + // Its caller timed out or went away: the backend holds nothing. + listPendingProjectOpens.mockResolvedValueOnce([]); + await expect(stillWanted()).resolves.toBe(false); + // A newer request for the same project replaced it: matched on the + // request id, never on the project. + listPendingProjectOpens.mockResolvedValueOnce([{ requestId: 'r2', projectId: 'p1' }]); + await expect(stillWanted()).resolves.toBe(false); + // Asked afresh each time: an earlier reply says nothing about now. + expect(listPendingProjectOpens).toHaveBeenCalledTimes(3); + }); + + it('sends no answer for a request the helper found withdrawn', async () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + openProjectExternally.mockResolvedValueOnce('withdrawn'); + await startListening(); + + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + + // The backend would refuse it, and 'withdrawn' is not an answer it knows. + expect(answerProjectOpen).not.toHaveBeenCalled(); + expect(consoleWarn).toHaveBeenCalledTimes(1); + + emit({ requestId: 'r2', projectId: 'p2' }); + await settle(); + expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r2', 'opened'); + }); + + it('does not navigate for a request withdrawn while the helper was waiting', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const wait = deferred(); + openProjectExternally.mockImplementation(helperThatWaits(wait.promise)); + listPendingProjectOpens.mockResolvedValue([{ requestId: 'r1', projectId: 'p1' }]); + await startListening(); + + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + expect(navigate).not.toHaveBeenCalled(); + + // The backend's wait ran out during the helper's: the caller has been + // told `timedOut` and the request is gone. + listPendingProjectOpens.mockResolvedValue([]); + wait.resolve(); + await settle(); + + expect(navigate).not.toHaveBeenCalled(); + expect(answerProjectOpen).not.toHaveBeenCalled(); + }); + + it('navigates for a request still pending when the helper’s wait ends', async () => { + const wait = deferred(); + openProjectExternally.mockImplementation(helperThatWaits(wait.promise)); + listPendingProjectOpens.mockResolvedValue([{ requestId: 'r1', projectId: 'p1' }]); + await startListening(); + + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + wait.resolve(); + await settle(); + + expect(navigate).toHaveBeenCalledExactlyOnceWith('p1'); + expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r1', 'opened'); + }); + + it('does not navigate for a request superseded while the helper was waiting', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const firstWait = deferred(); + openProjectExternally + .mockImplementationOnce(helperThatWaits(firstWait.promise)) + .mockImplementation(helperThatWaits(Promise.resolve())); + listPendingProjectOpens.mockResolvedValue([{ requestId: 'r1', projectId: 'p1' }]); + await startListening(); + + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + + // A newer request replaces it in the backend, whose caller of r1 now + // reads `superseded`, and reaches the window while r1 is still waiting. + listPendingProjectOpens.mockResolvedValue([{ requestId: 'r2', projectId: 'p2' }]); + emit({ requestId: 'r2', projectId: 'p2' }); + firstWait.resolve(); + await settle(); + + // Navigating for r1 would leave the window on p1 if r2 were then + // refused, after r1's caller was told otherwise. + expect(navigate.mock.calls).toEqual([['p2']]); + expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r2', 'opened'); + }); + + it('does not navigate for a request withdrawn while it waited its turn', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const firstWait = deferred(); + openProjectExternally + .mockImplementationOnce(helperThatWaits(firstWait.promise)) + .mockImplementation(helperThatWaits(Promise.resolve())); + listPendingProjectOpens.mockResolvedValue([{ requestId: 'r1', projectId: 'p1' }]); + await startListening(); + + // r2 replaces r1 in the backend and queues behind it in the window. + emit({ requestId: 'r1', projectId: 'p1' }); + emit({ requestId: 'r2', projectId: 'p2' }); + await settle(); + expect(opened()).toEqual(['p1']); + + // r2's caller gives up before r1's wait ends, so before r2's turn. + listPendingProjectOpens.mockResolvedValue([]); + firstWait.resolve(); + await settle(); + + // Both were taken and both had their turn; neither moved the window. + expect(opened()).toEqual(['p1', 'p2']); + expect(navigate).not.toHaveBeenCalled(); + expect(answerProjectOpen).not.toHaveBeenCalled(); + }); + + it('sends no answer and navigates nowhere when the check itself fails', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + openProjectExternally.mockImplementationOnce(helperThatWaits(Promise.resolve())); + await startListening(); + + listPendingProjectOpens.mockRejectedValueOnce(new Error('ipc down')); + emit({ requestId: 'r1', projectId: 'p1' }); + await settle(); + + // Not known to be wanted, so not navigated: the caller reads a timeout. + expect(navigate).not.toHaveBeenCalled(); + expect(answerProjectOpen).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledTimes(1); + }); + }); + + describe('startupHandled', () => { + it('settles only once a request parked before the window loaded is answered', async () => { + const open = deferred(); + const answer = deferred(); + openProjectExternally.mockReturnValueOnce(open.promise); + answerProjectOpen.mockReturnValueOnce(answer.promise); + listPendingProjectOpens.mockResolvedValue([{ requestId: 'r1', projectId: 'p1' }]); + const { startupHandled } = await startListening(); + // What App.svelte chains on it: the launch-resume offer, whose dialog + // would refuse the request if it opened first. + const loadResumeOffer = vi.fn(); + void startupHandled.then(loadResumeOffer); + + // Not while the registration is in flight, nor while the project is + // being opened (it can be waiting on the first project list). + await settle(); + expect(loadResumeOffer).not.toHaveBeenCalled(); + establish(); + await settle(); + expect(opened()).toEqual(['p1']); + expect(loadResumeOffer).not.toHaveBeenCalled(); + + // Nor until the answer is recorded: until then the request is pending. + open.resolve('opened'); + await settle(); + expect(answerProjectOpen).toHaveBeenCalledExactlyOnceWith('r1', 'opened'); + expect(loadResumeOffer).not.toHaveBeenCalled(); + + answer.resolve(); + await settle(); + expect(loadResumeOffer).toHaveBeenCalledTimes(1); + }); + + it('settles after the snapshot when nothing was parked', async () => { + const snapshot = deferred(); + listPendingProjectOpens.mockReturnValue(snapshot.promise); + const { startupHandled } = await startListening(); + + establish(); + // Not before the snapshot is back: it may hold a request. + expect(await hasSettled(startupHandled)).toBe(false); + + snapshot.resolve([]); + expect(await hasSettled(startupHandled)).toBe(true); + expect(openProjectExternally).not.toHaveBeenCalled(); + }); + + it('waits for a request the event delivered ahead of the snapshot', async () => { + const open = deferred(); + const snapshot = deferred(); + openProjectExternally.mockReturnValueOnce(open.promise); + listPendingProjectOpens.mockReturnValue(snapshot.promise); + const { startupHandled } = await startListening(); + + // The snapshot repeats the request the event already delivered, so it + // takes nothing new, and the request is still under way. + establish(); + emit({ requestId: 'r1', projectId: 'p1' }); + snapshot.resolve([{ requestId: 'r1', projectId: 'p1' }]); + expect(await hasSettled(startupHandled)).toBe(false); + + open.resolve('opened'); + expect(await hasSettled(startupHandled)).toBe(true); + expect(opened()).toEqual(['p1']); + }); + + it('waits for a request that arrives while the parked one is under way', async () => { + const first = deferred(); + const second = deferred(); + openProjectExternally.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + listPendingProjectOpens.mockResolvedValue([{ requestId: 'r1', projectId: 'p1' }]); + const { startupHandled } = await startListening(); + + establish(); + await settle(); + emit({ requestId: 'r2', projectId: 'p2' }); + first.resolve('opened'); + expect(await hasSettled(startupHandled)).toBe(false); + expect(opened()).toEqual(['p1', 'p2']); + + second.resolve('opened'); + expect(await hasSettled(startupHandled)).toBe(true); + }); + + it('settles when the parked request turns out to be withdrawn', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + openProjectExternally.mockResolvedValueOnce('withdrawn'); + listPendingProjectOpens.mockResolvedValue([{ requestId: 'r1', projectId: 'p1' }]); + const { startupHandled } = await startListening(); + + establish(); + + expect(await hasSettled(startupHandled)).toBe(true); + expect(answerProjectOpen).not.toHaveBeenCalled(); + }); + + it('settles when the snapshot fails', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + listPendingProjectOpens.mockRejectedValue(new Error('ipc down')); + const { startupHandled } = await startListening(); + + establish(); + + expect(await hasSettled(startupHandled)).toBe(true); + }); + + it('settles when the listener could not be registered', async () => { + const { startupHandled } = await startListening(); + expect(await hasSettled(startupHandled)).toBe(false); + + // No listener, so no snapshot either: nothing will be taken. + windowOptions?.onRegistrationFailed?.(new Error('no window')); + + expect(await hasSettled(startupHandled)).toBe(true); + expect(listPendingProjectOpens).not.toHaveBeenCalled(); + }); + + it('settles when the listener is stopped first', async () => { + const { stop, startupHandled } = await startListening(); + expect(await hasSettled(startupHandled)).toBe(false); + + stop(); + + expect(await hasSettled(startupHandled)).toBe(true); + }); + + it('settles after the backend’s wait when the open never finishes', async () => { + vi.useFakeTimers(); + openProjectExternally.mockReturnValueOnce(new Promise(() => {})); + listPendingProjectOpens.mockResolvedValue([{ requestId: 'r1', projectId: 'p1' }]); + const { listenForProjectOpens, STARTUP_HANDLED_TIMEOUT_MS } = + await import('./projectOpenListener'); + const { startupHandled } = listenForProjectOpens(); + let settled = false; + void startupHandled.then(() => (settled = true)); + establish(); + + // Mirrors `ANSWER_TIMEOUT` in `project_open.rs`: by then the caller + // has been told `timedOut`, so the offer no longer has to wait. + expect(STARTUP_HANDLED_TIMEOUT_MS).toBe(15_000); + await vi.advanceTimersByTimeAsync(STARTUP_HANDLED_TIMEOUT_MS - 1); + expect(opened()).toEqual(['p1']); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + expect(settled).toBe(true); + }); + }); }); diff --git a/apps/staged/src/lib/listeners/projectOpenListener.ts b/apps/staged/src/lib/listeners/projectOpenListener.ts index d9288cdd3..169552efc 100644 --- a/apps/staged/src/lib/listeners/projectOpenListener.ts +++ b/apps/staged/src/lib/listeners/projectOpenListener.ts @@ -26,6 +26,22 @@ * for last. The backend holds at most one request per window and has already * told the caller of a replaced one that it was superseded, so only the newest * waiting request is kept. + * + * **Not for a caller that was told it failed.** While a request waits, its + * caller can time out or be replaced by a newer request, and is then told so. + * Right before navigating, `openProjectExternally` asks the backend whether + * this request is still the window's pending one, and leaves the window alone + * when it is not. One gap is left: the caller can time out or be replaced + * between that reply and the navigation it allows. The window then switches + * and its answer is refused. + * + * **Ahead of the launch-resume offer.** On a cold start the request is parked + * before the window loads, and the "Resume interrupted sessions?" dialog would + * refuse it if it opened first. `startupHandled` settles once a request found + * at startup has been acted on, and App.svelte loads the offer after it, so + * the project opens and the dialog then appears over it. This covers the + * request the window finds waiting. One that arrives once the offer is open + * is refused like any other request with a dialog in the way. */ import { listenToWindowEvent, type UnlistenFn } from '../transport'; @@ -39,20 +55,55 @@ import type { ProjectOpenRequest } from '../types'; /** Mirrors `PROJECT_OPEN_REQUESTED_EVENT` in `project_open/control.rs`. */ export const PROJECT_OPEN_REQUESTED_EVENT = 'project-open-requested'; -export function listenForProjectOpens(): UnlistenFn { +/** + * The longest `startupHandled` stays unsettled. Mirrors `ANSWER_TIMEOUT` in + * `project_open.rs`: a request found at startup has waited about that long by + * then, so its caller is being told it timed out whatever a dialog does. + */ +export const STARTUP_HANDLED_TIMEOUT_MS = 15_000; + +export interface ProjectOpenListener { + /** Stop listening. A request already under way still answers. */ + stop: UnlistenFn; + /** + * Settles once the window has dealt with any request that was parked before + * it was listening: the listener is live, the backend's snapshot has been + * read, and every request taken by then has been acted on and answered. + * With nothing parked that is two round trips. + * + * It also settles when there is nothing to wait for (the registration or + * the snapshot failed, or the listener was stopped) and after + * `STARTUP_HANDLED_TIMEOUT_MS` at the latest, so whatever waits on it is + * never held up for good. Never rejects. + */ + startupHandled: Promise; +} + +export function listenForProjectOpens(): ProjectOpenListener { /** Ids already taken from the event or the snapshot. One short string per * CLI call for the life of the window, so it is never pruned. */ const taken = new Set(); /** The newest request still waiting its turn. */ let waiting: ProjectOpenRequest | null = null; let working = false; + /** The latest run of `work`: settled when no request is under way. */ + let idle: Promise = Promise.resolve(); let stopped = false; + let settleStartup!: () => void; + const startupHandled = new Promise((resolve) => { + const timer = setTimeout(resolve, STARTUP_HANDLED_TIMEOUT_MS); + settleStartup = () => { + clearTimeout(timer); + resolve(); + }; + }); + function take(request: ProjectOpenRequest): void { if (stopped || taken.has(request.requestId)) return; taken.add(request.requestId); waiting = request; - if (!working) void work(); + if (!working) idle = work(); } async function work(): Promise { @@ -69,41 +120,65 @@ export function listenForProjectOpens(): UnlistenFn { } async function loadPending(): Promise { - let pending: ProjectOpenRequest[]; try { - pending = await listPendingProjectOpens(); + for (const request of await listPendingProjectOpens()) take(request); } catch (e) { console.error('[projectOpen] Failed to load the pending project-open request:', e); - return; } - for (const request of pending) take(request); + // Whatever was parked before the window was listening has been taken by + // now, from here or from the event. A request that arrives while one of + // those is under way is waited for as well: `idle` is replaced only once + // the run before it has ended. + await idle; + settleStartup(); } const unlisten = listenToWindowEvent(PROJECT_OPEN_REQUESTED_EVENT, take, { onEstablished: () => void loadPending(), + // Nothing will be heard, and no snapshot is taken without a listener. + onRegistrationFailed: () => settleStartup(), }); - return () => { - stopped = true; - waiting = null; - unlisten(); + return { + stop: () => { + stopped = true; + waiting = null; + unlisten(); + settleStartup(); + }, + startupHandled, }; } +/** Whether `request` is still the one the backend holds for this window: not + * answered, not replaced by a newer one, and its caller still waiting. */ +async function stillPending(request: ProjectOpenRequest): Promise { + const pending = await listPendingProjectOpens(); + return pending.some((held) => held.requestId === request.requestId); +} + /** Act on one request and report the result. Never rejects. */ async function act(request: ProjectOpenRequest): Promise { - let outcome: ProjectOpenAnswer; + let outcome: ProjectOpenAnswer | 'withdrawn'; try { - outcome = await openProjectExternally(request.projectId); + outcome = await openProjectExternally(request.projectId, () => stillPending(request)); } catch (e) { // No answer is true here, so none is sent: the caller reads a timeout. console.error(`[projectOpen] Failed to open project ${request.projectId}:`, e); return; } + if (outcome === 'withdrawn') { + // Its caller timed out or was superseded while the window was busy, and + // has been told so. Nothing was navigated and there is nobody to answer. + console.warn( + `[projectOpen] Project ${request.projectId} was not opened: the request is no longer pending.` + ); + return; + } try { await answerProjectOpen(request.requestId, outcome); } catch (e) { - // Superseded, or the caller stopped waiting while the window was busy. + // Superseded, or the caller stopped waiting, after the last check. console.warn(`[projectOpen] The answer "${outcome}" was not recorded:`, e); } } From 4166ecdb3e58ad9e098427bff99e3b1afe1449e8 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 17:36:10 +1100 Subject: [PATCH 13/14] fix(staged): apply the review's four suggestions for project open The four suggestion-level points left from the review of `stagedctl project open`. Its four warnings were fixed in 1637a005 and 128c4bb8 and are not touched here. Each point was checked against the code first, and all four hold. Point 3 holds in a narrower form than the review's wording, as described below. 1. A Staged that is alive but slow is picked up by the launch wait (`launch.rs`). `Client::connect` reports any failed ping as `NotRunning`, a timeout included. So a Staged too busy to answer was taken for absent, and the bundle was opened, which for a running app only brings it forward. The wait then skipped that instance for the full 30 seconds, because its discovery file had the same path and mtime as before. The error said Staged "was started". - `wait_for_instance` now also tries the instances that were on disk before the launch. They are tried after the new ones, and only on every tenth poll (`KNOWN_PROBE_EVERY`, about every two seconds). Most are a crashed instance's leftover, and those must not slow the wait for a new instance. - One that now answers but cannot be used, such as another protocol version, does not end the wait: a new instance may still be coming. Its error is reported in place of the timeout message if nothing else answers. - The timeout message is now "stagedctl asked macOS to open Staged, but no Staged control server answered within 30 seconds". It no longer claims an app was started. - The stderr line before the open had the same flaw and is now "no running Staged answered; opening ". - The deadline is checked between polls, so the wait can run over by one probe of an instance that accepts a connection and says nothing. 2. A workspace outside a confined credential is not called deleted (`open.rs`). With a grant approved for one project, `list_projects` returns that project alone. Running `stagedctl project open` from another project's worktree then said Staged "does not have" the project and that it "was probably deleted". - `open_target` takes the client's `CredentialScope` and passes it to `project_for_directory`. - For `CredentialScope::Project` the message says the credential does not reach the project, names the one project it is confined to, says the project may still exist, and points at `stagedctl connect --help`. - The code stays `not_found` and the exit code stays 1. The unconfined wording is unchanged. 3. A leftover staging link no longer blocks a replace (`stagedctl_link/fs.rs`). `replace_link` makes the new link at `.stagedctl.link-` and renames it over the old one. The name carries the app's process id, so a leftover from another process has another name and was never in the way. A leftover at this process's own name does block: every later replace fails with "File exists". That needs a replace cut short after the link was made or a clean-up that failed, in this process or in an earlier one that had the same id. - `clear_stale_staging` runs before the staging link is made. It removes what is at exactly the staging name, and only if that is a symlink. - A file or a directory there is left as it is. The replace is refused with a message naming the path, in place of a bare "File exists". - Staging names carrying another process id are not looked at, and a plain create never touches the staging name. 4. `staged ` opens from that directory (`scripts/staged`). `staged .` and `staged /path/to/repo` were the documented usage before this branch, and had become a project name that failed with `not_found`. - When the one argument is an existing directory, the script changes to it and runs `stagedctl project open` with no project, so the project is resolved from the directory. - `--no-launch` and `--json` may sit beside the directory. Any other flag, a second argument, or `--` leaves every argument unchanged. - A name that is not an existing directory is passed on exactly as before. - A relative directory is entered as `./`, so `$CDPATH` is not searched. A `stagedctl` found through a relative PATH entry is made absolute first. - A directory that cannot be entered exits 1 with a message. Choices worth a look: - An existing directory always wins over a project of the same name. The README says to use `stagedctl project open ` for that case. - The script knows two of stagedctl's flags by name. Without that, `staged . --no-launch` would read `.` as a project name. - A directory outside every project workspace still fails with `not_in_workspace`, without starting Staged. `staged` does not open arbitrary repositories. Docs: the README's launcher section, exit code table and "When Staged is not running" bullet, the guide, and the verb's help text describe the directory form, the confined-credential wording and the slow instance. The help fixture is refreshed. Tests: - `launch.rs`, 5 new and 3 updated: a known instance used once it answers, the probe cadence for a leftover, new instances first and on every poll, an unusable known instance not ending the wait, its error reported at timeout, the message text, and `connect_or_launch` with an instance that starts answering only after the open. The fake app for that one binds a port its discovery file already names, so `client.rs`'s test helpers gain `grant_aware_server_on`. - `open.rs`, 2 new: the confined wording with and without a listed project, and a confined credential still opening its own project. - `stagedctl_link/fs.rs`, 4 new, all in a temporary directory: a leftover link cleared whether or not it dangles, a file and a directory at the staging name left intact and reported, another process's staging link left alone, and a create not touching the name. - Each fix was disabled in turn and the tests for it failed: 6 in `launch.rs`, 1 in `open.rs`, 2 in `fs.rs`. Checked: - `cargo fmt --check` passed. - `cargo clippy --workspace -- -D warnings` passed. - `cargo test -p stagedctl` passed: 182 unit tests and the help fixture. - `cargo test --lib -- stagedctl_link` in the app crate passed, 43 tests. - `bash -n scripts/staged` passed under `/bin/bash` 3.2, the only bash on this machine. - The launcher was run by hand against a fake `stagedctl` that echoes its arguments and working directory: no argument, names, flags, `.`, relative, nested and absolute directories, a trailing slash, a symlink to a directory, both flags around a directory, a name that is also a directory, two arguments, an unknown flag, `--`, `--help`, an empty argument, a directory named with a leading dash, a regular file, `$CDPATH`, a directory that cannot be entered, a relative PATH entry, the bundle fallback, `stagedctl` missing, a symlinked script, and exit codes 1 to 4 passed through. - It was also run over this branch's debug `stagedctl` under a temporary home with `$STAGEDCTL_LOCK` unset: a directory outside any workspace exited 1 with `not_in_workspace`, a made-up workspace directory with `--no-launch` exited 3, the same without a grant exited 4, and a name with `--no-launch` exited 3. Not exercised in a real app, and the full Rust and frontend suites were not run. Unverified without a live app: - A real Staged that is slow to answer its ping, and `open` bringing it forward instead of starting a second one. - The confined-credential message against a real one-project grant. - A replace through the Settings action with a leftover in a real PATH directory. No link was created or changed outside a temporary directory. - `staged ` opening a project in a running app. The running Staged was not started, quit or restarted. No backend type, command or state is added, and there is no schema change. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/README.md | 24 +- .../src-tauri/crates/stagedctl/src/cli.rs | 10 +- .../src-tauri/crates/stagedctl/src/client.rs | 35 +- .../src-tauri/crates/stagedctl/src/launch.rs | 329 ++++++++++++++++-- .../src-tauri/crates/stagedctl/src/open.rs | 165 +++++++-- .../crates/stagedctl/tests/fixtures/help.txt | 10 +- .../src-tauri/resources/stagedctl/guide.md | 21 +- .../staged/src-tauri/src/stagedctl_link/fs.rs | 156 ++++++++- scripts/staged | 54 ++- 9 files changed, 705 insertions(+), 99 deletions(-) diff --git a/apps/staged/README.md b/apps/staged/README.md index a10271d0d..1d9fd3faf 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -65,7 +65,7 @@ The Staged window you were last in comes to the front and switches to the projec - **By name or id.** `` is matched as an exact id, then an exact name, then a name that matches one project when case is ignored. A name that matches nothing, or several projects, fails before any window is touched. - **From a project workspace.** With no argument, the project is the one whose workspace the current directory is in: any directory under `~/.staged/workspaces//projects//`, where Staged keeps a project's worktrees. From any other directory the command fails with `not_in_workspace` without looking for the app, so pass a project there. It does not open arbitrary directories or repositories. - **A busy window.** The window is raised whether or not it could switch, so you can see what is in the way. A dialog that is open, or a diff comment that could not be saved, is left alone and reported as `ui_busy`: close or finish it and run the command again. -- **When Staged is not running.** The command starts the `Staged.app` that `stagedctl` is bundled in, waits up to thirty seconds for it to come up, and then opens the project. The new window is not shown early: it comes to the front once it has finished loading, which can be shortly after the command has exited. A line on stderr says Staged is being started. This is macOS only. +- **When Staged is not running.** The command starts the `Staged.app` that `stagedctl` is bundled in, waits up to thirty seconds for it to come up, and then opens the project. The new window is not shown early: it comes to the front once it has finished loading, which can be shortly after the command has exited. A line on stderr says the app is being opened. A Staged that is running but too busy to answer is treated the same way: opening the app only brings it forward, and the command uses it as soon as it answers. This is macOS only. - **`--no-launch`.** The command exits 3 instead of starting Staged. Staged is also never started when you are not yet authorized (see below), when the directory names no project, when `--lock-path` or `$STAGEDCTL_LOCK` points at one particular instance, or from a development build, whose app needs its dev server (run `just dev` instead). @@ -87,19 +87,27 @@ Two kinds of credential are refused with `scope_denied` (exit 4): - **Read-only grants** (`stagedctl connect --read-only`). Showing a project marks it read and records it as the last one shown, which is a write. - **Agent session credentials.** A session spawned by Staged cannot open a project, not even its own, so an agent cannot pull your window away. Run the command from your own terminal. -A grant confined to one project (`stagedctl connect --project`) opens that project only. Any other project reads `not_found`, because that grant does not list it. +A grant confined to one project (`stagedctl connect --project`) opens that project only. Any other project reads `not_found`, because that grant does not list it. From another project's workspace the message says the credential does not reach that project, not that it was deleted. #### The `staged` shorthand `staged` is a small wrapper script around the same command: ```bash -staged "My Project" # stagedctl project open "My Project" -staged # stagedctl project open -staged --no-launch # stagedctl project open --no-launch +staged "My Project" # stagedctl project open "My Project" +staged # stagedctl project open +staged --no-launch # stagedctl project open --no-launch +staged . # stagedctl project open, from this directory +staged /path/to/dir # cd /path/to/dir && stagedctl project open ``` -It passes its arguments to `stagedctl project open` unchanged, so the output and exit codes are the ones described here. It does not take a directory to open: a path is read as a project name. The script never starts the app itself. If it cannot find `stagedctl` on your PATH, in `/Applications/Staged.app` or in `~/Applications/Staged.app`, it says so and exits 127 without starting anything. +The output and exit codes are the ones described here. The script never starts the app itself. If it cannot find `stagedctl` on your PATH, in `/Applications/Staged.app` or in `~/Applications/Staged.app`, it says so and exits 127 without starting anything. + +- **A project.** A name or id goes to `stagedctl project open` unchanged, as do the flags. +- **A directory.** When the one argument is an existing directory, `staged` runs `stagedctl project open` from that directory with no project, so the project is the one whose workspace the directory is in. `--no-launch` and `--json` may sit beside it. +- **Only a project workspace opens.** `staged` does not open arbitrary directories or repositories. A directory outside every project workspace fails with `not_in_workspace`, and Staged is not started. +- **A name that is also a directory.** An existing directory always wins. To open a project whose name matches a directory in the current directory, use `stagedctl project open `. +- **A directory that cannot be entered.** `staged` says so and exits 1. #### Getting `stagedctl` on your PATH @@ -135,14 +143,14 @@ On failure, stderr carries one `code: message` line. | Exit | Code | Meaning | | ---- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | | 0 | | The project is on screen. | -| 1 | `not_found` | No project matches; the message lists the project names. Also a workspace whose project was deleted. | +| 1 | `not_found` | No project matches; the message lists the project names. Also a workspace whose project was deleted, or is outside a one-project grant. | | 1 | `ambiguous` | The name matches several projects when case is ignored. | | 1 | `not_in_workspace` | No argument, and the current directory is not inside a Staged project workspace. | | 1 | `ui_busy` | The window was raised but did not switch: a dialog is open, or a diff comment was not saved. Or another `project open` replaced this one. | | 1 | `ui_timeout` | The window was raised but did not answer in time. | | 1 | `app_error` | The app could not make the request, for example a running Staged from before this command existed (update it). | | 2 | `transport` | A fault talking to the app. | -| 3 | message only | Staged is not running and was not started, a started Staged did not come up in time, or the running Staged cannot be used. | +| 3 | message only | Staged is not running and was not started, no Staged answered in time after the app was opened, or the running Staged cannot be used. | | 4 | `authorization_required` | No grant yet. Run `stagedctl connect`. | | 4 | `scope_denied` | A read-only grant, or an agent session's credential. | | 127 | | `staged` only: `stagedctl` was not found. | diff --git a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs index f5da7aa29..6c18629bd 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/cli.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs @@ -207,8 +207,9 @@ keeps a project's worktrees. From any other directory the command exits 1 A that matches nothing (`not_found`), or several projects when case is ignored (`ambiguous`), exits 1 before any window is touched. So -does a directory whose project the app does not have (`not_found`), such -as the worktree of a project that was deleted. +does a directory whose project the app does not list (`not_found`): the +worktree of a project that was deleted, or of one that a grant confined +to another project does not reach. Otherwise the window is raised whether or not it could switch, so the user sees what is in the way. When it could not, the command exits 1: @@ -231,7 +232,10 @@ up, and then asks as above; the app holds the request until its window has loaded. A window that is still loading is not shown early: it comes to the front once it is ready, which can be after the command has exited. What it starts is the Staged.app this stagedctl is bundled in, -and nothing else. A line on stderr says so; stdout is unchanged. +and nothing else. A line on stderr says so; stdout is unchanged. A +Staged that is running but too busy to answer is treated the same way: +opening the app only brings it forward, and the command uses it as soon +as it answers. Staged is never started: - with --no-launch: the command exits 3 instead; diff --git a/apps/staged/src-tauri/crates/stagedctl/src/client.rs b/apps/staged/src-tauri/crates/stagedctl/src/client.rs index c7b41de42..f549507e9 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/client.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/client.rs @@ -1676,19 +1676,30 @@ pub(crate) mod tests { /// Loopback-TCP fake whose handler also sees the request body (the /// credential routes carry their arguments there). - pub(crate) fn fake_server_with_body(mut handler: F) -> (u16, std::thread::JoinHandle<()>) + pub(crate) fn fake_server_with_body(handler: F) -> (u16, std::thread::JoinHandle<()>) where F: FnMut(&str, &str) -> (u16, String) + Send + 'static, { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); - let handle = std::thread::spawn(move || { + (port, fake_server_with_body_on(listener, handler)) + } + + /// [`fake_server_with_body`] on a listener the caller bound, for a fake + /// that has to come up at a port already written to a discovery file. + fn fake_server_with_body_on( + listener: TcpListener, + mut handler: F, + ) -> std::thread::JoinHandle<()> + where + F: FnMut(&str, &str) -> (u16, String) + Send + 'static, + { + std::thread::spawn(move || { run_fake(listener.incoming(), move |head, body| { let (status, body) = handler(head, body); (status, body, Vec::new()) }) - }); - (port, handle) + }) } /// Unix-socket fake (the app's primary transport), bound at `socket`. @@ -1741,7 +1752,21 @@ pub(crate) mod tests { renew_to: i64, seen: std::sync::Arc>>, ) -> (u16, std::thread::JoinHandle<()>) { - fake_server_with_body(move |head, body| { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = grant_aware_server_on(listener, pid, known, renew_to, seen); + (port, handle) + } + + /// [`grant_aware_server`] on a listener the caller bound. + pub(crate) fn grant_aware_server_on( + listener: TcpListener, + pid: u32, + known: Vec<(String, String)>, + renew_to: i64, + seen: std::sync::Arc>>, + ) -> std::thread::JoinHandle<()> { + fake_server_with_body_on(listener, move |head, body| { seen.lock().unwrap().push( header_value(head, "authorization") .unwrap_or("") diff --git a/apps/staged/src-tauri/crates/stagedctl/src/launch.rs b/apps/staged/src-tauri/crates/stagedctl/src/launch.rs index 9195895b5..ea0d6216a 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/launch.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/launch.rs @@ -30,6 +30,15 @@ //! was. Only [`CtlError::NotRunning`] from every candidate counts as "not //! running". //! +//! "Not running" is a judgement from one ping, and a ping that timed out +//! reads the same as an endpoint nothing listens on. So a Staged that is +//! alive but too busy to answer is taken for absent, and asking macOS to +//! open the bundle only brings that app forward. Two things follow. The +//! wait looks again, now and then, at the instances that were on disk +//! before the launch, so such an app is used as soon as it answers +//! ([`wait_for_instance`]). And nothing printed says Staged "was started": +//! all this module knows is that it asked for the bundle to be opened. +//! //! The pieces are kept apart so the tests never start the real app: the //! decision ([`launch_target`]), the bundle path ([`bundle_for_executable`]) //! and the wait ([`wait_for_instance`]) take what they need as arguments, @@ -52,6 +61,12 @@ pub const LAUNCH_TIMEOUT: Duration = Duration::from_secs(30); /// The pause between looks for the new instance's discovery file. const LAUNCH_POLL_PAUSE: Duration = Duration::from_millis(200); +/// How many polls go by between looks at the instances that were already on +/// disk before the launch: with the default pause, about every two seconds. +/// Seldom, because the usual one is a crashed instance's leftover that will +/// never answer, and each probe of it costs a connect and a retry. +const KNOWN_PROBE_EVERY: u32 = 10; + /// The step that starts a process. Everything else in this module is /// reachable without one. pub trait Launcher { @@ -240,15 +255,30 @@ fn find_running( } } -/// Wait for a started Staged: poll `candidates` until one that is not in -/// `stale` passes the handshake, or `timeout` runs out (exit 3). +/// Wait for Staged after asking for it to be opened: poll `candidates` +/// until one passes the handshake, or `timeout` runs out (exit 3). /// /// `stale` is what was on disk before the launch, every entry already found -/// dead. Those are skipped, so a leftover directory costs nothing per poll; -/// a file the app has rewritten since (same path, new mtime) is new again. -/// A new candidate that is not answering yet is tried again next round. One -/// that fails any other way will not get better by waiting, so its error is -/// returned at once. +/// not answering. A candidate that is not in it is new: the instance that +/// was just started, or a file the app has rewritten since (same path, new +/// mtime). New candidates are tried on every poll. One that is not +/// answering yet is tried again next round; one that fails any other way +/// will not get better by waiting, so its error is returned at once. +/// +/// The `stale` entries that are still listed are tried too, but only on +/// every [`KNOWN_PROBE_EVERY`]th poll and after the new ones. Most are +/// leftovers that never answer, so they must not slow the wait for the new +/// instance. But one can be a Staged that was alive all along and too slow +/// to answer its ping: opening the bundle starts nothing beside it, so the +/// only way the wait can end well is for that instance to be asked again. +/// +/// A `stale` entry that now answers but cannot be used does not end the +/// wait, because a new instance may still be on its way. Its error is the +/// one reported if nothing else answers in time, in place of the timeout +/// message. +/// +/// The deadline is checked between polls, so the wait can run over by one +/// probe of an instance that accepts a connection and then says nothing. fn wait_for_instance( stale: &[DiscoveryCandidate], timeout: Duration, @@ -257,31 +287,45 @@ fn wait_for_instance( mut connect: impl FnMut(&Path) -> Result, ) -> Result { let deadline = Instant::now() + timeout; + let mut unusable: Option = None; + let mut poll: u32 = 0; loop { - for candidate in candidates() { - if stale.contains(&candidate) { - continue; - } + poll = poll.wrapping_add(1); + let (known, new): (Vec<_>, Vec<_>) = candidates() + .into_iter() + .partition(|candidate| stale.contains(candidate)); + for candidate in &new { match connect(&candidate.path) { Ok(client) => return Ok(client), Err(CtlError::NotRunning(_)) => {} Err(e) => return Err(e), } } + if poll % KNOWN_PROBE_EVERY == 0 { + for candidate in &known { + match connect(&candidate.path) { + Ok(client) => return Ok(client), + Err(CtlError::NotRunning(_)) => {} + Err(e) => unusable = Some(e), + } + } + } if Instant::now() >= deadline { - return Err(launch_timed_out(timeout)); + return Err(unusable.unwrap_or_else(|| launch_timed_out(timeout))); } std::thread::sleep(poll_pause); } } -/// The error for a started Staged whose control server never answered. Not -/// [`CtlError::NotRunning`]: the app was started, so starting it again is -/// not the remedy. +/// The error for a wait that no Staged answered. It says what was done and +/// what was seen, not that Staged "was started": the bundle was opened, and +/// whether that started an app or only brought forward one that is too busy +/// to answer cannot be told from here. Not [`CtlError::NotRunning`]: the +/// open has been asked for, so asking again is not the remedy. fn launch_timed_out(timeout: Duration) -> CtlError { CtlError::Environment(format!( - "Staged was started but its control server did not answer within {} seconds; run the \ - command again once its window is up", + "stagedctl asked macOS to open Staged, but no Staged control server answered within \ + {} seconds; run the command again once its window is up", timeout.as_secs() )) } @@ -320,8 +364,10 @@ pub fn connect_or_launch( grants::now_ms(), env.executable.as_deref(), )?; + // "No running Staged answered", not "Staged is not running": + // an instance too busy to answer its ping reads the same here. eprintln!( - "stagedctl: Staged is not running; starting {}", + "stagedctl: no running Staged answered; opening {}", bundle.display() ); env.launcher.launch(&bundle).map_err(|reason| { @@ -350,7 +396,7 @@ pub fn connect_or_launch( pub(crate) mod tests { use super::*; use crate::client::tests::{ - fake_server, grant_aware_server, grant_file, ping_body, private_tempdir, set_mtime, + fake_server, grant_aware_server_on, grant_file, ping_body, private_tempdir, set_mtime, write_tcp_discovery, }; use crate::client::PROTOCOL_VERSION; @@ -439,14 +485,23 @@ pub(crate) mod tests { /// A fake Staged that knows [`GRANT_ID`], listening but not yet /// discoverable: its port, for the discovery file to name. fn grant_aware_app() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + grant_aware_app_on(listener); + port + } + + /// [`grant_aware_app`] on a listener that is already bound, for an app + /// that starts answering at the port its discovery file names. + fn grant_aware_app_on(listener: TcpListener) { let renew_to = grants::now_ms() + grants::GRANT_LIFETIME_MS - 1000; - let (port, _handle) = grant_aware_server( + let _handle = grant_aware_server_on( + listener, std::process::id(), vec![(GRANT_ID.to_string(), GRANT_SECRET.to_string())], renew_to, Arc::new(Mutex::new(Vec::new())), ); - port } /// A port nothing listens on. @@ -810,29 +865,43 @@ pub(crate) mod tests { } #[test] - fn the_wait_never_probes_what_was_already_there_and_times_out_as_exit_3() { - let dir = private_tempdir(); - let zombie = write_zombie(dir.path()); - let stale = live_discovery_candidates(dir.path()); - assert_eq!(stale.len(), 1); + fn the_wait_looks_at_a_leftover_only_now_and_then_and_times_out_as_exit_3() { + // A crashed instance's directory never answers. It is asked again, + // in case it is a live app that was only slow, but seldom enough + // not to hold up the wait for the new instance. + let stale = [candidate("/zombie")]; + let polls = AtomicUsize::new(0); + let probes = AtomicUsize::new(0); let started = Instant::now(); let err = wait_for_instance( &stale, Duration::from_millis(200), - Duration::from_millis(20), - || live_discovery_candidates(dir.path()), + Duration::from_millis(1), + || { + polls.fetch_add(1, Ordering::SeqCst); + stale.to_vec() + }, |path| -> Result { - panic!("probed the stale {} (zombie is {zombie:?})", path.display()) + assert_eq!(path, Path::new("/zombie")); + probes.fetch_add(1, Ordering::SeqCst); + Err(not_running()) }, ) .unwrap_err(); assert!(started.elapsed() >= Duration::from_millis(200)); + let (polls, probes) = (polls.load(Ordering::SeqCst), probes.load(Ordering::SeqCst)); + assert!(probes >= 1, "never looked again in {polls} polls"); + assert_eq!( + probes, + polls / KNOWN_PROBE_EVERY as usize, + "probed on every {KNOWN_PROBE_EVERY}th poll and no other ({polls} polls)" + ); assert_eq!(err.exit_code(), 3); assert!( matches!(err, CtlError::Environment(_)), - "a started app that never answered is not `NotRunning`: {err:?}" + "the bundle was opened, so this is not `NotRunning`: {err:?}" ); assert_eq!( err.to_string(), @@ -841,12 +910,156 @@ pub(crate) mod tests { } #[test] - fn the_timeout_message_names_the_wait_and_the_remedy() { + fn the_timeout_message_says_what_was_done_and_seen_and_the_remedy() { + let message = launch_timed_out(LAUNCH_TIMEOUT).to_string(); assert_eq!( - launch_timed_out(LAUNCH_TIMEOUT).to_string(), - "Staged was started but its control server did not answer within 30 seconds; run \ - the command again once its window is up" + message, + "stagedctl asked macOS to open Staged, but no Staged control server answered within \ + 30 seconds; run the command again once its window is up" ); + // Opening the bundle may only have brought a busy app forward, so + // the message must not claim an app was started. + assert!(!message.contains("started"), "got: {message}"); + } + + #[test] + fn the_wait_uses_an_instance_that_was_there_all_along_once_it_answers() { + // A Staged too busy to answer its ping was taken for absent. + // Opening the bundle starts nothing beside it, so no new discovery + // file appears: the one to use has the path and mtime it had before. + let pid = std::process::id(); + let (port, _handle) = fake_server(move |_| (200, ping_body(pid, 3, PROTOCOL_VERSION))); + let dir = private_tempdir(); + let path = write_tcp_discovery(dir.path(), pid, port, 3); + let stale = live_discovery_candidates(dir.path()); + assert_eq!(stale.len(), 1); + let probes = AtomicUsize::new(0); + + let client = wait_for_instance( + &stale, + Duration::from_secs(20), + Duration::from_millis(2), + || live_discovery_candidates(dir.path()), + |probed| { + assert_eq!(probed, path); + // Still too slow the first time it is asked again. + if probes.fetch_add(1, Ordering::SeqCst) == 0 { + return Err(not_running()); + } + Client::connect(probed) + }, + ) + .unwrap(); + + assert_eq!((client.pid, client.generation), (pid, 3)); + assert_eq!(probes.load(Ordering::SeqCst), 2); + assert_eq!( + live_discovery_candidates(dir.path()), + stale, + "the file was never rewritten, so it was found as a known instance" + ); + } + + #[test] + fn the_wait_tries_a_new_instance_first_and_on_every_poll() { + let stale = [candidate("/known")]; + let listed = vec![candidate("/known"), candidate("/new")]; + let probed = Mutex::new(Vec::new()); + + wait_for_instance( + &stale, + Duration::from_millis(200), + Duration::from_millis(1), + || listed.clone(), + |path| -> Result { + probed + .lock() + .unwrap() + .push(path.to_string_lossy().into_owned()); + Err(not_running()) + }, + ) + .unwrap_err(); + + let probed = probed.into_inner().unwrap(); + let every = KNOWN_PROBE_EVERY as usize; + assert!(probed.len() > every, "too few polls: {probed:?}"); + // One probe of the new instance per poll; on the poll the known one + // is due, it comes after. + assert!( + probed[..every].iter().all(|path| path == "/new"), + "{probed:?}" + ); + assert_eq!(probed[every], "/known", "{probed:?}"); + } + + #[test] + fn a_known_instance_that_answers_but_cannot_be_used_does_not_end_the_wait() { + // The instance from before answers after all, but speaks another + // protocol version. A new instance may still be coming, so that is + // not yet the answer. + let pid = std::process::id(); + let (port, _handle) = fake_server(move |_| (200, ping_body(pid, 5, PROTOCOL_VERSION))); + let dir = private_tempdir(); + let stale = [candidate("/known")]; + let polls = AtomicUsize::new(0); + let known_probes = AtomicUsize::new(0); + + let client = wait_for_instance( + &stale, + Duration::from_secs(20), + Duration::from_millis(1), + || { + // The new instance turns up after the known one has been + // asked twice. + if polls.fetch_add(1, Ordering::SeqCst) == 2 * KNOWN_PROBE_EVERY as usize { + write_tcp_discovery(dir.path(), pid, port, 5); + } + let mut listed = stale.to_vec(); + listed.extend(live_discovery_candidates(dir.path())); + listed + }, + |path| { + if path == Path::new("/known") { + known_probes.fetch_add(1, Ordering::SeqCst); + return Err(CtlError::Environment("app updated".to_string())); + } + Client::connect(path) + }, + ) + .unwrap(); + + assert_eq!(client.generation, 5); + assert_eq!( + known_probes.load(Ordering::SeqCst), + 2, + "its error did not end the wait" + ); + } + + #[test] + fn a_known_instance_that_cannot_be_used_is_the_error_when_nothing_else_answers() { + let stale = [candidate("/known")]; + let started = Instant::now(); + + let err = wait_for_instance( + &stale, + Duration::from_millis(200), + Duration::from_millis(1), + || stale.to_vec(), + |_| -> Result { + Err(CtlError::Environment("app updated".to_string())) + }, + ) + .unwrap_err(); + + assert!( + started.elapsed() >= Duration::from_millis(200), + "the wait ran its course first" + ); + // What the instance said, not "no control server answered". + assert_eq!(err.to_string(), "app updated"); + assert_eq!(err.exit_code(), 3); } #[test] @@ -1009,6 +1222,46 @@ pub(crate) mod tests { assert_eq!(client.pid, std::process::id()); } + #[test] + fn a_staged_that_was_too_slow_to_answer_is_used_once_it_does() { + // A live instance whose ping failed is taken for "not running", so + // the bundle is opened. For a running app that starts nothing: no + // new discovery file is written. The instance on disk has to be + // asked again. + let dir = private_tempdir(); + store_grant(dir.path()); + let port = closed_port(); + let path = write_tcp_discovery(dir.path(), std::process::id(), port, 1); + let written = std::fs::metadata(&path).unwrap().modified().unwrap(); + // The app gets round to answering a little after the open, at the + // endpoint its file has named all along. + let launcher = FakeLauncher::new(move || { + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + grant_aware_app_on(TcpListener::bind(("127.0.0.1", port)).unwrap()); + }); + Ok(()) + }); + + let client = connect_or_launch( + None, + None, + Some(dir.path()), + false, + &test_env(Some(BUNDLED_EXECUTABLE), &launcher), + ) + .unwrap(); + + assert_eq!(launcher.launched(), vec![PathBuf::from(BUNDLE)]); + assert_eq!(client.pid, std::process::id()); + assert_eq!(client.bearer(), Some(format!("live-{GRANT_ID}").as_str())); + assert_eq!( + std::fs::metadata(&path).unwrap().modified().unwrap(), + written, + "found through the file that was there before the open" + ); + } + /// `connect_or_launch` with nothing running and a launcher that must /// not be reached. fn refused(data_dir: &Path, no_launch: bool, executable: Option<&str>) -> CtlError { @@ -1155,7 +1408,7 @@ pub(crate) mod tests { } #[test] - fn a_started_staged_that_never_answers_is_exit_3_and_is_started_only_once() { + fn a_staged_that_never_answers_after_the_open_is_exit_3_and_is_opened_only_once() { let dir = private_tempdir(); store_grant(dir.path()); let launcher = FakeLauncher::new(|| Ok(())); @@ -1165,9 +1418,9 @@ pub(crate) mod tests { let err = connect_or_launch(None, None, Some(dir.path()), false, &env).unwrap_err(); assert_eq!(launcher.launched().len(), 1, "no second attempt"); assert_eq!(err.exit_code(), 3); - assert!( - err.to_string().contains("did not answer within"), - "got: {err}" + assert_eq!( + err.to_string(), + launch_timed_out(Duration::from_millis(200)).to_string() ); } } diff --git a/apps/staged/src-tauri/crates/stagedctl/src/open.rs b/apps/staged/src-tauri/crates/stagedctl/src/open.rs index 0a8b1c8d0..d36e150d8 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/open.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/open.rs @@ -31,7 +31,7 @@ use std::path::{Component, Path, PathBuf}; use serde::Deserialize; use serde_json::{json, Value}; -use crate::client::{Client, CtlError}; +use crate::client::{Client, CredentialScope, CtlError}; use crate::commands::{find_project, list_projects, parse, CmdOutput, Project}; use crate::launch::{connect_or_launch, LaunchEnv}; @@ -173,30 +173,46 @@ pub fn target( /// The project whose workspace `dir` is in, from the app's list. Matched on /// the id alone: the segment is an id by construction, so a project that /// merely has it as a name is not the one. +/// +/// Why a project is missing from the list depends on `scope`, the reach of +/// the credential the list was read with. An unconfined credential sees +/// every project, so the app does not have this one. A credential confined +/// to one project is listed that project and no other, so all that is known +/// is that it does not reach this one; the project may well exist. fn project_for_directory<'a>( projects: &'a [Project], project_id: &str, dir: &Path, + scope: CredentialScope, ) -> Result<&'a Project, CtlError> { projects.iter().find(|p| p.id == project_id).ok_or_else(|| { let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect(); let dir = dir.display(); - CtlError::command( - "not_found", - if names.is_empty() { - format!( - "{dir} is in the workspace of project id {project_id}, which Staged \ - does not have; the project was probably deleted, and the app lists no \ - projects" - ) - } else { - format!( - "{dir} is in the workspace of project id {project_id}, which Staged \ - does not have; the project was probably deleted. Projects: {}", - names.join(", ") - ) - }, - ) + let message = match (scope, names.is_empty()) { + (CredentialScope::AllProjects, true) => format!( + "{dir} is in the workspace of project id {project_id}, which Staged does not \ + have; the project was probably deleted, and the app lists no projects" + ), + (CredentialScope::AllProjects, false) => format!( + "{dir} is in the workspace of project id {project_id}, which Staged does not \ + have; the project was probably deleted. Projects: {}", + names.join(", ") + ), + (CredentialScope::Project, true) => format!( + "{dir} is in the workspace of project id {project_id}, which this credential \ + does not reach: it is confined to one project, and Staged lists it none. The \ + project may still exist; run the command with a grant that covers it (see \ + `stagedctl connect --help`)" + ), + (CredentialScope::Project, false) => format!( + "{dir} is in the workspace of project id {project_id}, which this credential \ + does not reach: it is confined to one project, and Staged lists it only that \ + one ({}). The project may still exist; run the command with a grant that \ + covers it (see `stagedctl connect --help`)", + names.join(", ") + ), + }; + CtlError::command("not_found", message) }) } @@ -229,6 +245,7 @@ pub fn project_open(client: &Client, target: &Target) -> Result Result Result, CtlError>, invoke: impl FnOnce(Value) -> Result, ) -> Result { let projects = list()?; let project = match target { Target::Named(needle) => find_project(&projects, needle)?, - Target::Directory { project_id, dir } => project_for_directory(&projects, project_id, dir)?, + Target::Directory { project_id, dir } => { + project_for_directory(&projects, project_id, dir, scope)? + } }; open_resolved(project, pid, invoke) } @@ -662,6 +683,9 @@ mod tests { // Confirming the target with the app // ------------------------------------------------------------------- + /// The reach of an unconfined grant, which lists every project. + const ALL: CredentialScope = CredentialScope::AllProjects; + fn directory(project_id: &str) -> Target { Target::Directory { project_id: project_id.to_string(), @@ -679,6 +703,7 @@ mod tests { let output = open_target( &directory("p1"), 4242, + ALL, || Ok(projects), |args| { sent = Some(args); @@ -694,7 +719,7 @@ mod tests { #[test] fn a_directory_whose_project_the_app_does_not_have_is_not_found_and_raises_nothing() { let projects = vec![project("p0", "Gadgets"), widgets()]; - let err = open_target(&directory("gone"), 4242, || Ok(projects), no_open).unwrap_err(); + let err = open_target(&directory("gone"), 4242, ALL, || Ok(projects), no_open).unwrap_err(); assert_eq!(err.exit_code(), 1); assert_eq!( err.to_string(), @@ -703,7 +728,7 @@ mod tests { Projects: Gadgets, Widgets" ); - let err = open_target(&directory("gone"), 4242, || Ok(vec![]), no_open).unwrap_err(); + let err = open_target(&directory("gone"), 4242, ALL, || Ok(vec![]), no_open).unwrap_err(); assert_eq!(err.code(), "not_found"); assert!( err.to_string().ends_with("and the app lists no projects"), @@ -711,13 +736,81 @@ mod tests { ); } + #[test] + fn a_directory_outside_a_confined_credentials_project_is_not_called_deleted() { + // A grant approved for one project (or a session's lease) is listed + // that project alone. A worktree of any other project is then + // missing from the list whether or not the project exists, so the + // message must not say Staged lacks it or that it was deleted. + let err = open_target( + &directory("p7"), + 4242, + CredentialScope::Project, + || Ok(vec![widgets()]), + no_open, + ) + .unwrap_err(); + assert_eq!((err.exit_code(), err.code()), (1, "not_found")); + assert_eq!( + err.to_string(), + "not_found: /Users/me/.staged/workspaces/local/projects/p7/wt is in the workspace of \ + project id p7, which this credential does not reach: it is confined to one \ + project, and Staged lists it only that one (Widgets). The project may still \ + exist; run the command with a grant that covers it (see `stagedctl connect \ + --help`)" + ); + + // The credential's own project is gone too, so it is listed none. + let err = open_target( + &directory("p7"), + 4242, + CredentialScope::Project, + || Ok(vec![]), + no_open, + ) + .unwrap_err(); + assert_eq!((err.exit_code(), err.code()), (1, "not_found")); + for wrong in ["does not have", "deleted"] { + assert!(!err.to_string().contains(wrong), "{err}"); + } + assert!( + err.to_string() + .contains("this credential does not reach: it is confined to one project, and Staged lists it none"), + "{err}" + ); + } + + #[test] + fn a_confined_credential_still_opens_its_own_projects_directory() { + let mut sent = None; + let output = open_target( + &directory("p1"), + 4242, + CredentialScope::Project, + || Ok(vec![widgets()]), + |args| { + sent = Some(args); + opened(Value::Null) + }, + ) + .unwrap(); + assert_eq!(sent, Some(json!({ "projectId": "p1" }))); + assert_eq!(output.human, "opened Widgets id:p1 pid:4242"); + } + #[test] fn a_directory_id_is_never_matched_against_project_names() { // A directory called `Widgets` under `projects/` is not project p1, // whatever case it is written in. for segment in ["Widgets", "widgets"] { - let err = open_target(&directory(segment), 4242, || Ok(vec![widgets()]), no_open) - .unwrap_err(); + let err = open_target( + &directory(segment), + 4242, + ALL, + || Ok(vec![widgets()]), + no_open, + ) + .unwrap_err(); assert_eq!(err.code(), "not_found", "{segment}"); } } @@ -733,19 +826,32 @@ mod tests { }; for needle in ["p3", "Gears", "gears"] { let mut sent = None; - open_target(&Target::Named(needle.to_string()), 4242, projects, |args| { - sent = Some(args); - opened(Value::Null) - }) + open_target( + &Target::Named(needle.to_string()), + 4242, + ALL, + projects, + |args| { + sent = Some(args); + opened(Value::Null) + }, + ) .unwrap(); assert_eq!(sent, Some(json!({ "projectId": "p3" })), "{needle}"); } - let err = - open_target(&Target::Named("nope".to_string()), 4242, projects, no_open).unwrap_err(); + let err = open_target( + &Target::Named("nope".to_string()), + 4242, + ALL, + projects, + no_open, + ) + .unwrap_err(); assert_eq!((err.exit_code(), err.code()), (1, "not_found")); let err = open_target( &Target::Named("WIDGETS".to_string()), 4242, + ALL, projects, no_open, ) @@ -759,6 +865,7 @@ mod tests { let err = open_target( &target, 4242, + ALL, || { Err(CtlError::Authorization { code: "authorization_required".to_string(), diff --git a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt index 93a0a8874..fb5f2a8d3 100644 --- a/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt +++ b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt @@ -154,8 +154,9 @@ keeps a project's worktrees. From any other directory the command exits 1 A that matches nothing (`not_found`), or several projects when case is ignored (`ambiguous`), exits 1 before any window is touched. So -does a directory whose project the app does not have (`not_found`), such -as the worktree of a project that was deleted. +does a directory whose project the app does not list (`not_found`): the +worktree of a project that was deleted, or of one that a grant confined +to another project does not reach. Otherwise the window is raised whether or not it could switch, so the user sees what is in the way. When it could not, the command exits 1: @@ -178,7 +179,10 @@ up, and then asks as above; the app holds the request until its window has loaded. A window that is still loading is not shown early: it comes to the front once it is ready, which can be after the command has exited. What it starts is the Staged.app this stagedctl is bundled in, -and nothing else. A line on stderr says so; stdout is unchanged. +and nothing else. A line on stderr says so; stdout is unchanged. A +Staged that is running but too busy to answer is treated the same way: +opening the app only brings it forward, and the command uses it as soon +as it answers. Staged is never started: - with --no-launch: the command exits 3 instead; diff --git a/apps/staged/src-tauri/resources/stagedctl/guide.md b/apps/staged/src-tauri/resources/stagedctl/guide.md index 8e6ed53a7..09104c2d6 100644 --- a/apps/staged/src-tauri/resources/stagedctl/guide.md +++ b/apps/staged/src-tauri/resources/stagedctl/guide.md @@ -147,13 +147,16 @@ Outside a session: `~/.staged/workspaces//projects//`, where Staged keeps a project's worktrees. From any other directory it exits 1 (`not_in_workspace`) without looking for the app, and never starts it. - A workspace whose project was deleted exits 1 (`not_found`). + A workspace whose project was deleted exits 1 (`not_found`), as does + one whose project a grant confined to another project does not reach. - `--no-launch` exits 3 instead of starting Staged when it is not running. -- `staged [] [--no-launch]` is the same command, where the - `staged` launcher script is installed. It hands its arguments to - `stagedctl project open` unchanged and exits 127, starting nothing, - when it cannot find `stagedctl`. +- `staged [ | ] [--no-launch]` is the same command, + where the `staged` launcher script is installed. It hands its arguments + to `stagedctl project open` unchanged, except that a single argument + that is an existing directory (`staged .`) runs the command from that + directory with no project. It exits 127, starting nothing, when it + cannot find `stagedctl`. On success it prints `opened id: pid:`, the pid being the Staged instance that showed the project (`--json`: `{ outcome, @@ -189,9 +192,11 @@ request until its window has loaded. A window that is still loading is not shown early: it comes to the front once it is ready, which can be after the command has exited. What it starts is the `Staged.app` this `stagedctl` is bundled in and nothing else, on macOS only. A line on -stderr says so; stdout is unchanged. A project name is looked up only -once the app is up, so a name that matches nothing still exits 1 -(`not_found`) after Staged has started. +stderr says so; stdout is unchanged. A Staged that is running but too +busy to answer is treated the same way: opening the app only brings it +forward, and the command uses it as soon as it answers. A project name is +looked up only once the app is up, so a name that matches nothing still +exits 1 (`not_found`) after Staged has started. Staged is never started: diff --git a/apps/staged/src-tauri/src/stagedctl_link/fs.rs b/apps/staged/src-tauri/src/stagedctl_link/fs.rs index 6abccd1d8..e322bbba5 100644 --- a/apps/staged/src-tauri/src/stagedctl_link/fs.rs +++ b/apps/staged/src-tauri/src/stagedctl_link/fs.rs @@ -174,7 +174,8 @@ mod unix { Ok(target) if target == shown_target => {} _ => return Ok(false), } - let staging = link.with_file_name(format!(".{CLI_NAME}.link-{}", std::process::id())); + let staging = staging_path(link); + clear_stale_staging(&staging)?; symlink(source, &staging)?; if let Err(e) = std::fs::rename(&staging, link) { let _ = std::fs::remove_file(&staging); @@ -183,6 +184,48 @@ mod unix { Ok(true) } + /// Where a replacement for `link` is made before it is renamed over it: + /// a dot-name beside it, carrying this process's id. + pub(super) fn staging_path(link: &Path) -> PathBuf { + link.with_file_name(format!(".{CLI_NAME}.link-{}", std::process::id())) + } + + /// Clear what an earlier replace left at `staging`, so this one can make + /// its own. A replace that was cut short after making the staging link, + /// or whose clean-up failed, leaves it behind, and `symlink` then fails + /// with "File exists" on every later replace by a process with this id. + /// + /// Only a symlink is removed. That is the one thing this code ever puts + /// at the name, and removing a link loses nothing but the path it held. + /// A file or a directory there is not ours: it is left where it is and + /// the replace is refused, naming it. Staging names that carry another + /// process's id are not looked at. + /// + /// The look and the removal are two calls, like the check and the rename + /// in [`replace_link`]. `remove_file` never removes a directory. + fn clear_stale_staging(staging: &Path) -> io::Result<()> { + let Ok(metadata) = std::fs::symlink_metadata(staging) else { + // Nothing there. Or the directory cannot be read, and `symlink` + // says why next. + return Ok(()); + }; + if !metadata.file_type().is_symlink() { + return Err(io::Error::new( + ErrorKind::AlreadyExists, + format!( + "{} is in the way and is not a link Staged made, so it was left alone", + staging.display() + ), + )); + } + match std::fs::remove_file(staging) { + Ok(()) => Ok(()), + // Gone between the look and the removal: nothing left to clear. + Err(e) if e.kind() == ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } + } + fn nowhere_usable(refused: &[(PathBuf, String)]) -> String { let mut message = "Staged could not link stagedctl into a directory on your PATH.".to_string(); @@ -530,6 +573,117 @@ mod tests { assert!(!fx.root.join("home").exists()); } + /// Where a replace of `link` stages its new link, in this process. + fn staging_for(link: &Path) -> PathBuf { + super::unix::staging_path(link) + } + + /// A replace that was cut short after making its staging link, or whose + /// clean-up failed, leaves that link behind. The next replace clears it + /// and goes ahead instead of failing with "File exists". + #[test] + fn a_staging_link_left_by_an_earlier_replace_is_cleared() { + let fx = Fixture::new(); + let other = fx.other_build(); + fx.place_link(&fx.system, &other); + let system_dir = fx.system.parent().unwrap(); + let staging = staging_for(&fx.system); + assert_eq!(staging.parent(), Some(system_dir)); + // Left pointing at the other build, and then dangling: whatever it + // points at, it is a link at the staging name. + for leftover_target in [other.clone(), fx.root.join("deleted/stagedctl")] { + symlink(&leftover_target, &staging).unwrap(); + + assert_eq!( + fx.install(Some(&shown(&fx.system, &other))), + Ok(Installed::Linked(fx.system.clone())) + ); + + assert_eq!(std::fs::read_link(&fx.system).unwrap(), fx.source); + assert_eq!(names_in(system_dir), ["stagedctl"]); + // Only the leftover link went, not what it pointed at. + assert_eq!(std::fs::read_to_string(&other).unwrap(), "old"); + + std::fs::remove_file(&fx.system).unwrap(); + fx.place_link(&fx.system, &other); + } + } + + /// Only a symlink at the staging name is this code's to clear. A file or + /// a directory there is left exactly as it was, and the replace is + /// refused with its path. + #[test] + fn a_file_or_directory_at_the_staging_name_is_never_removed() { + let fx = Fixture::new(); + let other = fx.other_build(); + fx.place_link(&fx.system, &other); + let system_dir = fx.system.parent().unwrap(); + let staging = staging_for(&fx.system); + let staging_name = staging.file_name().unwrap().to_string_lossy().into_owned(); + let refused = |fx: &Fixture| { + let error = fx.install(Some(&shown(&fx.system, &other))).unwrap_err(); + assert!( + error.starts_with(&format!("Staged could not replace {}", fx.system.display())), + "{error}" + ); + assert!(error.contains(&staging.display().to_string()), "{error}"); + assert!(error.ends_with("The link is unchanged."), "{error}"); + assert_eq!(std::fs::read_link(&fx.system).unwrap(), other); + assert_eq!(names_in(system_dir), [staging_name.as_str(), "stagedctl"]); + }; + + std::fs::write(&staging, "someone's file").unwrap(); + refused(&fx); + assert_eq!(std::fs::read_to_string(&staging).unwrap(), "someone's file"); + + std::fs::remove_file(&staging).unwrap(); + std::fs::create_dir(&staging).unwrap(); + std::fs::write(staging.join("kept"), "someone's directory").unwrap(); + refused(&fx); + assert_eq!( + std::fs::read_to_string(staging.join("kept")).unwrap(), + "someone's directory" + ); + } + + /// The staging name carries the process id, so another process's + /// leftover has another name. It is not in the way, and it is not this + /// process's to remove. + #[test] + fn a_staging_link_under_another_name_is_left_alone() { + let fx = Fixture::new(); + let other = fx.other_build(); + fx.place_link(&fx.system, &other); + let system_dir = fx.system.parent().unwrap(); + let theirs = system_dir.join(".stagedctl.link-0"); + assert_ne!(theirs, staging_for(&fx.system)); + symlink(&other, &theirs).unwrap(); + + assert_eq!( + fx.install(Some(&shown(&fx.system, &other))), + Ok(Installed::Linked(fx.system.clone())) + ); + + assert_eq!(std::fs::read_link(&fx.system).unwrap(), fx.source); + assert_eq!(std::fs::read_link(&theirs).unwrap(), other); + assert_eq!(names_in(system_dir), [".stagedctl.link-0", "stagedctl"]); + } + + /// With nothing to replace, the staging name is never looked at: a + /// leftover there is not cleared by a plain create. + #[test] + fn a_create_does_not_touch_the_staging_name() { + let fx = Fixture::new(); + let system_dir = fx.system.parent().unwrap(); + std::fs::create_dir_all(system_dir).unwrap(); + let staging = staging_for(&fx.system); + symlink(fx.root.join("deleted/stagedctl"), &staging).unwrap(); + + assert_eq!(fx.install(None), Ok(Installed::Linked(fx.system.clone()))); + + assert!(staging.symlink_metadata().unwrap().file_type().is_symlink()); + } + #[test] fn a_bundle_with_no_sidecar_links_nothing() { let fx = Fixture::new(); diff --git a/scripts/staged b/scripts/staged index 681cde45c..ce565d7f9 100755 --- a/scripts/staged +++ b/scripts/staged @@ -1,15 +1,21 @@ #!/bin/bash # `staged`: show a project in the Staged app. # -# Usage: staged [] [--no-launch] +# Usage: staged [ | ] [--no-launch] # Project name or id. Omit it inside a project's workspace # (~/.staged/workspaces//projects//...) to # open that project. +# An existing directory, such as `.`: open the project whose +# workspace that directory is in, as if run from there. # --no-launch Fail instead of starting Staged when it is not running. # -# A thin wrapper: every argument goes unchanged to `stagedctl project open`, -# which also decides the output and the exit code. See -# `stagedctl project open --help`. +# A thin wrapper around `stagedctl project open`, which decides the output +# and the exit code. See `stagedctl project open --help`. The arguments go +# to it unchanged, with one exception: a single argument that is an existing +# directory is not passed on. stagedctl is run from that directory with no +# project instead, so the project is resolved from the directory, as +# `cd && staged` would. An argument that is not an existing +# directory is a project name or id, as it always was. # # This script never starts the app itself. stagedctl asks a running Staged # to show the project, and starts one only when none is running, the caller @@ -50,4 +56,44 @@ MESSAGE exit 127 fi +# `staged .` and `staged /path/to/repo`: one argument that is an existing +# directory, alone or beside flags that take no value. Anything else, a flag +# this script does not know included, is left for stagedctl to read. +dir="" +positionals=0 +known=1 +for arg in "$@"; do + case "$arg" in + --no-launch | --json) ;; + -*) known=0 ;; + *) + positionals=$((positionals + 1)) + dir="$arg" + ;; + esac +done + +if [ "$known" -eq 1 ] && [ "$positionals" -eq 1 ] && [ -d "$dir" ]; then + # Drop the directory from the arguments and keep the flags in order. + for arg in "$@"; do + shift + [ "$arg" = "$dir" ] || set -- "$@" "$arg" + done + # A stagedctl found through a relative PATH entry must still be the one + # that runs after the directory change. + case "$STAGEDCTL" in + /*) ;; + *) STAGEDCTL="$PWD/$STAGEDCTL" ;; + esac + # `./` keeps `cd` from searching $CDPATH for a relative name. + case "$dir" in + /*) ;; + *) dir="./$dir" ;; + esac + if ! cd -- "$dir" 2>/dev/null; then + printf 'staged: cannot change to the directory %s, so no project was opened.\n' "$dir" >&2 + exit 1 + fi +fi + exec "$STAGEDCTL" project open "$@" From a45032265d69db0aab891eeac931a24d8cddd936 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 13 Nov 2026 18:07:47 +1100 Subject: [PATCH 14/14] fix(staged): record window reveals and close the review's last points The three open points from the second review of `stagedctl project open` (#review:2a5dad41-bd21-49bc-b883-8d8ce61e7fab): one warning and two suggestions. Its other comments are confirmations. Each point was checked against the code first, and all three hold. 1. A revealed window that never held focus is raised (`window_reveal.rs`). 1637a005 stopped `open_project` showing a window before its frontend had. The backend was not told when a frontend called `show()`, so "revealed" was inferred: on screen now, or on the record of used windows, which a window joins when it gains focus, is closed to hide, or is raised. A window can be revealed and fit neither. Staged started in the background, or launched hidden, shows its window without it taking focus. Hide that app from another one and the window is no longer on screen either. `open_project` then took it for a window still loading: the request was parked, the live frontend answered `opened`, the CLI exited 0, and Staged never came forward. A 100 ms poll waited 60 seconds for a reveal that had already happened, then logged and gave up. The reveal is now reported instead of inferred: - The frontend calls a new Tauri command, `note_window_revealed`, right after its own `show()` at the end of mount, once the theme is applied. `revealWindow()` in `services/windowReveal.ts` is the two together, and `App.svelte` calls it where it called `show()`. - A new `app_lifecycle::window_reveal` module holds `RevealedWindows`, managed app state: the windows that have been revealed, and the windows owed a raise once they are. - `raise_window_once_revealed` reads that record and nothing else. A revealed window is raised on the spot, whatever it looks like now. Any other window is still loading: it is left alone and marked as owed a raise. - The owed raise is made by the command, when the reveal is reported. The frontend's own reveal is still the first, and the window still ends up in front and focused. - The poll and its spawned task are gone. The 60 second bound stays as a deadline on the owed raise: a reveal reported later than that is recorded but raises nothing. - A window comes off the record when it is destroyed, with any raise it was owed. The cold start from 1637a005 behaves as before. A window its frontend has not revealed is not shown early, the request is still parked and answered from the snapshot, and the raise follows the reveal, now at once instead of within 100 ms. Who may call the command: - Registered in `generate_handler!` beside the other window commands. - Refused by the web dispatch: a browser client has no native window. - Left out of `ALLOWED_COMMANDS`, so no stagedctl caller can have a loading window shown early. - It takes no arguments and acts on the calling window only. Choices worth a look: - A window the backend shows itself also goes on the record. The Dock reopen and `Window > Staged` raise without waiting for the frontend, so after one there is no first paint left to protect. That raise also settles any raise the window was owed, so the frontend's later reveal does not pull it forward a second time. - `hide_window` no longer puts a closed window on the record of used windows. That line existed only to feed the inference. Only the last window is closed to hide, so the pick is unchanged. - The record of used windows now only orders the pick. Its `contains` is test-only. - `open_project_within` loses its on-screen probe and is back to the shape it had before 1637a005. `is_on_screen` is removed. - With no record managed, as on most mock apps, the window is raised at once. The real app always manages one. - A report that fails is logged and dropped by the frontend, and the mount carries on. The window would then read as still loading until the backend next raises it itself. 2. A session is not told to use a grant (`open.rs`). `project open` run from a workspace the credential does not reach said to "run the command with a grant that covers it (see `stagedctl connect --help`)". The same branch is reached by an agent session's lease, and the app refuses `open_project` to every session, for its own project too. The remedy sent a session after a credential that does not exist. The client already knows which it holds: a per-session control file means a lease, and a grant file says what it was approved for. `CredentialScope` merged the two as `Project`. It now has a third variant, `Session`, set where the client sees a per-session control file. - A one-project grant keeps its wording, word for word. - A session reads that its credential is confined to the session's own project, that the project may still exist, that no credential lets a session open it, and to name the project for the user. It mentions neither a grant nor `stagedctl connect`. - The code stays `not_found` and the exit code stays 1. - `tags.rs` treats the new variant exactly as `Project`: `usageScope` is still `"project"` and the scope note still prints. 3. A bare word is always a project name (`scripts/staged`). An existing directory always beat a project name. Projects are usually named after their repository, so `staged builderbot` run beside a `builderbot/` folder changed into it and failed with `not_in_workspace`. - The directory form is taken only when the one argument is written as a path, `.`, `..` or anything with a slash in it, and is an existing directory. - A bare word goes to `stagedctl project open` as a project name, whether or not a directory of that name exists. - `staged ./builderbot` and `staged builderbot/` mean the folder. - An argument with a slash that is not an existing directory is passed on unchanged, as before. A project name with a slash in it that is also an existing directory still needs `stagedctl project open `. - The "cannot change to the directory" message now shows the argument as it was typed. It used to show the `./` the script adds for `$CDPATH`. Docs: the README's launcher section lists the path rule, the bare-word rule and both forms in its examples. The guide's launcher bullet says the same, and its session paragraph now covers a session run from another project's workspace. The script's header is rewritten. The verb's help text does not describe the launcher, so the help fixture is unchanged. This adds a backend type, which AGENTS.md asks a human to review: `RevealedWindows`, managed app state, and one Tauri command. Nothing is persisted and there is no schema change. `CredentialScope::Session` is a new variant in the stagedctl crate. Tests: - `window_reveal.rs`, 18 new. The record: a reveal with nothing owed, one in time, one too late, a newer request extending the wait, a revealed window owed nothing, and a forgotten window. On the mock runtime: a revealed window raised at once, one that was never used raised at once, a loading window raised only when its frontend reports, a used window that is not revealed still waited on, the deadline on either side, a reveal raising only its own window, a repeated reveal, a window the backend showed, a destroyed window, and an app with no record. The mock runtime calls every window visible, so these also show that visibility is not consulted. - `window_raise.rs`: the 5 tests of the poll and the probe are removed with them. Their cases are covered in `window_reveal.rs`. - `control.rs`, 3 new and 3 reworked: a revealed window that never held focus is raised as it is asked, a reveal while the caller waits raises before the answer, another window's reveal does not raise the loading one, and the cold-start and timed-out cases now reveal through the command. - `windowReveal.test.ts`, 6 new: the report follows the show, waits for it, is skipped when the show fails, a failed report still resolves, and web mode reports nothing. - `open.rs`, 2 new: the session wording in full, with and without a listed project and with no mention of a grant, and the grant wording kept. `client.rs` and `tags.rs` each have one test updated for the new variant. Run in full, after the last edit: - `cargo fmt --check` passed. - `prettier --check` on `src/` passed. - `cargo clippy --workspace -- -D warnings` passed. - `cargo test --workspace --lib --bins --tests` passed. App crate: 2200 passed, 0 failed, 2 ignored. stagedctl: 184 passed, 0 failed. Help fixture: 1 passed. The 2 ignored are the live `project_mcp` tests that spawn the installed agent bridge and call the model. - `pnpm run check` passed: 0 errors and 0 warnings. - `vitest run` passed: 239 files, 3598 tests, 0 failed. - `bash -n scripts/staged` passed. Nothing failed, so no comparison with the commit before the branch was needed. The launcher was run by hand under `/bin/bash` 3.2 against a fake `stagedctl` that echoes its arguments and working directory: - bare words: a name that is also a directory, a symlink to a directory, a name with a space, a missing name, a regular file, and each beside `--no-launch` or `--json`; - path forms: `.`, `..`, `./dir`, `dir/`, nested, absolute, a symlink, a directory with a space, `./-dash`, a directory named `--json`, and a slash name with and without a directory behind it; - left unchanged: two arguments, an unknown flag, `--`, `--help`, an empty argument and a bare `-dash`; - `$CDPATH` set, for a bare word and for `./word` that exists only under `$CDPATH`; - a directory that cannot be entered, a relative PATH entry, exit codes 1 to 4 passed through in both forms, `stagedctl` missing, and a symlinked script. Not exercised in a real app. The mock runtime sends no focus or destroy events and reports every window as visible, so these are untested: - the command invoked through Tauri from a real window, and the raise it makes from that call; - the original failure end to end: Staged started in the background, then hidden, then `project open` bringing it forward; - the cold start end to end: no flash, and the window fronted as its frontend reveals it; - the record being cleared when a real window is destroyed; - the session wording against a real session lease. The running Staged was not started, quit or restarted. Signed-off-by: Matt Toohey Co-Authored-By: Claude Fable 5.2 Signed-off-by: Matt Toohey --- apps/staged/README.md | 7 +- .../src-tauri/crates/stagedctl/src/client.rs | 15 +- .../src-tauri/crates/stagedctl/src/open.rs | 106 ++++- .../src-tauri/crates/stagedctl/src/tags.rs | 14 +- .../src-tauri/resources/stagedctl/guide.md | 15 +- apps/staged/src-tauri/src/app_lifecycle.rs | 16 +- .../src/app_lifecycle/window_raise.rs | 166 +------ .../src/app_lifecycle/window_reveal.rs | 442 ++++++++++++++++++ .../src-tauri/src/app_lifecycle/window_use.rs | 26 +- apps/staged/src-tauri/src/lib.rs | 4 + .../src-tauri/src/project_open/control.rs | 145 ++++-- apps/staged/src-tauri/src/web_server.rs | 4 + apps/staged/src/App.svelte | 14 +- apps/staged/src/lib/commands.ts | 9 + .../src/lib/services/windowReveal.test.ts | 122 +++++ apps/staged/src/lib/services/windowReveal.ts | 36 ++ scripts/staged | 40 +- 17 files changed, 922 insertions(+), 259 deletions(-) create mode 100644 apps/staged/src-tauri/src/app_lifecycle/window_reveal.rs create mode 100644 apps/staged/src/lib/services/windowReveal.test.ts create mode 100644 apps/staged/src/lib/services/windowReveal.ts diff --git a/apps/staged/README.md b/apps/staged/README.md index 1d9fd3faf..2a5ec1dc0 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -99,14 +99,17 @@ staged # stagedctl project open staged --no-launch # stagedctl project open --no-launch staged . # stagedctl project open, from this directory staged /path/to/dir # cd /path/to/dir && stagedctl project open +staged ./builderbot # cd builderbot && stagedctl project open +staged builderbot # stagedctl project open builderbot, a project name ``` The output and exit codes are the ones described here. The script never starts the app itself. If it cannot find `stagedctl` on your PATH, in `/Applications/Staged.app` or in `~/Applications/Staged.app`, it says so and exits 127 without starting anything. - **A project.** A name or id goes to `stagedctl project open` unchanged, as do the flags. -- **A directory.** When the one argument is an existing directory, `staged` runs `stagedctl project open` from that directory with no project, so the project is the one whose workspace the directory is in. `--no-launch` and `--json` may sit beside it. +- **A directory.** The one argument is read as a directory when it is written as a path and that directory exists. A path is `.`, `..`, or anything with a slash in it, such as `./builderbot`, `builderbot/` or `/path/to/dir`. `staged` then runs `stagedctl project open` from that directory with no project, so the project is the one whose workspace the directory is in. `--no-launch` and `--json` may sit beside it. +- **A bare word is always a project.** `staged builderbot` opens the project named `builderbot`, even when the current directory has a `builderbot` folder. To mean the folder, write `staged ./builderbot` or `staged builderbot/`. - **Only a project workspace opens.** `staged` does not open arbitrary directories or repositories. A directory outside every project workspace fails with `not_in_workspace`, and Staged is not started. -- **A name that is also a directory.** An existing directory always wins. To open a project whose name matches a directory in the current directory, use `stagedctl project open `. +- **A project name with a slash in it.** It is read as a directory only if that directory exists. If it does, use `stagedctl project open ` to open the project. - **A directory that cannot be entered.** `staged` says so and exits 1. #### Getting `stagedctl` on your PATH diff --git a/apps/staged/src-tauri/crates/stagedctl/src/client.rs b/apps/staged/src-tauri/crates/stagedctl/src/client.rs index f549507e9..a8b6b0d85 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/client.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/client.rs @@ -288,7 +288,7 @@ pub fn discover_and_connect( } else { // A per-session control file carries a session lease, which the // app confines to the session's project. - client.scope = CredentialScope::Project; + client.scope = CredentialScope::Session; } Ok(client) } @@ -615,10 +615,19 @@ struct PingResponse { /// grant approved for one project (`connect --project`); both are known at /// connect time. Anything else is either unconfined or refused by the app /// as `authorization_required` before any answer is read. +/// +/// The two confined kinds are kept apart because their remedies differ. A +/// grant can be swapped for one that reaches further. A session lease +/// cannot, and the app refuses it some commands outright, `open_project` +/// among them, whatever project they name. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CredentialScope { + /// A grant approved for every project. AllProjects, + /// A grant approved for one project. Project, + /// An agent session's lease, confined to the session's own project. + Session, } #[derive(Debug)] @@ -2236,8 +2245,8 @@ pub(crate) mod tests { assert_eq!(client.bearer(), Some(token.as_str())); assert_eq!(seen.lock().unwrap().len(), 1, "ping only; no challenge"); // A session lease is confined to its project; the client knows - // without asking. - assert_eq!(client.scope, CredentialScope::Project); + // without asking, and knows it is a lease and not a grant. + assert_eq!(client.scope, CredentialScope::Session); } #[test] diff --git a/apps/staged/src-tauri/crates/stagedctl/src/open.rs b/apps/staged/src-tauri/crates/stagedctl/src/open.rs index d36e150d8..1d3aeff5d 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/open.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/open.rs @@ -170,6 +170,14 @@ pub fn target( } } +/// What an agent session is told when its lease does not reach a directory's +/// project. There is no credential for it to switch to: the app refuses +/// `open_project` to every session, whichever project is named. +const SESSION_CANNOT_OPEN: &str = + "No credential lets an agent session open it: the app refuses `project open` to every \ + session, for the session's own project too. Name the project for the user and let them \ + open it"; + /// The project whose workspace `dir` is in, from the app's list. Matched on /// the id alone: the segment is an id by construction, so a project that /// merely has it as a name is not the one. @@ -179,6 +187,12 @@ pub fn target( /// every project, so the app does not have this one. A credential confined /// to one project is listed that project and no other, so all that is known /// is that it does not reach this one; the project may well exist. +/// +/// What to do about it depends on the kind of confined credential. A grant +/// for one project can be swapped for one that covers this project. A +/// session's lease cannot: the app refuses `open_project` to every session, +/// for its own project too, so pointing a session at `stagedctl connect` +/// would send it after a credential that does not exist. fn project_for_directory<'a>( projects: &'a [Project], project_id: &str, @@ -211,6 +225,18 @@ fn project_for_directory<'a>( covers it (see `stagedctl connect --help`)", names.join(", ") ), + (CredentialScope::Session, true) => format!( + "{dir} is in the workspace of project id {project_id}, which this agent \ + session's credential does not reach: it is confined to the session's own \ + project, and Staged lists it none. The project may still exist. \ + {SESSION_CANNOT_OPEN}" + ), + (CredentialScope::Session, false) => format!( + "{dir} is in the workspace of project id {project_id}, which this agent \ + session's credential does not reach: it is confined to the session's own \ + project ({}). The project may still exist. {SESSION_CANNOT_OPEN}", + names.join(", ") + ), }; CtlError::command("not_found", message) }) @@ -738,8 +764,7 @@ mod tests { #[test] fn a_directory_outside_a_confined_credentials_project_is_not_called_deleted() { - // A grant approved for one project (or a session's lease) is listed - // that project alone. A worktree of any other project is then + // A grant approved for one project is listed that project alone. A worktree of any other project is then // missing from the list whether or not the project exists, so the // message must not say Staged lacks it or that it was deleted. let err = open_target( @@ -780,6 +805,83 @@ mod tests { ); } + /// A session's lease is confined like a one-project grant and lands on + /// the same branch, but a grant is no remedy for it: the app refuses + /// `open_project` to every session. The message must not send it after + /// one. + #[test] + fn a_session_outside_its_project_is_not_told_to_use_a_grant() { + let err = open_target( + &directory("p7"), + 4242, + CredentialScope::Session, + || Ok(vec![widgets()]), + no_open, + ) + .unwrap_err(); + assert_eq!((err.exit_code(), err.code()), (1, "not_found")); + assert_eq!( + err.to_string(), + "not_found: /Users/me/.staged/workspaces/local/projects/p7/wt is in the workspace of \ + project id p7, which this agent session's credential does not reach: it is \ + confined to the session's own project (Widgets). The project may still exist. No \ + credential lets an agent session open it: the app refuses `project open` to every \ + session, for the session's own project too. Name the project for the user and \ + let them open it" + ); + + // The session's own project is gone, so it is listed none. + let listed_none = open_target( + &directory("p7"), + 4242, + CredentialScope::Session, + || Ok(vec![]), + no_open, + ) + .unwrap_err(); + assert_eq!( + (listed_none.exit_code(), listed_none.code()), + (1, "not_found") + ); + assert!( + listed_none.to_string().contains( + "confined to the session's own project, and Staged lists it none. The project \ + may still exist. No credential lets an agent session open it" + ), + "{listed_none}" + ); + + for err in [&err, &listed_none] { + let text = err.to_string(); + for wrong in ["grant", "stagedctl connect", "does not have", "deleted"] { + assert!(!text.contains(wrong), "must not say {wrong:?}: {text}"); + } + } + } + + /// The grant's remedy is its own: a grant that covers the project. + #[test] + fn a_one_project_grant_is_still_pointed_at_a_grant_that_covers_the_project() { + for projects in [vec![widgets()], vec![]] { + let err = open_target( + &directory("p7"), + 4242, + CredentialScope::Project, + || Ok(projects), + no_open, + ) + .unwrap_err(); + let text = err.to_string(); + assert!( + text.contains( + "run the command with a grant that covers it (see `stagedctl connect --help`)" + ), + "{text}" + ); + assert!(!text.contains("session"), "{text}"); + } + } + #[test] fn a_confined_credential_still_opens_its_own_projects_directory() { let mut sent = None; diff --git a/apps/staged/src-tauri/crates/stagedctl/src/tags.rs b/apps/staged/src-tauri/crates/stagedctl/src/tags.rs index 5a8c4bf66..270d53f5d 100644 --- a/apps/staged/src-tauri/crates/stagedctl/src/tags.rs +++ b/apps/staged/src-tauri/crates/stagedctl/src/tags.rs @@ -505,7 +505,7 @@ pub(crate) fn render_tag_show( pub fn usage_scope(scope: CredentialScope) -> &'static str { match scope { CredentialScope::AllProjects => "all", - CredentialScope::Project => "project", + CredentialScope::Project | CredentialScope::Session => "project", } } @@ -517,7 +517,9 @@ pub const USAGE_SCOPE_NOTE: &str = fn with_usage_scope_note(human: String, scope: CredentialScope) -> String { match scope { CredentialScope::AllProjects => human, - CredentialScope::Project => format!("{human}\n{USAGE_SCOPE_NOTE}"), + CredentialScope::Project | CredentialScope::Session => { + format!("{human}\n{USAGE_SCOPE_NOTE}") + } } } @@ -1700,10 +1702,14 @@ mod tests { fn usage_counts_say_when_they_cover_one_project_only() { assert_eq!(usage_scope(CredentialScope::AllProjects), "all"); assert_eq!(usage_scope(CredentialScope::Project), "project"); + // A session's lease reaches one project too, and reads the same. + assert_eq!(usage_scope(CredentialScope::Session), "project"); let text = with_usage_scope_note("Tags\n a".to_string(), CredentialScope::AllProjects); assert_eq!(text, "Tags\n a"); - let text = with_usage_scope_note("Tags\n a".to_string(), CredentialScope::Project); - assert_eq!(text, format!("Tags\n a\n{USAGE_SCOPE_NOTE}")); + for confined in [CredentialScope::Project, CredentialScope::Session] { + let text = with_usage_scope_note("Tags\n a".to_string(), confined); + assert_eq!(text, format!("Tags\n a\n{USAGE_SCOPE_NOTE}")); + } assert!( USAGE_SCOPE_NOTE.contains("only this credential's project"), "{USAGE_SCOPE_NOTE}" diff --git a/apps/staged/src-tauri/resources/stagedctl/guide.md b/apps/staged/src-tauri/resources/stagedctl/guide.md index 09104c2d6..fc31a83df 100644 --- a/apps/staged/src-tauri/resources/stagedctl/guide.md +++ b/apps/staged/src-tauri/resources/stagedctl/guide.md @@ -129,9 +129,10 @@ the user runs themselves: a terminal, a script, an agent they started. **Not from a spawned session.** The app refuses a session's credential (exit 4, `scope_denied`), for the session's own project too, so an agent -cannot pull the user's window away. Another project's name reads -`not_found` (exit 1) instead, because a session's credential lists only -its own project. Neither is a fault to retry or work around, and a +cannot pull the user's window away. Another project's name, or its +workspace as the current directory, reads `not_found` (exit 1) instead, +because a session's credential lists only its own project. Neither is a +fault to retry or work around, no other credential changes it, and a session never starts an app: with `$STAGEDCTL_LOCK` set, nothing is launched. If the user should look at a project, name it in your output and let them open it. @@ -154,9 +155,11 @@ Outside a session: - `staged [ | ] [--no-launch]` is the same command, where the `staged` launcher script is installed. It hands its arguments to `stagedctl project open` unchanged, except that a single argument - that is an existing directory (`staged .`) runs the command from that - directory with no project. It exits 127, starting nothing, when it - cannot find `stagedctl`. + written as a path (`.`, `..`, or anything with a slash, such as + `staged ./repo`) that is an existing directory runs the command from + that directory with no project. A bare word is always a project name, + even beside a directory of that name. It exits 127, starting nothing, + when it cannot find `stagedctl`. On success it prints `opened id: pid:`, the pid being the Staged instance that showed the project (`--json`: `{ outcome, diff --git a/apps/staged/src-tauri/src/app_lifecycle.rs b/apps/staged/src-tauri/src/app_lifecycle.rs index 7ffad52ed..9a7ef7f55 100644 --- a/apps/staged/src-tauri/src/app_lifecycle.rs +++ b/apps/staged/src-tauri/src/app_lifecycle.rs @@ -68,9 +68,11 @@ use crate::session_runner::SessionRegistry; use crate::store::{CompletionReason, Session, SessionStatus, Store}; pub(crate) mod window_raise; +pub mod window_reveal; pub(crate) mod window_use; pub use window_raise::show_a_window; +pub use window_reveal::RevealedWindows; pub use window_use::UsedWindows; /// Title of the quit confirmation alert. @@ -225,14 +227,18 @@ fn should_prompt(blockers: &QuitBlockers) -> bool { /// /// Also keeps the record of used windows ([`UsedWindows`]) in step with focus, /// which is how a reveal finds the window the user was last in once Staged is -/// no longer the frontmost app. +/// no longer the frontmost app, and drops a destroyed window from it and from +/// the record of revealed windows ([`RevealedWindows`]). pub fn on_window_event(window: &Window, event: &WindowEvent) { match event { WindowEvent::CloseRequested { api, .. } => on_close_requested(window, api), WindowEvent::Focused(true) => { window_use::note_window_used(window.app_handle(), window.label()); } - WindowEvent::Destroyed => window_use::forget_window(window.app_handle(), window.label()), + WindowEvent::Destroyed => { + window_use::forget_window(window.app_handle(), window.label()); + window_reveal::forget_window(window.app_handle(), window.label()); + } _ => {} } } @@ -284,12 +290,6 @@ fn hide_window(window: &Window) { return; } set_native_focus(window.app_handle(), window.label(), false); - // The user closed it, so it has been on screen. Normally it held focus - // first and is on the record already, but a window's close button works - // without the window taking focus. Without this, a window closed that way - // would read as one still loading, which nothing but its own frontend may - // show (`window_raise::raise_window_once_revealed`). - window_use::note_window_used(window.app_handle(), window.label()); } /// Mirror a native window's visibility onto its PR-poll client's focus hint. diff --git a/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs b/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs index 16ed79b03..6d221153c 100644 --- a/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs +++ b/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs @@ -5,25 +5,17 @@ //! *a* window on screen. A caller that has something to say to the window it //! lands on picks first, so it knows which window that is before raising it. //! One that may arrive while the window is still loading raises with -//! [`raise_window_once_revealed`], which leaves the first reveal to the -//! window's own frontend. +//! [`raise_window_once_revealed`](super::window_reveal::raise_window_once_revealed), +//! which leaves the first reveal to the window's own frontend. use std::collections::HashMap; -use std::time::Duration; use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; +use super::window_reveal::note_window_shown; use super::window_use::{note_window_used, UsedWindows}; use super::{set_native_focus, MAIN_WINDOW_LABEL}; -/// How often a window that was still loading is checked for having been -/// revealed. Nothing reports a window going on screen, so it is polled. -const REVEAL_POLL: Duration = Duration::from_millis(100); - -/// How long a window that was still loading is watched before the raise it is -/// owed is dropped. Far longer than a cold start takes to reach its reveal. -const REVEAL_WAIT: Duration = Duration::from_secs(60); - /// Bring a window back on screen: the Dock-icon click and `Window ▸ Staged` /// both funnel here. Deliberately *not* on the quit path — see the /// [`app_lifecycle`](super) module docs. @@ -104,7 +96,8 @@ fn fixed_rank(label: &str) -> (u8, usize, &str) { } /// Show, unminimize and focus `window`, put its PR-poll client back on the -/// focused tier, and record it as the window used most recently. +/// focused tier, and record it as revealed and as the window used most +/// recently. /// /// Best effort: each step that fails is logged and the rest still run, so a /// window that can't take focus is at least left on screen. @@ -119,77 +112,17 @@ pub(crate) fn raise_window(window: &WebviewWindow) { log::warn!("Failed to focus window: {e}"); } set_native_focus(window.app_handle(), window.label(), true); + // Shown by the backend, so there is no reveal left to wait for, whether + // or not its frontend has reported one. + note_window_shown(window.app_handle(), window.label()); // Recorded here as well as on the focus event, which does not come when // the window could not take focus. note_window_used(window.app_handle(), window.label()); } -/// Whether `window` is on screen: visible, or minimized to the Dock. A window -/// its frontend has not revealed yet is neither. -pub(crate) fn is_on_screen(window: &WebviewWindow) -> bool { - window.is_visible().unwrap_or(false) || window.is_minimized().unwrap_or(false) -} - -/// [`raise_window`] for a caller that may be ahead of the window's frontend. -/// -/// Every window is created hidden, and its frontend shows it once the theme -/// is applied; showing it sooner flashes an unpainted webview. A request that -/// arrives during a cold start must not do that. So: -/// -/// - A window its frontend has revealed is raised now. That is one on screen -/// (`is_on_screen`), or one the user has used before ([`UsedWindows`]), -/// which covers a window hidden since: the last window closed to hide, or -/// a window of an app hidden with `Cmd+H`. -/// - Any other window is still loading. It is left alone and raised once it -/// comes on screen, so the frontend's own reveal is the first, and the -/// window still ends up in front and focused however long that took. The -/// raise is dropped if the window is destroyed first or has not appeared -/// within [`REVEAL_WAIT`]. -/// -/// `is_on_screen` is [`is_on_screen`] outside tests. The mock runtime calls -/// every window visible, so the tests pass their own. -/// -/// Call it from within a tokio runtime: the wait for a loading window is a -/// spawned task. -pub(crate) fn raise_window_once_revealed( - window: &WebviewWindow, - is_on_screen: impl Fn(&WebviewWindow) -> bool + Send + 'static, -) { - let used = window - .app_handle() - .try_state::() - .is_some_and(|used| used.contains(window.label())); - if used || is_on_screen(window) { - raise_window(window); - return; - } - - let window = window.clone(); - tokio::spawn(async move { - let label = window.label(); - let give_up_at = tokio::time::Instant::now() + REVEAL_WAIT; - loop { - tokio::time::sleep(REVEAL_POLL).await; - if window.app_handle().get_webview_window(label).is_none() { - return; - } - if is_on_screen(&window) { - raise_window(&window); - return; - } - if tokio::time::Instant::now() >= give_up_at { - log::warn!("Window {label} was not revealed in time; leaving it unraised"); - return; - } - } - }); -} - #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; /// A mock app with one window per label and an empty record of used @@ -232,15 +165,6 @@ mod tests { app.state::().most_recent_first() } - /// An `is_on_screen` probe the test flips, standing in for the frontend - /// showing its window. - fn on_screen_when( - shown: &Arc, - ) -> impl Fn(&WebviewWindow) -> bool + Send + 'static { - let shown = Arc::clone(shown); - move |_| shown.load(Ordering::SeqCst) - } - #[test] fn the_focused_window_wins_over_the_last_used_and_visible_ones() { let app = mock_app(&["main", "win-2", "win-3"]); @@ -397,78 +321,4 @@ mod tests { assert_eq!(used(&app), ["main", "win-2"]); } - - /// No tokio runtime here: the raise is made on the spot, with no wait - /// spawned. - #[test] - fn a_window_on_screen_is_raised_at_once() { - let app = mock_app(&["main"]); - let window = app.get_webview_window("main").unwrap(); - - raise_window_once_revealed(&window, |_| true); - - assert_eq!(used(&app), ["main"]); - } - - /// The mock runtime's windows are visible, so the real probe takes the - /// same path. - #[test] - fn the_real_probe_reads_the_windows_own_state() { - let app = mock_app(&["main"]); - let window = app.get_webview_window("main").unwrap(); - assert!(is_on_screen(&window)); - - raise_window_once_revealed(&window, is_on_screen); - - assert_eq!(used(&app), ["main"]); - } - - /// A window the user has used was revealed long ago. Off screen now, as - /// the last window is once closed to hide, it is waiting for exactly this - /// raise, and nothing else would show it. - #[test] - fn a_used_window_that_is_hidden_is_raised_at_once() { - let app = mock_app(&["main", "win-2"]); - let record = app.state::(); - record.note("main"); - record.note("win-2"); - - raise_window_once_revealed(&app.get_webview_window("main").unwrap(), |_| false); - - assert_eq!(used(&app), ["main", "win-2"]); - } - - /// The cold start: the window is hidden until its frontend shows it, and - /// the backend must not get there first. - #[tokio::test(start_paused = true)] - async fn a_window_still_loading_is_raised_only_once_its_frontend_shows_it() { - let app = mock_app(&["main"]); - let window = app.get_webview_window("main").unwrap(); - let shown = Arc::new(AtomicBool::new(false)); - - raise_window_once_revealed(&window, on_screen_when(&shown)); - assert!(used(&app).is_empty(), "not raised while it is loading"); - - tokio::time::sleep(REVEAL_POLL * 20).await; - assert!(used(&app).is_empty(), "still loading, still not raised"); - - shown.store(true, Ordering::SeqCst); - tokio::time::sleep(REVEAL_POLL * 2).await; - assert_eq!(used(&app), ["main"], "raised once its frontend showed it"); - } - - #[tokio::test(start_paused = true)] - async fn a_window_that_is_never_revealed_is_given_up_on() { - let app = mock_app(&["main"]); - let window = app.get_webview_window("main").unwrap(); - let shown = Arc::new(AtomicBool::new(false)); - - raise_window_once_revealed(&window, on_screen_when(&shown)); - tokio::time::sleep(REVEAL_WAIT + REVEAL_POLL * 2).await; - - // Too late: the wait is over, so nothing raises it now. - shown.store(true, Ordering::SeqCst); - tokio::time::sleep(REVEAL_POLL * 20).await; - assert!(used(&app).is_empty()); - } } diff --git a/apps/staged/src-tauri/src/app_lifecycle/window_reveal.rs b/apps/staged/src-tauri/src/app_lifecycle/window_reveal.rs new file mode 100644 index 000000000..daa31a75e --- /dev/null +++ b/apps/staged/src-tauri/src/app_lifecycle/window_reveal.rs @@ -0,0 +1,442 @@ +//! Which windows have been revealed, and which are owed a raise once they are. +//! +//! Every window is created hidden. Its frontend shows it at the end of mount, +//! once the theme is applied, and then says so with [`note_window_revealed`]. +//! Showing it sooner flashes an unpainted webview, so a caller that may be +//! ahead of the frontend raises with [`raise_window_once_revealed`]: +//! +//! - A window on the record is raised on the spot, whatever it looks like +//! now: hidden, minimized, never focused, or in an app that is hidden. +//! - Any other window is still loading. It is left alone, and the raise it is +//! owed is made when its frontend reports the reveal. Nothing polls for it. +//! +//! The record is told, not inferred. Whether a window is visible now, or has +//! held focus, does not say whether its frontend has revealed it: a window +//! revealed while Staged was in the background and hidden since is neither, +//! and would be waited on for a reveal that already happened. +//! +//! A window goes on the record when its frontend reports the reveal, and when +//! the backend shows it itself ([`raise_window`]): from then on there is no +//! first paint left to wait for. It comes off when it is destroyed. + +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use tauri::{AppHandle, Manager, Runtime, WebviewWindow}; + +use super::window_raise::raise_window; + +/// How long a raise stays owed to a window that was still loading. Far longer +/// than a cold start takes to reach its reveal. A reveal that comes later +/// than this raises nothing: whoever asked has long since moved on, and +/// pulling Staged in front of them then would be a surprise. +pub(crate) const REVEAL_WAIT: Duration = Duration::from_secs(60); + +/// Labels of the windows that have been revealed, and of those owed a raise +/// once they are. Managed as Tauri state, in memory only: window labels do +/// not outlive the process. +#[derive(Default)] +pub struct RevealedWindows { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + revealed: HashSet, + /// Windows owed a raise once revealed, each with the moment the raise + /// stops being wanted. + owed: HashMap, +} + +impl RevealedWindows { + /// Record `label` as revealed. Returns whether it was owed a raise that + /// is still wanted at `now`; either way nothing is owed to it any more. + pub(crate) fn note(&self, label: &str, now: Instant) -> bool { + let mut inner = self.inner.lock().unwrap(); + inner.revealed.insert(label.to_string()); + inner.owed.remove(label).is_some_and(|until| now < until) + } + + /// Whether `label` has been revealed. When it has not, it is owed a raise + /// if the reveal comes before `until`. + /// + /// One step under one lock, so a reveal reported at the same moment is + /// either seen here or finds the raise owed. + pub(crate) fn contains_or_owe(&self, label: &str, until: Instant) -> bool { + let mut inner = self.inner.lock().unwrap(); + if inner.revealed.contains(label) { + return true; + } + inner.owed.insert(label.to_string(), until); + false + } + + /// Drop a destroyed window from the record, with any raise it was owed. + pub(crate) fn forget(&self, label: &str) { + let mut inner = self.inner.lock().unwrap(); + inner.revealed.remove(label); + inner.owed.remove(label); + } + + /// Whether `label` has been revealed. + #[cfg(test)] + pub(crate) fn contains(&self, label: &str) -> bool { + self.inner.lock().unwrap().revealed.contains(label) + } +} + +/// The calling window's frontend has shown it: the theme is applied and the +/// window is on screen. Sent once per page load, right after the frontend's +/// own `show()`. +/// +/// Records the window as revealed and makes the raise it is owed, if a +/// `stagedctl project open` arrived while it was loading. Acts on the calling +/// window only. Desktop only: `web_server::dispatch` refuses it, since a +/// browser client has no native window, and it stays out of +/// `control_server::ALLOWED_COMMANDS`, so no stagedctl caller can have a +/// loading window shown early. +/// +/// Generic over the runtime so the tests below can call it with windows from +/// tauri's mock runtime; `generate_handler!` instantiates it with `Wry`. +#[tauri::command] +pub fn note_window_revealed(window: WebviewWindow) { + window_revealed_at(&window, Instant::now()); +} + +/// [`note_window_revealed`] with the clock passed in. +fn window_revealed_at(window: &WebviewWindow, now: Instant) { + let owed = window + .app_handle() + .try_state::() + .is_some_and(|revealed| revealed.note(window.label(), now)); + if owed { + // The frontend's `show()` put the window on screen. It did not bring + // Staged forward if the user had gone back to another app meanwhile. + raise_window(window); + } +} + +/// [`raise_window`] for a caller that may be ahead of the window's frontend. +/// +/// A window that has been revealed is raised now. One that is still loading +/// is left alone and raised when its frontend reports the reveal +/// ([`note_window_revealed`]), so the frontend's own reveal is the first and +/// the window still ends up in front and focused. That raise is dropped if +/// the window is destroyed first, or is revealed more than [`REVEAL_WAIT`] +/// from now. +/// +/// On an app that manages no record there is nothing to defer against, and +/// the window is raised now. +pub(crate) fn raise_window_once_revealed(window: &WebviewWindow) { + raise_window_once_revealed_at(window, Instant::now()); +} + +/// [`raise_window_once_revealed`] with the clock passed in. +fn raise_window_once_revealed_at(window: &WebviewWindow, now: Instant) { + let revealed = match window.app_handle().try_state::() { + Some(revealed) => revealed.contains_or_owe(window.label(), now + REVEAL_WAIT), + None => true, + }; + if revealed { + raise_window(window); + } +} + +/// Record a window the backend has shown itself as revealed. A raise that was +/// owed to it has just been made, so nothing is owed to it any more. A no-op +/// on an app that manages no record, as the mock apps in most tests do not. +pub(crate) fn note_window_shown(app: &AppHandle, label: &str) { + if let Some(revealed) = app.try_state::() { + revealed.note(label, Instant::now()); + } +} + +/// [`RevealedWindows::forget`] on the app's record. +pub(crate) fn forget_window(app: &AppHandle, label: &str) { + if let Some(revealed) = app.try_state::() { + revealed.forget(label); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_lifecycle::window_use::UsedWindows; + use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; + + /// A mock app with one window per label, none of them revealed or used. + /// + /// The mock runtime calls every window visible, so a test here that + /// leaves a window unraised also shows that visibility is not consulted. + fn mock_app(labels: &[&str]) -> tauri::App { + let app = mock_builder() + .build(mock_context(noop_assets())) + .expect("failed to build mock app"); + app.manage(RevealedWindows::default()); + app.manage(UsedWindows::default()); + for label in labels { + tauri::WebviewWindowBuilder::new(&app, *label, Default::default()) + .build() + .expect("failed to create mock window"); + } + app + } + + fn window(app: &tauri::App, label: &str) -> WebviewWindow { + app.get_webview_window(label).expect("window should exist") + } + + /// The windows raised so far, most recent first: a raise puts its window + /// at the head of the record of used windows, and nothing else here does. + fn raised(app: &tauri::App) -> Vec { + app.state::().most_recent_first() + } + + #[test] + fn a_window_is_revealed_only_once_noted() { + let record = RevealedWindows::default(); + assert!(!record.contains("main")); + + record.note("main", Instant::now()); + + assert!(record.contains("main")); + assert!(!record.contains("win-2")); + } + + #[test] + fn a_reveal_with_nothing_owed_asks_for_no_raise() { + let record = RevealedWindows::default(); + + assert!(!record.note("main", Instant::now())); + } + + #[test] + fn a_reveal_in_time_asks_for_the_raise_it_was_owed_once() { + let record = RevealedWindows::default(); + let now = Instant::now(); + + assert!(!record.contains_or_owe("main", now + REVEAL_WAIT)); + assert!(record.note("main", now + Duration::from_secs(5))); + assert!( + !record.note("main", now + Duration::from_secs(6)), + "the raise is owed once, not on every reveal" + ); + } + + #[test] + fn a_reveal_that_comes_too_late_asks_for_no_raise() { + let record = RevealedWindows::default(); + let now = Instant::now(); + + assert!(!record.contains_or_owe("main", now + REVEAL_WAIT)); + assert!(!record.note("main", now + REVEAL_WAIT)); + assert!(record.contains("main"), "late, but revealed all the same"); + } + + /// A second request for a window still loading moves the deadline rather + /// than owing two raises. + #[test] + fn a_newer_request_extends_the_wait() { + let record = RevealedWindows::default(); + let now = Instant::now(); + let later = now + Duration::from_secs(30); + + assert!(!record.contains_or_owe("main", now + REVEAL_WAIT)); + assert!(!record.contains_or_owe("main", later + REVEAL_WAIT)); + assert!(record.note("main", now + REVEAL_WAIT + Duration::from_secs(1))); + assert!(!record.note("main", now + REVEAL_WAIT + Duration::from_secs(2))); + } + + #[test] + fn a_revealed_window_is_owed_nothing() { + let record = RevealedWindows::default(); + let now = Instant::now(); + record.note("main", now); + + assert!(record.contains_or_owe("main", now + REVEAL_WAIT)); + assert!(!record.note("main", now), "nothing was left owed"); + } + + #[test] + fn a_forgotten_window_is_off_the_record_and_owed_nothing() { + let record = RevealedWindows::default(); + let now = Instant::now(); + record.note("main", now); + assert!(!record.contains_or_owe("win-2", now + REVEAL_WAIT)); + + record.forget("main"); + record.forget("win-2"); + // Forgetting a window never seen is harmless. + record.forget("win-3"); + + assert!(!record.contains("main")); + assert!(!record.note("win-2", now), "the raise went with the window"); + } + + #[test] + fn a_revealed_window_is_raised_at_once() { + let app = mock_app(&["main"]); + note_window_revealed(window(&app, "main")); + assert!(raised(&app).is_empty(), "a reveal alone raises nothing"); + + raise_window_once_revealed(&window(&app, "main")); + + assert_eq!(raised(&app), ["main"]); + } + + /// The regression this record closes. A window revealed while Staged was + /// in the background has never held focus, so it is not on the record of + /// used windows, and once hidden it is not on screen either. Its frontend + /// revealed it all the same, so it is raised now, not waited on for a + /// reveal that will never come again. + #[test] + fn a_revealed_window_that_was_never_used_is_raised_at_once() { + let app = mock_app(&["main", "win-2"]); + note_window_revealed(window(&app, "win-2")); + assert!(!app.state::().contains("win-2")); + + raise_window_once_revealed(&window(&app, "win-2")); + + assert_eq!(raised(&app), ["win-2"]); + } + + /// The cold start: the window is hidden until its frontend shows it, and + /// the backend must not get there first. + #[test] + fn a_window_still_loading_is_raised_only_once_its_frontend_reveals_it() { + let app = mock_app(&["main"]); + + raise_window_once_revealed(&window(&app, "main")); + assert!(raised(&app).is_empty(), "not raised while it is loading"); + + note_window_revealed(window(&app, "main")); + assert_eq!(raised(&app), ["main"], "raised as its frontend revealed it"); + } + + /// Having held focus is not a reveal. Nothing but the record decides. + #[test] + fn a_used_window_that_is_not_revealed_is_still_waited_on() { + let app = mock_app(&["main", "win-2"]); + let used = app.state::(); + used.note("main"); + used.note("win-2"); + + raise_window_once_revealed(&window(&app, "main")); + assert_eq!(raised(&app), ["win-2", "main"], "main was not raised"); + + note_window_revealed(window(&app, "main")); + assert_eq!(raised(&app), ["main", "win-2"]); + } + + #[test] + fn a_window_revealed_too_late_is_not_raised() { + let app = mock_app(&["main"]); + let now = Instant::now(); + + raise_window_once_revealed_at(&window(&app, "main"), now); + window_revealed_at(&window(&app, "main"), now + REVEAL_WAIT); + + assert!(raised(&app).is_empty()); + // It is revealed, though, so the next request raises it at once. + raise_window_once_revealed(&window(&app, "main")); + assert_eq!(raised(&app), ["main"]); + } + + #[test] + fn a_window_revealed_just_in_time_is_raised() { + let app = mock_app(&["main"]); + let now = Instant::now(); + + raise_window_once_revealed_at(&window(&app, "main"), now); + window_revealed_at( + &window(&app, "main"), + now + REVEAL_WAIT - Duration::from_millis(1), + ); + + assert_eq!(raised(&app), ["main"]); + } + + /// The raise is owed to one window. Another window's reveal does not make + /// it, and is not raised itself. + #[test] + fn a_reveal_raises_only_its_own_window() { + let app = mock_app(&["main", "win-2"]); + raise_window_once_revealed(&window(&app, "main")); + + note_window_revealed(window(&app, "win-2")); + assert!(raised(&app).is_empty()); + + note_window_revealed(window(&app, "main")); + assert_eq!(raised(&app), ["main"]); + } + + /// A page reload runs the frontend's mount again, and with it the reveal. + #[test] + fn a_repeated_reveal_raises_nothing_more() { + let app = mock_app(&["main", "win-2"]); + raise_window_once_revealed(&window(&app, "main")); + note_window_revealed(window(&app, "main")); + raise_window(&window(&app, "win-2")); + + note_window_revealed(window(&app, "main")); + + assert_eq!(raised(&app), ["win-2", "main"]); + } + + /// A Dock click raises a window without waiting for its frontend. There + /// is then no first paint left to protect, and the raise owed has been + /// made: the frontend's reveal must not pull the window forward again. + #[test] + fn a_window_the_backend_showed_is_revealed_and_owed_nothing() { + let app = mock_app(&["main", "win-2"]); + raise_window_once_revealed(&window(&app, "main")); + + raise_window(&window(&app, "main")); + assert!(app.state::().contains("main")); + raise_window(&window(&app, "win-2")); + + note_window_revealed(window(&app, "main")); + assert_eq!(raised(&app), ["win-2", "main"], "main was not raised twice"); + + // And a later request raises it at once. + raise_window_once_revealed(&window(&app, "main")); + assert_eq!(raised(&app), ["main", "win-2"]); + } + + #[test] + fn a_destroyed_window_is_owed_nothing_and_is_no_longer_revealed() { + let app = mock_app(&["main", "win-2"]); + note_window_revealed(window(&app, "win-2")); + raise_window_once_revealed(&window(&app, "main")); + + forget_window(app.handle(), "main"); + forget_window(app.handle(), "win-2"); + + // The mock runtime keeps a destroyed window, so its reveal can still + // be sent: nothing is owed to it now. + note_window_revealed(window(&app, "main")); + assert!(raised(&app).is_empty()); + assert!(!app.state::().contains("win-2")); + } + + /// Without a record there is nothing to defer against. Raising is the + /// behaviour every other caller of [`raise_window`] has. + #[test] + fn with_no_record_the_window_is_raised_at_once() { + let app = mock_builder() + .build(mock_context(noop_assets())) + .expect("failed to build mock app"); + app.manage(UsedWindows::default()); + let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .expect("failed to create mock window"); + + raise_window_once_revealed(&window); + assert_eq!(raised(&app), ["main"]); + + // And the reveal has nowhere to go, which is harmless. + note_window_revealed(window); + forget_window(app.handle(), "main"); + } +} diff --git a/apps/staged/src-tauri/src/app_lifecycle/window_use.rs b/apps/staged/src-tauri/src/app_lifecycle/window_use.rs index 0a8882846..3a3e45de2 100644 --- a/apps/staged/src-tauri/src/app_lifecycle/window_use.rs +++ b/apps/staged/src-tauri/src/app_lifecycle/window_use.rs @@ -1,21 +1,18 @@ //! Which windows the user has used, and in what order. //! //! Nothing in tauri reports "the window the user was last in" once Staged is -//! not the frontmost app, and nothing reports a window going on screen. Both -//! are read off this record instead: +//! not the frontmost app. It is read off this record instead: +//! [`pick_window_to_show`](super::window_raise::pick_window_to_show) lands on +//! the most recently used window when none holds focus, which is the normal +//! state for a request that comes from a terminal. //! -//! - [`pick_window_to_show`](super::window_raise::pick_window_to_show) lands -//! on the most recently used window when none holds focus, which is the -//! normal state for a request that comes from a terminal. -//! - [`raise_window_once_revealed`](super::window_raise::raise_window_once_revealed) -//! takes a window that is on the record as one its frontend has already -//! revealed. Every window is created hidden and shown by its own frontend. -//! It cannot be focused or closed before that, and one the backend raises -//! is on screen from then on. +//! A window goes on the record when it gains focus and when the backend +//! raises it. It comes off when it is destroyed. //! -//! A window goes on the record when it gains focus, when the last window is -//! closed to hide, and when the backend raises it. It comes off when it is -//! destroyed. +//! The record says where the user was, and nothing else. Whether a window's +//! frontend has revealed it is a separate record, +//! [`RevealedWindows`](super::window_reveal::RevealedWindows): a window can +//! be revealed without ever having held focus. use std::sync::Mutex; @@ -41,7 +38,8 @@ impl UsedWindows { self.order.lock().unwrap().retain(|known| known != label); } - /// Whether `label` has been used, and so has been on screen. + /// Whether `label` has been used. + #[cfg(test)] pub(crate) fn contains(&self, label: &str) -> bool { self.order .lock() diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 6878b4bd6..8d6642072 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -2150,6 +2150,7 @@ pub fn run() { app.manage(action_registry); app.manage(app_lifecycle::QuitState::default()); app.manage(app_lifecycle::UsedWindows::default()); + app.manage(app_lifecycle::RevealedWindows::default()); app.manage(launch_resume::LaunchResumeState::default()); app.manage(DbState { db_path, @@ -2324,6 +2325,9 @@ pub fn run() { window_commands::new_window, window_commands::take_window_seed, window_commands::claim_updater_ownership, + // Window reveal — desktop only; refused in web-mode `dispatch`, + // where there is no native window to reveal. + app_lifecycle::window_reveal::note_window_revealed, // Lifecycle — desktop only; the web-mode `dispatch` table refuses // this so a browser client can't quit the host. app_lifecycle::quit_app, diff --git a/apps/staged/src-tauri/src/project_open/control.rs b/apps/staged/src-tauri/src/project_open/control.rs index 0a5789c3d..9bccb4583 100644 --- a/apps/staged/src-tauri/src/project_open/control.rs +++ b/apps/staged/src-tauri/src/project_open/control.rs @@ -12,8 +12,8 @@ //! **A window still loading is not shown early.** On a cold start the request //! arrives while the window is hidden, waiting for its frontend to apply the //! theme and show it. The request is parked for it all the same, but the -//! raise waits for that reveal -//! ([`raise_window_once_revealed`](crate::app_lifecycle::window_raise::raise_window_once_revealed)), +//! raise waits for the window's frontend to report that reveal +//! ([`raise_window_once_revealed`](crate::app_lifecycle::window_reveal::raise_window_once_revealed)), //! which may come after the window has answered. //! //! **A refusal is a result, not an error.** The answer goes back as @@ -28,12 +28,11 @@ use std::time::Duration; use serde_json::{json, Value}; -use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow}; +use tauri::{AppHandle, Emitter, Manager, Runtime}; use super::{ProjectOpenAnswer, ProjectOpenOutcome, ProjectOpenState, ANSWER_TIMEOUT}; -use crate::app_lifecycle::window_raise::{ - is_on_screen, pick_window_to_show, raise_window_once_revealed, -}; +use crate::app_lifecycle::window_raise::pick_window_to_show; +use crate::app_lifecycle::window_reveal::raise_window_once_revealed; use crate::store::Store; /// Emitted to one window, by label, with the @@ -47,18 +46,15 @@ pub(crate) async fn open_project( store: &Store, project_id: &str, ) -> Result { - open_project_within(app, store, project_id, ANSWER_TIMEOUT, is_on_screen).await + open_project_within(app, store, project_id, ANSWER_TIMEOUT).await } -/// [`open_project`] with the wait passed in, and the probe that says whether -/// a window is on screen: the mock runtime calls every window visible, so the -/// tests pass their own to stand in for one that is still loading. +/// [`open_project`] with the wait passed in. async fn open_project_within( app: &AppHandle, store: &Store, project_id: &str, timeout: Duration, - is_on_screen: impl Fn(&WebviewWindow) -> bool + Send + 'static, ) -> Result { // Before any window is touched: a typo must not pull Staged to the front. store @@ -88,7 +84,7 @@ async fn open_project_within( // then showing the user the dialog or comment that is in the way. A // window its frontend has not revealed yet is raised when it has been, // not now, so this never shows an unpainted webview. - raise_window_once_revealed(&window, is_on_screen); + raise_window_once_revealed(&window); let outcome = pending.wait(timeout).await; Ok(json!({ "outcome": outcome_name(outcome), "window": label })) @@ -110,23 +106,36 @@ fn outcome_name(outcome: ProjectOpenOutcome) -> &'static str { #[cfg(test)] mod tests { use super::*; - use crate::app_lifecycle::UsedWindows; + use crate::app_lifecycle::window_reveal::note_window_revealed; + use crate::app_lifecycle::{RevealedWindows, UsedWindows}; use crate::project_open::ProjectOpenRequest; use crate::store::Project; - use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; use tauri::Listener; /// A mock app managing the state and an empty record of used windows, /// with one window per label, a store holding one project, and that - /// project's id. + /// project's id. Every window's frontend has revealed it, as in an app + /// that finished loading a while ago. fn fixture(labels: &[&str]) -> (tauri::App, Store, String) { + let (app, store, project_id) = loading_fixture(labels); + for label in labels { + reveal(&app, label); + } + (app, store, project_id) + } + + /// [`fixture`] during a cold start: no window's frontend has revealed it + /// yet. The mock runtime calls these windows visible all the same, so a + /// raise held back here is held back by the record alone. + fn loading_fixture(labels: &[&str]) -> (tauri::App, Store, String) { let app = mock_builder() .build(mock_context(noop_assets())) .expect("failed to build mock app"); app.manage(ProjectOpenState::default()); app.manage(UsedWindows::default()); + app.manage(RevealedWindows::default()); for label in labels { tauri::WebviewWindowBuilder::new(&app, *label, Default::default()) .build() @@ -138,6 +147,12 @@ mod tests { (app, store, project.id) } + /// `label`'s frontend shows its window and says so, as it does at the end + /// of mount. + fn reveal(app: &tauri::App, label: &str) { + note_window_revealed(app.get_webview_window(label).expect("window should exist")); + } + /// The requests `label`'s window hears, in order. fn heard_by(app: &tauri::App, label: &str) -> Arc>> { let heard = Arc::new(Mutex::new(Vec::new())); @@ -276,9 +291,10 @@ mod tests { /// The window is raised as it is asked, and so becomes the last used. #[tokio::test] - async fn a_window_on_screen_is_raised_before_it_answers() { + async fn a_revealed_window_is_raised_before_it_answers() { let (app, store, project_id) = fixture(&["main"]); let used = app.state::(); + assert!(!used.contains("main"), "a reveal alone raises nothing"); let (result, ()) = tokio::join!(open_project(app.handle(), &store, &project_id), async { assert!(used.contains("main"), "raised before any answer"); @@ -291,24 +307,39 @@ mod tests { ); } + /// The window was revealed while Staged was in the background, so it has + /// never held focus and is on no record of used windows. `opened` must + /// still mean Staged came forward: the raise is made at once, not left + /// waiting on a reveal that already happened. + #[tokio::test] + async fn a_revealed_window_that_never_held_focus_is_raised_at_once() { + let (app, store, project_id) = fixture(&["main", "win-2"]); + let used = app.state::(); + assert!(used.most_recent_first().is_empty()); + + let (result, ()) = tokio::join!(open_project(app.handle(), &store, &project_id), async { + assert_eq!(used.most_recent_first(), ["main"], "raised as it is asked"); + answer(&app, "main", ProjectOpenAnswer::Opened) + }); + + assert_eq!( + result.unwrap(), + json!({ "outcome": "opened", "window": "main" }) + ); + } + /// The cold start. The window is hidden until its frontend shows it, so /// the request is parked and answered as usual, but nothing is raised /// until that reveal, which here comes after the answer. #[tokio::test(start_paused = true)] async fn a_window_still_loading_is_asked_at_once_and_raised_only_when_revealed() { - let (app, store, project_id) = fixture(&["main"]); + let (app, store, project_id) = loading_fixture(&["main"]); let heard = heard_by(&app, "main"); let state = app.state::(); let used = app.state::(); - let shown = Arc::new(AtomicBool::new(false)); - let is_shown = { - let shown = Arc::clone(&shown); - move |_: &WebviewWindow| shown.load(Ordering::SeqCst) - }; - - let (result, parked) = tokio::join!( - open_project_within(app.handle(), &store, &project_id, ANSWER_TIMEOUT, is_shown), - async { + + let (result, parked) = + tokio::join!(open_project(app.handle(), &store, &project_id), async { // What the loading window's snapshot will offer it. let parked = state .pending_for("main") @@ -319,8 +350,7 @@ mod tests { assert!(!used.contains("main"), "still not raised"); assert!(state.answer("main", &parked.request_id, ProjectOpenAnswer::Opened)); parked - } - ); + }); assert_eq!( result.unwrap(), @@ -332,26 +362,40 @@ mod tests { "answering is not the reveal: the frontend has not shown the window" ); - // The frontend shows its window; the raise that was owed follows. - shown.store(true, Ordering::SeqCst); - tokio::time::sleep(Duration::from_secs(1)).await; + // The frontend shows its window and says so; the raise that was owed + // is made there and then. + reveal(&app, "main"); assert!(used.contains("main"), "raised once revealed"); } + /// A reveal that lands while the caller is still waiting raises the + /// window then, ahead of the answer. + #[tokio::test] + async fn a_window_revealed_while_its_caller_waits_is_raised_before_it_answers() { + let (app, store, project_id) = loading_fixture(&["main"]); + let used = app.state::(); + + let (result, ()) = tokio::join!(open_project(app.handle(), &store, &project_id), async { + assert!(!used.contains("main"), "not raised while it is loading"); + reveal(&app, "main"); + assert!(used.contains("main"), "raised as it was revealed"); + answer(&app, "main", ProjectOpenAnswer::Opened) + }); + + assert_eq!( + result.unwrap(), + json!({ "outcome": "opened", "window": "main" }) + ); + } + /// A window that does not answer in time is still owed its raise: the caller reads /// the timeout, and the window comes to the front when it does appear. #[tokio::test(start_paused = true)] async fn a_loading_window_whose_caller_timed_out_is_still_raised_when_revealed() { - let (app, store, project_id) = fixture(&["main"]); + let (app, store, project_id) = loading_fixture(&["main"]); let used = app.state::(); - let shown = Arc::new(AtomicBool::new(false)); - let is_shown = { - let shown = Arc::clone(&shown); - move |_: &WebviewWindow| shown.load(Ordering::SeqCst) - }; - let result = - open_project_within(app.handle(), &store, &project_id, ANSWER_TIMEOUT, is_shown).await; + let result = open_project(app.handle(), &store, &project_id).await; assert_eq!( result.unwrap(), @@ -359,11 +403,30 @@ mod tests { ); assert!(!used.contains("main")); - shown.store(true, Ordering::SeqCst); - tokio::time::sleep(Duration::from_secs(1)).await; + reveal(&app, "main"); assert!(used.contains("main")); } + /// Only the window the request was parked for is owed the raise. + #[tokio::test] + async fn another_windows_reveal_does_not_raise_the_loading_one() { + let (app, store, project_id) = loading_fixture(&["main", "win-2"]); + let used = app.state::(); + + let (result, ()) = tokio::join!(open_project(app.handle(), &store, &project_id), async { + reveal(&app, "win-2"); + assert!(used.most_recent_first().is_empty()); + answer(&app, "main", ProjectOpenAnswer::Opened) + }); + + assert_eq!( + result.unwrap(), + json!({ "outcome": "opened", "window": "main" }) + ); + reveal(&app, "main"); + assert_eq!(used.most_recent_first(), ["main"]); + } + #[tokio::test] async fn an_unknown_project_is_refused_before_any_window_is_told() { let (app, store, _) = fixture(&["main"]); diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 6c34f5bfa..daf8dcd1b 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -1074,6 +1074,10 @@ pub(crate) async fn dispatch( "claim_updater_ownership" => { Err("claim_updater_ownership is not supported in web mode".to_string()) } + // A native window telling the backend its frontend has shown it, + // which makes the raise a `stagedctl project open` is owed. A browser + // client has no native window to reveal or raise. + "note_window_revealed" => Err(format!("{command} is not available in web mode")), // ===================================================================== // App lifecycle diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 20d4c4756..acad7082e 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -6,13 +6,7 @@ -->