use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Starter {
App(PathBuf),
Service,
}
impl Starter {
pub fn name(&self) -> String {
match self {
Starter::Service => "the Docker service".into(),
_ => "the Docker engine".into(),
}
}
pub fn run(&self) -> Result<(), String> {
let status = |command: &mut Command| {
command.status().map_err(|e| e.to_string()).and_then(|s| {
s.success()
.then_some(())
.ok_or_else(|| format!("it exited with {s}"))
})
};
let result = match self {
Starter::App(app) => status(Command::new("/usr/bin/open").arg("-gj").arg(app)),
Starter::Service => status(Command::new("systemctl").args(["start", "docker"]))
.or_else(|_| status(Command::new("pkexec").args(["systemctl", "start", "docker"]))),
};
result.map_err(|e| format!("Couldn't start {}: {e}", self.name()))
}
}
fn socket_path(host: &str) -> Option<PathBuf> {
host.strip_prefix("unix://").map(PathBuf::from)
}
fn normalized(name: &str) -> String {
name.trim_start_matches('.')
.trim_end_matches(".app")
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
fn hints(host: &str, engine: &str) -> Vec<String> {
let mut hints = vec![normalized(engine)];
if let Some(socket) = socket_path(host) {
let target = std::fs::read_link(&socket).ok();
for path in std::iter::once(socket).chain(target) {
let hidden = path
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.find(|c| c.starts_with('.') && c.len() > 1);
if let Some(dir) = hidden {
hints.push(normalized(&dir));
}
}
}
hints.retain(|h| !h.is_empty() && h != "default");
hints
}
fn app_named(hints: &[String]) -> Option<PathBuf> {
let home_apps = std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Applications"));
let dirs = std::iter::once(PathBuf::from("/Applications")).chain(home_apps);
dirs.filter_map(|dir| std::fs::read_dir(dir).ok())
.flatten()
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|e| e == "app"))
.find(|path| {
path.file_name()
.is_some_and(|n| hints.contains(&normalized(&n.to_string_lossy())))
})
}
pub fn starter(host: &str, engine: &str, remembered: Option<&str>) -> Option<Starter> {
if cfg!(target_os = "macos") {
let remembered = remembered.map(PathBuf::from).filter(|app| app.is_dir());
return remembered
.or_else(|| app_named(&hints(host, engine)))
.map(Starter::App);
}
if cfg!(windows) {
return None;
}
let units = [
"/etc/systemd/system/docker.service",
"/lib/systemd/system/docker.service",
"/usr/lib/systemd/system/docker.service",
];
units
.iter()
.any(|u| Path::new(u).exists())
.then_some(Starter::Service)
}
pub fn provider_app(host: &str) -> Option<String> {
if !cfg!(target_os = "macos") {
return None;
}
let socket = std::fs::canonicalize(socket_path(host)?).ok()?;
let output = Command::new("/usr/sbin/lsof")
.arg("-t")
.arg(&socket)
.output()
.ok()?;
let pid = String::from_utf8_lossy(&output.stdout)
.lines()
.next()?
.trim()
.to_owned();
let output = Command::new("/bin/ps")
.args(["-o", "comm=", "-p", &pid])
.output()
.ok()?;
let program = String::from_utf8_lossy(&output.stdout).trim().to_owned();
outermost_app(Path::new(&program)).map(|app| app.display().to_string())
}
fn outermost_app(path: &Path) -> Option<&Path> {
path.ancestors()
.filter(|p| p.extension().is_some_and(|e| e == "app"))
.last()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_the_app() {
assert_eq!(
outermost_app(Path::new(
"/Applications/Some Engine.app/Contents/Frameworks/Helper.app/Contents/MacOS/helper"
)),
Some(Path::new("/Applications/Some Engine.app"))
);
assert_eq!(outermost_app(Path::new("/usr/bin/dockerd")), None);
let app = Starter::App(PathBuf::from("/Applications/Some Engine.app"));
assert_eq!(app.name(), "the Docker engine");
assert_eq!(normalized("Some Engine.app"), normalized(".some-engine"));
assert_eq!(
hints("unix:///Users/me/.someengine/run/docker.sock", "work"),
["work", "someengine"]
);
assert!(hints("unix:///var/run/nothing-here.sock", "default").is_empty());
assert_eq!(Starter::Service.name(), "the Docker service");
}
}