diff --git a/apps/staged/README.md b/apps/staged/README.md index 2c87703f7..2a5ec1dc0 100644 --- a/apps/staged/README.md +++ b/apps/staged/README.md @@ -46,22 +46,119 @@ 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 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. -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. 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). + +#### 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. 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 . # 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.** 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 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 + +- **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.** 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 + sudo mkdir -p /usr/local/bin + sudo ln -sfn /Applications/Staged.app/Contents/MacOS/stagedctl /usr/local/bin/stagedctl + ``` + + 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. + +#### 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, 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, 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. | + +`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/crates/stagedctl/src/cli.rs b/apps/staged/src-tauri/crates/stagedctl/src/cli.rs index dc7f29c07..6c18629bd 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,77 @@ 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 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. + +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. So +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: + 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 answered 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. + +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. 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. 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; + - 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 #[command(long_about = "\ Show one project's tags. @@ -919,6 +991,61 @@ mod tests { assert!(matches!(cli.command, Noun::Disconnect { client: Some(c) } if c == "x")); } + #[test] + 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, no_launch: false }) if project.as_deref() == Some("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, no_launch: false }) 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, + 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" + ); + } + #[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..a8b6b0d85 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." @@ -279,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) } @@ -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() @@ -586,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)] @@ -636,7 +674,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() @@ -1031,11 +1069,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)) } @@ -1072,8 +1115,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; @@ -1130,6 +1175,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(), @@ -1172,6 +1222,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"); @@ -1207,7 +1283,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 @@ -1255,7 +1331,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, @@ -1386,7 +1467,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) @@ -1580,7 +1661,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, { @@ -1604,19 +1685,30 @@ 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(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`. @@ -1638,7 +1730,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(), @@ -1663,13 +1755,27 @@ 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, 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("") @@ -2139,8 +2245,8 @@ 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] @@ -2360,7 +2466,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, @@ -2370,7 +2476,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()) @@ -2580,6 +2686,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 7b1717749..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,8 @@ use crate::client::{ Client, CtlError, STAGEDCTL_LOCK_ENV, }; use crate::grants::{self, GrantFile}; +use crate::launch; +use crate::open; use crate::tags; /// One command's result: the value `--json` prints, and the human rendering. @@ -91,6 +93,21 @@ pub fn run(cli: Cli) -> Result { _ => {} } + // `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(), + *no_launch, + cli.lock_path.as_deref(), + std::env::var_os(STAGEDCTL_LOCK_ENV), + std::env::current_dir, + data_dir.as_deref(), + &launch::LaunchEnv::real(), + ); + } + let client = discover_and_connect( cli.lock_path.as_deref(), std::env::var_os(STAGEDCTL_LOCK_ENV), @@ -190,7 +207,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") + } } } @@ -746,10 +767,14 @@ pub(crate) fn find_project<'a>( } } -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/launch.rs b/apps/staged/src-tauri/crates/stagedctl/src/launch.rs new file mode 100644 index 000000000..ea0d6216a --- /dev/null +++ b/apps/staged/src-tauri/crates/stagedctl/src/launch.rs @@ -0,0 +1,1426 @@ +//! 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". +//! +//! "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, +//! 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); + +/// 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 { + /// 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 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 +/// 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, + poll_pause: Duration, + mut candidates: impl FnMut() -> Vec, + mut connect: impl FnMut(&Path) -> Result, +) -> Result { + let deadline = Instant::now() + timeout; + let mut unusable: Option = None; + let mut poll: u32 = 0; + loop { + 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(unusable.unwrap_or_else(|| launch_timed_out(timeout))); + } + std::thread::sleep(poll_pause); + } +} + +/// 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!( + "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() + )) +} + +/// 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(), + )?; + // "No running Staged answered", not "Staged is not running": + // an instance too busy to answer its ping reads the same here. + eprintln!( + "stagedctl: no running Staged answered; opening {}", + 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_on, 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 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 _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())), + ); + } + + /// 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_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(1), + || { + polls.fetch_add(1, Ordering::SeqCst); + stale.to_vec() + }, + |path| -> Result { + 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(_)), + "the bundle was opened, so this is not `NotRunning`: {err:?}" + ); + assert_eq!( + err.to_string(), + launch_timed_out(Duration::from_millis(200)).to_string() + ); + } + + #[test] + fn the_timeout_message_says_what_was_done_and_seen_and_the_remedy() { + let message = launch_timed_out(LAUNCH_TIMEOUT).to_string(); + assert_eq!( + 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] + 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()); + } + + #[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 { + 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_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(())); + 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_eq!( + err.to_string(), + launch_timed_out(Duration::from_millis(200)).to_string() + ); + } +} diff --git a/apps/staged/src-tauri/crates/stagedctl/src/lib.rs b/apps/staged/src-tauri/crates/stagedctl/src/lib.rs index 7cf7be988..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,10 @@ //! - [`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, `` //! and `` resolution, and their renderers //! @@ -16,4 +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 new file mode 100644 index 000000000..1d3aeff5d --- /dev/null +++ b/apps/staged/src-tauri/crates/stagedctl/src/open.rs @@ -0,0 +1,1373 @@ +//! `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 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. +//! +//! The project is resolved here, before the call, so a name that matches +//! 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`. +//! +//! [`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; +use serde_json::{json, Value}; + +use crate::client::{Client, CredentialScope, 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 +/// 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 unless it was + /// still loading: that one is raised once its frontend has shown it. + window: String, +} + +/// 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() + ), + )), + } +} + +/// 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. +/// +/// 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. +/// +/// 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, + 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(); + 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(", ") + ), + (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) + }) +} + +/// `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, + client.pid, + client.scope, + || 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. `scope` is how far the credential behind `list` +/// reaches, for wording a directory's project that the list lacks. +fn open_target( + target: &Target, + pid: u32, + scope: CredentialScope, + 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, scope)? + } + }; + 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. +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 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 answered it, so {name:?} may not be showing; 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 project(id: &str, name: &str) -> Project { + Project { + 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 + // ------------------------------------------------------------------- + + /// 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(), + 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, + ALL, + || 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, ALL, || 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, 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"), + "{err}" + ); + } + + #[test] + fn a_directory_outside_a_confined_credentials_project_is_not_called_deleted() { + // 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( + &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}" + ); + } + + /// 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; + 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, + ALL, + || 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, + 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, + 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, + ) + .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, + ALL, + || { + 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" }); + 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 answered it, so \"Widgets\" may not be \ + showing; 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"); + } + + // ------------------------------------------------------------------- + // 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/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/crates/stagedctl/tests/fixtures/help.txt b/apps/staged/src-tauri/crates/stagedctl/tests/fixtures/help.txt index 31175de22..fb5f2a8d3 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 @@ -138,6 +139,86 @@ Options: -h, --help Print help (see a summary with '-h') +=== stagedctl project open --help === +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. + +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. So +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: + 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 answered 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. + +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. 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. 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; + - 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: + [PROJECT] + Project name or id (see `stagedctl project list`); omit it to open the project the current directory belongs to + +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 + + -h, --help + Print help (see a summary with '-h') + === stagedctl project tags --help === Show one project's tags. diff --git a/apps/staged/src-tauri/resources/stagedctl/guide.md b/apps/staged/src-tauri/resources/stagedctl/guide.md index 41a00b18d..fc31a83df 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 @@ -69,6 +91,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 +120,106 @@ 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, 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. + +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`), 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, except that a single argument + 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, +project: { id, name }, window, pid }`). A project that is already on +screen is a success too. + +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 + 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. 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 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: + +- 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/apps/staged/src-tauri/src/app_lifecycle.rs b/apps/staged/src-tauri/src/app_lifecycle.rs index 5ac230f0a..9a7ef7f55 100644 --- a/apps/staged/src-tauri/src/app_lifecycle.rs +++ b/apps/staged/src-tauri/src/app_lifecycle.rs @@ -67,6 +67,14 @@ use crate::session_commands::{self, ActiveSessionInfo}; 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. const QUIT_PROMPT_TITLE: &str = "Quit Staged?"; @@ -216,9 +224,22 @@ 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, and drops a destroyed window from it and from +/// the record of revealed windows ([`RevealedWindows`]). 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()); + window_reveal::forget_window(window.app_handle(), window.label()); + } + _ => {} } } @@ -271,46 +292,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..6d221153c --- /dev/null +++ b/apps/staged/src-tauri/src/app_lifecycle/window_raise.rs @@ -0,0 +1,324 @@ +//! 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. +//! One that may arrive while the window is still loading raises with +//! [`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 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}; + +/// 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: 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), + ) + .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. +/// +/// `recently_used` is most recent first, and may name windows that are gone. +/// Each tier is probed only when the ones above it found nothing. +fn pick_window<'a, W>( + windows: &'a HashMap, + recently_used: &[String], + is_focused: impl Fn(&W) -> bool, + is_visible: impl Fn(&W) -> bool, +) -> 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(|| 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`, put its PR-poll client back on the +/// 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. +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); + // 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()); +} + +#[cfg(test)] +mod tests { + use super::*; + use tauri::test::{mock_builder, mock_context, noop_assets, MockRuntime}; + + /// 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() + .expect("failed to create mock window"); + } + app + } + + /// Label [`pick_window`] lands on when exactly the `focused` and `visible` + /// 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() + } + + #[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_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-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")); + } + + #[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 the_oldest_hidden_peer_is_picked_once_main_is_gone() { + let app = mock_app(&["win-10", "win-3", "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, &[], &["main"], &[]), 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()); + } + + /// 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, and with no record nothing is noted. + #[test] + 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"]); + } +} 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 new file mode 100644 index 000000000..3a3e45de2 --- /dev/null +++ b/apps/staged/src-tauri/src/app_lifecycle/window_use.rs @@ -0,0 +1,120 @@ +//! 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. 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. +//! +//! A window goes on the record when it gains focus 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; + +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. + #[cfg(test)] + 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/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/lib.rs b/apps/staged/src-tauri/src/lib.rs index f1e8af1e8..8d6642072 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; @@ -50,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; @@ -2142,10 +2144,13 @@ 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); 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, @@ -2320,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, @@ -2579,6 +2587,11 @@ 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, + // 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/project_open.rs b/apps/staged/src-tauri/src/project_open.rs new file mode 100644 index 000000000..42785993e --- /dev/null +++ b/apps/staged/src-tauri/src/project_open.rs @@ -0,0 +1,406 @@ +//! 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 ([`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 +//! 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, 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. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +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 +/// 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..faceae6eb --- /dev/null +++ b/apps/staged/src-tauri/src/project_open/commands.rs @@ -0,0 +1,124 @@ +//! 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. 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, + 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/project_open/control.rs b/apps/staged/src-tauri/src/project_open/control.rs new file mode 100644 index 000000000..9bccb4583 --- /dev/null +++ b/apps/staged/src-tauri/src/project_open/control.rs @@ -0,0 +1,534 @@ +//! 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 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 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 +//! `{ "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; +use crate::app_lifecycle::window_reveal::raise_window_once_revealed; +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. 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( + 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. 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); + + 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::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::{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. 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() + .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) + } + + /// `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())); + 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. 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 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"); + + // 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_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"); + answer(&app, "main", ProjectOpenAnswer::DialogOpen) + }); + + assert_eq!( + result.unwrap(), + json!({ "outcome": "dialogOpen", "window": "main" }) + ); + } + + /// 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) = loading_fixture(&["main"]); + let heard = heard_by(&app, "main"); + let state = app.state::(); + let used = app.state::(); + + 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") + .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!( + !used.contains("main"), + "answering is not the reveal: the frontend has not shown the window" + ); + + // 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) = loading_fixture(&["main"]); + let used = app.state::(); + + let result = open_project(app.handle(), &store, &project_id).await; + + assert_eq!( + result.unwrap(), + json!({ "outcome": "timedOut", "window": "main" }) + ); + assert!(!used.contains("main")); + + 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"]); + 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/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..e322bbba5 --- /dev/null +++ b/apps/staged/src-tauri/src/stagedctl_link/fs.rs @@ -0,0 +1,713 @@ +//! 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 = 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); + return Err(e); + } + 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(); + 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()); + } + + /// 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(); + 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 61b2afd83..daf8dcd1b 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, } } @@ -1069,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 @@ -1105,6 +1114,32 @@ 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")), + // 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 + } + + // ===================================================================== + // 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 // ===================================================================== @@ -4563,11 +4598,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 })), diff --git a/apps/staged/src/App.svelte b/apps/staged/src/App.svelte index 2c73ff7a6..acad7082e 100644 --- a/apps/staged/src/App.svelte +++ b/apps/staged/src/App.svelte @@ -6,13 +6,7 @@ --> + +
+
+
+

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/listeners/projectOpenListener.test.ts b/apps/staged/src/lib/listeners/projectOpenListener.test.ts new file mode 100644 index 000000000..edb907e40 --- /dev/null +++ b/apps/staged/src/lib/listeners/projectOpenListener.test.ts @@ -0,0 +1,639 @@ +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, + * 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'; +/** 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() { + 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>; +/** 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'); + 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().mockResolvedValue('opened'); + navigate = vi.fn<(projectId: string) => void>(); + + 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.useRealTimers(); + 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(opened()).toEqual(['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(opened()).toEqual(['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(opened()).toEqual(['p1']); + + first.resolve('opened'); + await settle(); + + expect(opened()).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(opened()).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 { stop } = await startListening(); + establish(); + emit({ requestId: 'r1', projectId: 'p1' }); + emit({ requestId: 'r2', projectId: 'p2' }); + await settle(); + + stop(); + 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(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 new file mode 100644 index 000000000..169552efc --- /dev/null +++ b/apps/staged/src/lib/listeners/projectOpenListener.ts @@ -0,0 +1,184 @@ +/** + * 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. + * + * **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'; +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'; + +/** + * 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) idle = 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 { + try { + for (const request of await listPendingProjectOpens()) take(request); + } catch (e) { + console.error('[projectOpen] Failed to load the pending project-open request:', e); + } + // 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 { + 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 | 'withdrawn'; + try { + 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, after the last check. + console.warn(`[projectOpen] The answer "${outcome}" was not recorded:`, e); + } +} diff --git a/apps/staged/src/lib/services/windowReveal.test.ts b/apps/staged/src/lib/services/windowReveal.test.ts new file mode 100644 index 000000000..dfc78175e --- /dev/null +++ b/apps/staged/src/lib/services/windowReveal.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * The window's reveal. What it has to get right: the backend is told only + * after the window is really on screen, a failed report does not undo a + * reveal that worked, and web mode, which has no native window, reports + * nothing. + */ + +/** 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 show: ReturnType Promise>>; +let noteWindowRevealed: ReturnType Promise>>; + +/** Load the module with the transport reporting desktop or web mode. */ +async function load(isTauri: boolean) { + vi.doMock('../transport', () => ({ isTauri, getWindowSync: () => ({ show }) })); + vi.doMock('../commands', () => ({ noteWindowRevealed })); + return import('./windowReveal'); +} + +beforeEach(() => { + vi.resetModules(); + show = vi.fn<() => Promise>().mockResolvedValue(undefined); + noteWindowRevealed = vi.fn<() => Promise>().mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.doUnmock('../transport'); + vi.doUnmock('../commands'); + vi.restoreAllMocks(); +}); + +describe('revealWindow', () => { + it('shows the window and then tells the backend it is revealed', async () => { + const { revealWindow } = await load(true); + + await revealWindow(); + + expect(show).toHaveBeenCalledTimes(1); + // Mirrors `note_window_revealed` in `app_lifecycle/window_reveal.rs`. + expect(noteWindowRevealed).toHaveBeenCalledTimes(1); + expect(show.mock.invocationCallOrder[0]).toBeLessThan( + noteWindowRevealed.mock.invocationCallOrder[0] + ); + }); + + it('does not report the reveal until the window is on screen', async () => { + // The backend raises a window it is told is revealed. Told early, it + // would show the window before the frontend had. + const shown = deferred(); + show.mockReturnValue(shown.promise); + const { revealWindow } = await load(true); + + const revealing = revealWindow(); + await Promise.resolve(); + expect(noteWindowRevealed).not.toHaveBeenCalled(); + + shown.resolve(); + await revealing; + expect(noteWindowRevealed).toHaveBeenCalledTimes(1); + }); + + it('settles only once the report has', async () => { + const reported = deferred(); + noteWindowRevealed.mockReturnValue(reported.promise); + const { revealWindow } = await load(true); + let settled = false; + + const revealing = revealWindow().then(() => { + settled = true; + }); + await vi.waitFor(() => expect(noteWindowRevealed).toHaveBeenCalled()); + expect(settled).toBe(false); + + reported.resolve(); + await revealing; + expect(settled).toBe(true); + }); + + it('reports nothing when the window could not be shown', async () => { + show.mockRejectedValue(new Error('no window')); + const { revealWindow } = await load(true); + + await expect(revealWindow()).rejects.toThrow('no window'); + + expect(noteWindowRevealed).not.toHaveBeenCalled(); + }); + + it('still resolves when the report fails', async () => { + // The window is on screen all the same, and the rest of the mount must + // go on. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + noteWindowRevealed.mockRejectedValue(new Error('ipc down')); + const { revealWindow } = await load(true); + + await expect(revealWindow()).resolves.toBeUndefined(); + + expect(show).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('reports nothing in web mode', async () => { + // The web dispatch refuses the command: a browser tab is not a native + // window the backend could raise. + const { revealWindow } = await load(false); + + await revealWindow(); + + expect(show).toHaveBeenCalledTimes(1); + expect(noteWindowRevealed).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/staged/src/lib/services/windowReveal.ts b/apps/staged/src/lib/services/windowReveal.ts new file mode 100644 index 000000000..d8eb54114 --- /dev/null +++ b/apps/staged/src/lib/services/windowReveal.ts @@ -0,0 +1,36 @@ +/** + * The reveal of this window, and the word to the backend that it happened. + * + * Every window is created hidden, so the first thing the user sees is a + * themed page and not an unpainted webview. The frontend shows the window + * itself once the theme is applied. The backend cannot see that happen: a + * window's visibility does not say who showed it, and a window that was + * revealed can be hidden again. So the frontend reports it. + * + * The report matters to `stagedctl project open`. A request that lands while + * the window is still loading is not allowed to show it early; the backend + * owes the window a raise instead, and makes it when this report arrives. + * Once the report is in, a later request raises the window at once, even one + * that has never held focus and is hidden at the time. + */ + +import { noteWindowRevealed } from '../commands'; +import { getWindowSync, isTauri } from '../transport'; + +/** + * Show this window, then tell the backend it is revealed. + * + * Rejects only when the window could not be shown, as the bare `show()` did. + * A report that fails is logged and dropped: the window is on screen, which + * is what the caller asked for. Web mode has no native window, so nothing is + * reported there. + */ +export async function revealWindow(): Promise { + await getWindowSync().show(); + if (!isTauri) return; + try { + await noteWindowRevealed(); + } catch (e) { + console.warn('[windowReveal] Failed to report the window as revealed:', 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..bffa8b46e 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -1379,6 +1379,51 @@ 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; +} + +/** + * 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 bf75bf015..f7f9cba38 100755 --- a/scripts/staged +++ b/scripts/staged @@ -1,47 +1,115 @@ #!/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. +# An existing directory written as a path: `.`, `..`, or +# anything with a slash in it, such as `./repo`, `repo/` or +# `/path/to/repo`. Opens 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 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 written as +# a path and 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. +# +# A bare word is always a project name or id, whether or not a directory of +# that name exists. Projects are usually named after their repository, so +# `staged builderbot` beside a `builderbot/` folder means the project. Write +# `staged ./builderbot` or `staged builderbot/` for the folder. +# +# 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="." +# 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 +} + +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, 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 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 -} +# `staged .` and `staged /path/to/repo`: one argument that is written as a +# path and 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 -# 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" +# Only what cannot be a bare project name is read as a path: `.`, `..`, or +# an argument with a slash in it. A bare word stays a name even when a +# directory of that name exists here. +is_path=0 +case "$dir" in + . | .. | */*) is_path=1 ;; +esac - 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 +if [ "$known" -eq 1 ] && [ "$positionals" -eq 1 ] && [ "$is_path" -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. + asked="$dir" + 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' "$asked" >&2 exit 1 fi 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 "$@"