diff --git a/app/src-tauri/src/commands/install_helper_commands.rs b/app/src-tauri/src/commands/install_helper_commands.rs new file mode 100644 index 0000000..416209f --- /dev/null +++ b/app/src-tauri/src/commands/install_helper_commands.rs @@ -0,0 +1,11 @@ +use crate::install_helper::{self, InstallOptions}; + +#[tauri::command] +pub async fn detect_install_options() -> Result { + Ok(install_helper::detect_install_options()) +} + +#[tauri::command] +pub async fn run_docker_install(app_handle: tauri::AppHandle) -> Result<(), String> { + install_helper::platform::run_install(&app_handle).await +} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 6af0fe7..555b692 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod aws_commands; pub mod docker_commands; pub mod file_commands; pub mod help_commands; +pub mod install_helper_commands; pub mod mcp_commands; pub mod project_commands; pub mod settings_commands; diff --git a/app/src-tauri/src/install_helper/mod.rs b/app/src-tauri/src/install_helper/mod.rs new file mode 100644 index 0000000..f1becca --- /dev/null +++ b/app/src-tauri/src/install_helper/mod.rs @@ -0,0 +1,53 @@ +// Helpers for detecting whether Docker (or a Docker-compatible runtime) is +// installed on the host and, when missing, offering to install it for the user. +// +// We use the Docker convenience script on Linux and Rancher Desktop on macOS / +// Windows. On every platform we also surface an official documentation URL so +// users without a recognised package manager can install manually. + +use serde::{Deserialize, Serialize}; + +pub mod platform; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstallOptions { + /// "linux" | "macos" | "windows" | "unknown" + pub os: String, + /// User-facing name of what we'd install ("Docker Engine" / "Rancher Desktop"). + pub product_name: String, + /// Whether we can kick off a one-click install with what's on this machine. + pub can_auto_install: bool, + /// Short identifier of the method we'd use ("pkexec", "brew", "winget", or None). + pub auto_install_method: Option, + /// If auto-install isn't possible, a human-readable reason to show the user. + pub auto_install_blocker: Option, + /// Official documentation URL for manual install. + pub docs_url: String, + /// Ordered manual install steps (plain text lines). + pub manual_steps: Vec, + /// Notes to display after a successful auto-install (e.g. log out/back in). + pub post_install_notes: Vec, +} + +pub fn detect_install_options() -> InstallOptions { + if cfg!(target_os = "linux") { + platform::linux_options() + } else if cfg!(target_os = "macos") { + platform::macos_options() + } else if cfg!(target_os = "windows") { + platform::windows_options() + } else { + InstallOptions { + os: "unknown".into(), + product_name: "Docker".into(), + can_auto_install: false, + auto_install_method: None, + auto_install_blocker: Some("Unsupported operating system".into()), + docs_url: "https://docs.docker.com/get-docker/".into(), + manual_steps: vec![ + "Visit the Docker documentation and follow the install guide for your OS.".into(), + ], + post_install_notes: vec![], + } + } +} diff --git a/app/src-tauri/src/install_helper/platform.rs b/app/src-tauri/src/install_helper/platform.rs new file mode 100644 index 0000000..d7aef3c --- /dev/null +++ b/app/src-tauri/src/install_helper/platform.rs @@ -0,0 +1,288 @@ +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 { + #[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()) + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 5a45c27..5b041e5 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod commands; mod docker; +mod install_helper; mod logging; mod models; mod storage; @@ -197,6 +198,9 @@ pub fn run() { commands::update_commands::check_image_update, // Help commands::help_commands::get_help_content, + // Install helper + commands::install_helper_commands::detect_install_options, + commands::install_helper_commands::run_docker_install, // Web Terminal commands::web_terminal_commands::start_web_terminal, commands::web_terminal_commands::stop_web_terminal, diff --git a/app/src/App.tsx b/app/src/App.tsx index 8378f29..46cc759 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,9 +1,10 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import Sidebar from "./components/layout/Sidebar"; import TopBar from "./components/layout/TopBar"; import StatusBar from "./components/layout/StatusBar"; import TerminalView from "./components/terminal/TerminalView"; +import DockerInstallDialog from "./components/DockerInstallDialog"; import { useDocker } from "./hooks/useDocker"; import { useSettings } from "./hooks/useSettings"; import { useProjects } from "./hooks/useProjects"; @@ -21,6 +22,7 @@ export default function App() { const { sessions, activeSessionId, setProjects } = useAppState( useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects })) ); + const [showInstallDialog, setShowInstallDialog] = useState(false); // Initialize on mount useEffect(() => { @@ -38,6 +40,7 @@ export default function App() { refresh(); }); } else { + setShowInstallDialog(true); stopPolling = startDockerPolling(); } }); @@ -80,6 +83,9 @@ export default function App() { + {showInstallDialog && ( + setShowInstallDialog(false)} /> + )} ); } diff --git a/app/src/components/DockerInstallDialog.tsx b/app/src/components/DockerInstallDialog.tsx new file mode 100644 index 0000000..69ddef2 --- /dev/null +++ b/app/src/components/DockerInstallDialog.tsx @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useInstallHelper } from "../hooks/useInstallHelper"; +import { useDocker } from "../hooks/useDocker"; + +interface Props { + onClose: () => void; +} + +type Phase = "idle" | "installing" | "done" | "error"; + +export default function DockerInstallDialog({ onClose }: Props) { + const { options, loadOptions, runInstall } = useInstallHelper(); + const { checkDocker } = useDocker(); + const [showManual, setShowManual] = useState(false); + const [phase, setPhase] = useState("idle"); + const [log, setLog] = useState([]); + const [error, setError] = useState(null); + const overlayRef = useRef(null); + + useEffect(() => { + loadOptions(); + }, [loadOptions]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && phase !== "installing") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose, phase]); + + const handleOverlayClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === overlayRef.current && phase !== "installing") onClose(); + }, + [onClose, phase], + ); + + const handleInstall = async () => { + setPhase("installing"); + setLog([]); + setError(null); + try { + await runInstall((line) => setLog((prev) => [...prev, line])); + setPhase("done"); + // Re-check Docker so the rest of the app can proceed without a reload. + await checkDocker(); + } catch (e) { + setError(String(e)); + setPhase("error"); + } + }; + + const handleOpenDocs = async () => { + if (!options) return; + try { + await openUrl(options.docs_url); + } catch (e) { + console.error("Failed to open docs URL:", e); + } + }; + + const handleRecheck = async () => { + const available = await checkDocker(); + if (available) onClose(); + }; + + if (!options) { + return null; + } + + const installVerb = phase === "installing" ? "Installing…" : `Install ${options.product_name}`; + + return ( +
+
+

