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."}
- 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