diff --git a/CHANGELOG.md b/CHANGELOG.md index f8283729c3..e03a9881d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ### OpenBot Desktop +- **Sign-in comes first, and macOS asks for your password only after OpenBot says why.** On a new + Mac the Account step waited for Install to put Podman in before any browser sign-in opened, and + the "osascript wants to make changes" password box came up by itself over it. The CopilotKit + sign-in now opens in your browser as soon as the Account step opens: the CLI's sign-in needs no + engine, and only the license agreement waits for one. Podman downloads meanwhile. Once you're + signed in, the step says "OpenBot installs Podman, which runs OpenBot's back end on this + computer. macOS asks for your password once to install it." and the password box comes up only + when you choose Continue. It now says "OpenBot needs your password to install Podman, which runs + OpenBot's back end." Closing it without a password offers Continue again instead of stopping + Install. Windows and Linux install as before. - **A back end update that was stopped partway no longer breaks every later Start.** Quitting while the back end updated could leave it half updated, and every Start after that failed with "Local update-quiesce failed (exit 1)". OpenBot now lets an update finish once it has begun @@ -20,11 +30,12 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. fails. A failed update says the step it stopped at, and that your data is kept. - **The Account step says when it is waiting for Install to set up the engine.** On a new Mac it said "Checking your CopilotKit account…" for minutes while Install put Podman in, because the - sign-in check needs a running engine. It now says "OpenBot is setting up the software it runs on. - Your CopilotKit sign-in comes next.", with what Install is doing ("Installing Podman.", - "Preparing the engine machine.", "Starting the engine machine."). The desktop log says each change - once as `[account]`: what the CLI's preflight answered, the install step, how long the wait took - and what the sign-in check answered, and how long the browser sign-in took. + license agreement check needs a running engine. It now says "You're signed in. OpenBot is setting + up the software it runs on. The license agreement comes next.", with what Install is doing + ("Downloading Podman.", "Preparing the engine machine.", "Starting the engine machine."). The + desktop log says each change once as `[account]`: what the CLI's preflight answered, the install + step, how long the wait took and what the sign-in check answered, and how long the browser + sign-in took. - **Connecting an app from OpenBot Desktop brings you back to OpenBot Desktop.** The app's sign-in opens in your browser, and when it finished, the browser showed OpenBot in a new tab while the window behind it still had the app as not connected. The browser now shows a short "Back to diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 07ee721263..a9385f7210 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -44,10 +44,14 @@ use serde::{Deserialize, Serialize}; /// the system's password prompt for Podman's installer. Those windows come to the front over /// OpenBot's. The setup window then says the line on whichever screen the person is on, and OpenBot /// comes back to the front once that window is done with. +/// +/// `asks` when the step waits for the person's go-ahead in OpenBot's own window before it raises +/// such a prompt (`install::GoAhead`). The window explains what comes next, with one button. #[derive(Clone, Debug, PartialEq, Eq)] pub struct StepLine { pub said: String, pub elsewhere: bool, + pub asks: bool, } impl StepLine { @@ -56,6 +60,7 @@ impl StepLine { Self { said: said.into(), elsewhere: false, + asks: false, } } @@ -64,6 +69,16 @@ impl StepLine { Self { said: said.into(), elsewhere: true, + asks: false, + } + } + + /// A line about a step that waits for the person's go-ahead in OpenBot's window. + pub fn asks(said: impl Into) -> Self { + Self { + said: said.into(), + elsewhere: false, + asks: true, } } } diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 1b1d3a5da5..1e9b6719e3 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -34,13 +34,18 @@ //! already runs. //! - **macOS.** The package writes to `/opt/podman` and needs administrator rights, so the person //! sees one standard macOS authorization prompt. There is no way around that prompt and no reason -//! to want one: it is the same dialog every other installer raises. +//! to want one: it is the same dialog every other installer raises. It is never raised unexplained: +//! the package downloads while the person signs in, then the step waits (`GoAhead`) while OpenBot's +//! window says what comes next and why, and raises the prompt only once they choose Continue. The +//! prompt itself names OpenBot and Podman (`PODMAN_PASSWORD_PROMPT`). //! - **Linux.** Podman there is not a binary but a set of them (`conmon`, `crun`, `netavark`, //! `slirp4netns`), wired to the distribution's own paths, so downloading one file would produce //! something that runs nothing. The distribution's package manager installs it, through //! `pkexec`, which raises that desktop's own authorization prompt. use std::path::{Path, PathBuf}; +use std::sync::{Condvar, Mutex}; +use std::time::Duration; use sha2::{Digest, Sha256}; @@ -218,7 +223,8 @@ fn digest_of(bytes: &[u8]) -> String { /// somebody presses Start. Answers with the sentence for the step row, or a failure in both /// registers. `saved` is the engine the installation was set up on, if it saved one. pub fn install_engine(cache: &Path, saved: Option) -> Result { - install_engine_observed(cache, saved, |_, _| {}, &mut |_| {}, &|| {}).map(|chosen| chosen.said) + install_engine_observed(cache, saved, |_, _| {}, &mut |_| {}, &|| {}, &|| false) + .map(|chosen| chosen.said) } /// The engine the install step settled on, and the sentence for its step row. @@ -233,12 +239,15 @@ pub struct EngineChosen { /// /// `back` brings OpenBot's window to the front again after another app's window took it: Docker /// Desktop once it answers, or the system's password prompt once Podman's installer is done. +/// +/// `cancelled` ends a wait for the person's go-ahead (`GoAhead`): Stop or Quit while it waits. pub fn install_engine_observed( cache: &Path, saved: Option, installed: impl FnMut(Engine, bool), progress: &mut dyn FnMut(StepLine), back: &dyn Fn(), + cancelled: &dyn Fn() -> bool, ) -> Result { // Only one of the two runs, so they share the step row's lines one at a time. let progress = std::cell::RefCell::new(progress); @@ -248,20 +257,172 @@ pub fn install_engine_observed( saved, installed, || crate::docker_desktop::start(&mut **progress.borrow_mut(), back), - |into| { - let line = podman_install_said(std::env::consts::OS); - let prompted = line.elsewhere; - (progress.borrow_mut())(line); - let result = install_podman(into); - if prompted { - back(); - } - result - }, + |into| install_podman(into, &mut **progress.borrow_mut(), back, cancelled), place_compose, ) } +/// The step row's line while Podman's package downloads, before anything needs the person. +pub const PODMAN_DOWNLOADING: &str = "Downloading Podman."; + +/// The step row's line while it waits for the person's go-ahead to install Podman. The window +/// shows its own explanation and Continue beside it. +pub const PODMAN_GO_AHEAD: &str = "Podman is downloaded and ready to install."; + +/// The same wait, after the person closed macOS's password prompt without a password. +pub const PODMAN_DECLINED: &str = "macOS didn't get your password, so Podman isn't installed yet."; + +/// What macOS's password prompt says, under the name of the process that raised it. +pub const PODMAN_PASSWORD_PROMPT: &str = + "OpenBot needs your password to install Podman, which runs OpenBot's back end."; + +/** +The person's go-ahead for a step that raises the system's password prompt. + +The prompt came up by itself in the middle of the Account step, over a screen about something else, +before the person knew what Podman was or why it needed their password. Apple's guidance for a screen +before a system prompt is to explain it there, with one button that opens the prompt. So the step +says it is asking (`StepLine::asks`), the window explains, and the prompt waits for Continue. + +Only a go-ahead given while a step asks counts: a click left over from an earlier ask never lets a +later prompt through unexplained. Each go-ahead is used once. +*/ +pub struct GoAhead { + state: Mutex, + changed: Condvar, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Asked { + Nothing, + Waiting, + Given, +} + +impl Default for GoAhead { + fn default() -> Self { + Self::new() + } +} + +impl GoAhead { + pub const fn new() -> Self { + Self { + state: Mutex::new(Asked::Nothing), + changed: Condvar::new(), + } + } + + fn state(&self) -> std::sync::MutexGuard<'_, Asked> { + self.state.lock().unwrap_or_else(|held| held.into_inner()) + } + + /// The person chose Continue. True when a step was asking for it. + pub fn give(&self) -> bool { + let mut state = self.state(); + if *state != Asked::Waiting { + return false; + } + *state = Asked::Given; + self.changed.notify_all(); + true + } + + /// Ask, then wait for the go-ahead. `ask` says so to the window. `cancelled` ends the wait. + pub fn wait(&self, ask: impl FnOnce(), cancelled: &dyn Fn() -> bool) -> Result<(), Problem> { + *self.state() = Asked::Waiting; + ask(); + let mut state = self.state(); + loop { + if *state == Asked::Given { + *state = Asked::Nothing; + return Ok(()); + } + if cancelled() { + *state = Asked::Nothing; + return Err(Problem::plain( + "OpenBot stopped before installing Podman. Start again when you're ready.", + )); + } + state = self + .changed + .wait_timeout(state, Duration::from_millis(200)) + .unwrap_or_else(|held| held.into_inner()) + .0; + } + } +} + +/// The go-ahead Install waits on before macOS asks for the password to install Podman. +pub static PODMAN_GO_AHEAD_GATE: GoAhead = GoAhead::new(); + +/// The window's Continue under the explanation. True when Install was waiting for it. +pub fn allow_podman_install() -> bool { + PODMAN_GO_AHEAD_GATE.give() +} + +/// How the password prompt and the installer behind it ended. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +#[derive(Debug)] +enum Prompted { + Installed, + /// The person closed the prompt. A decision, not a failure: the step asks again. + Declined, + Failed(Problem), +} + +/** +Download Podman's package, wait for the person's go-ahead, then raise the password prompt. + +The download needs nobody, so it runs while the person signs in. Only the prompt waits for them. A +prompt closed without a password asks again, with the explanation, rather than failing Install +behind a screen that has no way to start it again. +*/ +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn install_after_go_ahead( + fetch: impl FnOnce() -> Result, + progress: &mut dyn FnMut(StepLine), + gate: &GoAhead, + cancelled: &dyn Fn() -> bool, + mut prompt: impl FnMut(&str) -> Prompted, + back: &dyn Fn(), +) -> Result<(), Problem> { + progress(StepLine::here(PODMAN_DOWNLOADING)); + let package = fetch()?; + let script = podman_install_script(&package); + let mut asking = PODMAN_GO_AHEAD; + loop { + gate.wait(|| progress(StepLine::asks(asking)), cancelled)?; + progress(podman_install_said("macos")); + let prompted = prompt(&script); + // The prompt was in front of OpenBot's window, however it ended. + back(); + match prompted { + Prompted::Installed => return Ok(()), + Prompted::Declined => asking = PODMAN_DECLINED, + Prompted::Failed(problem) => return Err(problem), + } + } +} + +/// The AppleScript that installs Podman's package as an administrator, with OpenBot's own words in +/// the password prompt. `with prompt` is `do shell script`'s text for that dialog (StandardAdditions). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn podman_install_script(package: &Path) -> String { + format!( + "do shell script \"/usr/sbin/installer -pkg {} -target /\" with administrator privileges \ + with prompt {}", + applescript_shell_arg(package), + applescript_string(PODMAN_PASSWORD_PROMPT) + ) +} + +/// Text as an AppleScript string literal. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn applescript_string(text: &str) -> String { + format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")) +} + /// The step row's line while Podman installs, naming the prompt the platform raises for it. /// /// macOS asks for the person's password, because the package writes to `/opt/podman`. Windows @@ -465,7 +626,14 @@ pub fn compose_provider_name() -> &'static str { } #[cfg(target_os = "windows")] -fn install_podman(into: &Path) -> Result<(), Problem> { +fn install_podman( + into: &Path, + progress: &mut dyn FnMut(StepLine), + _back: &dyn Fn(), + _cancelled: &dyn Fn() -> bool, +) -> Result<(), Problem> { + // The MSI installs for this account alone and asks for nothing. + progress(podman_install_said("windows")); let download = podman_download()?; let msi = fetch_verified(&download, into)?; let log = into.join("podman-install.log"); @@ -554,39 +722,48 @@ fn installer_stopped(verb: &str, msi: &Path, failure: MsiexecFailure, log: &Path } #[cfg(target_os = "macos")] -fn install_podman(into: &Path) -> Result<(), Problem> { - let download = podman_download()?; - let pkg = fetch_verified(&download, into)?; - - // The package writes to `/opt/podman`, which needs administrator rights. `do shell script ... - // with administrator privileges` is how macOS asks for them: the person sees the standard - // authorization dialog, and no password passes through this process. - let script = format!( - "do shell script \"/usr/sbin/installer -pkg {} -target /\" with administrator privileges", - applescript_shell_arg(&pkg) - ); - let output = crate::quiet::command("osascript") - .args(["-e", &script]) +fn install_podman( + into: &Path, + progress: &mut dyn FnMut(StepLine), + back: &dyn Fn(), + cancelled: &dyn Fn() -> bool, +) -> Result<(), Problem> { + install_after_go_ahead( + || fetch_verified(&podman_download()?, into), + progress, + &PODMAN_GO_AHEAD_GATE, + cancelled, + run_administrator_script, + back, + ) +} + +/// Run `script` through `osascript`. `do shell script ... with administrator privileges` is how +/// macOS asks for administrator rights: the person sees the standard authorization dialog, and no +/// password passes through this process. +#[cfg(target_os = "macos")] +fn run_administrator_script(script: &str) -> Prompted { + let output = match crate::quiet::command("osascript") + .args(["-e", script]) .output() - .map_err(|error| { - Problem::with( + { + Ok(output) => output, + Err(error) => { + return Prompted::Failed(Problem::with( "OpenBot could not start the installer for the software it needs.", format!("osascript: {error}"), - ) - })?; - + )) + } + }; if output.status.success() { - return Ok(()); + return Prompted::Installed; } let said = crate::quiet::said(&output.stderr); // -128 is AppleScript's "user cancelled", which is a decision rather than a failure. if said.contains("-128") { - return Err(Problem::plain( - "The install was cancelled, so OpenBot does not have the software it needs yet. Press \ - Start to try again.", - )); + return Prompted::Declined; } - Err(Problem::with( + Prompted::Failed(Problem::with( "Installing the software OpenBot needs did not finish. Try again.", said, )) @@ -596,14 +773,28 @@ fn install_podman(into: &Path) -> Result<(), Problem> { /// /// Two layers, applied in this order: single-quote it for the shell, then escape what AppleScript /// treats as special in the double-quoted string that carries it. -#[cfg(target_os = "macos")] +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] fn applescript_shell_arg(path: &Path) -> String { let quoted = format!("'{}'", path.to_string_lossy().replace('\'', "'\\''")); quoted.replace('\\', "\\\\").replace('"', "\\\"") } #[cfg(target_os = "linux")] -fn install_podman(_into: &Path) -> Result<(), Problem> { +fn install_podman( + _into: &Path, + progress: &mut dyn FnMut(StepLine), + back: &dyn Fn(), + _cancelled: &dyn Fn() -> bool, +) -> Result<(), Problem> { + progress(podman_install_said("linux")); + let result = install_podman_with_pkexec(); + // pkexec's prompt was in front of OpenBot's window. + back(); + result +} + +#[cfg(target_os = "linux")] +fn install_podman_with_pkexec() -> Result<(), Problem> { let (manager, args) = linux_package_manager().ok_or_else(|| { Problem::with( "OpenBot cannot install the software it needs on this system. Install the `podman` \ @@ -1293,4 +1484,200 @@ esac StepLine::here("Installing Podman.") ); } + + /// Where Podman's package lands: the app's cache, which has a space in its path on macOS. + const PACKAGE: &str = "/Users/a b/Library/Caches/OpenBot/podman-installer-macos-arm64.pkg"; + + /// What happened, in order: the step's lines, the download, the prompt and OpenBot coming back. + #[derive(Clone, Default)] + struct Seen(std::sync::Arc>>); + + impl Seen { + fn add(&self, what: impl Into) { + self.0.lock().unwrap().push(what.into()); + } + + fn line(&self, line: StepLine) { + let kind = if line.asks { + "asks" + } else if line.elsewhere { + "elsewhere" + } else { + "here" + }; + self.add(format!("{kind}: {}", line.said)); + } + + fn all(&self) -> Vec { + self.0.lock().unwrap().clone() + } + + /// The window: waits until the step asks for the `nth` time, then until the person reads + /// it, then chooses Continue. Nothing may have been prompted by then. + fn continue_when_asked(&self, gate: &std::sync::Arc, nth: usize) { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while self + .all() + .iter() + .filter(|seen| seen.starts_with("asks: ")) + .count() + < nth + { + assert!(std::time::Instant::now() < deadline, "{:?}", self.all()); + std::thread::sleep(Duration::from_millis(10)); + } + std::thread::sleep(Duration::from_millis(300)); + let prompts = self.all().iter().filter(|seen| *seen == "prompt").count(); + assert_eq!( + prompts, + nth - 1, + "the prompt came up before Continue: {:?}", + self.all() + ); + assert!(gate.give()); + } + } + + /// The person met macOS's password prompt over the Account step, mid-sign-in, with nothing on + /// screen saying what it was for. The package downloads first, then the step asks and waits; + /// the prompt comes up only after Continue, and says in OpenBot's words why it is there. + #[test] + fn the_password_prompt_waits_for_continue_and_says_why() { + let gate = std::sync::Arc::new(GoAhead::new()); + let seen = Seen::default(); + let window = { + let (gate, seen) = (gate.clone(), seen.clone()); + std::thread::spawn(move || seen.continue_when_asked(&gate, 1)) + }; + let mut scripts = Vec::new(); + let installed = install_after_go_ahead( + || { + seen.add("downloaded"); + Ok(PathBuf::from(PACKAGE)) + }, + &mut |line| seen.line(line), + &gate, + &|| false, + |script| { + seen.add("prompt"); + scripts.push(script.to_owned()); + Prompted::Installed + }, + &|| seen.add("back"), + ); + window.join().unwrap(); + assert!(installed.is_ok(), "{installed:?}"); + assert_eq!( + seen.all(), + [ + "here: Downloading Podman.", + "downloaded", + "asks: Podman is downloaded and ready to install.", + "elsewhere: Installing Podman. Enter your Mac password when macOS asks for it.", + "prompt", + "back", + ] + ); + assert_eq!( + scripts, + [concat!( + r#"do shell script "/usr/sbin/installer -pkg '/Users/a b/Library/Caches/OpenBot/"#, + r#"podman-installer-macos-arm64.pkg' -target /" with administrator privileges "#, + r#"with prompt "OpenBot needs your password to install Podman, which runs "#, + r#"OpenBot's back end.""# + )] + ); + } + + /// Closing the prompt without a password is a decision. Install did fail on it, behind an + /// Account step with no way to start it again; now the step asks again, and says why. + #[test] + fn a_closed_password_prompt_asks_again() { + let gate = std::sync::Arc::new(GoAhead::new()); + let seen = Seen::default(); + let window = { + let (gate, seen) = (gate.clone(), seen.clone()); + std::thread::spawn(move || { + seen.continue_when_asked(&gate, 1); + seen.continue_when_asked(&gate, 2); + }) + }; + let mut answers = [Prompted::Declined, Prompted::Installed].into_iter(); + let installed = install_after_go_ahead( + || Ok(PathBuf::from(PACKAGE)), + &mut |line| seen.line(line), + &gate, + &|| false, + |_| { + seen.add("prompt"); + answers.next().unwrap() + }, + &|| seen.add("back"), + ); + window.join().unwrap(); + assert!(installed.is_ok(), "{installed:?}"); + assert_eq!( + seen.all(), + [ + "here: Downloading Podman.", + "asks: Podman is downloaded and ready to install.", + "elsewhere: Installing Podman. Enter your Mac password when macOS asks for it.", + "prompt", + "back", + "asks: macOS didn't get your password, so Podman isn't installed yet.", + "elsewhere: Installing Podman. Enter your Mac password when macOS asks for it.", + "prompt", + "back", + ] + ); + } + + /// Stop or Quit while the step waits ends the wait, and no prompt comes up after it. + #[test] + fn stopping_while_it_asks_raises_no_prompt() { + let gate = GoAhead::new(); + let seen = Seen::default(); + let stopped = std::sync::atomic::AtomicBool::new(false); + let failed = install_after_go_ahead( + || Ok(PathBuf::from(PACKAGE)), + &mut |line| { + seen.line(line); + stopped.store(true, std::sync::atomic::Ordering::SeqCst); + }, + &gate, + &|| stopped.load(std::sync::atomic::Ordering::SeqCst), + |_| { + seen.add("prompt"); + Prompted::Installed + }, + &|| seen.add("back"), + ) + .unwrap_err(); + assert_eq!( + failed.said, + "OpenBot stopped before installing Podman. Start again when you're ready." + ); + assert!( + !seen.all().contains(&"prompt".to_owned()), + "{:?}", + seen.all() + ); + } + + /// A Continue left over from an earlier ask, or sent twice, never lets a later prompt through. + #[test] + fn a_continue_with_nothing_asking_counts_for_nothing() { + let gate = GoAhead::new(); + assert!(!gate.give()); + let asked_once = std::sync::atomic::AtomicUsize::new(0); + // The earlier click is not taken: the wait still needs one given while it asks. + let waited = gate.wait( + || { + asked_once.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }, + &|| asked_once.load(std::sync::atomic::Ordering::SeqCst) == 1, + ); + assert!(waited.is_err()); + assert!(!gate.give()); + } } diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index e741a7bf5a..9c0726767e 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -250,6 +250,9 @@ struct Progress { /// The detail sends the person to another app's window: see `engine::StepLine`. #[serde(skip_serializing_if = "std::ops::Not::not")] elsewhere: bool, + /// The step waits for the person's go-ahead in this window: see `install::GoAhead`. + #[serde(skip_serializing_if = "std::ops::Not::not")] + asks: bool, } #[derive(Serialize)] @@ -525,6 +528,7 @@ fn report( download_bytes: None, downloads: None, elsewhere: false, + asks: false, }, ); } @@ -558,6 +562,7 @@ fn report_running_line( let engine::StepLine { said: detail, elsewhere, + asks, } = line; { let mut logged = RUNNING_LOGGED @@ -579,6 +584,7 @@ fn report_running_line( download_bytes, downloads: None, elsewhere, + asks, }, ); } @@ -617,6 +623,22 @@ fn bring_setup_to_front(app: tauri::AppHandle, why: String) { bring_to_front(&app, &why); } +/// The person chose Continue under the setup window's explanation of the password prompt, so +/// Install may raise it now. See `install::GoAhead`. False when nothing was waiting for it. +#[tauri::command] +fn allow_podman_install() -> bool { + let waiting = install::allow_podman_install(); + log::info!( + "[install] Continue to install Podman{}", + if waiting { + "" + } else { + ", with nothing waiting for it" + } + ); + waiting +} + /// Install's running image downloads: the step's sentence, the overall bytes as `downloadBytes` /// for anything that reads only that, and the per-image detail. fn report_downloads( @@ -633,6 +655,7 @@ fn report_downloads( download_bytes: (downloads.bytes > 0).then_some(downloads.bytes), downloads: Some(downloads), elsewhere: false, + asks: false, }, ); } @@ -1908,6 +1931,12 @@ async fn engine_ready( let telemetry_app = app.clone(); let progress_app = app.clone(); let back_app = app.clone(); + // Stop and Quit move the ticket on: a wait for the person's go-ahead ends with them. + let watching = app.clone(); + let ticket = app + .state::() + .start_generation + .load(std::sync::atomic::Ordering::SeqCst); let installed = tauri::async_runtime::spawn_blocking(move || { install::install_engine_observed( &stack::default_root(), @@ -1932,6 +1961,13 @@ async fn engine_ready( &mut |line| report_running_line(&progress_app, "install-engine", line, None), // Docker Desktop's window, or the password prompt, came to the front over OpenBot's. &|| bring_to_front(&back_app, "the engine no longer needs another app's window"), + &|| { + watching + .state::() + .start_generation + .load(std::sync::atomic::Ordering::SeqCst) + != ticket + }, ) }) .await @@ -3461,6 +3497,7 @@ impl QuitStatus { download_bytes: None, downloads: None, elsewhere: false, + asks: false, }; match self.steps.iter_mut().find(|known| known.step == row.step) { Some(known) => *known = row, @@ -8722,6 +8759,7 @@ fn main() { .invoke_handler(tauri::generate_handler![ record_setup_event, bring_setup_to_front, + allow_podman_install, wake_engine, fit_setup_window, detect_engine, diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 01e2698e73..22f97f5d70 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -12,6 +12,7 @@ type ProgressEvent = { detail: string; running?: boolean; elsewhere?: boolean; + asks?: boolean; downloadBytes?: number; downloads?: { done: number; @@ -328,7 +329,13 @@ function withLocalCli(local: Record) { if (command === "list_local_eval") return localListing; if (command === "cancel_local_eval") return null; if (command === "show_openbot") return null; - // Signed in and agreed already, unless a test says otherwise: the account step passes. + // Signed in and agreed already, unless a test says otherwise: the account step passes. The + // CLI's `login` answers at once for a sign-in it already has, and opens no browser. + if ( + command === "sign_in_to_copilotkit" && + !("sign_in_to_copilotkit" in local) + ) + return { signedIn: true }; if (command === "local_eval_terms" && !("local_eval_terms" in local)) return ACCEPTED_TERMS; // Apps connect once signed in, unless a test says otherwise. @@ -611,16 +618,15 @@ test("a signed-out first run signs in to CopilotKit and accepts the agreement as setupRootConfiguration("/tmp/local-eval-account", async () => emptyConfiguration(), ); - let signedIn = false; + const browser = deferred<{ signedIn: boolean }>(); withLocalCli({ - local_eval_terms: () => - signedIn - ? { ...ACCEPTED_TERMS, accepted: false } - : { status: "signed-out" }, - sign_in_to_copilotkit: () => { - signedIn = true; - return { signedIn: true }; - }, + local_eval_terms: () => ({ ...ACCEPTED_TERMS, accepted: false }), + // The CLI opens the browser and waits for the person to approve there. + sign_in_to_copilotkit: () => browser.promise, + copilotkit_sign_in_page: () => ({ + url: "https://dashboard.copilotkit.ai/cli-auth/device?user_code=WXYZ-2345", + userCode: "WXYZ-2345", + }), set_up_local_eval: localStatus("starting", "valid"), connect_local_eval: localConnection, run_local_eval: localStatus("ready", "valid"), @@ -628,8 +634,9 @@ test("a signed-out first run signs in to CopilotKit and accepts the agreement as const view = await renderApp(); await installLocal(view); // The first step. OpenBot needs no account, so it installs behind it, exactly once; nothing - // of Intelligence's runs before the agreement. + // of Intelligence's runs before the agreement. The browser sign-in starts without a click. await view.findByRole("heading", { name: "Sign in to CopilotKit" }); + await view.findByText("Finish signing in to CopilotKit in your browser…"); expect( view.getByText( "Your conversations and files are stored on this computer. Signing in does not send them to CopilotKit.", @@ -642,9 +649,7 @@ test("a signed-out first run signs in to CopilotKit and accepts the agreement as ]), ); expect(localSequence()).toEqual([]); - await userEvent.click( - view.getByRole("button", { name: "Sign in to CopilotKit" }), - ); + await act(async () => browser.resolve({ signedIn: true })); await view.findByRole("heading", { name: "Accept the license agreement" }); expect( view.getByText( @@ -680,6 +685,132 @@ test("a signed-out first run signs in to CopilotKit and accepts the agreement as expectPlain(view); }); +/** + * A new Mac sat on "Checking your CopilotKit account…" for the whole of Podman's install, and no + * browser opened: the check ran the CLI's `onboard local-terms`, which checks the engine first. The + * CLI's `login` needs no engine. So the sign-in starts the moment the step opens, and the engine is + * waited for only after it, for the agreement. + */ +test("the account step opens the CopilotKit sign-in at once, without waiting for the engine Install is setting up", async () => { + setupRootConfiguration("/tmp/local-eval-sign-in-first", async () => + emptyConfiguration(), + ); + const browser = deferred<{ signedIn: boolean }>(); + withLocalCli({ + // Install is still putting Podman in: no engine answers, for as long as this test runs. + prepare_installation: () => new Promise(() => {}), + local_eval_terms: () => ({ status: "engine-not-ready" }), + sign_in_to_copilotkit: () => browser.promise, + copilotkit_sign_in_page: () => ({ + url: "https://dashboard.copilotkit.ai/cli-auth/device?user_code=WXYZ-2345", + userCode: "WXYZ-2345", + }), + }); + const view = await renderApp(); + await installLocal(view); + await emitProgress({ + step: "install-engine", + ok: true, + running: true, + detail: "Downloading Podman.", + }); + + // The browser sign-in, first, with nothing asked of the engine before it. + await view.findByText("Finish signing in to CopilotKit in your browser…"); + expect(view.getByText("WXYZ-2345")).toBeTruthy(); + const accountCalls = () => + invokeCalls + .map((call) => call.command) + .filter((command) => + ["sign_in_to_copilotkit", "local_eval_terms"].includes(command), + ); + expect(accountCalls()).toEqual(["sign_in_to_copilotkit"]); + + // Signed in, the agreement waits for the engine, and the step says what it waits on. + await act(async () => browser.resolve({ signedIn: true })); + await view.findByRole("heading", { name: "Setting up this computer" }); + expect( + view.getByText( + "You're signed in. OpenBot is setting up the software it runs on. The license agreement comes next.", + ), + ).toBeTruthy(); + expectWorking(view, "Downloading Podman."); + expect(accountCalls()[0]).toBe("sign_in_to_copilotkit"); + expect(accountCalls()).toContain("local_eval_terms"); +}); + +// What Install says while it waits for Continue (`install::PODMAN_GO_AHEAD`), and once it has it. +const PODMAN_READY = "Podman is downloaded and ready to install."; +const PODMAN_PROMPTING = + "Installing Podman. Enter your Mac password when macOS asks for it."; + +/** + * macOS asked for the person's password over the Account step, mid-sign-in, before they knew what + * Podman was. Install now downloads it and waits; the person signs in first, then reads what comes + * next and why, and Continue is what raises the prompt. + */ +test("macOS asks for the password only after the person reads why and chooses Continue, once signed in", async () => { + setupRootConfiguration("/tmp/local-eval-podman-go-ahead", async () => + emptyConfiguration(), + ); + const browser = deferred<{ signedIn: boolean }>(); + withLocalCli({ + prepare_installation: () => new Promise(() => {}), + local_eval_terms: () => ({ status: "engine-not-ready" }), + sign_in_to_copilotkit: () => browser.promise, + copilotkit_sign_in_page: () => null, + allow_podman_install: () => true, + }); + const view = await renderApp(); + await installLocal(view); + await view.findByText("Checking your CopilotKit account…"); + + // Downloaded while the person signs in. Nothing about it is asked of them in the meantime. + await emitProgress({ + step: "install-engine", + ok: true, + running: true, + asks: true, + detail: PODMAN_READY, + }); + expect(view.queryByRole("heading", { name: "Install Podman" })).toBeNull(); + expect(view.queryByRole("button", { name: "Continue" })).toBeNull(); + + // Signed in: what happens next and why, with one button, and nothing raised until it is chosen. + await act(async () => browser.resolve({ signedIn: true })); + await view.findByRole("heading", { name: "Install Podman" }); + expect( + view.getByText( + "OpenBot installs Podman, which runs OpenBot's back end on this computer. macOS asks for your password once to install it.", + ), + ).toBeTruthy(); + const waiting = view + .getAllByRole("status") + .find((line) => line.textContent === PODMAN_READY); + expect(waiting?.querySelector(".step-spinner")).toBeNull(); + expect( + view.getAllByRole("button").map((button) => button.textContent), + ).toEqual(["Continue"]); + const allowed = () => + invokeCalls.filter((call) => call.command === "allow_podman_install"); + expect(allowed()).toHaveLength(0); + + await userEvent.click(view.getByRole("button", { name: "Continue" })); + expect(allowed()).toHaveLength(1); + + // Install raises the prompt, and the step says what to do in it. + await emitProgress({ + step: "install-engine", + ok: true, + running: true, + elsewhere: true, + detail: PODMAN_PROMPTING, + }); + expect(view.queryByRole("heading", { name: "Install Podman" })).toBeNull(); + expect(view.queryByRole("button", { name: "Continue" })).toBeNull(); + expectWorking(view, PODMAN_PROMPTING); +}); + test("the account step waits for a container engine Install is still setting up, rather than failing", async () => { // A new Windows computer installs Podman while the person signs in, and until an engine answers // the CLI says nothing about the account ("engine-not-ready"). That was shown as "Couldn't check @@ -728,7 +859,7 @@ test("the account step says Docker Desktop is starting while Install waits on it }); const view = await renderApp(); await installLocal(view); - await view.findByRole("heading", { name: "Sign in to CopilotKit" }); + await view.findByRole("heading", { name: "Setting up this computer" }); await emitProgress({ step: "install-engine", @@ -765,7 +896,7 @@ test("a line about OpenBot's own work is said on the account step only while it }); const view = await renderApp(); await installLocal(view); - await view.findByRole("heading", { name: "Sign in to CopilotKit" }); + await view.findByRole("heading", { name: "Setting up this computer" }); await emitProgress({ step: "install-engine", ok: true, @@ -878,9 +1009,10 @@ test("a first run already signed in with the agreement accepted passes the accou const view = await renderApp(); await installLocal(view); await view.findByRole("heading", { name: "Connect your AI" }); + // The CLI's sign-in answered at once from the sign-in it had: asked once, and nothing clicked. expect( - invokeCalls.some((call) => call.command === "sign_in_to_copilotkit"), - ).toBe(false); + invokeCalls.filter((call) => call.command === "sign_in_to_copilotkit"), + ).toHaveLength(1); await waitFor(() => expect(localSequence()).toContain("set_up_local_eval")); expect( invokeCalls.find((call) => call.command === "set_up_local_eval")?.args, diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 29980e766a..9047b21cb5 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -27,8 +27,10 @@ import { } from "./ProviderPicker"; import { Sections } from "./Sections"; import { StartFresh } from "./StartFresh"; +import { PodmanGoAhead } from "./PodmanGoAhead"; import { engineNow, + goAheadNow, handOffNow, INSTALL_BLURB, type Progress, @@ -722,6 +724,10 @@ function Setup() { if (localAuto) local.start(); }, [localAuto, resumingStart]); + // Install waits for the person before macOS asks for their password: whichever screen they are + // on says why, with Continue. See `install::GoAhead`. + const goAhead = goAheadNow(steps); + // Quit outranks every screen: the app is closing, whatever was on it. const quitting = useQuitStatus(false); if (quitting) return ; @@ -802,6 +808,8 @@ function Setup() { steps={plainInstallSteps(steps, busy)} showElapsed={false} /> + {/* Install waits for the person before macOS asks for their password. Its row says so. */} + {goAhead && } {/* Intelligence sets itself up alongside, once the install has Docker answering. */} {displayedFailure && } @@ -856,6 +864,8 @@ function Setup() { // while the account check waits on the engine, it says what Install is doing about it. handOff={handOffNow(steps)} engine={engineNow(steps)} + // Once signed in, the step explains the password prompt Install waits to raise. + goAhead={goAhead} onDone={(acceptedVersion) => { if (acceptedVersion) local.acceptTerms(acceptedVersion); // Intelligence sets itself up once the install has Docker answering; see above. The @@ -950,6 +960,7 @@ function Setup() { : "Your installation and saved connections are ready to reopen."}

