Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions desktop/src-tauri/src/docker_desktop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
//! Docker Desktop: started when it is installed and not running.
//!
//! **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::path::PathBuf;
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);
const POLL: Duration = Duration::from_secs(2);

/// Where Docker Desktop is installed on this machine, if it is.
pub fn installed_app() -> Option<PathBuf> {
installed_app_in(&app_places())
}

fn installed_app_in(places: &[PathBuf]) -> Option<PathBuf> {
places.iter().find(|place| place.exists()).cloned()
}

pub(crate) fn app_places() -> Vec<PathBuf> {
#[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"));
Comment thread
dustywusty marked this conversation as resolved.
}
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()
}

/// What `start_with` needs from the machine, so its decisions can be tested without one.
pub(crate) struct Machine<'a> {
pub answers: &'a dyn Fn() -> bool,
pub open: &'a dyn Fn() -> Result<(), Problem>,
pub sleep: &'a dyn Fn(Duration),
pub now: &'a dyn Fn() -> Instant,
}

/// 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<String, Problem> {
start_with(
&Machine {
answers: &answers,
open: &open,
sleep: &std::thread::sleep,
now: &Instant::now,
},
progress,
)
}

pub(crate) fn start_with(
machine: &Machine,
progress: &mut dyn FnMut(String),
) -> Result<String, Problem> {
if (machine.answers)() {
return Ok("Docker Desktop is running.".into());
}
progress("Starting Docker Desktop. Accept anything it asks in its own window.".into());
(machine.open)()?;

let started = (machine.now)();
while (machine.now)().duration_since(started) < START_PATIENCE {
if (machine.answers)() {
return Ok("Docker Desktop is running.".into());
}
(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",
START_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();
// 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(|_| std::process::ExitStatus::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()),
)),
}
}

#[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 {
polls: Cell<u32>,
answers_after: Option<u32>,
opened: Cell<u32>,
clock: Cell<Instant>,
}

impl Fake {
fn new(answers_after: Option<u32>) -> Self {
Self {
polls: Cell::new(0),
answers_after,
opened: Cell::new(0),
clock: Cell::new(Instant::now()),
}
}

fn run(&self) -> (Result<String, Problem>, Vec<String>) {
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 = start_with(
&Machine {
answers: &answers,
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(Some(0));
fake.opened.set(1);
let (result, said) = fake.run();
assert_eq!(result.unwrap(), "Docker Desktop is running.");
assert_eq!(fake.opened.get(), 1, "nothing was opened again");
assert!(said.is_empty());
}

#[test]
fn an_installed_docker_desktop_that_is_stopped_is_started() {
let fake = Fake::new(Some(3));
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."]
);
}

#[test]
fn a_docker_desktop_that_never_answers_says_so_after_its_patience() {
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"));
}

#[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));
}
}
41 changes: 30 additions & 11 deletions desktop/src-tauri/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,13 @@ fn on_path(binary: &str) -> Option<PathBuf> {

/// The fixed places each platform's installers use.
fn where_installers_put(engine: Engine) -> Option<PathBuf> {
let places: Vec<PathBuf> = match engine {
installer_places(engine)
.into_iter()
.find(|candidate| candidate.is_file())
}

fn installer_places(engine: Engine) -> Vec<PathBuf> {
match engine {
#[cfg(target_os = "windows")]
Engine::Podman => {
// The MSI uses Programs\Podman per user and Program Files\Podman per machine.
Expand Down Expand Up @@ -444,15 +450,19 @@ fn where_installers_put(engine: Engine) -> Option<PathBuf> {
.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()
Expand All @@ -463,8 +473,7 @@ fn where_installers_put(engine: Engine) -> Option<PathBuf> {
.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.
Expand Down Expand Up @@ -787,6 +796,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]
Expand Down
Loading
Loading