Docker not detected

+

+ Triple-C needs a Docker-compatible runtime to manage sandboxed project containers. + We can install {options.product_name}{" "} + for you, or you can follow the official instructions. +

+ + {phase === "idle" && ( +
+ {options.can_auto_install ? ( + + ) : ( +
+ One-click install unavailable:{" "} + + {options.auto_install_blocker ?? "required tooling missing."} + +
+ )} + + + + +
+ )} + + {phase === "installing" && ( +
+ Installing… a system password prompt may appear. Do not close this window. +
+ )} + + {phase === "done" && ( +
+
Install finished.
+ {options.post_install_notes.length > 0 && ( +
    + {options.post_install_notes.map((note, i) => ( +
  • {note}
  • + ))} +
+ )} +
+ + +
+
+ )} + + {phase === "error" && ( +
+
Install failed.
+ {error &&
{error}
} +
+ + +
+
+ )} + + {(showManual || phase === "error") && ( +
+
+ Manual install steps +
+
    + {options.manual_steps.map((step, i) => ( +
  1. {step}
  2. + ))} +
+
+ )} + + {log.length > 0 && ( +
+ {log.map((line, i) => ( +
{line}
+ ))} +
+ )} + + {phase === "idle" && ( +
+ +
+ )} +
+
+ ); +} diff --git a/app/src/hooks/useInstallHelper.ts b/app/src/hooks/useInstallHelper.ts new file mode 100644 index 0000000..ebabff3 --- /dev/null +++ b/app/src/hooks/useInstallHelper.ts @@ -0,0 +1,35 @@ +import { useCallback, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import * as commands from "../lib/tauri-commands"; +import type { InstallOptions } from "../lib/types"; + +export function useInstallHelper() { + const [options, setOptions] = useState(null); + + const loadOptions = useCallback(async () => { + try { + const opts = await commands.detectInstallOptions(); + setOptions(opts); + return opts; + } catch { + setOptions(null); + return null; + } + }, []); + + const runInstall = useCallback( + async (onProgress?: (line: string) => void) => { + const unlisten = onProgress + ? await listen("docker-install-progress", (e) => onProgress(e.payload)) + : null; + try { + await commands.runDockerInstall(); + } finally { + unlisten?.(); + } + }, + [], + ); + + return { options, loadOptions, runInstall }; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 3499119..4fdd6da 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo, SttStatus } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer, FileEntry, WebTerminalInfo, SttStatus, InstallOptions } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -107,3 +107,8 @@ export const buildSttImage = () => invoke("build_stt_image"); export const pullSttImage = () => invoke("pull_stt_image"); export const transcribeAudio = (audioData: number[]) => invoke("transcribe_audio", { audioData }); + +// Docker install helper +export const detectInstallOptions = () => + invoke("detect_install_options"); +export const runDockerInstall = () => invoke("run_docker_install"); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 2e74802..4baabb6 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -211,3 +211,14 @@ export interface FileEntry { modified: string; permissions: string; } + +export interface InstallOptions { + os: "linux" | "macos" | "windows" | "unknown"; + product_name: string; + can_auto_install: boolean; + auto_install_method: string | null; + auto_install_blocker: string | null; + docs_url: string; + manual_steps: string[]; + post_install_notes: string[]; +}