From 3479d27eb0b1b6cfa1dcb8e85a6e3a029471a012 Mon Sep 17 00:00:00 2001 From: Dusty Date: Thu, 22 Oct 2026 12:57:07 -0700 Subject: [PATCH 1/7] fix(desktop): start Docker Desktop when it is installed, install it when it is not; never Podman in its place With Docker Desktop installed but quit, setup found no answering engine and installed Podman behind an administrator prompt: a second engine on a machine that already had Docker, and one local Intelligence refuses. On macOS and Windows the engine is now Docker Desktop. Installed and stopped, it is opened and waited for. Missing, Docker's own installer (4.96.3, pinned to the digests Docker publishes) is downloaded with progress, installed without accepting Docker's terms, and opened so the person accepts them in Docker's window. A Podman somebody already has is still used; a stopped docker with no Docker Desktop (OrbStack, Colima) is left for its owner to start. Linux is unchanged. --- desktop/src-tauri/src/docker_desktop.rs | 569 ++++++++++++++++++++++++ desktop/src-tauri/src/install.rs | 271 ++++++++--- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 40 +- 4 files changed, 808 insertions(+), 73 deletions(-) create mode 100644 desktop/src-tauri/src/docker_desktop.rs diff --git a/desktop/src-tauri/src/docker_desktop.rs b/desktop/src-tauri/src/docker_desktop.rs new file mode 100644 index 0000000000..a7809925f0 --- /dev/null +++ b/desktop/src-tauri/src/docker_desktop.rs @@ -0,0 +1,569 @@ +//! Docker Desktop: started when it is installed, installed when it is not. +//! +//! Docker is the engine OpenBot runs on, and the only one local Intelligence accepts. A Mac or a +//! Windows machine with Docker Desktop installed but not running is not a machine without an engine: +//! starting it is the repair, not installing a second engine beside it. A machine without it gets +//! Docker's own installer. +//! +//! **Podman is never chosen for somebody.** It is for an organization that cannot use Docker +//! Desktop under its licence, and that is a decision a person makes, not a fallback the app takes +//! because Docker happened to be stopped. A Podman somebody already runs is still used (see +//! `install::install_engine_with`); one is never installed in Docker's place. +//! +//! **Docker's terms are accepted by the person, in Docker's own window.** The installer is run +//! without `--accept-license`, so the first start shows Docker's subscription agreement and its +//! privileged-helper prompt, and the wait below allows for somebody reading them. +//! +//! Linux is not handled here: Docker there is the distribution's Docker Engine, not Docker Desktop. + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; + +use crate::engine::{self, Engine}; +use crate::install::Download; +use crate::problem::Problem; + +/// The release whose installers are pinned below, and Docker's build number for it. +/// +/// Pinned for the reason every other download is: these files are executed. The digests come from +/// the `checksums.txt` Docker publishes beside each build, recorded here rather than fetched, since a +/// checksum from the server that served the file proves nothing. Moving the version means +/// re-recording all three. +pub const DOCKER_DESKTOP: &str = "4.96.3"; +const BUILD: &str = "244472"; + +/// How long a Docker Desktop that was already installed gets to start answering. +const START_PATIENCE: Duration = Duration::from_secs(180); +/// How long a first start gets: it shows Docker's terms and a helper prompt, which somebody reads. +const FIRST_START_PATIENCE: Duration = Duration::from_secs(15 * 60); +const POLL: Duration = Duration::from_secs(2); + +/// Docker Desktop's installer for this machine, or why there is not one. +pub fn download() -> Result { + download_for(std::env::consts::OS, std::env::consts::ARCH) +} + +fn download_for(os: &str, arch: &str) -> Result { + let (path, file, sha256) = + match (os, arch) { + ("macos", "aarch64") => ( + "mac/main/arm64", + "Docker.dmg", + "a3ac78b7de67af169ca58f33a4fa4c26007840870574c80c59a7ab0c7f68d954", + ), + ("macos", "x86_64") => ( + "mac/main/amd64", + "Docker.dmg", + "9d3409c689628d4f42972e4773a700817ff3526b1810a30a8de33fc1379e016b", + ), + ("windows", "x86_64") => ( + "win/main/amd64", + "Docker Desktop Installer.exe", + "3d8adad4f6c86583e0b00ea19a76197c51ccf5c83437c9ed775b739756452507", + ), + (os, arch) => return Err(Problem::with( + "OpenBot cannot install Docker Desktop on this kind of computer. Install Docker, \ + then start OpenBot again.", + format!("no Docker Desktop {DOCKER_DESKTOP} installer for {os} on {arch}"), + )), + }; + Ok(Download { + url: format!( + "https://desktop.docker.com/{path}/{BUILD}/{}", + file.replace(' ', "%20") + ), + sha256, + file, + }) +} + +/// Where Docker Desktop is installed on this machine, if it is. +pub fn installed_app() -> Option { + installed_app_in(&app_places()) +} + +fn installed_app_in(places: &[PathBuf]) -> Option { + places.iter().find(|place| place.exists()).cloned() +} + +fn app_places() -> Vec { + #[cfg(target_os = "macos")] + { + let mut places = vec![PathBuf::from("/Applications/Docker.app")]; + if let Some(home) = std::env::var_os("HOME") { + places.push(PathBuf::from(home).join("Applications/Docker.app")); + } + places + } + #[cfg(target_os = "windows")] + { + std::env::var_os("ProgramFiles") + .map(|files| PathBuf::from(files).join("Docker\\Docker\\Docker Desktop.exe")) + .into_iter() + .collect() + } + #[cfg(target_os = "linux")] + { + Vec::new() + } +} + +/// Whether Docker answers now. +pub fn answers() -> bool { + engine::Address::new(Engine::Docker, None).responds() +} + +/// Downloads and installs Docker Desktop, saying how it is going. +type Install<'a> = &'a mut dyn FnMut(&mut dyn FnMut(Progress)) -> Result<(), Problem>; + +/// What `ensure` needs from the machine, so its decisions can be tested without one. +pub(crate) struct Machine<'a> { + pub installed: &'a dyn Fn() -> bool, + pub answers: &'a dyn Fn() -> bool, + pub install: Install<'a>, + pub open: &'a dyn Fn() -> Result<(), Problem>, + pub sleep: &'a dyn Fn(Duration), + pub now: &'a dyn Fn() -> Instant, +} + +/// One line for the setup screen's step row, with download progress when there is some. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Progress { + pub said: String, + pub downloaded: Option, +} + +impl Progress { + fn said(said: impl Into) -> Self { + Self { + said: said.into(), + downloaded: None, + } + } +} + +/// What `ensure` did, for the step row and for telemetry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Ensured { + pub said: String, + /// True when Docker Desktop was installed by this call. + pub installed: bool, +} + +/// Docker answering, by starting it or installing and starting it. See the module comment. +pub fn ensure(cache: &Path, progress: &mut dyn FnMut(Progress)) -> Result { + let into = crate::acquire::download_dir(cache); + ensure_with( + &mut Machine { + installed: &|| installed_app().is_some(), + answers: &answers, + install: &mut |progress| install(&into, progress), + open: &open, + sleep: &std::thread::sleep, + now: &Instant::now, + }, + progress, + ) +} + +pub(crate) fn ensure_with( + machine: &mut Machine, + progress: &mut dyn FnMut(Progress), +) -> Result { + if (machine.answers)() { + return Ok(Ensured { + said: "Docker Desktop is running.".into(), + installed: false, + }); + } + let fresh = !(machine.installed)(); + if fresh { + (machine.install)(progress)?; + } + progress(Progress::said(if fresh { + "Starting Docker Desktop. Accept its terms in its own window when it asks." + } else { + "Starting Docker Desktop." + })); + (machine.open)()?; + + let patience = if fresh { + FIRST_START_PATIENCE + } else { + START_PATIENCE + }; + let started = (machine.now)(); + while (machine.now)().duration_since(started) < patience { + if (machine.answers)() { + return Ok(Ensured { + said: if fresh { + format!("Docker Desktop {DOCKER_DESKTOP} installed and running.") + } else { + "Docker Desktop is running.".into() + }, + installed: fresh, + }); + } + (machine.sleep)(POLL); + } + Err(Problem::with( + "Docker Desktop did not start. Open Docker Desktop, finish anything it asks for, then try \ + again.", + format!( + "Docker did not answer within {}s of opening Docker Desktop", + patience.as_secs() + ), + )) +} + +/// Open Docker Desktop the way the person would. +fn open() -> Result<(), Problem> { + let Some(app) = installed_app() else { + return Err(Problem::plain( + "OpenBot cannot find Docker Desktop. Install it, then try again.", + )); + }; + #[cfg(target_os = "macos")] + let spawned = crate::quiet::command("open").arg("-a").arg(&app).status(); + #[cfg(not(target_os = "macos"))] + let spawned = crate::quiet::command(&app) + .spawn() + .map(|_| Default::default()); + match spawned { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(Problem::with( + "OpenBot could not open Docker Desktop. Open it yourself, then try again.", + format!("opening {} ended with {status}", app.display()), + )), + Err(error) => Err(Problem::with( + "OpenBot could not open Docker Desktop. Open it yourself, then try again.", + format!("opening {}: {error}", app.display()), + )), + } +} + +/// Download Docker Desktop's installer and install it, without accepting its terms. +fn install(into: &Path, progress: &mut dyn FnMut(Progress)) -> Result<(), Problem> { + let download = download()?; + let installer = fetch_streamed(&download, into, progress)?; + progress(Progress::said("Installing Docker Desktop.")); + install_from(&installer, into) +} + +#[cfg(target_os = "macos")] +fn install_from(dmg: &Path, into: &Path) -> Result<(), Problem> { + let mount = into.join("docker-desktop-volume"); + let _ = std::fs::create_dir_all(&mount); + let attached = crate::quiet::command("hdiutil") + .args([ + "attach", + "-nobrowse", + "-readonly", + "-noautoopen", + "-mountpoint", + ]) + .arg(&mount) + .arg(dmg) + .output() + .map_err(|error| install_failed(format!("hdiutil attach: {error}")))?; + if !attached.status.success() { + return Err(install_failed(format!( + "hdiutil attach: {}", + crate::quiet::said(&attached.stderr) + ))); + } + // Copied as the person, which an administrator account can do into /Applications with no + // prompt. Docker's own first start then asks for its privileged helper. + let copied = crate::quiet::command("ditto") + .arg(mount.join("Docker.app")) + .arg("/Applications/Docker.app") + .output(); + let _ = crate::quiet::command("hdiutil") + .args(["detach", "-quiet"]) + .arg(&mount) + .output(); + match copied { + Ok(out) if out.status.success() => Ok(()), + Ok(out) => Err(install_failed(format!( + "ditto Docker.app /Applications: {}", + crate::quiet::said(&out.stderr) + ))), + Err(error) => Err(install_failed(format!("ditto: {error}"))), + } +} + +#[cfg(target_os = "windows")] +fn install_from(exe: &Path, _into: &Path) -> Result<(), Problem> { + // Docker's installer elevates itself through UAC. `install` without `--accept-license` leaves + // the agreement to Docker Desktop's first start, where the person accepts it. + let status = crate::quiet::command(exe) + .arg("install") + .status() + .map_err(|error| install_failed(format!("{}: {error}", exe.display())))?; + if status.success() { + Ok(()) + } else { + Err(install_failed(format!( + "{} install ended with {status}", + exe.display() + ))) + } +} + +#[cfg(target_os = "linux")] +fn install_from(_installer: &Path, _into: &Path) -> Result<(), Problem> { + Err(Problem::plain( + "On Linux, Docker comes from the distribution's own packages.", + )) +} + +fn install_failed(detail: String) -> Problem { + Problem::with( + "OpenBot could not install Docker Desktop. Install it from docker.com, then try again.", + detail, + ) +} + +/// Fetch to `into`, hashing as it streams, and refuse anything whose digest is not the pinned one. +/// +/// Streamed rather than read whole like the smaller downloads: the installer is about 600 MB, and +/// the step row shows how much has arrived. A file already there with the right digest is kept. +fn fetch_streamed( + download: &Download, + into: &Path, + progress: &mut dyn FnMut(Progress), +) -> Result { + let path = into.join(download.file); + if file_digest(&path).as_deref() == Some(download.sha256) { + return Ok(path); + } + std::fs::create_dir_all(into).map_err(|error| unwritable(into, &error.to_string()))?; + + let unreachable = |error: String| { + Problem::with( + "OpenBot could not download Docker Desktop. Check the internet connection and try \ + again.", + format!("{}: {error}", download.url), + ) + }; + let mut response = reqwest::blocking::Client::builder() + .user_agent("openbot-desktop") + .build() + .map_err(|error| unreachable(error.to_string()))? + .get(&download.url) + .send() + .map_err(|error| unreachable(error.to_string()))?; + if !response.status().is_success() { + return Err(unreachable(format!("answered {}", response.status()))); + } + + let partial = into.join(format!("{}.part", download.file)); + let mut out = std::fs::File::create(&partial) + .map_err(|error| unwritable(&partial, &error.to_string()))?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0; 1 << 20]; + let mut received: u64 = 0; + let mut last_said = 0; + loop { + let read = response + .read(&mut buffer) + .map_err(|error| unreachable(format!("the download did not finish: {error}")))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + out.write_all(&buffer[..read]) + .map_err(|error| unwritable(&partial, &error.to_string()))?; + received += read as u64; + if received - last_said >= 8 << 20 { + last_said = received; + progress(Progress { + said: "Downloading Docker Desktop.".into(), + downloaded: Some(received), + }); + } + } + drop(out); + + let got = format!("{:x}", hasher.finalize()); + if got != download.sha256 { + let _ = std::fs::remove_file(&partial); + return Err(Problem::with( + "What OpenBot downloaded is not what it was expecting, so it has not been run. Try \ + again.", + format!( + "{} from {}: expected sha256 {}, got {got}", + download.file, download.url, download.sha256 + ), + )); + } + std::fs::rename(&partial, &path).map_err(|error| unwritable(&path, &error.to_string()))?; + Ok(path) +} + +fn file_digest(path: &Path) -> Option { + let mut file = std::fs::File::open(path).ok()?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher).ok()?; + Some(format!("{:x}", hasher.finalize())) +} + +fn unwritable(path: &Path, error: &str) -> Problem { + Problem::with( + "OpenBot could not save Docker Desktop's installer. Check there is free disk space and try \ + again.", + format!("{}: {error}", path.display()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::{Cell, RefCell}; + + /// A machine whose Docker answers after `answers_after` polls, counting what was done to it. + struct Fake { + installed: Cell, + polls: Cell, + answers_after: Option, + opened: Cell, + installs: Cell, + clock: Cell, + } + + impl Fake { + fn new(installed: bool, answers_after: Option) -> Self { + Self { + installed: Cell::new(installed), + polls: Cell::new(0), + answers_after, + opened: Cell::new(0), + installs: Cell::new(0), + clock: Cell::new(Instant::now()), + } + } + + fn run(&self) -> (Result, Vec) { + let said = RefCell::new(Vec::new()); + let answers = || { + let polls = self.polls.get(); + self.polls.set(polls + 1); + self.opened.get() > 0 && self.answers_after.is_some_and(|after| polls >= after) + }; + let result = ensure_with( + &mut Machine { + installed: &|| self.installed.get(), + answers: &answers, + install: &mut |progress| { + self.installs.set(self.installs.get() + 1); + progress(Progress { + said: "Downloading Docker Desktop.".into(), + downloaded: Some(8 << 20), + }); + self.installed.set(true); + Ok(()) + }, + open: &|| { + self.opened.set(self.opened.get() + 1); + Ok(()) + }, + sleep: &|step| self.clock.set(self.clock.get() + step), + now: &|| self.clock.get(), + }, + &mut |line| said.borrow_mut().push(line), + ); + (result, said.into_inner()) + } + } + + #[test] + fn an_answering_docker_is_used_as_it_is() { + let fake = Fake::new(true, Some(0)); + fake.opened.set(1); + let (result, said) = fake.run(); + assert!(!result.unwrap().installed); + assert_eq!(fake.opened.get(), 1, "nothing was opened again"); + assert_eq!(fake.installs.get(), 0); + assert!(said.is_empty()); + } + + #[test] + fn an_installed_docker_desktop_that_is_stopped_is_started_not_replaced() { + let fake = Fake::new(true, Some(3)); + let (result, said) = fake.run(); + let ensured = result.unwrap(); + assert!(!ensured.installed); + assert_eq!( + fake.installs.get(), + 0, + "an installed Docker is never installed again" + ); + assert_eq!(fake.opened.get(), 1); + assert_eq!(said, [Progress::said("Starting Docker Desktop.")]); + } + + #[test] + fn a_machine_without_docker_desktop_gets_it_installed_then_started() { + let fake = Fake::new(false, Some(2)); + let (result, said) = fake.run(); + let ensured = result.unwrap(); + assert!(ensured.installed); + assert_eq!(fake.installs.get(), 1); + assert_eq!(fake.opened.get(), 1); + assert_eq!(said[0].downloaded, Some(8 << 20)); + // The person accepts Docker's terms themselves, in Docker's window. + assert!(said[1].said.contains("Accept its terms in its own window")); + } + + #[test] + fn a_docker_desktop_that_never_answers_says_so_after_its_patience() { + let fake = Fake::new(true, None); + let (result, _) = fake.run(); + let problem = result.unwrap_err(); + assert!(problem.said.starts_with("Docker Desktop did not start.")); + assert!(problem.detail.unwrap().contains("180s")); + // A first start waits longer: it shows terms somebody reads. + let fresh = Fake::new(false, None); + let problem = fresh.run().0.unwrap_err(); + assert!(problem.detail.unwrap().contains("900s")); + } + + #[test] + fn the_installers_are_pinned_to_docker_s_published_digests() { + for (os, arch) in [ + ("macos", "aarch64"), + ("macos", "x86_64"), + ("windows", "x86_64"), + ] { + let download = download_for(os, arch).unwrap(); + assert!( + download.url.starts_with("https://desktop.docker.com/"), + "{download:?}" + ); + assert!(download.url.contains(&format!("/{BUILD}/")), "{download:?}"); + assert_eq!(download.sha256.len(), 64); + assert!(download + .sha256 + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + } + assert!(download_for("windows", "x86_64") + .unwrap() + .url + .ends_with("Docker%20Desktop%20Installer.exe")); + assert!(download_for("linux", "x86_64").is_err()); + } + + #[test] + fn an_installed_app_is_found_in_any_of_its_places() { + let root = crate::test_support::temp_root("docker-desktop-places"); + let first = root.join("Applications/Docker.app"); + let second = root.join("home/Applications/Docker.app"); + assert_eq!(installed_app_in(&[first.clone(), second.clone()]), None); + std::fs::create_dir_all(&second).unwrap(); + assert_eq!(installed_app_in(&[first, second.clone()]), Some(second)); + } +} diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 2900d3a579..199019517e 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -1,5 +1,11 @@ //! Installing an engine, instead of telling somebody to go and get one. //! +//! **On macOS and Windows the engine is Docker Desktop** (see `docker_desktop`): started when it is +//! installed, installed when it is not. Podman is never installed there for somebody; it is for an +//! organization that cannot use Docker under its licence, and that is their decision. The Podman +//! installers below are kept for that explicit path and for Linux, where Podman comes from the +//! distribution's packages. +//! //! The person this app is for does not have a package manager, has never heard of Podman, and is //! not going to read a release page. Every sentence that begins "install ..." is a step where an //! install stops for good, so the engine is fetched and installed here. @@ -369,35 +375,104 @@ pub(crate) fn verify_bun(binary: &Path) -> Result<(), Problem> { /// somebody presses Start. Answers with the sentence for the step row, or a failure in both /// registers. pub fn install_engine(cache: &Path) -> Result { - install_engine_observed(cache, |_| {}) + install_engine_observed(cache, |_, _| {}, &mut |_| {}) } -/// Observe an actual Podman installer invocation, excluding existing engines and Compose repair. +/// Observe an actual engine install (Docker Desktop, or Podman on Linux), excluding engines that +/// were already here and Compose repair; `progress` is each line for the step row. pub fn install_engine_observed( cache: &Path, - installed: impl FnMut(bool), + installed: impl FnMut(Engine, bool), + progress: &mut dyn FnMut(crate::docker_desktop::Progress), ) -> Result { - install_engine_with(cache, installed, install_podman, place_compose) + install_engine_with( + cache, + Found::here(), + installed, + |cache| crate::docker_desktop::ensure(cache, progress), + install_podman, + place_compose, + ) +} + +/// What engines this machine has, read once so the choice below can be tested on any machine. +#[derive(Debug, Clone, Copy)] +struct Found { + /// Docker Desktop is how this platform gets Docker: macOS and Windows, not Linux. + docker_desktop_platform: bool, + docker_answers: bool, + docker_app_installed: bool, + docker_cli_installed: bool, + podman_installed: bool, +} + +impl Found { + fn here() -> Self { + Self { + docker_desktop_platform: cfg!(any(target_os = "macos", target_os = "windows")), + docker_answers: engine::Address::new(Engine::Docker, None).responds(), + docker_app_installed: crate::docker_desktop::installed_app().is_some(), + docker_cli_installed: engine::program(Engine::Docker).is_some(), + podman_installed: engine::program(Engine::Podman).is_some(), + } + } } fn install_engine_with( cache: &Path, - mut installed: impl FnMut(bool), + found: Found, + mut installed: impl FnMut(Engine, bool), + ensure_docker: impl FnOnce(&Path) -> Result, install_podman: impl FnOnce(&Path) -> Result<(), Problem>, place_compose: impl FnOnce(&Path) -> Result, ) -> Result { let into = crate::acquire::download_dir(cache); + let docker_app_installed = found.docker_app_installed; - // engine_ready can start an installed Podman machine, but cannot start Docker. A stopped - // Docker CLI must not skip installing the Podman that preparation will then try to run. - if engine::program(Engine::Podman).is_some() - || engine::Address::new(Engine::Docker, None).responds() - { + if found.docker_answers { + return place_compose(&into); + } + + if found.docker_desktop_platform { + /* + * DOCKER IS THE ENGINE, AND PODMAN IS NEVER CHOSEN FOR SOMEBODY. + * + * An installed Docker Desktop that is not running is started; a machine with no engine at + * all gets Docker Desktop. This used to install Podman in both cases, behind an + * administrator prompt, which put a second engine on a machine that already had Docker and + * gave local Intelligence an engine it refuses. Podman is for an organization that cannot + * use Docker under its licence: a Podman somebody already has is theirs and is used, and + * one is never installed in Docker's place. See `docker_desktop`. + */ + if docker_app_installed || !found.podman_installed { + // A `docker` with no Docker Desktop behind it is OrbStack, Colima or similar, stopped. + // Installing Docker Desktop over it is not the repair; starting it is, and only they + // can. + if !docker_app_installed && found.docker_cli_installed { + return Err(Problem::with( + "Docker is installed but not running. Start it, then try again.", + "a docker command is installed, Docker Desktop is not, and docker does not \ + answer", + )); + } + let result = ensure_docker(cache); + if !docker_app_installed { + installed(Engine::Docker, result.is_ok()); + } + let ensured = result?; + place_compose(&into)?; + return Ok(ensured.said); + } + return place_compose(&into); + } + + // Linux: the distribution's own Podman, through its package manager. See the module comment. + if found.podman_installed { return place_compose(&into); } let result = install_podman(&into); - installed(result.is_ok()); + installed(Engine::Podman, result.is_ok()); result?; // Installed is not found. The MSI extends the *user's* PATH and this process was started with @@ -665,65 +740,139 @@ mod tests { use super::*; use crate::test_support::temp_root; + fn found(docker_answers: bool, docker_app: bool, docker_cli: bool, podman: bool) -> Found { + Found { + docker_desktop_platform: true, + docker_answers, + docker_app_installed: docker_app, + docker_cli_installed: docker_cli, + podman_installed: podman, + } + } + + fn ensured(installed: bool) -> crate::docker_desktop::Ensured { + crate::docker_desktop::Ensured { + said: if installed { "installed" } else { "started" }.into(), + installed, + } + } + #[test] - #[cfg(windows)] - fn a_stopped_docker_does_not_skip_the_podman_needed_for_setup() { - if crate::test_support::isolated_process( - "install::tests::a_stopped_docker_does_not_skip_the_podman_needed_for_setup", - ) { - return; + fn a_stopped_docker_desktop_is_started_and_podman_is_never_installed_for_it() { + let root = temp_root("stopped-docker-desktop"); + let mut observed = Vec::new(); + // Installed and stopped, with or without a Podman beside it: Docker is started. + for podman in [false, true] { + let said = install_engine_with( + &root, + found(false, true, true, podman), + |engine, ok| observed.push((engine, ok)), + |_| Ok(ensured(false)), + |_| panic!("Podman is never installed in Docker's place"), + |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!(said, "started"); } - let root = temp_root("stopped-docker-install"); - let bin = root.join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - let source = root.join("engine.rs"); - std::fs::write( - &source, - r#"fn main() { - if std::env::var_os("OPENBOT_TEST_DOCKER_RUNNING").is_none() { - std::process::exit(1); - } - println!("1.44"); - }"#, + assert!(observed.is_empty(), "starting is not an install"); + } + + #[test] + fn a_machine_with_no_engine_gets_docker_desktop_not_podman() { + let root = temp_root("no-engine"); + let mut observed = Vec::new(); + let said = install_engine_with( + &root, + found(false, false, false, false), + |engine, ok| observed.push((engine, ok)), + |_| Ok(ensured(true)), + |_| panic!("Podman is never installed in Docker's place"), + |_| Ok("Compose placed".into()), ) .unwrap(); - crate::test_support::compile_fixture(&source, &bin.join("docker.exe")); - std::env::set_var("PATH", &bin); - std::env::set_var("LOCALAPPDATA", root.join("Local")); - std::env::set_var("ProgramFiles", root.join("Program Files")); - std::env::remove_var("OPENBOT_TEST_DOCKER_RUNNING"); - - let found = engine::detect(); - assert_eq!(found.engine, Some(Engine::Docker)); - assert!(!found.responding); - assert!(engine::program(Engine::Podman).is_none()); + assert_eq!(said, "installed"); + assert_eq!(observed, [(Engine::Docker, true)]); + // A failed Docker Desktop install is reported as one, and nothing else is tried. let mut observed = Vec::new(); - let result = install_engine_with( + let problem = install_engine_with( &root, - |success| observed.push(success), - |_| { - // Installation supplies the engine that engine_ready will create/start. - std::fs::copy(bin.join("docker.exe"), bin.join("podman.exe")).unwrap(); - Ok(()) - }, + found(false, false, false, false), + |engine, ok| observed.push((engine, ok)), + |_| Err(Problem::plain("no")), + |_| panic!("no Podman fallback"), + |_| panic!("no Compose without an engine"), + ) + .unwrap_err(); + assert_eq!(problem.said, "no"); + assert_eq!(observed, [(Engine::Docker, false)]); + } + + #[test] + fn a_podman_somebody_already_has_is_theirs_and_is_used() { + let root = temp_root("existing-podman"); + let said = install_engine_with( + &root, + found(false, false, false, true), + |_, _| panic!("nothing is installed"), + |_| panic!("Docker Desktop is not installed over somebody's own Podman"), + |_| panic!("no install"), |_| Ok("Compose placed".into()), - ); - result.expect("setup should install its missing Podman"); - assert_eq!(observed, [true], "the Podman installer must run"); - assert!(engine::program(Engine::Podman).is_some()); - - // A responding Docker still supplies the engine; never install a replacement. - std::fs::remove_file(bin.join("podman.exe")).unwrap(); - std::env::set_var("OPENBOT_TEST_DOCKER_RUNNING", "1"); - assert!(engine::detect().responding); - let result = install_engine_with( + ) + .unwrap(); + assert_eq!(said, "Compose placed"); + } + + #[test] + fn a_stopped_docker_without_docker_desktop_is_left_for_its_owner_to_start() { + let root = temp_root("stopped-orbstack"); + let problem = install_engine_with( &root, - |_| panic!("no Podman installation should be observed"), - |_| panic!("responding Docker must be reused"), + found(false, false, true, false), + |_, _| panic!("nothing is installed"), + |_| panic!("Docker Desktop is not installed over another Docker"), + |_| panic!("no Podman fallback"), + |_| panic!("no Compose without an engine"), + ) + .unwrap_err(); + assert!(problem + .said + .starts_with("Docker is installed but not running.")); + } + + #[test] + fn an_answering_docker_is_used_as_it_is() { + let root = temp_root("answering-docker"); + let said = install_engine_with( + &root, + found(true, false, true, false), + |_, _| panic!("nothing is installed"), + |_| panic!("an answering Docker is not started again"), + |_| panic!("no install"), |_| Ok("Compose placed".into()), - ); - assert_eq!(result.unwrap(), "Compose placed"); - std::fs::remove_dir_all(root).unwrap(); + ) + .unwrap(); + assert_eq!(said, "Compose placed"); + } + + #[test] + fn linux_keeps_its_distribution_podman() { + let root = temp_root("linux-engine"); + let mut observed = Vec::new(); + let linux = Found { + docker_desktop_platform: false, + ..found(false, false, false, false) + }; + install_engine_with( + &root, + linux, + |engine, ok| observed.push((engine, ok)), + |_| panic!("Linux does not get Docker Desktop"), + |_| Ok(()), + |_| Ok("Compose placed".into()), + ) + .unwrap_err(); + // The Podman install ran; it then finds no podman program on this test machine. + assert_eq!(observed, [(Engine::Podman, true)]); } /// Every platform this app runs on has a Compose build, or the stack cannot be raised there. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1a0a49877b..2204fdf5fe 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod acquire; pub mod ask; pub mod deployment; pub mod deployment_release; +pub mod docker_desktop; pub mod engine; pub mod env; pub mod harness; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3877d8b906..1c19ff638f 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -658,20 +658,36 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result telemetry::Engine::Docker, + engine::Engine::Podman => telemetry::Engine::Podman, + }, + outcome: if success { + telemetry::EngineInstallOutcome::Success + } else { + telemetry::EngineInstallOutcome::Failure + }, }, - }, - ); - }) + ); + }, + // Starting or installing Docker Desktop takes minutes; each line says where it is. + &mut |progress| { + report_running( + &progress_app, + "install-engine", + progress.said, + progress.downloaded, + ) + }, + ) }) .await .map_err(|error| { From 3e892f49c6a52ba5268efc28e043c9d6f9d5fb59 Mon Sep 17 00:00:00 2001 From: Dusty Date: Mon, 26 Oct 2026 10:43:07 -0700 Subject: [PATCH 2/7] fix(desktop): name the exit status when opening Docker Desktop off macOS The non-macOS branch mapped a spawned process to Default::default(), leaving the type to inference, which had nothing to go on: error[E0282] on Linux and Windows. It is std::process::ExitStatus's default, which reads as success; a started Docker Desktop is the whole job there. --- desktop/src-tauri/src/docker_desktop.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/docker_desktop.rs b/desktop/src-tauri/src/docker_desktop.rs index a7809925f0..2b7797f8f8 100644 --- a/desktop/src-tauri/src/docker_desktop.rs +++ b/desktop/src-tauri/src/docker_desktop.rs @@ -228,10 +228,13 @@ fn open() -> Result<(), Problem> { }; #[cfg(target_os = "macos")] let spawned = crate::quiet::command("open").arg("-a").arg(&app).status(); + // Starting Docker Desktop's own executable is the whole job, so a started process counts as + // success and its exit is not waited for. The status is named: `Default::default()` here left + // the type to inference, which had nothing to go on, and failed to compile off macOS. #[cfg(not(target_os = "macos"))] let spawned = crate::quiet::command(&app) .spawn() - .map(|_| Default::default()); + .map(|_| std::process::ExitStatus::default()); match spawned { Ok(status) if status.success() => Ok(()), Ok(status) => Err(Problem::with( From 36f69695afd1bbff40fabb514d9cb63a568e649c Mon Sep 17 00:00:00 2001 From: Dusty Date: Tue, 27 Oct 2026 13:48:13 -0700 Subject: [PATCH 3/7] fix(desktop): Docker first, Podman only when there is no Docker The engine choice on macOS and Windows is now: a Docker that answers is used; an installed Docker Desktop that is stopped is started; any other stopped `docker` (OrbStack, Colima) is left for its owner; a machine with no Docker gets Podman, as it did before; an existing Podman is used. OpenBot no longer installs Docker Desktop itself, so its pinned installers, the streamed download and the 15-minute first-start wait are removed. Linux is unchanged. --- desktop/src-tauri/src/docker_desktop.rs | 427 +++--------------------- desktop/src-tauri/src/install.rs | 193 +++++------ desktop/src-tauri/src/main.rs | 11 +- 3 files changed, 125 insertions(+), 506 deletions(-) diff --git a/desktop/src-tauri/src/docker_desktop.rs b/desktop/src-tauri/src/docker_desktop.rs index 2b7797f8f8..e46caa52cd 100644 --- a/desktop/src-tauri/src/docker_desktop.rs +++ b/desktop/src-tauri/src/docker_desktop.rs @@ -1,85 +1,23 @@ -//! Docker Desktop: started when it is installed, installed when it is not. +//! Docker Desktop: started when it is installed and not running. //! -//! Docker is the engine OpenBot runs on, and the only one local Intelligence accepts. A Mac or a -//! Windows machine with Docker Desktop installed but not running is not a machine without an engine: -//! starting it is the repair, not installing a second engine beside it. A machine without it gets -//! Docker's own installer. -//! -//! **Podman is never chosen for somebody.** It is for an organization that cannot use Docker -//! Desktop under its licence, and that is a decision a person makes, not a fallback the app takes -//! because Docker happened to be stopped. A Podman somebody already runs is still used (see -//! `install::install_engine_with`); one is never installed in Docker's place. -//! -//! **Docker's terms are accepted by the person, in Docker's own window.** The installer is run -//! without `--accept-license`, so the first start shows Docker's subscription agreement and its -//! privileged-helper prompt, and the wait below allows for somebody reading them. +//! **Docker first, Podman when there is no Docker.** A Mac or a Windows machine with Docker Desktop +//! installed but not running is not a machine without an engine: starting it is the repair, not +//! installing a second engine beside it. A machine with no Docker at all gets Podman (see +//! `install::install_engine_with`). OpenBot never installs Docker Desktop itself. //! //! Linux is not handled here: Docker there is the distribution's Docker Engine, not Docker Desktop. -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::time::{Duration, Instant}; -use sha2::{Digest, Sha256}; - use crate::engine::{self, Engine}; -use crate::install::Download; use crate::problem::Problem; -/// The release whose installers are pinned below, and Docker's build number for it. -/// -/// Pinned for the reason every other download is: these files are executed. The digests come from -/// the `checksums.txt` Docker publishes beside each build, recorded here rather than fetched, since a -/// checksum from the server that served the file proves nothing. Moving the version means -/// re-recording all three. -pub const DOCKER_DESKTOP: &str = "4.96.3"; -const BUILD: &str = "244472"; - -/// How long a Docker Desktop that was already installed gets to start answering. -const START_PATIENCE: Duration = Duration::from_secs(180); -/// How long a first start gets: it shows Docker's terms and a helper prompt, which somebody reads. -const FIRST_START_PATIENCE: Duration = Duration::from_secs(15 * 60); +/// How long an installed Docker Desktop gets to start answering. A first start shows Docker's +/// terms and a helper prompt in Docker's own window, so this allows for somebody reading them. +const START_PATIENCE: Duration = Duration::from_secs(5 * 60); const POLL: Duration = Duration::from_secs(2); -/// Docker Desktop's installer for this machine, or why there is not one. -pub fn download() -> Result { - download_for(std::env::consts::OS, std::env::consts::ARCH) -} - -fn download_for(os: &str, arch: &str) -> Result { - let (path, file, sha256) = - match (os, arch) { - ("macos", "aarch64") => ( - "mac/main/arm64", - "Docker.dmg", - "a3ac78b7de67af169ca58f33a4fa4c26007840870574c80c59a7ab0c7f68d954", - ), - ("macos", "x86_64") => ( - "mac/main/amd64", - "Docker.dmg", - "9d3409c689628d4f42972e4773a700817ff3526b1810a30a8de33fc1379e016b", - ), - ("windows", "x86_64") => ( - "win/main/amd64", - "Docker Desktop Installer.exe", - "3d8adad4f6c86583e0b00ea19a76197c51ccf5c83437c9ed775b739756452507", - ), - (os, arch) => return Err(Problem::with( - "OpenBot cannot install Docker Desktop on this kind of computer. Install Docker, \ - then start OpenBot again.", - format!("no Docker Desktop {DOCKER_DESKTOP} installer for {os} on {arch}"), - )), - }; - Ok(Download { - url: format!( - "https://desktop.docker.com/{path}/{BUILD}/{}", - file.replace(' ', "%20") - ), - sha256, - file, - }) -} - /// Where Docker Desktop is installed on this machine, if it is. pub fn installed_app() -> Option { installed_app_in(&app_places()) @@ -116,51 +54,20 @@ pub fn answers() -> bool { engine::Address::new(Engine::Docker, None).responds() } -/// Downloads and installs Docker Desktop, saying how it is going. -type Install<'a> = &'a mut dyn FnMut(&mut dyn FnMut(Progress)) -> Result<(), Problem>; - -/// What `ensure` needs from the machine, so its decisions can be tested without one. +/// What `start_with` needs from the machine, so its decisions can be tested without one. pub(crate) struct Machine<'a> { - pub installed: &'a dyn Fn() -> bool, pub answers: &'a dyn Fn() -> bool, - pub install: Install<'a>, pub open: &'a dyn Fn() -> Result<(), Problem>, pub sleep: &'a dyn Fn(Duration), pub now: &'a dyn Fn() -> Instant, } -/// One line for the setup screen's step row, with download progress when there is some. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Progress { - pub said: String, - pub downloaded: Option, -} - -impl Progress { - fn said(said: impl Into) -> Self { - Self { - said: said.into(), - downloaded: None, - } - } -} - -/// What `ensure` did, for the step row and for telemetry. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Ensured { - pub said: String, - /// True when Docker Desktop was installed by this call. - pub installed: bool, -} - -/// Docker answering, by starting it or installing and starting it. See the module comment. -pub fn ensure(cache: &Path, progress: &mut dyn FnMut(Progress)) -> Result { - let into = crate::acquire::download_dir(cache); - ensure_with( - &mut Machine { - installed: &|| installed_app().is_some(), +/// Docker answering, by opening the installed Docker Desktop. Answers with the sentence for the +/// step row; `progress` is each line before that. See the module comment. +pub fn start(progress: &mut dyn FnMut(String)) -> Result { + start_with( + &Machine { answers: &answers, - install: &mut |progress| install(&into, progress), open: &open, sleep: &std::thread::sleep, now: &Instant::now, @@ -169,43 +76,20 @@ pub fn ensure(cache: &Path, progress: &mut dyn FnMut(Progress)) -> Result Result { +pub(crate) fn start_with( + machine: &Machine, + progress: &mut dyn FnMut(String), +) -> Result { if (machine.answers)() { - return Ok(Ensured { - said: "Docker Desktop is running.".into(), - installed: false, - }); + return Ok("Docker Desktop is running.".into()); } - let fresh = !(machine.installed)(); - if fresh { - (machine.install)(progress)?; - } - progress(Progress::said(if fresh { - "Starting Docker Desktop. Accept its terms in its own window when it asks." - } else { - "Starting Docker Desktop." - })); + progress("Starting Docker Desktop. Accept anything it asks in its own window.".into()); (machine.open)()?; - let patience = if fresh { - FIRST_START_PATIENCE - } else { - START_PATIENCE - }; let started = (machine.now)(); - while (machine.now)().duration_since(started) < patience { + while (machine.now)().duration_since(started) < START_PATIENCE { if (machine.answers)() { - return Ok(Ensured { - said: if fresh { - format!("Docker Desktop {DOCKER_DESKTOP} installed and running.") - } else { - "Docker Desktop is running.".into() - }, - installed: fresh, - }); + return Ok("Docker Desktop is running.".into()); } (machine.sleep)(POLL); } @@ -214,7 +98,7 @@ pub(crate) fn ensure_with( again.", format!( "Docker did not answer within {}s of opening Docker Desktop", - patience.as_secs() + START_PATIENCE.as_secs() ), )) } @@ -248,180 +132,6 @@ fn open() -> Result<(), Problem> { } } -/// Download Docker Desktop's installer and install it, without accepting its terms. -fn install(into: &Path, progress: &mut dyn FnMut(Progress)) -> Result<(), Problem> { - let download = download()?; - let installer = fetch_streamed(&download, into, progress)?; - progress(Progress::said("Installing Docker Desktop.")); - install_from(&installer, into) -} - -#[cfg(target_os = "macos")] -fn install_from(dmg: &Path, into: &Path) -> Result<(), Problem> { - let mount = into.join("docker-desktop-volume"); - let _ = std::fs::create_dir_all(&mount); - let attached = crate::quiet::command("hdiutil") - .args([ - "attach", - "-nobrowse", - "-readonly", - "-noautoopen", - "-mountpoint", - ]) - .arg(&mount) - .arg(dmg) - .output() - .map_err(|error| install_failed(format!("hdiutil attach: {error}")))?; - if !attached.status.success() { - return Err(install_failed(format!( - "hdiutil attach: {}", - crate::quiet::said(&attached.stderr) - ))); - } - // Copied as the person, which an administrator account can do into /Applications with no - // prompt. Docker's own first start then asks for its privileged helper. - let copied = crate::quiet::command("ditto") - .arg(mount.join("Docker.app")) - .arg("/Applications/Docker.app") - .output(); - let _ = crate::quiet::command("hdiutil") - .args(["detach", "-quiet"]) - .arg(&mount) - .output(); - match copied { - Ok(out) if out.status.success() => Ok(()), - Ok(out) => Err(install_failed(format!( - "ditto Docker.app /Applications: {}", - crate::quiet::said(&out.stderr) - ))), - Err(error) => Err(install_failed(format!("ditto: {error}"))), - } -} - -#[cfg(target_os = "windows")] -fn install_from(exe: &Path, _into: &Path) -> Result<(), Problem> { - // Docker's installer elevates itself through UAC. `install` without `--accept-license` leaves - // the agreement to Docker Desktop's first start, where the person accepts it. - let status = crate::quiet::command(exe) - .arg("install") - .status() - .map_err(|error| install_failed(format!("{}: {error}", exe.display())))?; - if status.success() { - Ok(()) - } else { - Err(install_failed(format!( - "{} install ended with {status}", - exe.display() - ))) - } -} - -#[cfg(target_os = "linux")] -fn install_from(_installer: &Path, _into: &Path) -> Result<(), Problem> { - Err(Problem::plain( - "On Linux, Docker comes from the distribution's own packages.", - )) -} - -fn install_failed(detail: String) -> Problem { - Problem::with( - "OpenBot could not install Docker Desktop. Install it from docker.com, then try again.", - detail, - ) -} - -/// Fetch to `into`, hashing as it streams, and refuse anything whose digest is not the pinned one. -/// -/// Streamed rather than read whole like the smaller downloads: the installer is about 600 MB, and -/// the step row shows how much has arrived. A file already there with the right digest is kept. -fn fetch_streamed( - download: &Download, - into: &Path, - progress: &mut dyn FnMut(Progress), -) -> Result { - let path = into.join(download.file); - if file_digest(&path).as_deref() == Some(download.sha256) { - return Ok(path); - } - std::fs::create_dir_all(into).map_err(|error| unwritable(into, &error.to_string()))?; - - let unreachable = |error: String| { - Problem::with( - "OpenBot could not download Docker Desktop. Check the internet connection and try \ - again.", - format!("{}: {error}", download.url), - ) - }; - let mut response = reqwest::blocking::Client::builder() - .user_agent("openbot-desktop") - .build() - .map_err(|error| unreachable(error.to_string()))? - .get(&download.url) - .send() - .map_err(|error| unreachable(error.to_string()))?; - if !response.status().is_success() { - return Err(unreachable(format!("answered {}", response.status()))); - } - - let partial = into.join(format!("{}.part", download.file)); - let mut out = std::fs::File::create(&partial) - .map_err(|error| unwritable(&partial, &error.to_string()))?; - let mut hasher = Sha256::new(); - let mut buffer = vec![0; 1 << 20]; - let mut received: u64 = 0; - let mut last_said = 0; - loop { - let read = response - .read(&mut buffer) - .map_err(|error| unreachable(format!("the download did not finish: {error}")))?; - if read == 0 { - break; - } - hasher.update(&buffer[..read]); - out.write_all(&buffer[..read]) - .map_err(|error| unwritable(&partial, &error.to_string()))?; - received += read as u64; - if received - last_said >= 8 << 20 { - last_said = received; - progress(Progress { - said: "Downloading Docker Desktop.".into(), - downloaded: Some(received), - }); - } - } - drop(out); - - let got = format!("{:x}", hasher.finalize()); - if got != download.sha256 { - let _ = std::fs::remove_file(&partial); - return Err(Problem::with( - "What OpenBot downloaded is not what it was expecting, so it has not been run. Try \ - again.", - format!( - "{} from {}: expected sha256 {}, got {got}", - download.file, download.url, download.sha256 - ), - )); - } - std::fs::rename(&partial, &path).map_err(|error| unwritable(&path, &error.to_string()))?; - Ok(path) -} - -fn file_digest(path: &Path) -> Option { - let mut file = std::fs::File::open(path).ok()?; - let mut hasher = Sha256::new(); - std::io::copy(&mut file, &mut hasher).ok()?; - Some(format!("{:x}", hasher.finalize())) -} - -fn unwritable(path: &Path, error: &str) -> Problem { - Problem::with( - "OpenBot could not save Docker Desktop's installer. Check there is free disk space and try \ - again.", - format!("{}: {error}", path.display()), - ) -} - #[cfg(test)] mod tests { use super::*; @@ -429,46 +139,32 @@ mod tests { /// A machine whose Docker answers after `answers_after` polls, counting what was done to it. struct Fake { - installed: Cell, polls: Cell, answers_after: Option, opened: Cell, - installs: Cell, clock: Cell, } impl Fake { - fn new(installed: bool, answers_after: Option) -> Self { + fn new(answers_after: Option) -> Self { Self { - installed: Cell::new(installed), polls: Cell::new(0), answers_after, opened: Cell::new(0), - installs: Cell::new(0), clock: Cell::new(Instant::now()), } } - fn run(&self) -> (Result, Vec) { + fn run(&self) -> (Result, Vec) { let said = RefCell::new(Vec::new()); let answers = || { let polls = self.polls.get(); self.polls.set(polls + 1); self.opened.get() > 0 && self.answers_after.is_some_and(|after| polls >= after) }; - let result = ensure_with( - &mut Machine { - installed: &|| self.installed.get(), + let result = start_with( + &Machine { answers: &answers, - install: &mut |progress| { - self.installs.set(self.installs.get() + 1); - progress(Progress { - said: "Downloading Docker Desktop.".into(), - downloaded: Some(8 << 20), - }); - self.installed.set(true); - Ok(()) - }, open: &|| { self.opened.set(self.opened.get() + 1); Ok(()) @@ -484,80 +180,33 @@ mod tests { #[test] fn an_answering_docker_is_used_as_it_is() { - let fake = Fake::new(true, Some(0)); + let fake = Fake::new(Some(0)); fake.opened.set(1); let (result, said) = fake.run(); - assert!(!result.unwrap().installed); + assert_eq!(result.unwrap(), "Docker Desktop is running."); assert_eq!(fake.opened.get(), 1, "nothing was opened again"); - assert_eq!(fake.installs.get(), 0); assert!(said.is_empty()); } #[test] - fn an_installed_docker_desktop_that_is_stopped_is_started_not_replaced() { - let fake = Fake::new(true, Some(3)); + fn an_installed_docker_desktop_that_is_stopped_is_started() { + let fake = Fake::new(Some(3)); let (result, said) = fake.run(); - let ensured = result.unwrap(); - assert!(!ensured.installed); + assert_eq!(result.unwrap(), "Docker Desktop is running."); + assert_eq!(fake.opened.get(), 1); assert_eq!( - fake.installs.get(), - 0, - "an installed Docker is never installed again" + said, + ["Starting Docker Desktop. Accept anything it asks in its own window."] ); - assert_eq!(fake.opened.get(), 1); - assert_eq!(said, [Progress::said("Starting Docker Desktop.")]); - } - - #[test] - fn a_machine_without_docker_desktop_gets_it_installed_then_started() { - let fake = Fake::new(false, Some(2)); - let (result, said) = fake.run(); - let ensured = result.unwrap(); - assert!(ensured.installed); - assert_eq!(fake.installs.get(), 1); - assert_eq!(fake.opened.get(), 1); - assert_eq!(said[0].downloaded, Some(8 << 20)); - // The person accepts Docker's terms themselves, in Docker's window. - assert!(said[1].said.contains("Accept its terms in its own window")); } #[test] fn a_docker_desktop_that_never_answers_says_so_after_its_patience() { - let fake = Fake::new(true, None); + let fake = Fake::new(None); let (result, _) = fake.run(); let problem = result.unwrap_err(); assert!(problem.said.starts_with("Docker Desktop did not start.")); - assert!(problem.detail.unwrap().contains("180s")); - // A first start waits longer: it shows terms somebody reads. - let fresh = Fake::new(false, None); - let problem = fresh.run().0.unwrap_err(); - assert!(problem.detail.unwrap().contains("900s")); - } - - #[test] - fn the_installers_are_pinned_to_docker_s_published_digests() { - for (os, arch) in [ - ("macos", "aarch64"), - ("macos", "x86_64"), - ("windows", "x86_64"), - ] { - let download = download_for(os, arch).unwrap(); - assert!( - download.url.starts_with("https://desktop.docker.com/"), - "{download:?}" - ); - assert!(download.url.contains(&format!("/{BUILD}/")), "{download:?}"); - assert_eq!(download.sha256.len(), 64); - assert!(download - .sha256 - .chars() - .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); - } - assert!(download_for("windows", "x86_64") - .unwrap() - .url - .ends_with("Docker%20Desktop%20Installer.exe")); - assert!(download_for("linux", "x86_64").is_err()); + assert!(problem.detail.unwrap().contains("300s")); } #[test] diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 199019517e..4c8a188ec2 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -1,10 +1,9 @@ //! Installing an engine, instead of telling somebody to go and get one. //! -//! **On macOS and Windows the engine is Docker Desktop** (see `docker_desktop`): started when it is -//! installed, installed when it is not. Podman is never installed there for somebody; it is for an -//! organization that cannot use Docker under its licence, and that is their decision. The Podman -//! installers below are kept for that explicit path and for Linux, where Podman comes from the -//! distribution's packages. +//! **Docker first, Podman when there is no Docker.** A Docker that answers is used. On macOS and +//! Windows an installed Docker Desktop that is not running is started (see `docker_desktop`), and +//! any other stopped Docker is left for its owner to start. Only a machine with no Docker gets +//! Podman, and a Podman somebody already has is used as it is. //! //! The person this app is for does not have a package manager, has never heard of Podman, and is //! not going to read a release page. Every sentence that begins "install ..." is a step where an @@ -378,18 +377,18 @@ pub fn install_engine(cache: &Path) -> Result { install_engine_observed(cache, |_, _| {}, &mut |_| {}) } -/// Observe an actual engine install (Docker Desktop, or Podman on Linux), excluding engines that -/// were already here and Compose repair; `progress` is each line for the step row. +/// Observe an actual engine install, excluding engines that were already here, starting Docker +/// Desktop and Compose repair; `progress` is each line for the step row. pub fn install_engine_observed( cache: &Path, installed: impl FnMut(Engine, bool), - progress: &mut dyn FnMut(crate::docker_desktop::Progress), + progress: &mut dyn FnMut(String), ) -> Result { install_engine_with( cache, Found::here(), installed, - |cache| crate::docker_desktop::ensure(cache, progress), + || crate::docker_desktop::start(progress), install_podman, place_compose, ) @@ -422,51 +421,40 @@ fn install_engine_with( cache: &Path, found: Found, mut installed: impl FnMut(Engine, bool), - ensure_docker: impl FnOnce(&Path) -> Result, + start_docker: impl FnOnce() -> Result, install_podman: impl FnOnce(&Path) -> Result<(), Problem>, place_compose: impl FnOnce(&Path) -> Result, ) -> Result { let into = crate::acquire::download_dir(cache); - let docker_app_installed = found.docker_app_installed; if found.docker_answers { return place_compose(&into); } + /* + * DOCKER FIRST, PODMAN WHEN THERE IS NO DOCKER. + * + * An installed Docker Desktop that is not running is started, never replaced: installing + * Podman beside it put a second engine on a machine that already had Docker, behind an + * administrator prompt. A `docker` with no Docker Desktop behind it is OrbStack, Colima or + * similar, stopped; starting it is the repair, and only its owner can. Linux keeps its old + * behaviour: a stopped Docker Engine there needs root to start, and Podman is installed. + */ if found.docker_desktop_platform { - /* - * DOCKER IS THE ENGINE, AND PODMAN IS NEVER CHOSEN FOR SOMEBODY. - * - * An installed Docker Desktop that is not running is started; a machine with no engine at - * all gets Docker Desktop. This used to install Podman in both cases, behind an - * administrator prompt, which put a second engine on a machine that already had Docker and - * gave local Intelligence an engine it refuses. Podman is for an organization that cannot - * use Docker under its licence: a Podman somebody already has is theirs and is used, and - * one is never installed in Docker's place. See `docker_desktop`. - */ - if docker_app_installed || !found.podman_installed { - // A `docker` with no Docker Desktop behind it is OrbStack, Colima or similar, stopped. - // Installing Docker Desktop over it is not the repair; starting it is, and only they - // can. - if !docker_app_installed && found.docker_cli_installed { - return Err(Problem::with( - "Docker is installed but not running. Start it, then try again.", - "a docker command is installed, Docker Desktop is not, and docker does not \ - answer", - )); - } - let result = ensure_docker(cache); - if !docker_app_installed { - installed(Engine::Docker, result.is_ok()); - } - let ensured = result?; + if found.docker_app_installed { + let said = start_docker()?; place_compose(&into)?; - return Ok(ensured.said); + return Ok(said); + } + if found.docker_cli_installed && !found.podman_installed { + return Err(Problem::with( + "Docker is installed but not running. Start it, then try again.", + "a docker command is installed, Docker Desktop is not, and docker does not \ + answer", + )); } - return place_compose(&into); } - // Linux: the distribution's own Podman, through its package manager. See the module comment. if found.podman_installed { return place_compose(&into); } @@ -750,15 +738,8 @@ mod tests { } } - fn ensured(installed: bool) -> crate::docker_desktop::Ensured { - crate::docker_desktop::Ensured { - said: if installed { "installed" } else { "started" }.into(), - installed, - } - } - #[test] - fn a_stopped_docker_desktop_is_started_and_podman_is_never_installed_for_it() { + fn a_stopped_docker_desktop_is_started_and_podman_is_never_installed_beside_it() { let root = temp_root("stopped-docker-desktop"); let mut observed = Vec::new(); // Installed and stopped, with or without a Podman beside it: Docker is started. @@ -767,59 +748,76 @@ mod tests { &root, found(false, true, true, podman), |engine, ok| observed.push((engine, ok)), - |_| Ok(ensured(false)), - |_| panic!("Podman is never installed in Docker's place"), + || Ok("started".into()), + |_| panic!("Podman is never installed beside Docker"), |_| Ok("Compose placed".into()), ) .unwrap(); assert_eq!(said, "started"); } assert!(observed.is_empty(), "starting is not an install"); + // A Docker Desktop that does not start is reported as that, and Podman is not tried. + let problem = install_engine_with( + &root, + found(false, true, true, false), + |_, _| panic!("nothing is installed"), + || Err(Problem::plain("no")), + |_| panic!("no Podman in Docker's place"), + |_| panic!("no Compose without an engine"), + ) + .unwrap_err(); + assert_eq!(problem.said, "no"); } - #[test] - fn a_machine_with_no_engine_gets_docker_desktop_not_podman() { + /// Run on every desktop platform, and on Linux, where the old behaviour is kept. + fn no_engine_installs_podman(found: Found) { let root = temp_root("no-engine"); let mut observed = Vec::new(); - let said = install_engine_with( + // Whether it then finds a podman program depends on the machine running the test: hosted + // Ubuntu runners ship one. What matters is that the engine installed was Podman. + let _ = install_engine_with( &root, - found(false, false, false, false), + found, |engine, ok| observed.push((engine, ok)), - |_| Ok(ensured(true)), - |_| panic!("Podman is never installed in Docker's place"), + || panic!("no Docker Desktop to start"), + |_| Ok(()), |_| Ok("Compose placed".into()), - ) - .unwrap(); - assert_eq!(said, "installed"); - assert_eq!(observed, [(Engine::Docker, true)]); - // A failed Docker Desktop install is reported as one, and nothing else is tried. - let mut observed = Vec::new(); - let problem = install_engine_with( - &root, - found(false, false, false, false), - |engine, ok| observed.push((engine, ok)), - |_| Err(Problem::plain("no")), - |_| panic!("no Podman fallback"), - |_| panic!("no Compose without an engine"), - ) - .unwrap_err(); - assert_eq!(problem.said, "no"); - assert_eq!(observed, [(Engine::Docker, false)]); + ); + assert_eq!(observed, [(Engine::Podman, true)]); + } + + #[test] + fn a_machine_with_no_docker_gets_podman() { + no_engine_installs_podman(found(false, false, false, false)); + } + + #[test] + fn linux_keeps_installing_podman() { + let linux = |docker_cli| Found { + docker_desktop_platform: false, + ..found(false, false, docker_cli, false) + }; + no_engine_installs_podman(linux(false)); + // A stopped Docker Engine needs root to start, so Linux installs Podman as it always has. + no_engine_installs_podman(linux(true)); } #[test] fn a_podman_somebody_already_has_is_theirs_and_is_used() { let root = temp_root("existing-podman"); - let said = install_engine_with( - &root, - found(false, false, false, true), - |_, _| panic!("nothing is installed"), - |_| panic!("Docker Desktop is not installed over somebody's own Podman"), - |_| panic!("no install"), - |_| Ok("Compose placed".into()), - ) - .unwrap(); - assert_eq!(said, "Compose placed"); + // With or without a stopped `docker` beside it (Podman's own compatibility command). + for docker_cli in [false, true] { + let said = install_engine_with( + &root, + found(false, false, docker_cli, true), + |_, _| panic!("nothing is installed"), + || panic!("no Docker Desktop to start"), + |_| panic!("no install"), + |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!(said, "Compose placed"); + } } #[test] @@ -829,8 +827,8 @@ mod tests { &root, found(false, false, true, false), |_, _| panic!("nothing is installed"), - |_| panic!("Docker Desktop is not installed over another Docker"), - |_| panic!("no Podman fallback"), + || panic!("there is no Docker Desktop to start"), + |_| panic!("no Podman beside another Docker"), |_| panic!("no Compose without an engine"), ) .unwrap_err(); @@ -844,9 +842,9 @@ mod tests { let root = temp_root("answering-docker"); let said = install_engine_with( &root, - found(true, false, true, false), + found(true, false, true, true), |_, _| panic!("nothing is installed"), - |_| panic!("an answering Docker is not started again"), + || panic!("an answering Docker is not started again"), |_| panic!("no install"), |_| Ok("Compose placed".into()), ) @@ -854,27 +852,6 @@ mod tests { assert_eq!(said, "Compose placed"); } - #[test] - fn linux_keeps_its_distribution_podman() { - let root = temp_root("linux-engine"); - let mut observed = Vec::new(); - let linux = Found { - docker_desktop_platform: false, - ..found(false, false, false, false) - }; - install_engine_with( - &root, - linux, - |engine, ok| observed.push((engine, ok)), - |_| panic!("Linux does not get Docker Desktop"), - |_| Ok(()), - |_| Ok("Compose placed".into()), - ) - .unwrap_err(); - // The Podman install ran; it then finds no podman program on this test machine. - assert_eq!(observed, [(Engine::Podman, true)]); - } - /// Every platform this app runs on has a Compose build, or the stack cannot be raised there. #[test] fn this_platform_has_a_compose_build() { diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 1c19ff638f..3bd12bc1b7 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -678,15 +678,8 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result Date: Wed, 28 Oct 2026 21:13:45 -0700 Subject: [PATCH 4/7] fix(desktop): skip the Podman machine once Docker Desktop answers; find its CLI in ~/Applications Starting a stopped Docker Desktop returned from the install step into the Podman-only machine steps, which fail at `podman machine list` on a machine with no Podman. engine_ready now detects again after the install step and returns an answering engine (with Compose) directly. The CLI fallback for macOS looked only in /Applications/Docker.app, while setup also starts a Docker Desktop in ~/Applications; Docker answering was never seen and the start timed out. The CLI places are now derived from docker_desktop::app_places, so the two lists cannot drift. --- desktop/src-tauri/src/docker_desktop.rs | 2 +- desktop/src-tauri/src/engine.rs | 41 +++++++++++----- desktop/src-tauri/src/main.rs | 65 +++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/desktop/src-tauri/src/docker_desktop.rs b/desktop/src-tauri/src/docker_desktop.rs index e46caa52cd..f830468196 100644 --- a/desktop/src-tauri/src/docker_desktop.rs +++ b/desktop/src-tauri/src/docker_desktop.rs @@ -27,7 +27,7 @@ fn installed_app_in(places: &[PathBuf]) -> Option { places.iter().find(|place| place.exists()).cloned() } -fn app_places() -> Vec { +pub(crate) fn app_places() -> Vec { #[cfg(target_os = "macos")] { let mut places = vec![PathBuf::from("/Applications/Docker.app")]; diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 2754f4970b..1845669bd2 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -413,7 +413,13 @@ fn on_path(binary: &str) -> Option { /// The fixed places each platform's installers use. fn where_installers_put(engine: Engine) -> Option { - let places: Vec = match engine { + installer_places(engine) + .into_iter() + .find(|candidate| candidate.is_file()) +} + +fn installer_places(engine: Engine) -> Vec { + match engine { #[cfg(target_os = "windows")] Engine::Podman => { // The MSI uses Programs\Podman per user and Program Files\Podman per machine. @@ -444,15 +450,19 @@ fn where_installers_put(engine: Engine) -> Option { .iter() .map(PathBuf::from) .collect(), + // Docker Desktop's own copy is looked for in every place `docker_desktop` looks for the + // app, `~/Applications` included: an app that setup starts has to have a CLI that setup + // can find, or Docker answering is never seen and the start times out. #[cfg(target_os = "macos")] - Engine::Docker => [ - "/usr/local/bin/docker", - "/opt/homebrew/bin/docker", - "/Applications/Docker.app/Contents/Resources/bin/docker", - ] - .iter() - .map(PathBuf::from) - .collect(), + Engine::Docker => ["/usr/local/bin/docker", "/opt/homebrew/bin/docker"] + .iter() + .map(PathBuf::from) + .chain( + crate::docker_desktop::app_places() + .into_iter() + .map(|app| app.join("Contents/Resources/bin/docker")), + ) + .collect(), #[cfg(target_os = "linux")] Engine::Podman => ["/usr/bin/podman", "/usr/local/bin/podman"] .iter() @@ -463,8 +473,7 @@ fn where_installers_put(engine: Engine) -> Option { .iter() .map(PathBuf::from) .collect(), - }; - places.into_iter().find(|candidate| candidate.is_file()) + } } /// The rootless socket on Linux, which is the one Compose must mount. @@ -804,6 +813,16 @@ fn main() { } } + #[test] + #[cfg(target_os = "macos")] + fn every_docker_desktop_setup_can_start_has_a_cli_setup_can_find() { + let places = installer_places(Engine::Docker); + for app in crate::docker_desktop::app_places() { + let cli = app.join("Contents/Resources/bin/docker"); + assert!(places.contains(&cli), "{} is not looked for", cli.display()); + } + } + /// The tools live under the downloads they came from, so one install of the app has one place /// for both and a retry finds what it already fetched. #[test] diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3bd12bc1b7..d5809ede24 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -249,6 +249,27 @@ struct AlreadyConfigured { auto_start: bool, } +/// The engine the install step left answering, if it left one. +/// +/// Starting Docker Desktop leaves Docker answering, and the machine steps after the install step +/// are Podman's: on a Mac or a PC without Podman, the first of them fails at `podman machine list` +/// with Docker already running. A Podman the step installed or found answers only once its +/// machine is up, so it is `None` here and the machine steps run. +fn answering_after_install( + ready: &engine::EngineStatus, + composes: impl FnOnce(&engine::Address) -> bool, +) -> Result, Problem> { + let Some(address) = ready.address.clone().filter(|_| ready.responding) else { + return Ok(None); + }; + if !composes(&address) { + return Err(Problem::plain(acquire::missing_compose( + address.engine.binary(), + ))); + } + Ok(Some(address)) +} + struct ReadyRespondingEngine { address: engine::Address, detail: String, @@ -699,6 +720,13 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result Date: Wed, 28 Oct 2026 21:17:48 -0700 Subject: [PATCH 5/7] fix(desktop): place no Compose after starting Docker Desktop place_compose asks Podman whether Compose runs. On a Docker-only machine there is no Podman, so it downloaded a docker-compose provider into OpenBot's tools directory. Docker's `docker compose` looks for plugins in its cli-plugins directories, never on PATH, so nothing used it. Docker Desktop ships Compose itself, and engine_ready checks Docker's Compose once Docker answers. --- desktop/src-tauri/src/install.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 4c8a188ec2..32bcda2046 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -442,9 +442,11 @@ fn install_engine_with( */ if found.docker_desktop_platform { if found.docker_app_installed { - let said = start_docker()?; - place_compose(&into)?; - return Ok(said); + // Nothing is placed: Docker Desktop ships Compose as a CLI plugin, and `docker compose` + // never looks on PATH, where a placed provider goes. `place_compose` asks Podman, which + // a Docker-only machine does not have, so it downloaded a provider nothing used. The + // caller checks Docker's own Compose once Docker answers. + return start_docker(); } if found.docker_cli_installed && !found.podman_installed { return Err(Problem::with( @@ -750,7 +752,7 @@ mod tests { |engine, ok| observed.push((engine, ok)), || Ok("started".into()), |_| panic!("Podman is never installed beside Docker"), - |_| Ok("Compose placed".into()), + |_| panic!("Docker Desktop ships its own Compose"), ) .unwrap(); assert_eq!(said, "started"); From 4522457aa31dd435d169e8889ac71477b2a038a2 Mon Sep 17 00:00:00 2001 From: Dusty Date: Wed, 28 Oct 2026 22:08:43 -0700 Subject: [PATCH 6/7] fix(desktop): open Docker Desktop only when it has run before; keep each installation on its engine OpenBot opened any installed Docker Desktop and waited up to 5 minutes for Docker to answer. A Docker Desktop that was installed and never opened shows its subscription terms first, and nothing outside it tells that window from a helper prompt, a required sign-in or a slow boot. So a person could be put in front of Docker's terms and left waiting. A new installation now uses, in order: a Docker that answers; on macOS and Windows, a stopped Docker Desktop whose VM disk exists (it has run, so its terms are done), which is opened; otherwise Podman, as before. A stopped `docker` with no Docker Desktop behind it is no longer an error: it gets Podman. The disk is checked with a stat only, which on macOS 27.0.1 raises no "access data from other apps" request (checked against the TCC log). The engine chosen is saved with the installation (.openbot-engine.json, preparation::saved_engine) and every later install, Start and sign-in uses only that engine. A Podman installation no longer moves to a Docker that happens to answer, onto an empty database, and the reverse. A saved Docker whose Docker Desktop is stopped is opened before Start, with a 3-minute limit and a message for one that stops on a screen of its own. Installations from before this record are pinned on their next successful Start. --- desktop/src-tauri/src/docker_desktop.rs | 135 ++++++-- desktop/src-tauri/src/engine.rs | 32 +- desktop/src-tauri/src/install.rs | 421 +++++++++++++++++++----- desktop/src-tauri/src/main.rs | 85 ++++- desktop/src-tauri/src/preparation.rs | 52 ++- 5 files changed, 591 insertions(+), 134 deletions(-) diff --git a/desktop/src-tauri/src/docker_desktop.rs b/desktop/src-tauri/src/docker_desktop.rs index f830468196..ebd4049f8e 100644 --- a/desktop/src-tauri/src/docker_desktop.rs +++ b/desktop/src-tauri/src/docker_desktop.rs @@ -1,11 +1,15 @@ -//! Docker Desktop: started when it is installed and not running. +//! Docker Desktop: opened when it is installed, stopped, and safe to open. //! -//! **Docker first, Podman when there is no Docker.** A Mac or a Windows machine with Docker Desktop -//! installed but not running is not a machine without an engine: starting it is the repair, not -//! installing a second engine beside it. A machine with no Docker at all gets Podman (see -//! `install::install_engine_with`). OpenBot never installs Docker Desktop itself. +//! **OpenBot never puts a person in front of Docker's terms, and never waits on them.** A Docker +//! Desktop that was installed and never opened shows its subscription terms on first open, and +//! from outside it nothing tells that window apart from a password prompt, a required sign-in or a +//! slow boot. So a stopped Docker Desktop is opened only when it is known to start by itself: //! -//! Linux is not handled here: Docker there is the distribution's Docker Engine, not Docker Desktop. +//! - it has run before on this machine (`used_before`: its VM disk exists), or +//! - this installation was set up on Docker, so it answered for OpenBot before. +//! +//! Anything else gets Podman (see `install::install_engine_with`). OpenBot never installs Docker +//! Desktop itself. Linux is not handled here: Docker there is the distribution's Docker Engine. use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -13,9 +17,10 @@ use std::time::{Duration, Instant}; use crate::engine::{self, Engine}; use crate::problem::Problem; -/// How long an installed Docker Desktop gets to start answering. A first start shows Docker's -/// terms and a helper prompt in Docker's own window, so this allows for somebody reading them. -const START_PATIENCE: Duration = Duration::from_secs(5 * 60); +/// How long a Docker Desktop that is safe to open gets to start answering: a cold start on a slow +/// machine, with room to spare. One still not answering by then is most likely stopped on a screen +/// of its own (updated terms, a required sign-in), and the person is told to finish it. +const START_PATIENCE: Duration = Duration::from_secs(3 * 60); const POLL: Duration = Duration::from_secs(2); /// Where Docker Desktop is installed on this machine, if it is. @@ -49,6 +54,68 @@ pub(crate) fn app_places() -> Vec { } } +/// Whether this machine's Docker Desktop has run before: its terms were accepted and its first-run +/// screens are done, so opening it starts Docker and shows nothing that needs the person. +/// +/// The evidence is Docker Desktop's VM disk, which holds every image and container. Docker creates +/// it when its engine first starts, and the engine starts only after the terms are accepted. +/// Docker documents these places in "How to back up and restore your Docker Desktop data". A disk +/// moved elsewhere in Docker's settings is not found, and that machine gets Podman: the safe side. +/// +/// METADATA ONLY. On macOS the disk is inside Docker's app container, and reading or listing +/// anything there raises "would like to access data from other apps". A `stat` of a path inside it +/// raises nothing: checked on macOS 27.0.1 against the TCC log, where listing does show up. Never +/// open, read or list here, and never run `docker` to find out. +pub fn used_before() -> bool { + any_disk(&vm_disks()) +} + +fn any_disk(disks: &[PathBuf]) -> bool { + disks.iter().any(|disk| disk.is_file()) +} + +fn vm_disks() -> Vec { + #[cfg(target_os = "macos")] + { + std::env::var_os("HOME") + .map(|home| { + PathBuf::from(home) + .join("Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw") + }) + .into_iter() + .collect() + } + #[cfg(target_os = "windows")] + { + // WSL 2 now, then WSL 2 as earlier releases laid it out, then Docker's own VM manager and + // Hyper-V. + let local = std::env::var_os("LOCALAPPDATA").map(PathBuf::from); + let data = std::env::var_os("ProgramData").map(PathBuf::from); + [ + local + .as_ref() + .map(|l| l.join("Docker\\wsl\\disk\\docker_data.vhdx")), + local + .as_ref() + .map(|l| l.join("Docker\\wsl\\data\\docker_data.vhdx")), + local + .as_ref() + .map(|l| l.join("Docker\\wsl\\data\\ext4.vhdx")), + local + .as_ref() + .map(|l| l.join("Docker\\vm-data\\DockerDesktop.vhdx")), + data.map(|d| d.join("DockerDesktop\\vm-data\\DockerDesktop.vhdx")), + ] + .into_iter() + .flatten() + .collect() + } + #[cfg(target_os = "linux")] + { + Vec::new() + } +} + /// Whether Docker answers now. pub fn answers() -> bool { engine::Address::new(Engine::Docker, None).responds() @@ -63,7 +130,8 @@ pub(crate) struct Machine<'a> { } /// Docker answering, by opening the installed Docker Desktop. Answers with the sentence for the -/// step row; `progress` is each line before that. See the module comment. +/// step row; `progress` is each line before that. Only for a Docker Desktop that is safe to open: +/// see the module comment. pub fn start(progress: &mut dyn FnMut(String)) -> Result { start_with( &Machine { @@ -83,7 +151,7 @@ pub(crate) fn start_with( if (machine.answers)() { return Ok("Docker Desktop is running.".into()); } - progress("Starting Docker Desktop. Accept anything it asks in its own window.".into()); + progress("Starting Docker Desktop.".into()); (machine.open)()?; let started = (machine.now)(); @@ -94,8 +162,7 @@ pub(crate) fn start_with( (machine.sleep)(POLL); } Err(Problem::with( - "Docker Desktop did not start. Open Docker Desktop, finish anything it asks for, then try \ - again.", + "Docker Desktop didn't start. Open it and finish anything it shows, then try again.", format!( "Docker did not answer within {}s of opening Docker Desktop", START_PATIENCE.as_secs() @@ -103,6 +170,16 @@ pub(crate) fn start_with( )) } +/// Before Start, for an installation set up on Docker: a stopped Docker Desktop is opened. It +/// answered for this installation before, so its first-run screens are done. Without a Docker +/// Desktop to open, nothing is done here and Start reports that Docker is not answering. +pub fn start_saved(progress: &mut dyn FnMut(String)) -> Result<(), Problem> { + if cfg!(any(target_os = "macos", target_os = "windows")) && installed_app().is_some() { + start(progress)?; + } + Ok(()) +} + /// Open Docker Desktop the way the person would. fn open() -> Result<(), Problem> { let Some(app) = installed_app() else { @@ -194,10 +271,7 @@ mod tests { let (result, said) = fake.run(); assert_eq!(result.unwrap(), "Docker Desktop is running."); assert_eq!(fake.opened.get(), 1); - assert_eq!( - said, - ["Starting Docker Desktop. Accept anything it asks in its own window."] - ); + assert_eq!(said, ["Starting Docker Desktop."]); } #[test] @@ -205,8 +279,31 @@ mod tests { let fake = Fake::new(None); let (result, _) = fake.run(); let problem = result.unwrap_err(); - assert!(problem.said.starts_with("Docker Desktop did not start.")); - assert!(problem.detail.unwrap().contains("300s")); + assert!(problem.said.starts_with("Docker Desktop didn't start.")); + assert!(problem.detail.unwrap().contains("180s")); + } + + #[test] + fn a_docker_desktop_is_used_before_only_once_its_vm_disk_exists() { + let root = crate::test_support::temp_root("docker-desktop-vm-disk"); + let disk = root.join("vms/0/data/Docker.raw"); + // Installed and never opened: no disk, and the folder alone is not a disk. + assert!(!any_disk(std::slice::from_ref(&disk))); + std::fs::create_dir_all(&disk).unwrap(); + assert!(!any_disk(std::slice::from_ref(&disk))); + std::fs::remove_dir(&disk).unwrap(); + std::fs::write(&disk, "").unwrap(); + assert!(any_disk(&[root.join("elsewhere.vhdx"), disk])); + } + + #[test] + #[cfg(target_os = "macos")] + fn the_mac_vm_disk_is_where_docker_documents_it() { + let home = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()); + assert_eq!( + vm_disks(), + [home.join("Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw")] + ); } #[test] diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index 916f333541..2108ce1913 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -41,7 +41,7 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Engine { - /// Docker Desktop, OrbStack, Colima, or a Docker daemon by any other name. + /// Any Docker daemon, whatever product runs it. Docker, Podman, } @@ -502,7 +502,23 @@ pub fn rootless_socket() -> Option { /// What is here, before anything is installed. pub fn detect() -> EngineStatus { - for engine in [Engine::Docker, Engine::Podman] { + detect_among(&[Engine::Docker, Engine::Podman]) +} + +/// What is here of the engine an installation was set up on, or of either when it saved none. +/// +/// The other engine is never looked at: an installation on Podman that found an answering Docker +/// would start on it with none of its images and an empty database. See +/// `preparation::saved_engine`. +pub fn detect_saved(saved: Option) -> EngineStatus { + match saved { + Some(engine) => detect_among(&[engine]), + None => detect(), + } +} + +fn detect_among(engines: &[Engine]) -> EngineStatus { + for &engine in engines { let address = Address::new(engine, None); if address.responds() { return answering(address); @@ -511,14 +527,16 @@ pub fn detect() -> EngineStatus { // Podman's default connection can name a machine that is not running while another one is. That // is not "no engine", and creating a second machine in answer to it is the wrong repair. - if let Some(machine) = running_machine(crate::acquire::MACHINE) { - let address = Address::new(Engine::Podman, Some(machine)); - if address.responds() { - return answering(address); + if engines.contains(&Engine::Podman) { + if let Some(machine) = running_machine(crate::acquire::MACHINE) { + let address = Address::new(Engine::Podman, Some(machine)); + if address.responds() { + return answering(address); + } } } - for engine in [Engine::Docker, Engine::Podman] { + for &engine in engines { if program(engine).is_some() { return EngineStatus { engine: Some(engine), diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs index 32bcda2046..a63b5c6b43 100644 --- a/desktop/src-tauri/src/install.rs +++ b/desktop/src-tauri/src/install.rs @@ -1,9 +1,14 @@ //! Installing an engine, instead of telling somebody to go and get one. //! -//! **Docker first, Podman when there is no Docker.** A Docker that answers is used. On macOS and -//! Windows an installed Docker Desktop that is not running is started (see `docker_desktop`), and -//! any other stopped Docker is left for its owner to start. Only a machine with no Docker gets -//! Podman, and a Podman somebody already has is used as it is. +//! **Which engine.** A new installation uses, in this order: +//! +//! 1. a Docker that answers; +//! 2. on macOS and Windows, a stopped Docker Desktop that has run before, which is opened (see +//! `docker_desktop`); +//! 3. Podman: the one somebody already has, or one installed here. +//! +//! The engine chosen is saved with the installation (`preparation::saved_engine`), and every later +//! install and start uses it and only it. //! //! The person this app is for does not have a package manager, has never heard of Podman, and is //! not going to read a release page. Every sentence that begins "install ..." is a step where an @@ -372,21 +377,30 @@ pub(crate) fn verify_bun(binary: &Path) -> Result<(), Problem> { /// /// Both halves, in that order, because the second is invisible until the first has succeeded and /// somebody presses Start. Answers with the sentence for the step row, or a failure in both -/// registers. -pub fn install_engine(cache: &Path) -> Result { - install_engine_observed(cache, |_, _| {}, &mut |_| {}) +/// 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) +} + +/// The engine the install step settled on, and the sentence for its step row. +#[derive(Debug, PartialEq, Eq)] +pub struct EngineChosen { + pub engine: Engine, + pub said: String, } /// Observe an actual engine install, excluding engines that were already here, starting Docker /// Desktop and Compose repair; `progress` is each line for the step row. pub fn install_engine_observed( cache: &Path, + saved: Option, installed: impl FnMut(Engine, bool), progress: &mut dyn FnMut(String), -) -> Result { +) -> Result { install_engine_with( cache, - Found::here(), + Found::here(saved), + saved, installed, || crate::docker_desktop::start(progress), install_podman, @@ -395,70 +409,116 @@ pub fn install_engine_observed( } /// What engines this machine has, read once so the choice below can be tested on any machine. +/// +/// Nothing here asks what product is behind a `docker` command. A Docker that answers is used, a +/// Docker Desktop that is safe to open is opened, and anything else is a machine without Docker. #[derive(Debug, Clone, Copy)] struct Found { /// Docker Desktop is how this platform gets Docker: macOS and Windows, not Linux. docker_desktop_platform: bool, docker_answers: bool, docker_app_installed: bool, - docker_cli_installed: bool, + /// Docker Desktop has run on this machine before, so it opens without its terms. See + /// `docker_desktop::used_before`. + docker_desktop_used_before: bool, podman_installed: bool, } impl Found { - fn here() -> Self { + fn here(saved: Option) -> Self { + // An installation on Podman never looks at Docker, and so never runs `docker` either. + let docker = saved != Some(Engine::Podman); + let docker_app_installed = docker && crate::docker_desktop::installed_app().is_some(); Self { docker_desktop_platform: cfg!(any(target_os = "macos", target_os = "windows")), - docker_answers: engine::Address::new(Engine::Docker, None).responds(), - docker_app_installed: crate::docker_desktop::installed_app().is_some(), - docker_cli_installed: engine::program(Engine::Docker).is_some(), + docker_answers: docker && engine::Address::new(Engine::Docker, None).responds(), + docker_app_installed, + docker_desktop_used_before: docker_app_installed + && crate::docker_desktop::used_before(), podman_installed: engine::program(Engine::Podman).is_some(), } } } +/// What the install step does to have an engine answering. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Choice { + /// Docker answers now. + UseDocker, + /// Docker Desktop is installed and stopped, and opening it shows nothing that needs the person. + OpenDockerDesktop, + /// This installation is on Docker, Docker does not answer, and there is no Docker Desktop to + /// open. Only the person can start it. + DockerNotRunning, + UsePodman, + InstallPodman, +} + +/* + * DOCKER WHEN IT IS READY, PODMAN OTHERWISE, AND NEVER A MOVE BETWEEN THEM. + * + * A new installation uses a Docker that answers, then a stopped Docker Desktop that has run before, + * then Podman. A Docker Desktop that has never run is not opened: its first window is Docker's + * terms, and OpenBot must never put a person in front of them or wait on them. A `docker` command + * that does not answer is not a reason to stop either. That machine gets Podman too. + * + * An installation that saved its engine keeps it. Its images and its database are there, and + * the other engine would start it empty. A saved Docker Desktop that is stopped is opened: it + * answered for this installation before. + * + * Linux has no Docker Desktop. An answering Docker is used there, and anything else gets Podman. + */ +fn choose(found: Found, saved: Option) -> Choice { + let can_open_docker_desktop = found.docker_desktop_platform && found.docker_app_installed; + let podman = if found.podman_installed { + Choice::UsePodman + } else { + Choice::InstallPodman + }; + match saved { + Some(Engine::Podman) => podman, + Some(Engine::Docker) if found.docker_answers => Choice::UseDocker, + Some(Engine::Docker) if can_open_docker_desktop => Choice::OpenDockerDesktop, + Some(Engine::Docker) => Choice::DockerNotRunning, + None if found.docker_answers => Choice::UseDocker, + None if can_open_docker_desktop && found.docker_desktop_used_before => { + Choice::OpenDockerDesktop + } + None => podman, + } +} + fn install_engine_with( cache: &Path, found: Found, + saved: Option, mut installed: impl FnMut(Engine, bool), start_docker: impl FnOnce() -> Result, install_podman: impl FnOnce(&Path) -> Result<(), Problem>, place_compose: impl FnOnce(&Path) -> Result, -) -> Result { +) -> Result { let into = crate::acquire::download_dir(cache); - - if found.docker_answers { - return place_compose(&into); - } - - /* - * DOCKER FIRST, PODMAN WHEN THERE IS NO DOCKER. - * - * An installed Docker Desktop that is not running is started, never replaced: installing - * Podman beside it put a second engine on a machine that already had Docker, behind an - * administrator prompt. A `docker` with no Docker Desktop behind it is OrbStack, Colima or - * similar, stopped; starting it is the repair, and only its owner can. Linux keeps its old - * behaviour: a stopped Docker Engine there needs root to start, and Podman is installed. - */ - if found.docker_desktop_platform { - if found.docker_app_installed { - // Nothing is placed: Docker Desktop ships Compose as a CLI plugin, and `docker compose` - // never looks on PATH, where a placed provider goes. `place_compose` asks Podman, which - // a Docker-only machine does not have, so it downloaded a provider nothing used. The - // caller checks Docker's own Compose once Docker answers. - return start_docker(); + let chosen = |engine, said| EngineChosen { engine, said }; + + match choose(found, saved) { + Choice::UseDocker => return place_compose(&into).map(|said| chosen(Engine::Docker, said)), + // Nothing is placed: Docker Desktop ships Compose as a CLI plugin, and `docker compose` + // never looks on PATH, where a placed provider goes. `place_compose` asks Podman, which a + // Docker-only machine does not have, so it downloaded a provider nothing used. The caller + // checks Docker's own Compose once Docker answers. + Choice::OpenDockerDesktop => { + return start_docker().map(|said| chosen(Engine::Docker, said)) } - if found.docker_cli_installed && !found.podman_installed { + Choice::DockerNotRunning => { return Err(Problem::with( - "Docker is installed but not running. Start it, then try again.", - "a docker command is installed, Docker Desktop is not, and docker does not \ - answer", - )); + "OpenBot was installed with Docker, and Docker isn't running. Start Docker, then \ + try again.", + "this installation saved Docker as its engine; docker does not answer and there \ + is no Docker Desktop to open", + )) } - } - - if found.podman_installed { - return place_compose(&into); + Choice::UsePodman => return place_compose(&into).map(|said| chosen(Engine::Podman, said)), + Choice::InstallPodman => {} } let result = install_podman(&into); @@ -481,7 +541,10 @@ fn install_engine_with( } place_compose(&into)?; - Ok(format!("Podman {PODMAN} and Compose {COMPOSE} installed.")) + Ok(chosen( + Engine::Podman, + format!("Podman {PODMAN} and Compose {COMPOSE} installed."), + )) } /// Place the Compose provider where the engine will find it, unless something already provides one. @@ -730,38 +793,192 @@ mod tests { use super::*; use crate::test_support::temp_root; - fn found(docker_answers: bool, docker_app: bool, docker_cli: bool, podman: bool) -> Found { + /// A Mac or a PC. `docker_app` is a Docker Desktop that is installed; `used` is one that has + /// run before. + fn found(docker_answers: bool, docker_app: bool, used: bool, podman: bool) -> Found { Found { docker_desktop_platform: true, docker_answers, docker_app_installed: docker_app, - docker_cli_installed: docker_cli, + docker_desktop_used_before: used, podman_installed: podman, } } + fn linux(found: Found) -> Found { + Found { + docker_desktop_platform: false, + ..found + } + } + + const NOTHING: Found = Found { + docker_desktop_platform: true, + docker_answers: false, + docker_app_installed: false, + docker_desktop_used_before: false, + podman_installed: false, + }; + + /// The whole table, for a new installation and for one that saved its engine. #[test] - fn a_stopped_docker_desktop_is_started_and_podman_is_never_installed_beside_it() { + fn the_engine_is_chosen_by_this_table() { + use Choice::*; + let docker = Some(Engine::Docker); + let podman = Some(Engine::Podman); + let rows: [(&str, Found, Option, Choice); 18] = [ + // A new installation. + ( + "answering Docker", + found(true, false, false, false), + None, + UseDocker, + ), + ( + "answering Docker Desktop beside Podman", + found(true, true, true, true), + None, + UseDocker, + ), + ( + "stopped Docker Desktop, used before", + found(false, true, true, false), + None, + OpenDockerDesktop, + ), + ( + "stopped Docker Desktop, used before, Podman too", + found(false, true, true, true), + None, + OpenDockerDesktop, + ), + ( + "Docker Desktop never used", + found(false, true, false, false), + None, + InstallPodman, + ), + ( + "Docker Desktop never used, Podman here", + found(false, true, false, true), + None, + UsePodman, + ), + // A `docker` command that does not answer, with no Docker Desktop behind it, is no + // Docker at all: nothing in `Found` even records that the command is there. + ( + "stopped docker without Docker Desktop", + NOTHING, + None, + InstallPodman, + ), + ( + "existing Podman", + found(false, false, false, true), + None, + UsePodman, + ), + ("nothing", NOTHING, None, InstallPodman), + // An installation saved on Docker. + ( + "saved Docker, answering", + found(true, true, true, false), + docker, + UseDocker, + ), + ( + "saved Docker, Docker Desktop stopped", + found(false, true, true, true), + docker, + OpenDockerDesktop, + ), + // It answered for this installation, so it opens even with no disk where Docker + // documents it (moved in Docker's settings, say). + ( + "saved Docker, disk moved", + found(false, true, false, true), + docker, + OpenDockerDesktop, + ), + ( + "saved Docker, none to open", + found(false, false, false, true), + docker, + DockerNotRunning, + ), + // An installation saved on Podman never moves to Docker. + ( + "saved Podman, Docker answering", + found(true, true, true, true), + podman, + UsePodman, + ), + ( + "saved Podman, Docker Desktop used before", + found(false, true, true, true), + podman, + UsePodman, + ), + ( + "saved Podman, Podman removed", + found(true, true, true, false), + podman, + InstallPodman, + ), + // Linux: unchanged, and no Docker Desktop to open. + ( + "Linux, answering Docker", + linux(found(true, false, false, false)), + None, + UseDocker, + ), + ( + "Linux, saved Docker stopped", + linux(found(false, true, true, true)), + docker, + DockerNotRunning, + ), + ]; + for (row, found, saved, expected) in rows { + assert_eq!(choose(found, saved), expected, "{row}"); + } + } + + #[test] + fn a_stopped_docker_desktop_that_has_run_before_is_opened_and_podman_is_never_installed_beside_it( + ) { let root = temp_root("stopped-docker-desktop"); let mut observed = Vec::new(); - // Installed and stopped, with or without a Podman beside it: Docker is started. - for podman in [false, true] { - let said = install_engine_with( + // Used before, or saved by this installation: Docker Desktop is opened. + for (found, saved) in [ + (found(false, true, true, false), None), + (found(false, true, true, true), None), + (found(false, true, false, false), Some(Engine::Docker)), + ] { + let chosen = install_engine_with( &root, - found(false, true, true, podman), + found, + saved, |engine, ok| observed.push((engine, ok)), || Ok("started".into()), |_| panic!("Podman is never installed beside Docker"), |_| panic!("Docker Desktop ships its own Compose"), ) .unwrap(); - assert_eq!(said, "started"); + assert_eq!( + chosen, + EngineChosen { + engine: Engine::Docker, + said: "started".into() + } + ); } assert!(observed.is_empty(), "starting is not an install"); // A Docker Desktop that does not start is reported as that, and Podman is not tried. let problem = install_engine_with( &root, found(false, true, true, false), + None, |_, _| panic!("nothing is installed"), || Err(Problem::plain("no")), |_| panic!("no Podman in Docker's place"), @@ -772,7 +989,7 @@ mod tests { } /// Run on every desktop platform, and on Linux, where the old behaviour is kept. - fn no_engine_installs_podman(found: Found) { + fn podman_is_installed(found: Found, saved: Option) { let root = temp_root("no-engine"); let mut observed = Vec::new(); // Whether it then finds a podman program depends on the machine running the test: hosted @@ -780,8 +997,9 @@ mod tests { let _ = install_engine_with( &root, found, + saved, |engine, ok| observed.push((engine, ok)), - || panic!("no Docker Desktop to start"), + || panic!("no Docker Desktop is opened"), |_| Ok(()), |_| Ok("Compose placed".into()), ); @@ -790,68 +1008,91 @@ mod tests { #[test] fn a_machine_with_no_docker_gets_podman() { - no_engine_installs_podman(found(false, false, false, false)); + podman_is_installed(NOTHING, None); + } + + #[test] + fn a_docker_desktop_that_has_never_run_is_not_opened_and_podman_is_installed() { + podman_is_installed(found(false, true, false, false), None); } #[test] fn linux_keeps_installing_podman() { - let linux = |docker_cli| Found { - docker_desktop_platform: false, - ..found(false, false, docker_cli, false) - }; - no_engine_installs_podman(linux(false)); - // A stopped Docker Engine needs root to start, so Linux installs Podman as it always has. - no_engine_installs_podman(linux(true)); + podman_is_installed(linux(NOTHING), None); + // Linux has no Docker Desktop to open, whatever the flags say. + podman_is_installed(linux(found(false, true, true, false)), None); } #[test] - fn a_podman_somebody_already_has_is_theirs_and_is_used() { - let root = temp_root("existing-podman"); - // With or without a stopped `docker` beside it (Podman's own compatibility command). - for docker_cli in [false, true] { - let said = install_engine_with( - &root, - found(false, false, docker_cli, true), - |_, _| panic!("nothing is installed"), - || panic!("no Docker Desktop to start"), - |_| panic!("no install"), - |_| Ok("Compose placed".into()), - ) - .unwrap(); - assert_eq!(said, "Compose placed"); - } + fn an_installation_saved_on_podman_never_moves_to_an_answering_docker() { + let root = temp_root("saved-podman"); + let chosen = install_engine_with( + &root, + found(true, true, true, true), + Some(Engine::Podman), + |_, _| panic!("nothing is installed"), + || panic!("Docker Desktop is not opened for a Podman installation"), + |_| panic!("no install"), + |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!(chosen.engine, Engine::Podman); + // And a Podman that has gone is installed again, not swapped for Docker. + podman_is_installed(found(true, true, true, false), Some(Engine::Podman)); } #[test] - fn a_stopped_docker_without_docker_desktop_is_left_for_its_owner_to_start() { - let root = temp_root("stopped-orbstack"); + fn an_installation_saved_on_docker_with_none_to_open_asks_for_docker() { + let root = temp_root("saved-docker-stopped"); let problem = install_engine_with( &root, - found(false, false, true, false), + found(false, false, false, true), + Some(Engine::Docker), |_, _| panic!("nothing is installed"), - || panic!("there is no Docker Desktop to start"), - |_| panic!("no Podman beside another Docker"), + || panic!("there is no Docker Desktop to open"), + |_| panic!("no Podman in Docker's place"), |_| panic!("no Compose without an engine"), ) .unwrap_err(); - assert!(problem - .said - .starts_with("Docker is installed but not running.")); + assert!(problem.said.contains("Start Docker"), "{}", problem.said); + } + + #[test] + fn a_podman_somebody_already_has_is_theirs_and_is_used() { + let root = temp_root("existing-podman"); + let chosen = install_engine_with( + &root, + found(false, false, false, true), + None, + |_, _| panic!("nothing is installed"), + || panic!("no Docker Desktop to open"), + |_| panic!("no install"), + |_| Ok("Compose placed".into()), + ) + .unwrap(); + assert_eq!( + chosen, + EngineChosen { + engine: Engine::Podman, + said: "Compose placed".into() + } + ); } #[test] fn an_answering_docker_is_used_as_it_is() { let root = temp_root("answering-docker"); - let said = install_engine_with( + let chosen = install_engine_with( &root, - found(true, false, true, true), + found(true, false, false, true), + None, |_, _| panic!("nothing is installed"), || panic!("an answering Docker is not started again"), |_| panic!("no install"), |_| Ok("Compose placed".into()), ) .unwrap(); - assert_eq!(said, "Compose placed"); + assert_eq!(chosen.engine, Engine::Docker); } /// Every platform this app runs on has a Compose build, or the stack cannot be raised there. diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3010b2e40e..caa49e19a3 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -411,7 +411,12 @@ fn cleanup_root(shell: &Shell, fallback_root: &Path) -> PathBuf { #[tauri::command] fn detect_engine(app: tauri::AppHandle) -> engine::EngineStatus { - let status = engine::detect(); + let root = app + .try_state::() + .and_then(|shell| shell.selected_root.lock().unwrap().clone()); + let status = engine::detect_saved(preparation::saved_engine( + &root.unwrap_or_else(stack::default_root), + )); desktop_telemetry::observe_engine(&app, &status); status } @@ -463,8 +468,13 @@ fn windows_blocker_instruction(blocker: win::Blocker) -> String { /// nothing moving in it reads as a hang. #[tauri::command] async fn prepare_engine(app: tauri::AppHandle) -> Result { - engine_ready(&app).await?; - Ok(engine::detect()) + let saved = { + let shell = app.state::(); + let root = shell.selected_root.lock().unwrap().clone(); + preparation::saved_engine(&root.unwrap_or_else(stack::default_root)) + }; + let address = engine_ready(&app, saved).await?; + Ok(engine::detect_saved(Some(address.engine))) } /// Complete all local software acquisition before either authentication screen is available. @@ -488,9 +498,15 @@ async fn prepare_installation( } remember_selected_root(&shell, &root); } - let address = tauri::async_runtime::block_on(engine_ready(&app))?.pin()?; + let saved = preparation::saved_engine(&root); + let address = tauri::async_runtime::block_on(engine_ready(&app, saved))?.pin()?; attempt.require_current()?; tauri::async_runtime::block_on(deployment_ready(&app, &root))?; + // Saved once the folder is there, before any image is pulled into the engine. Every later + // install and start uses this engine: see `preparation::saved_engine`. + if saved.is_none() { + preparation::save_engine(&root, address.engine)?; + } let picked = harness::picked(harness.as_ref(), &root).map_err(Problem::from)?; let handle = app.clone(); let _startup = attempt.lock_current()?; @@ -631,17 +647,22 @@ async fn prepare_installation( /// /// Reported step by step rather than as one result, because these take minutes and a window with /// nothing moving in it reads as a hang. -async fn engine_ready(app: &tauri::AppHandle) -> Result { +/// `saved` is the engine the installation was set up on: only that one is looked at, started or +/// installed. With none saved, `install::install_engine_with` chooses. +async fn engine_ready( + app: &tauri::AppHandle, + saved: Option, +) -> Result { report_running(app, "engine", "Checking the software OpenBot needs.", None); - let found = engine::detect(); + let found = engine::detect_saved(saved); desktop_telemetry::observe_engine(app, &found); let root = stack::default_root(); let existing = tauri::async_runtime::spawn_blocking(move || { ready_responding_engine_after_compose_repair( found, cfg!(target_os = "linux"), - || install::install_engine(&root), - engine::detect, + || install::install_engine(&root, saved), + || engine::detect_saved(saved), engine::Address::composes, ) }) @@ -701,6 +722,7 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result Result { - report(app, "install-engine", true, said); + let chosen = match installed { + Ok(chosen) => { + report(app, "install-engine", true, chosen.said); + chosen.engine } Err(problem) => { report(app, "install-engine", false, problem.said.clone()); return Err(problem); } - } + }; - let after = engine::detect(); + // Only the engine the install step settled on: a Docker that started answering meanwhile is + // not the one a Podman installation's machine steps are for. + let after = engine::detect_saved(Some(chosen)); if let Some(address) = answering_after_install(&after, engine::Address::composes)? { desktop_telemetry::observe_engine(app, &after); report(app, "engine", true, after.detail); @@ -774,7 +799,7 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result( /// /// Require completed local installation before starting an authentication container. fn prepared_sign_in(root: &Path, published: &str) -> Result<(engine::Address, String), Problem> { - let status = engine::detect(); + let status = engine::detect_saved(preparation::saved_engine(root)); let address = status .address .filter(|_| status.responding) @@ -1521,6 +1546,27 @@ async fn start_stack_inner( ) })?; + // An installation on Docker whose Docker Desktop is stopped: opened here, before startup is + // held, because it can take minutes and Quit must not wait on it. See `docker_desktop`. + let saved = preparation::saved_engine(&root); + let owned = shell.containers.lock().unwrap().is_some(); + if saved == Some(engine::Engine::Docker) && !owned { + let progress_app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + openbot_desktop_lib::docker_desktop::start_saved(&mut |said| { + report_running(&progress_app, "engine", said, None) + }) + }) + .await + .map_err(|error| { + Problem::with( + "OpenBot could not start Docker Desktop. Open it yourself, then try again.", + error.to_string(), + ) + })??; + attempt.require_current()?; + } + let (logs, bun, mut secrets, ports, local_browser) = { let _startup = attempt.lock_current()?; @@ -1539,7 +1585,7 @@ async fn start_stack_inner( .map(|owned| owned.address.clone()); let status = match owned_address { Some(address) => address.status(), - None => engine::detect(), + None => engine::detect_saved(saved), }; let Some(found) = status.address.clone().filter(|_| status.responding) else { return Err(status.detail.into()); @@ -1551,6 +1597,11 @@ async fn start_stack_inner( let status = found.status(); acquire::prepare_for_compose(&found)?; let bun = preparation::require(&root, Some(&requested_harness), &found)?; + // An installation from before engines were saved is pinned to the one its images were + // just found on. Best effort: Start does not fail over a hint for next time. + if saved.is_none() { + let _ = preparation::save_engine(&root, found.engine); + } // Checked here as well as in the health gate, because the gate only runs when an engine had to // be installed. A machine that already had Podman skips all of that and arrives at Compose, @@ -3135,7 +3186,7 @@ async fn ask_the_bot_with_settings( Err(None) => { let log = log_service .and_then(|service| { - engine::detect() + engine::detect_saved(preparation::saved_engine(&root)) .address .map(|found| stack::service_log(&found, &root, service, 40)) }) diff --git a/desktop/src-tauri/src/preparation.rs b/desktop/src-tauri/src/preparation.rs index 9b0fa32242..364699140a 100644 --- a/desktop/src-tauri/src/preparation.rs +++ b/desktop/src-tauri/src/preparation.rs @@ -6,13 +6,21 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{ - deployment, engine::Address, harness::HarnessChoice, local_browser, problem::Problem, stack, + deployment, + engine::{Address, Engine}, + harness::HarnessChoice, + local_browser, + problem::Problem, + stack, }; pub const REQUIRED: &str = "Finish installing OpenBot's local software before signing in or starting. Return to Install and try again."; pub const FILE: &str = ".openbot-prepared.json"; const DEPENDENCIES: &str = ".openbot-dependencies.json"; const LAUNCH: &str = ".openbot-launched.json"; +/// The engine this installation was set up on. Never invalidated with the preparation record: a +/// new release is prepared again on the same engine, where its database already is. +pub const ENGINE: &str = ".openbot-engine.json"; const PACKAGES: [&str; 7] = [ "", "server", @@ -96,6 +104,28 @@ pub fn installation(root: &Path) -> Option { }) } +#[derive(Serialize, Deserialize)] +struct SavedEngine { + engine: Engine, +} + +/// The engine this installation was set up on, or `None` when it saved none (a new installation, +/// or one from before this record existed). +/// +/// Every later install and start uses this engine and never the other one. The installation's +/// images and its database are in the engine it was set up on, so moving to the other one, say +/// because Docker happens to be answering, starts OpenBot with an empty database. +pub fn saved_engine(root: &Path) -> Option { + let record: SavedEngine = + serde_json::from_slice(&std::fs::read(root.join(ENGINE)).ok()?).ok()?; + Some(record.engine) +} + +/// Save the engine this installation runs on. Called once, when it has none saved. +pub fn save_engine(root: &Path, engine: Engine) -> Result<(), Problem> { + write_record(root, ENGINE, &SavedEngine { engine }) +} + pub fn save_selected_root(config: &Path, root: &Path) -> Result<(), Problem> { let root = std::fs::canonicalize(root).map_err(|e| { Problem::with( @@ -494,6 +524,26 @@ pub fn image_settings( mod tests { use super::*; + #[test] + fn the_saved_engine_outlives_a_new_preparation() { + let root = crate::test_support::temp_root("preparation-saved-engine"); + std::fs::create_dir_all(&root).unwrap(); + assert_eq!(saved_engine(&root), None, "nothing saved yet"); + save_engine(&root, Engine::Podman).unwrap(); + assert_eq!(saved_engine(&root), Some(Engine::Podman)); + // A new release is prepared again on the engine its database is in. + invalidate(&root).unwrap(); + assert_eq!(saved_engine(&root), Some(Engine::Podman)); + save_engine(&root, Engine::Docker).unwrap(); + assert_eq!(saved_engine(&root), Some(Engine::Docker)); + std::fs::write(root.join(ENGINE), "not json").unwrap(); + assert_eq!( + saved_engine(&root), + None, + "an unreadable record is no record" + ); + } + #[test] fn a_release_from_before_the_toolkit_needs_no_shared_or_toolkit_manifest() { let root = crate::test_support::temp_root("preparation-v020-layout"); From ac6bbb94f7ec55869c5ba3c8379b4971802c0a68 Mon Sep 17 00:00:00 2001 From: Dusty Date: Wed, 28 Oct 2026 22:24:31 -0700 Subject: [PATCH 7/7] fix(desktop): start the installation's own engine at Start, not only at install Only install started the Podman machine. Reopening OpenBot goes straight to Start, which only looked for an engine, so after a restart a Podman installation failed with "podman is installed but not answering. Start it and try again." Nobody this app is for can act on that. Start now brings up the saved engine first, before startup is held: a saved Docker opens Docker Desktop, and a saved Podman (or an installation from before engines were saved, with nothing answering) starts OpenBot's own machine when it exists. No machine is created at Start and nobody else's is started. An older installation that was on Docker cannot land on an empty machine: Start requires the installation's images on the engine it uses. --- desktop/src-tauri/src/main.rs | 137 ++++++++++++++++++++++++++++++++-- 1 file changed, 130 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index caa49e19a3..b7cc830b88 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -282,6 +282,42 @@ struct ReadyRespondingEngine { installed: Option, } +/// What Start does first, so the engine this installation runs on is answering. +/// +/// Install brings the engine up; Start used to only look for it. So after a restart a Podman +/// installation's machine was stopped and Start failed with "podman is installed but not +/// answering", which nobody this app is for can act on. Now the saved engine is brought up: +/// +/// - Docker: a stopped Docker Desktop is opened. It answered for this installation before. +/// - Podman, or an installation from before engines were saved: OpenBot's own machine is +/// started, when it exists. A machine is never created here, and nobody else's is started. +/// +/// An engine that still does not answer is reported by the check after this one. An older +/// installation that was on Docker cannot land on an empty machine: Start requires the +/// installation's images on the engine it uses before it runs or saves anything. +fn wake_saved_engine( + saved: Option, + engine_in_a_machine: bool, + answers: impl FnOnce() -> bool, + open_docker_desktop: impl FnOnce() -> Result<(), Problem>, + our_machine_exists: impl FnOnce() -> bool, + start_our_machine: impl FnOnce() -> Result<(), Problem>, +) -> Result<(), Problem> { + if answers() { + return Ok(()); + } + match saved { + Some(engine::Engine::Docker) => open_docker_desktop(), + Some(engine::Engine::Podman) | None => { + if engine_in_a_machine && our_machine_exists() { + start_our_machine() + } else { + Ok(()) + } + } + } +} + /// Return a responding engine only after Compose is present too. fn ready_responding_engine_after_compose_repair( found: engine::EngineStatus, @@ -1546,21 +1582,49 @@ async fn start_stack_inner( ) })?; - // An installation on Docker whose Docker Desktop is stopped: opened here, before startup is - // held, because it can take minutes and Quit must not wait on it. See `docker_desktop`. + // The installation's own engine, brought up when it is stopped: here, before startup is held, + // because it can take minutes and Quit must not wait on it. See `wake_saved_engine`. let saved = preparation::saved_engine(&root); let owned = shell.containers.lock().unwrap().is_some(); - if saved == Some(engine::Engine::Docker) && !owned { + if !owned { let progress_app = app.clone(); tauri::async_runtime::spawn_blocking(move || { - openbot_desktop_lib::docker_desktop::start_saved(&mut |said| { - report_running(&progress_app, "engine", said, None) - }) + wake_saved_engine( + saved, + cfg!(any(target_os = "macos", target_os = "windows")), + || engine::detect_saved(saved).responding, + || { + openbot_desktop_lib::docker_desktop::start_saved(&mut |said| { + report_running(&progress_app, "engine", said, None) + }) + }, + || acquire::machine_exists().unwrap_or(false), + || { + report_running( + &progress_app, + "start-machine", + "Starting the engine machine.", + None, + ); + let started = acquire::start_machine(); + report( + &progress_app, + "start-machine", + started.ok, + started.said.clone(), + ); + if started.ok { + Ok(()) + } else { + Err(started.problem()) + } + }, + ) }) .await .map_err(|error| { Problem::with( - "OpenBot could not start Docker Desktop. Open it yourself, then try again.", + "OpenBot could not start the software it runs on. Try again.", error.to_string(), ) })??; @@ -6804,6 +6868,65 @@ mod tests { ); } + #[test] + fn start_brings_up_the_engine_this_installation_runs_on() { + use engine::Engine::{Docker, Podman}; + use std::cell::RefCell; + // (saved, in a machine, answers, our machine exists) -> what was done + let run = |saved, in_machine, answers, exists| { + let done = RefCell::new(Vec::new()); + wake_saved_engine( + saved, + in_machine, + || answers, + || { + done.borrow_mut().push("open Docker Desktop"); + Ok(()) + }, + || exists, + || { + done.borrow_mut().push("start our machine"); + Ok(()) + }, + ) + .unwrap(); + done.into_inner() + }; + let none: [&str; 0] = []; + // Answering: nothing is done, whatever was saved. + for saved in [Some(Docker), Some(Podman), None] { + assert_eq!(run(saved, true, true, true), none, "{saved:?}"); + } + // A saved Docker opens Docker Desktop, never a Podman machine. + assert_eq!( + run(Some(Docker), true, false, true), + ["open Docker Desktop"] + ); + // A saved Podman after a restart: OpenBot's machine is started. + assert_eq!(run(Some(Podman), true, false, true), ["start our machine"]); + // An installation from before engines were saved, with nothing answering: the same. + assert_eq!(run(None, true, false, true), ["start our machine"]); + // No machine of ours: none is created and nobody else's is started. + assert_eq!(run(Some(Podman), true, false, false), none); + assert_eq!(run(None, true, false, false), none); + // Linux runs Podman natively: there is no machine to start. + assert_eq!(run(Some(Podman), false, false, true), none); + } + + #[test] + fn a_machine_that_fails_to_start_stops_start_with_its_own_words() { + let problem = wake_saved_engine( + Some(engine::Engine::Podman), + true, + || false, + || panic!("a Podman installation never opens Docker Desktop"), + || true, + || Err(Problem::plain("machine did not start")), + ) + .unwrap_err(); + assert_eq!(problem.said, "machine did not start"); + } + #[test] fn linux_failed_native_engine_is_an_error_instead_of_a_request_for_qemu() { let stopped = engine::EngineStatus {