When Docker isn't detected on startup, surface a dialog offering a one-click install (pkexec + get.docker.com on Linux, brew cask on macOS, winget on Windows) with a graceful fallback to manual steps and a link to official documentation. Install output streams back to the UI via a tauri event. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
289 lines
11 KiB
Rust
289 lines
11 KiB
Rust
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
|
|
use tauri::{AppHandle, Emitter};
|
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
|
use tokio::process::Command;
|
|
|
|
use super::InstallOptions;
|
|
|
|
const PROGRESS_EVENT: &str = "docker-install-progress";
|
|
|
|
fn which(cmd: &str) -> bool {
|
|
find_on_path(cmd).is_some()
|
|
}
|
|
|
|
/// Search PATH for an executable, plus a handful of well-known locations that
|
|
/// GUI-launched apps on macOS/Linux typically miss (Homebrew prefixes, etc.).
|
|
fn find_on_path(cmd: &str) -> Option<PathBuf> {
|
|
#[cfg(unix)]
|
|
let extra: &[&str] = &[
|
|
"/opt/homebrew/bin",
|
|
"/usr/local/bin",
|
|
"/usr/bin",
|
|
"/bin",
|
|
];
|
|
#[cfg(windows)]
|
|
let extra: &[&str] = &[];
|
|
|
|
if let Ok(path) = std::env::var("PATH") {
|
|
let sep = if cfg!(windows) { ';' } else { ':' };
|
|
for dir in path.split(sep).chain(extra.iter().copied()) {
|
|
let candidate = PathBuf::from(dir).join(cmd);
|
|
if candidate.is_file() {
|
|
return Some(candidate);
|
|
}
|
|
#[cfg(windows)]
|
|
for ext in ["exe", "cmd", "bat"] {
|
|
let mut with_ext = candidate.clone();
|
|
with_ext.set_extension(ext);
|
|
if with_ext.is_file() {
|
|
return Some(with_ext);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for dir in extra {
|
|
let candidate = PathBuf::from(dir).join(cmd);
|
|
if candidate.is_file() {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
async fn stream(app: &AppHandle, mut child: tokio::process::Child) -> Result<(), String> {
|
|
let stdout = child.stdout.take();
|
|
let stderr = child.stderr.take();
|
|
|
|
let app_out = app.clone();
|
|
let out_task = tokio::spawn(async move {
|
|
if let Some(out) = stdout {
|
|
let mut lines = BufReader::new(out).lines();
|
|
while let Ok(Some(line)) = lines.next_line().await {
|
|
let _ = app_out.emit(PROGRESS_EVENT, line);
|
|
}
|
|
}
|
|
});
|
|
|
|
let app_err = app.clone();
|
|
let err_task = tokio::spawn(async move {
|
|
if let Some(err) = stderr {
|
|
let mut lines = BufReader::new(err).lines();
|
|
while let Ok(Some(line)) = lines.next_line().await {
|
|
let _ = app_err.emit(PROGRESS_EVENT, line);
|
|
}
|
|
}
|
|
});
|
|
|
|
let status = child
|
|
.wait()
|
|
.await
|
|
.map_err(|e| format!("install process failed: {}", e))?;
|
|
let _ = out_task.await;
|
|
let _ = err_task.await;
|
|
|
|
if !status.success() {
|
|
return Err(format!(
|
|
"installer exited with status {}",
|
|
status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into())
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ─── Linux ───────────────────────────────────────────────────────────────────
|
|
|
|
pub fn linux_options() -> InstallOptions {
|
|
let has_pkexec = which("pkexec");
|
|
let has_curl = which("curl");
|
|
|
|
let (can_auto, blocker) = match (has_pkexec, has_curl) {
|
|
(true, true) => (true, None),
|
|
(false, _) => (
|
|
false,
|
|
Some("pkexec not found — install policykit-1 or follow manual steps.".into()),
|
|
),
|
|
(_, false) => (
|
|
false,
|
|
Some("curl not found — install curl or follow manual steps.".into()),
|
|
),
|
|
};
|
|
|
|
InstallOptions {
|
|
os: "linux".into(),
|
|
product_name: "Docker Engine".into(),
|
|
can_auto_install: can_auto,
|
|
auto_install_method: if can_auto { Some("pkexec".into()) } else { None },
|
|
auto_install_blocker: blocker,
|
|
docs_url: "https://docs.docker.com/engine/install/".into(),
|
|
manual_steps: vec![
|
|
"Open a terminal.".into(),
|
|
"Run: curl -fsSL https://get.docker.com | sh".into(),
|
|
"Add yourself to the docker group: sudo usermod -aG docker $USER".into(),
|
|
"Log out and log back in for group changes to take effect.".into(),
|
|
],
|
|
post_install_notes: vec![
|
|
"Log out and log back in (or reboot) so your user picks up the docker group.".into(),
|
|
"If Docker isn't detected after re-login, start the service: sudo systemctl start docker".into(),
|
|
],
|
|
}
|
|
}
|
|
|
|
async fn run_linux_install(app: &AppHandle) -> Result<(), String> {
|
|
// Grab the current username so pkexec (which runs as root) can add the
|
|
// original invoking user to the docker group.
|
|
let invoking_user = std::env::var("USER")
|
|
.or_else(|_| std::env::var("LOGNAME"))
|
|
.map_err(|_| "could not determine invoking username".to_string())?;
|
|
|
|
// Write a self-contained installer script to a temp file. Running the
|
|
// Docker convenience script then appending the user to the docker group
|
|
// and enabling the service.
|
|
let script = format!(
|
|
r#"#!/bin/sh
|
|
set -e
|
|
echo "[triple-c] Downloading Docker install script..."
|
|
curl -fsSL https://get.docker.com -o /tmp/triple-c-get-docker.sh
|
|
echo "[triple-c] Running Docker install script (may take a few minutes)..."
|
|
sh /tmp/triple-c-get-docker.sh
|
|
rm -f /tmp/triple-c-get-docker.sh
|
|
echo "[triple-c] Adding {user} to docker group..."
|
|
usermod -aG docker "{user}" || true
|
|
echo "[triple-c] Enabling docker service..."
|
|
systemctl enable --now docker 2>/dev/null || service docker start 2>/dev/null || true
|
|
echo "[triple-c] Install complete. Log out and back in to use Docker without sudo."
|
|
"#,
|
|
user = invoking_user
|
|
);
|
|
|
|
let script_path: PathBuf = std::env::temp_dir().join("triple-c-install-docker.sh");
|
|
tokio::fs::write(&script_path, script)
|
|
.await
|
|
.map_err(|e| format!("failed to write install script: {}", e))?;
|
|
|
|
let _ = app.emit(
|
|
PROGRESS_EVENT,
|
|
format!("Requesting administrator privileges via pkexec..."),
|
|
);
|
|
|
|
let child = Command::new("pkexec")
|
|
.arg("sh")
|
|
.arg(&script_path)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|e| format!("failed to launch pkexec: {}", e))?;
|
|
|
|
let result = stream(app, child).await;
|
|
let _ = tokio::fs::remove_file(&script_path).await;
|
|
result
|
|
}
|
|
|
|
// ─── macOS ───────────────────────────────────────────────────────────────────
|
|
|
|
pub fn macos_options() -> InstallOptions {
|
|
let has_brew = which("brew");
|
|
InstallOptions {
|
|
os: "macos".into(),
|
|
product_name: "Rancher Desktop".into(),
|
|
can_auto_install: has_brew,
|
|
auto_install_method: if has_brew { Some("brew".into()) } else { None },
|
|
auto_install_blocker: if has_brew {
|
|
None
|
|
} else {
|
|
Some("Homebrew not found — use the manual download.".into())
|
|
},
|
|
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
|
|
manual_steps: vec![
|
|
"Download the Rancher Desktop .dmg from the official site.".into(),
|
|
"Open the .dmg and drag Rancher Desktop into Applications.".into(),
|
|
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
|
|
"Once the Docker socket is available, come back and click Refresh.".into(),
|
|
],
|
|
post_install_notes: vec![
|
|
"Launch Rancher Desktop from Applications if it didn't open automatically.".into(),
|
|
"In Preferences, make sure the container engine is set to dockerd (moby).".into(),
|
|
],
|
|
}
|
|
}
|
|
|
|
async fn run_macos_install(app: &AppHandle) -> Result<(), String> {
|
|
let brew = find_on_path("brew")
|
|
.ok_or_else(|| "Homebrew not found — follow the manual steps instead.".to_string())?;
|
|
let _ = app.emit(
|
|
PROGRESS_EVENT,
|
|
format!("Running: {} install --cask rancher", brew.display()),
|
|
);
|
|
let child = Command::new(&brew)
|
|
.args(["install", "--cask", "rancher"])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|e| format!("failed to launch brew: {}", e))?;
|
|
stream(app, child).await
|
|
}
|
|
|
|
// ─── Windows ─────────────────────────────────────────────────────────────────
|
|
|
|
pub fn windows_options() -> InstallOptions {
|
|
let has_winget = which("winget");
|
|
InstallOptions {
|
|
os: "windows".into(),
|
|
product_name: "Rancher Desktop".into(),
|
|
can_auto_install: has_winget,
|
|
auto_install_method: if has_winget { Some("winget".into()) } else { None },
|
|
auto_install_blocker: if has_winget {
|
|
None
|
|
} else {
|
|
Some("winget not found — use the manual download.".into())
|
|
},
|
|
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
|
|
manual_steps: vec![
|
|
"Download the Rancher Desktop .msi from the official site.".into(),
|
|
"Run the installer and accept the WSL2 prompts if asked.".into(),
|
|
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
|
|
"Once the Docker engine is running, come back and click Refresh.".into(),
|
|
],
|
|
post_install_notes: vec![
|
|
"Launch Rancher Desktop from the Start menu if it didn't open automatically.".into(),
|
|
"In Preferences > Container Engine, make sure dockerd (moby) is selected.".into(),
|
|
],
|
|
}
|
|
}
|
|
|
|
async fn run_windows_install(app: &AppHandle) -> Result<(), String> {
|
|
let _ = app.emit(
|
|
PROGRESS_EVENT,
|
|
"Running: winget install --id SUSE.RancherDesktop -e --accept-package-agreements --accept-source-agreements".to_string(),
|
|
);
|
|
let child = Command::new("winget")
|
|
.args([
|
|
"install",
|
|
"--id",
|
|
"SUSE.RancherDesktop",
|
|
"-e",
|
|
"--accept-package-agreements",
|
|
"--accept-source-agreements",
|
|
])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|e| format!("failed to launch winget: {}", e))?;
|
|
stream(app, child).await
|
|
}
|
|
|
|
// ─── Dispatcher ──────────────────────────────────────────────────────────────
|
|
|
|
pub async fn run_install(app: &AppHandle) -> Result<(), String> {
|
|
if cfg!(target_os = "linux") {
|
|
run_linux_install(app).await
|
|
} else if cfg!(target_os = "macos") {
|
|
run_macos_install(app).await
|
|
} else if cfg!(target_os = "windows") {
|
|
run_windows_install(app).await
|
|
} else {
|
|
Err("auto-install is not supported on this OS".into())
|
|
}
|
|
}
|