+ {goAhead && } {updatingLine} {displayedFailure && } {/* While a local step runs, its Cancel is the only control. */} @@ -1024,6 +1035,7 @@ function Setup() { it’s ready. This can take a few minutes.

+ {goAhead && } {updatingLine} {/* Leftover data is a choice, offered above, not a failure to report here. */} {displayedFailure && leftoverVolumes.length === 0 && ( diff --git a/desktop/src/CopilotKitAccount.test.tsx b/desktop/src/CopilotKitAccount.test.tsx index 5af36d1aa2..0813e223ed 100644 --- a/desktop/src/CopilotKitAccount.test.tsx +++ b/desktop/src/CopilotKitAccount.test.tsx @@ -32,6 +32,18 @@ const PAGE = { userCode: "WXYZ-2345", }; +/** + * The CLI's `login` for a sign-in it already has: answered at once, with no browser. Every other + * command goes to `handler`. + */ +function alreadySignedIn(handler: (call: Call) => Promise) { + return async (call: Call) => { + if (call.command === "sign_in_to_copilotkit") return { signedIn: true }; + if (call.command === "copilotkit_sign_in_page") return null; + return handler(call); + }; +} + /** * A signed-out account whose sign-in waits on the browser until it is cancelled, as it did for an * account in several organizations that landed on the CopilotKit dashboard. @@ -60,19 +72,16 @@ function signInAttempt() { ?.args?.attempt; } +/** The step opens: its sign-in starts by itself, with no click. */ async function startSigningIn() { - const screen = render( + return render( {}} />, ); - await userEvent.click( - await screen.findByRole("button", { name: "Sign in to CopilotKit" }), - ); - return screen; } /** OpenBot's own engine check: a Docker with a full disk is caught here, before Start fails on it. */ test("an engine short of disk says how to raise it, and Try again checks again", async () => { - invokeHandler = async ({ command }) => { + invokeHandler = alreadySignedIn(async ({ command }) => { if (command === "local_eval_terms") return { status: "engine-too-small", @@ -82,7 +91,7 @@ test("an engine short of disk says how to raise it, and Try again checks again", disk: true, }; throw new Error(`unexpected ${command}`); - }; + }); const screen = render( {}} />, ); @@ -118,10 +127,10 @@ const orbStack = JSON.parse( * with what to do about it, and nothing is asked again until Try again. */ test("an engine local setup doesn't run on is said at once, with how to switch", async () => { - invokeHandler = async ({ command }) => { + invokeHandler = alreadySignedIn(async ({ command }) => { if (command === "local_eval_terms") return orbStack; throw new Error(`unexpected ${command}`); - }; + }); const screen = render( {}} />, ); @@ -178,13 +187,13 @@ function expectWorking(screen: ReturnType, said: string) { /** * 1.0.0 on a new Mac said only "Checking your CopilotKit account…" for minutes while Install put - * Podman in behind this step. It says what it is waiting on, in Install's own words as Install - * moves on, and that sign-in comes next. Then it goes on to sign-in by itself. + * Podman in behind this step. Signed in first, it says what the agreement is waiting on, in + * Install's own words as Install moves on. Then it goes on to the agreement by itself. */ -test("while Install sets up the engine, the account step says so, then goes on to sign-in", async () => { +test("while Install sets up the engine, the signed-in account step says so, then goes on to the agreement", async () => { let asked = 0; let answerSecond!: (terms: unknown) => void; - invokeHandler = async ({ command }) => { + invokeHandler = alreadySignedIn(async ({ command }) => { if (command === "local_eval_terms") { asked += 1; if (asked === 1) return noEngine; @@ -192,10 +201,16 @@ test("while Install sets up the engine, the account step says so, then goes on t return new Promise((resolve) => { answerSecond = resolve; }); - return { status: "signed-out" }; + return { + status: "required", + accepted: false, + version: "developer-edition-2026-10-01-web", + url: "https://copilotkit.ai/legal/developer-edition", + organizationName: "Acme", + }; } throw new Error(`unexpected ${command}`); - }; + }); const account = (engine: string) => ( ); - const screen = render( - account( - "Installing Podman. Enter your Mac password when macOS asks for it.", - ), - ); + const screen = render(account("Preparing the engine machine.")); await screen.findByText( - "OpenBot is setting up the software it runs on. Your CopilotKit sign-in comes next.", - ); - expectWorking( - screen, - "Installing Podman. Enter your Mac password when macOS asks for it.", + "You're signed in. OpenBot is setting up the software it runs on. The license agreement comes next.", ); + expect( + screen.getByRole("heading", { name: "Setting up this computer" }), + ).toBeTruthy(); + expectWorking(screen, "Preparing the engine machine."); expect(screen.queryByText("Checking your CopilotKit account…")).toBeNull(); // Install moves on, and so does what the step says. Asking again, it still says it. - screen.rerender(account("Preparing the engine machine.")); - expectWorking(screen, "Preparing the engine machine."); + screen.rerender(account("Starting the engine machine.")); + expectWorking(screen, "Starting the engine machine."); await waitFor(() => expect(asked).toBe(2), { timeout: 3000 }); - expectWorking(screen, "Preparing the engine machine."); + expectWorking(screen, "Starting the engine machine."); expect(screen.queryByText("Checking your CopilotKit account…")).toBeNull(); answerSecond(noEngine); - // The engine answers: the step is about signing in again. + // The engine answers: the agreement. expect( await screen.findByRole( - "button", - { name: "Sign in to CopilotKit" }, + "heading", + { name: "Accept the license agreement" }, { timeout: 5000 }, ), ).toBeTruthy(); expect(asked).toBe(3); - expect(screen.queryByText("Preparing the engine machine.")).toBeNull(); + expect(screen.queryByText("Starting the engine machine.")).toBeNull(); + // Signed in once, at the start: never asked again. + expect( + invokeCalls.filter((call) => call.command === "sign_in_to_copilotkit"), + ).toHaveLength(1); +}, 15000); + +/** + * Closing macOS's password prompt without a password is a decision: Install asks again, the step + * says why, and Continue works again. + */ +test("a closed password prompt is asked again, with Continue", async () => { + invokeHandler = alreadySignedIn(async ({ command }) => { + if (command === "local_eval_terms") return noEngine; + if (command === "allow_podman_install") return true; + throw new Error(`unexpected ${command}`); + }); + const account = (goAhead: string) => ( + {}} + goAhead={goAhead} + /> + ); + const screen = render(account("Podman is downloaded and ready to install.")); + await screen.findByRole("heading", { name: "Install Podman" }); + await userEvent.click(screen.getByRole("button", { name: "Continue" })); + expect( + (screen.getByRole("button", { name: "Continue" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + + screen.rerender( + account("macOS didn't get your password, so Podman isn't installed yet."), + ); expect( screen.getByText( - "OpenBot runs on this computer with your CopilotKit account. Sign in, then accept the license agreement.", + "macOS didn't get your password, so Podman isn't installed yet.", ), ).toBeTruthy(); -}, 15000); + const again = screen.getByRole("button", { + name: "Continue", + }) as HTMLButtonElement; + expect(again.disabled).toBe(false); + await userEvent.click(again); + expect( + invokeCalls.filter((call) => call.command === "allow_podman_install"), + ).toHaveLength(2); +}); /** A refusal with no sentence of the CLI's own still says what is wrong, and is not waited out. */ test("a refused engine with no sentence from the CLI says so in OpenBot's words", async () => { - invokeHandler = async ({ command }) => { + invokeHandler = alreadySignedIn(async ({ command }) => { if (command === "local_eval_terms") return { status: "blocked", reason: "unsupported-engine" }; throw new Error(`unexpected ${command}`); - }; + }); const screen = render( {}} />, ); @@ -319,18 +372,14 @@ function expectCopilotKitLogo(screen: ReturnType) { * The step signs in to a CopilotKit account, so it shows CopilotKit's logotype, signed out and * while the sign-in waits. It showed OpenBot's mark alone. */ -test("the CopilotKit sign-in shows CopilotKit's logotype, before and while it waits", async () => { +test("the CopilotKit sign-in shows CopilotKit's logotype, while it waits and once stopped", async () => { waitingSignIn(); - const screen = render( - {}} />, - ); - await screen.findByRole("button", { name: "Sign in to CopilotKit" }); + const screen = await startSigningIn(); + await screen.findByText(PAGE.userCode); expectCopilotKitLogo(screen); - await userEvent.click( - screen.getByRole("button", { name: "Sign in to CopilotKit" }), - ); - await screen.findByText(PAGE.userCode); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + await screen.findByRole("button", { name: "Sign in to CopilotKit" }); expectCopilotKitLogo(screen); }); @@ -338,7 +387,7 @@ test("after Accept, the step says it is setting up your apps, not to sign in", a // Seen on Windows: Accept was followed for two seconds by "Sign in to CopilotKit" and its // sign-in lede, over "Connecting your apps…", for a person already signed in and agreed. let connected!: () => void; - invokeHandler = async ({ command }) => { + invokeHandler = alreadySignedIn(async ({ command }) => { if (command === "local_eval_terms") return { status: "required", @@ -353,7 +402,7 @@ test("after Accept, the step says it is setting up your apps, not to sign in", a }); if (command === "cancel_local_eval") return null; throw new Error(`unexpected ${command}`); - }; + }); let done: string | null | undefined; const screen = render( } | { at: "apps"; version: string | null } | { at: "apps-failed"; version: string | null; problem: Problem } - | { at: "failed"; said: string } + /** `retry` is what Try again starts again: the sign-in, or the check after it. */ + | { at: "failed"; said: string; retry: "sign-in" | "check" } /** The CLI refused the container engine. Trying again by itself would not change it. */ | { at: "unsupported-engine"; said: string; switchEngine: boolean }; const COULD_NOT_CHECK = "Couldn’t check your CopilotKit account. Try again."; +const SIGN_IN_DID_NOT_FINISH = "Sign-in didn’t finish. Try again."; const SETTING_UP_ENGINE = - "OpenBot is setting up the software it runs on. Your CopilotKit sign-in comes next."; + "You're signed in. OpenBot is setting up the software it runs on. The license agreement comes next."; const ENGINE_REFUSED = "OpenBot's back end can't run on the container engine this computer uses."; const BLOCKED = "OpenBot's back end can't run on this computer as it's set up."; @@ -57,6 +63,12 @@ const ENGINE_WAIT_LIMIT_MS = 15 * 60 * 1000; * local evaluation licence is issued to this account and under this agreement. Somebody already * signed in who has accepted the current agreement passes through it without a click. * + * The sign-in comes first, the moment the step opens, and waits for nothing. The CLI's `login` + * needs no container engine: it answers at once for a sign-in it already has, and otherwise opens + * the browser. Only the agreement waits for the engine (`onboard local-terms` checks it first). On a + * new Mac that is minutes of Podman installing behind this step, and the sign-in waited behind it + * too: people who left in that time had never signed in, so nobody knew who they were. + * * Accepting is the person's own act: they read the agreement at its address and click Accept here, * and that click is what setup later passes to the CLI as the version they accepted (a product * decision of 2026-10-22). Nothing accepts it without the click. @@ -72,6 +84,7 @@ export function CopilotKitAccount({ failure, handOff = null, engine = null, + goAhead = null, }: { /** The installation folder the apps are connected for. */ root: string; @@ -86,6 +99,12 @@ export function CopilotKitAccount({ * account check waits for an engine. */ engine?: string | null; + /** + * Install, behind this step, waits for the person's go-ahead before macOS asks for their + * password to install Podman: its line, from `goAheadNow`. Once signed in, the step explains and + * offers Continue. + */ + goAhead?: string | null; /** Signed in and agreed: the version accepted here, or null when nothing needed accepting. */ onDone: (acceptedVersion: string | null) => void; /** A failure from setup itself, shown inside this card rather than under it. */ @@ -155,7 +174,11 @@ export function CopilotKitAccount({ } waitingSince.current = null; if (terms.status === "engine-too-small") - setSeen({ at: "failed", said: engineTooSmallSaid(terms) }); + setSeen({ + at: "failed", + said: engineTooSmallSaid(terms), + retry: "check", + }); // Refused, not missing: said at once, in the CLI's own sentence when it gave one. else if ( terms.status === "blocked" && @@ -167,7 +190,11 @@ export function CopilotKitAccount({ switchEngine: terms.switchEngine === true, }); else if (terms.status === "blocked") - setSeen({ at: "failed", said: terms.said ?? BLOCKED }); + setSeen({ + at: "failed", + said: terms.said ?? BLOCKED, + retry: "check", + }); else if (terms.status === "signed-out") setSeen({ at: "signed-out" }); else if (terms.status === "required" && !terms.accepted) setSeen({ at: "agreement", terms }); @@ -175,51 +202,61 @@ export function CopilotKitAccount({ setSeen({ at: "failed", said: "The license agreement isn’t available right now. Try again later.", + retry: "check", }); // Accepted already, or none needed: nothing to accept here. else if (terms.status === "required" || terms.status === "not-required") connectApps(null); // A status this window does not know is never taken as nothing to do. - else setSeen({ at: "failed", said: COULD_NOT_CHECK }); + else setSeen({ at: "failed", said: COULD_NOT_CHECK, retry: "check" }); }, () => { if (attempt.current !== started.attempt) return; attempt.current = null; waitingSince.current = null; - setSeen({ at: "failed", said: COULD_NOT_CHECK }); + setSeen({ at: "failed", said: COULD_NOT_CHECK, retry: "check" }); }, ); }, [connectApps]); const checkAgain = useRef(check); checkAgain.current = check; - // biome-ignore lint/correctness/useExhaustiveDependencies: once, on arrival. - useEffect(() => { - check(); - return () => { - // Leaving stops whatever is still waiting, the browser sign-in included. - if (askAgain.current) clearTimeout(askAgain.current); - if (attempt.current) cancelLocalEval(attempt.current).catch(() => {}); - attempt.current = null; - }; - }, []); - - async function signIn() { + /** Signed in during this step: from here on, the step is about the agreement and the engine. */ + const signedIn = useRef(false); + /** + * The CLI's `login`: done at once when it already has a sign-in, otherwise it opens the browser + * and waits for the person to approve. Then the agreement. + */ + const signIn = useCallback(async () => { const started = signInToCopilotKit(); attempt.current = started.attempt; setSeen({ at: "signing-in", attempt: started.attempt }); + const failed = () => + setSeen({ at: "failed", said: SIGN_IN_DID_NOT_FINISH, retry: "sign-in" }); try { - const signedIn = await started.signedIn; + const signed = await started.signedIn; if (attempt.current !== started.attempt) return; attempt.current = null; - if (signedIn) check(); - else setSeen({ at: "failed", said: "Sign-in didn’t finish. Try again." }); + if (!signed) return failed(); + signedIn.current = true; + checkAgain.current(); } catch { if (attempt.current !== started.attempt) return; attempt.current = null; - setSeen({ at: "failed", said: "Sign-in didn’t finish. Try again." }); + failed(); } - } + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: once, on arrival. + useEffect(() => { + void signIn(); + return () => { + // Leaving stops whatever is still waiting, the browser sign-in included. + if (askAgain.current) clearTimeout(askAgain.current); + if (attempt.current) cancelLocalEval(attempt.current).catch(() => {}); + attempt.current = null; + }; + }, []); /** Stop the CLI's sign-in and go back to the start of this step. */ function cancelSignIn() { @@ -236,6 +273,12 @@ export function CopilotKitAccount({ const appsFailed = seen.at === "apps-failed"; const refused = seen.at === "unsupported-engine" ? seen : null; const waitingForEngine = seen.at === "waiting-for-engine"; + // Signed in, and Install waits for the person before macOS asks for their password. Not while + // they sign in: only the step that needs them waits for them, and the sign-in comes first. + const asking = + goAhead !== null && + signedIn.current && + (waitingForEngine || seen.at === "checking"); return (
{/* OpenBot is what is being set up, so its mark heads this step as it heads every other. @@ -243,28 +286,34 @@ export function CopilotKitAccount({

- {agreement - ? "Accept the license agreement" - : connecting - ? "Setting up your apps" - : appsFailed - ? "Connect your apps" - : refused - ? refused.switchEngine - ? "Switch to Docker Desktop or Podman" - : "Change your container engine setup" - : "Sign in to CopilotKit"} + {asking + ? "Install Podman" + : agreement + ? "Accept the license agreement" + : connecting + ? "Setting up your apps" + : appsFailed + ? "Connect your apps" + : refused + ? refused.switchEngine + ? "Switch to Docker Desktop or Podman" + : "Change your container engine setup" + : waitingForEngine + ? "Setting up this computer" + : "Sign in to CopilotKit"}

- {agreement - ? `To run OpenBot on this computer, accept the CopilotKit license agreement for ${agreement.organizationName}.` - : connecting || appsFailed - ? "OpenBot uses your CopilotKit account to set up apps like Gmail and Slack." - : refused - ? refused.said - : waitingForEngine - ? SETTING_UP_ENGINE - : "OpenBot runs on this computer with your CopilotKit account. Sign in, then accept the license agreement."} + {asking + ? PODMAN_EXPLAINED + : agreement + ? `To run OpenBot on this computer, accept the CopilotKit license agreement for ${agreement.organizationName}.` + : connecting || appsFailed + ? "OpenBot uses your CopilotKit account to set up apps like Gmail and Slack." + : refused + ? refused.said + : waitingForEngine + ? SETTING_UP_ENGINE + : "OpenBot runs on this computer with your CopilotKit account. Sign in, then accept the license agreement."}

{refused?.switchEngine && (
    @@ -283,30 +332,42 @@ export function CopilotKitAccount({ {refused && !refused.switchEngine && (

    Make the change, then choose Try again.

    )} - {!agreement && !connecting && !appsFailed && !refused && ( -

    - Your conversations and files are stored on this computer. Signing in - does not send them to CopilotKit. -

    - )} + {/* Said where signing in is what the step is about, not once it is done. */} + {!agreement && + !connecting && + !appsFailed && + !refused && + !waitingForEngine && + !asking && ( +

    + Your conversations and files are stored on this computer. Signing in + does not send them to CopilotKit. +

    + )} - {/* The account check waits on the engine too. While it does, this says what Install is doing - to bring it up, as Install's own row says it. While Docker Desktop starts or a password - prompt is up, it says what the person may have to do in that window, whatever the step - is at. */} - {waitingForEngine ? ( + {/* The agreement waits on the engine. While it does, this says what Install is doing to bring + it up, as Install's own row says it. While Docker Desktop starts or a password prompt is + up, it says what the person may have to do in that window, whatever the step is at. When + Install waits for the person instead, it says that, and Continue goes on to the prompt. */} + {asking ? ( + <> + {goAhead} + + + ) : waitingForEngine ? ( {engine ?? handOff ?? "Preparing this computer…"} ) : ( handOff && {handOff} )} - {seen.at === "checking" && !handOff && ( + {seen.at === "checking" && !handOff && !asking && ( Checking your CopilotKit account… )} {seen.at === "signing-in" && ( @@ -357,7 +418,15 @@ export function CopilotKitAccount({ )} - {(seen.at === "failed" || refused) && ( + {seen.at === "failed" && ( + + )} + {refused && ( diff --git a/desktop/src/CopilotKitSignInWait.tsx b/desktop/src/CopilotKitSignInWait.tsx index 0d1b004e2d..cb63da0323 100644 --- a/desktop/src/CopilotKitSignInWait.tsx +++ b/desktop/src/CopilotKitSignInWait.tsx @@ -21,12 +21,18 @@ const ASK_EVERY_MS = 500; export function CopilotKitSignInWait({ attempt, waiting, + before = waiting, onCancel, }: { /** The sign-in being waited on (`signInToCopilotKit`). */ attempt: string; /** What is said while it waits. */ waiting: string; + /** + * What is said until the CLI has opened a page. It checks for a sign-in it already has first, and + * opens nothing when it finds one. + */ + before?: string; onCancel: () => void; }) { const [page, setPage] = useState(null); @@ -55,7 +61,7 @@ export function CopilotKitSignInWait({ return ( <> - {waiting} + {page ? waiting : before} {page && ( <>

    diff --git a/desktop/src/PodmanGoAhead.tsx b/desktop/src/PodmanGoAhead.tsx new file mode 100644 index 0000000000..eaa689b7b3 --- /dev/null +++ b/desktop/src/PodmanGoAhead.tsx @@ -0,0 +1,66 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useState } from "react"; +import { StatusLine } from "./SetupProgress"; + +/** What happens next and why, said before macOS asks for the person's password. */ +export const PODMAN_EXPLAINED = + "OpenBot installs Podman, which runs OpenBot's back end on this computer. macOS asks for your password once to install it."; + +/** + * Continue, under the explanation: Install raises macOS's password prompt (`install::GoAhead`). + * + * The prompt used to come up by itself, over the Account step, before the person knew what Podman + * was. Apple's guidance for a screen shown before a system prompt is one button, titled to say it + * goes on to the prompt ("Continue", never "Allow"), and no way past it but the prompt. Install + * waits for nothing else, so there is nothing else to offer here. + * + * Give it `key={asking}`: asked again after the prompt was closed without a password, Continue + * works again. + */ +export function ContinueToPodman() { + const [sent, setSent] = useState(false); + return ( +

    + +
    + ); +} + +/** + * Before macOS asks for a password to install Podman, on a screen about something else (Install, + * Start): the card a step that waits on the person gets (`.local-alert.needs`), saying what happens + * next and why, Install's own line about it unless `quiet`, and Continue. + */ +export function PodmanGoAhead({ + asking, + quiet = false, +}: { + /** Install's line while it waits (`goAheadNow`). */ + asking: string; + /** Install's line is said already, in its row on this screen. */ + quiet?: boolean; +}) { + return ( +
    +

    Install Podman

    +

    {PODMAN_EXPLAINED}

    + {!quiet && {asking}} + +
    + ); +} diff --git a/desktop/src/SetupProgress.tsx b/desktop/src/SetupProgress.tsx index 089ba248cc..26e861d38b 100644 --- a/desktop/src/SetupProgress.tsx +++ b/desktop/src/SetupProgress.tsx @@ -34,6 +34,11 @@ export type Progress = { * password prompt. That window is in front of this one. See `handOffNow`. */ elsewhere?: boolean; + /** + * The step waits for the person's go-ahead in this window before it raises the system's password + * prompt (`install::GoAhead`). See `goAheadNow`. + */ + asks?: boolean; }; /** @@ -66,9 +71,23 @@ export function engineNow(steps: Progress[]): string | null { const preparing = [...steps] .reverse() .find((step) => step.running && PREPARING.has(step.step) && step.detail); + // Waiting for the person is not work going on: `goAheadNow` says it, with its own button. + if (preparing?.asks) return null; return preparing?.detail ?? null; } +/** + * The line of a step that waits for the person's go-ahead before macOS asks for their password + * ("Podman is downloaded and ready to install."), or null. The screens explain what comes next + * and offer Continue (`PodmanGoAhead`). + */ +export function goAheadNow(steps: Progress[]): string | null { + const asking = [...steps] + .reverse() + .find((step) => step.running && step.asks && step.detail); + return asking?.detail ?? null; +} + /** Where OpenBot's own icon is, as this computer names it: macOS has a menu bar, Windows a tray. */ export function trayPlace(userAgent = navigator.userAgent): string { return /Macintosh|Mac OS X/.test(userAgent) diff --git a/docs/desktop-install.md b/docs/desktop-install.md index 223d040679..7d0802cecd 100644 --- a/docs/desktop-install.md +++ b/docs/desktop-install.md @@ -25,12 +25,15 @@ sequenceDiagram participant W as Window (App.tsx) participant N as Native side (src-tauri) participant C as Intelligence CLI (bundled) - P->>W: Get started (Welcome) + P->>W: Install (Welcome) W->>N: prepare_installation (Install starts at once) N->>N: engine, deployment download, image pulls - P->>W: Sign in to CopilotKit (Account) - W->>N: sign_in_to_copilotkit, local_eval_terms - N->>C: local login, terms + W->>N: sign_in_to_copilotkit (Account opens) + N->>C: login (no engine needed; opens the browser) + P->>W: Continue (macOS only, when Podman needs installing) + W->>N: allow_podman_install (the password prompt comes up) + W->>N: local_eval_terms, once an engine answers + N->>C: onboard local-terms P->>W: Accept the evaluation agreement Note over W,N: Once the engine answers, Intelligence setup is queued W->>N: set_up_local_eval @@ -46,8 +49,11 @@ sequenceDiagram 1. **Welcome.** The first click starts Install in the default folder, `~/OpenBot`. Install needs no account, so it runs while the person signs in. -2. **Account.** The person signs in to CopilotKit in the browser and accepts the local evaluation - agreement. Nothing licensed is set up before this acceptance. +2. **Account.** The browser sign-in opens as soon as the step does: the CLI's `login` needs no + container engine, and answers at once for a sign-in it already has. Then the person accepts the + local evaluation agreement, which the CLI reads only once an engine answers (`onboard + local-terms` checks the engine first), so on a new computer the step waits for Install here and + says what Install is doing. Nothing licensed is set up before this acceptance. 3. **Install.** The app makes a container engine answer, lays out the deployment, and pulls the images. The screen shows each image, its size and the download speed (`desktop/src/SetupProgress.tsx`, `src-tauri/src/pull_metrics.rs`). Intelligence setup starts @@ -130,8 +136,16 @@ fill a gap. Three rules hold that boundary: ## Decisions and the reasons for them -- **Install before sign-in.** Downloading software needs no account, and it is the slow part. - Starting it on the first click means the person signs in while it runs. +- **Install before sign-in, sign-in before the engine.** Downloading software needs no account, and + it is the slow part. Starting it on the first click means the person signs in while it runs. The + sign-in waits for nothing: someone who leaves while Podman installs has still signed in. +- **No password prompt without an explanation first.** On macOS, Podman's package needs an + administrator, so macOS asks for the person's password. Install downloads the package, then waits + (`install::GoAhead`) while the window says what comes next and why, with one Continue button, as + Apple's guidance for a screen before a system prompt asks. The prompt names OpenBot and Podman + (`do shell script ... with prompt`). Closing it without a password asks again. Windows installs + Podman for the person's own account with no prompt. When WSL is missing, the step says that + Windows asks for administrator approval before Podman's own installer raises it. - **Licence work after acceptance.** Intelligence setup waits for the evaluation agreement, and uses the version of the terms the person accepted. - **One engine for both.** OpenBot and Intelligence share one container engine. The app uses, in