feature/docker-install-helper #3
11
app/src-tauri/src/commands/install_helper_commands.rs
Normal file
11
app/src-tauri/src/commands/install_helper_commands.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use crate::install_helper::{self, InstallOptions};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn detect_install_options() -> Result<InstallOptions, String> {
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
53
app/src-tauri/src/install_helper/mod.rs
Normal file
53
app/src-tauri/src/install_helper/mod.rs
Normal file
@@ -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<String>,
|
||||
/// If auto-install isn't possible, a human-readable reason to show the user.
|
||||
pub auto_install_blocker: Option<String>,
|
||||
/// Official documentation URL for manual install.
|
||||
pub docs_url: String,
|
||||
/// Ordered manual install steps (plain text lines).
|
||||
pub manual_steps: Vec<String>,
|
||||
/// Notes to display after a successful auto-install (e.g. log out/back in).
|
||||
pub post_install_notes: Vec<String>,
|
||||
}
|
||||
|
||||
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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
288
app/src-tauri/src/install_helper/platform.rs
Normal file
288
app/src-tauri/src/install_helper/platform.rs
Normal file
@@ -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<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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
</main>
|
||||
</div>
|
||||
<StatusBar />
|
||||
{showInstallDialog && (
|
||||
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
211
app/src/components/DockerInstallDialog.tsx
Normal file
211
app/src/components/DockerInstallDialog.tsx
Normal file
@@ -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<Phase>("idle");
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[32rem] max-h-[85vh] overflow-y-auto shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-1">Docker not detected</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
Triple-C needs a Docker-compatible runtime to manage sandboxed project containers.
|
||||
We can install <span className="text-[var(--text-primary)]">{options.product_name}</span>{" "}
|
||||
for you, or you can follow the official instructions.
|
||||
</p>
|
||||
|
||||
{phase === "idle" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{options.can_auto_install ? (
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
{installVerb} ({options.auto_install_method})
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
|
||||
One-click install unavailable:{" "}
|
||||
<span className="text-[var(--text-primary)]">
|
||||
{options.auto_install_blocker ?? "required tooling missing."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowManual((s) => !s)}
|
||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
{showManual ? "Hide manual instructions" : "Show manual instructions"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleOpenDocs}
|
||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Open official documentation ↗
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "installing" && (
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
Installing… a system password prompt may appear. Do not close this window.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "done" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm text-[var(--success)]">Install finished.</div>
|
||||
{options.post_install_notes.length > 0 && (
|
||||
<ul className="text-xs text-[var(--text-secondary)] list-disc list-inside space-y-1">
|
||||
{options.post_install_notes.map((note, i) => (
|
||||
<li key={i}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={handleRecheck}
|
||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
Re-check Docker
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "error" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-sm text-[var(--error)]">Install failed.</div>
|
||||
{error && <div className="text-xs font-mono text-[var(--error)]">{error}</div>}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setPhase("idle")}
|
||||
className="px-3 py-2 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenDocs}
|
||||
className="px-3 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
Open official docs ↗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(showManual || phase === "error") && (
|
||||
<div className="mt-4">
|
||||
<div className="text-xs font-medium mb-1.5 text-[var(--text-secondary)]">
|
||||
Manual install steps
|
||||
</div>
|
||||
<ol className="text-xs text-[var(--text-secondary)] list-decimal list-inside space-y-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2">
|
||||
{options.manual_steps.map((step, i) => (
|
||||
<li key={i}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.length > 0 && (
|
||||
<div className="mt-4 max-h-48 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded p-2 text-xs font-mono text-[var(--text-secondary)]">
|
||||
{log.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "idle" && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
app/src/hooks/useInstallHelper.ts
Normal file
35
app/src/hooks/useInstallHelper.ts
Normal file
@@ -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<InstallOptions | null>(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<string>("docker-install-progress", (e) => onProgress(e.payload))
|
||||
: null;
|
||||
try {
|
||||
await commands.runDockerInstall();
|
||||
} finally {
|
||||
unlisten?.();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { options, loadOptions, runInstall };
|
||||
}
|
||||
@@ -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<boolean>("check_docker");
|
||||
@@ -107,3 +107,8 @@ export const buildSttImage = () => invoke<void>("build_stt_image");
|
||||
export const pullSttImage = () => invoke<void>("pull_stt_image");
|
||||
export const transcribeAudio = (audioData: number[]) =>
|
||||
invoke<string>("transcribe_audio", { audioData });
|
||||
|
||||
// Docker install helper
|
||||
export const detectInstallOptions = () =>
|
||||
invoke<InstallOptions>("detect_install_options");
|
||||
export const runDockerInstall = () => invoke<void>("run_docker_install");
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user