fix: require explicit user confirmation before install, cancel on close, tree-kill on Windows

P0: Add "confirm" phase — wizard checks prereqs then shows Install button,
    user must explicitly click before any brew/winget/npm command runs.
P1: Closing the wizard dialog while install is running now auto-calls
    install:cancel to stop the backend process.
P2: On Windows, use taskkill /T /F /PID to kill the entire process tree
    instead of just the shell wrapper, ensuring npm/winget actually stops.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-13 13:51:15 +08:00
1 parent 51d4906c77
commit 572bd355f8
2 files changed
+126 -42

No files matched your search

+19 -3
View File
@@ -669,14 +669,23 @@ app.whenReady().then(async () => {
}
installState.status = 'cancelled';
installState.logs.push('Cancelling installation...');
if (installProcess) {
const pid = installProcess.pid;
try {
installProcess.kill();
if (process.platform === 'win32' && pid) {
// Windows: kill entire process tree (shell: true spawns cmd.exe which
// spawns npm/winget — child.kill() only kills the shell, not the tree)
spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' });
} else {
installProcess.kill();
}
} catch {
// already dead
}
installProcess = null;
installState.logs.push('Installation process terminated.');
}
mainWindow?.webContents.send('install:progress', installState);
@@ -738,9 +747,16 @@ app.on('activate', async () => {
});
app.on('before-quit', async (e) => {
// Kill any running install process
// Kill any running install process (tree-kill on Windows)
if (installProcess) {
try { installProcess.kill(); } catch { /* already dead */ }
const pid = installProcess.pid;
try {
if (process.platform === 'win32' && pid) {
spawn('taskkill', ['/T', '/F', '/PID', String(pid)], { stdio: 'ignore' });
} else {
installProcess.kill();
}
} catch { /* already dead */ }
installProcess = null;
}
+107 -39
View File
@@ -18,6 +18,7 @@ import {
LoaderIcon,
CircleIcon,
CopyIcon,
DownloadIcon,
} from "lucide-react";
interface InstallProgress {
@@ -40,22 +41,25 @@ interface InstallWizardProps {
type WizardPhase =
| "checking"
| "confirm"
| "already-installed"
| "installing"
| "success"
| "failed";
interface PrereqResult {
hasNode: boolean;
nodeVersion?: string;
hasClaude: boolean;
claudeVersion?: string;
}
function getInstallAPI() {
if (typeof window !== "undefined") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (window as any).electronAPI?.install as
| {
checkPrerequisites: () => Promise<{
hasNode: boolean;
nodeVersion?: string;
hasClaude: boolean;
claudeVersion?: string;
}>;
checkPrerequisites: () => Promise<PrereqResult>;
start: (options?: { includeNode?: boolean }) => Promise<void>;
cancel: () => Promise<void>;
getLogs: () => Promise<string[]>;
@@ -92,6 +96,7 @@ export function InstallWizard({
const [progress, setProgress] = useState<InstallProgress | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const [prereqs, setPrereqs] = useState<PrereqResult | null>(null);
const logEndRef = useRef<HTMLDivElement>(null);
const cleanupRef = useRef<(() => void) | null>(null);
@@ -103,6 +108,17 @@ export function InstallWizard({
scrollToBottom();
}, [logs, scrollToBottom]);
// Cancel backend install and clean up listener
const cancelInstall = useCallback(async () => {
const api = getInstallAPI();
if (!api) return;
try {
await api.cancel();
} catch {
// ignore cancel errors
}
}, []);
const startInstall = useCallback(async (options?: { includeNode?: boolean }) => {
const api = getInstallAPI();
if (!api) return;
@@ -138,9 +154,11 @@ export function InstallWizard({
setPhase("checking");
setLogs(["Checking environment..."]);
setProgress(null);
setPrereqs(null);
try {
const result = await api.checkPrerequisites();
setPrereqs(result);
if (result.hasClaude) {
setLogs((prev) => [
@@ -152,37 +170,34 @@ export function InstallWizard({
return;
}
if (!result.hasNode) {
setLogs((prev) => [
...prev,
"Node.js not found. Will attempt to install Node.js and Claude Code...",
]);
startInstall({ includeNode: true });
} else {
// Don't auto-install — show confirmation first
if (result.hasNode) {
setLogs((prev) => [
...prev,
`Node.js ${result.nodeVersion} found.`,
"Claude Code not found. Starting installation...",
"Claude Code CLI not detected.",
]);
} else {
setLogs((prev) => [
...prev,
"Node.js not found.",
"Claude Code CLI not detected.",
]);
startInstall();
}
setPhase("confirm");
} catch (err: unknown) {
setPhase("failed");
const msg = err instanceof Error ? err.message : String(err);
setLogs((prev) => [...prev, `Error checking prerequisites: ${msg}`]);
}
}, [startInstall]);
const handleCancel = useCallback(async () => {
const api = getInstallAPI();
if (!api) return;
try {
await api.cancel();
} catch {
// ignore cancel errors
}
}, []);
// User explicitly clicks "Install" — only then start the actual install
const handleConfirmInstall = useCallback(() => {
const needsNode = prereqs ? !prereqs.hasNode : false;
startInstall({ includeNode: needsNode });
}, [prereqs, startInstall]);
const handleCopyLogs = useCallback(async () => {
try {
await navigator.clipboard.writeText(logs.join("\n"));
@@ -198,6 +213,17 @@ export function InstallWizard({
onInstallComplete?.();
}, [onOpenChange, onInstallComplete]);
// [P1] Close dialog = cancel running install
const handleOpenChange = useCallback(
async (nextOpen: boolean) => {
if (!nextOpen && phase === "installing") {
await cancelInstall();
}
onOpenChange(nextOpen);
},
[phase, cancelInstall, onOpenChange]
);
// Auto-check when dialog opens
useEffect(() => {
if (open) {
@@ -205,6 +231,7 @@ export function InstallWizard({
setLogs([]); // eslint-disable-line react-hooks/set-state-in-effect
setProgress(null); // eslint-disable-line react-hooks/set-state-in-effect
setCopied(false); // eslint-disable-line react-hooks/set-state-in-effect
setPrereqs(null); // eslint-disable-line react-hooks/set-state-in-effect
checkPrereqs();
}
return () => {
@@ -218,17 +245,19 @@ export function InstallWizard({
const steps = progress?.steps ?? [];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Install Claude Code</DialogTitle>
<DialogDescription>
Automatically install Claude Code CLI
{phase === "confirm"
? "Claude Code CLI was not detected. Install it now?"
: "Automatically install Claude Code CLI"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Step list */}
{/* Step list (only during/after install) */}
{steps.length > 0 && (
<div className="space-y-2">
{steps.map((step) => (
@@ -258,7 +287,7 @@ export function InstallWizard({
</div>
)}
{/* Phase-specific messages */}
{/* Phase: checking */}
{phase === "checking" && steps.length === 0 && (
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
<LoaderIcon className="size-4 animate-spin" />
@@ -266,6 +295,32 @@ export function InstallWizard({
</div>
)}
{/* Phase: confirm — ask user before installing */}
{phase === "confirm" && (
<div className="space-y-3">
<div className="rounded-lg bg-amber-500/10 px-4 py-3 text-sm space-y-1.5">
{prereqs && !prereqs.hasNode && (
<p className="text-amber-700 dark:text-amber-400">
Node.js — not found (will be installed via {process.platform === "win32" ? "winget" : "Homebrew"})
</p>
)}
{prereqs?.hasNode && (
<p className="text-emerald-700 dark:text-emerald-400">
Node.js {prereqs.nodeVersion} — found
</p>
)}
<p className="text-amber-700 dark:text-amber-400">
Claude Code CLI — not found
</p>
</div>
<p className="text-sm text-muted-foreground">
Click <strong>Install</strong> to automatically set up{" "}
{prereqs && !prereqs.hasNode ? "Node.js and " : ""}Claude Code CLI.
</p>
</div>
)}
{/* Phase: already-installed */}
{phase === "already-installed" && (
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
<CheckIcon className="size-5 text-emerald-500 shrink-0" />
@@ -280,6 +335,7 @@ export function InstallWizard({
</div>
)}
{/* Phase: success */}
{phase === "success" && (
<div className="flex items-center gap-3 rounded-lg bg-emerald-500/10 px-4 py-3">
<CheckIcon className="size-5 text-emerald-500 shrink-0" />
@@ -310,28 +366,40 @@ export function InstallWizard({
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={handleCopyLogs}
disabled={logs.length === 0}
>
<CopyIcon />
{copied ? "Copied" : "Copy Logs"}
</Button>
{logs.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleCopyLogs}
>
<CopyIcon />
{copied ? "Copied" : "Copy Logs"}
</Button>
)}
{/* Confirm phase: single "Install" button */}
{phase === "confirm" && (
<Button size="sm" onClick={handleConfirmInstall}>
<DownloadIcon />
Install
</Button>
)}
{/* Installing: cancel button */}
{phase === "installing" && (
<Button variant="destructive" size="sm" onClick={handleCancel}>
<Button variant="destructive" size="sm" onClick={cancelInstall}>
Cancel
</Button>
)}
{/* Failed: retry */}
{phase === "failed" && (
<Button size="sm" onClick={checkPrereqs}>
Retry
</Button>
)}
{/* Success / already-installed: done */}
{(phase === "success" || phase === "already-installed") && (
<Button size="sm" onClick={handleDone}>
Done