From 17376623549c6848aca63f9976344b83f1c40b14 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Feb 2026 20:55:38 +0200 Subject: [PATCH] feat: add download badges with direct links per platform --- README.md | 40 ++++- src/main/index.ts | 7 + .../infrastructure/PtyTerminalService.ts | 10 +- .../services/team/ClaudeBinaryResolver.ts | 4 +- .../services/team/TeamProvisioningService.ts | 43 ++++- .../components/common/UpdateDialog.tsx | 35 +--- .../components/dashboard/CliStatusBanner.tsx | 3 + .../components/terminal/TerminalModal.tsx | 155 ++++++++++++++---- 8 files changed, 217 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 42c3464f..fae15422 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,39 @@
-

+ + + + + + +
- Download for macOS -    - - Download for Linux -    - - Download for Windows + macOS Apple Silicon -

+
+ + macOS Intel + +
+ + Windows + + + + Linux AppImage + +
+ + .deb +   + + .rpm +   + + .pacman + +

100% free, open source. No API keys. No configuration. diff --git a/src/main/index.ts b/src/main/index.ts index 4547d15c..1486cb1a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -572,6 +572,13 @@ function shutdownServices(): void { sshConnectionManager.dispose(); } + // Stop all running team provisioning processes + if (teamProvisioningService) { + for (const teamName of teamProvisioningService.getAliveTeams()) { + teamProvisioningService.stopTeam(teamName); + } + } + // Kill all PTY processes if (ptyTerminalService) { ptyTerminalService.killAll(); diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index 55f02753..7e50e3de 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -58,12 +58,18 @@ export class PtyTerminalService { ? (process.env.COMSPEC ?? 'powershell.exe') : (process.env.SHELL ?? '/bin/bash')); + const home = getHomeDir(); const pty = nodePty.spawn(shell, options?.args ?? [], { name: 'xterm-256color', cols: options?.cols ?? 80, rows: options?.rows ?? 24, - cwd: options?.cwd ?? getHomeDir(), - env: { ...process.env, ...options?.env } as Record, + cwd: options?.cwd ?? home, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + ...options?.env, + } as Record, }); pty.onData((data) => this.send(TERMINAL_DATA, id, data)); diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index f1a39934..90e345fd 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -176,9 +176,7 @@ export class ClaudeBinaryResolver { path.join(getHomeDir(), '.npm-global', 'bin'), path.join(getHomeDir(), '.npm', 'bin'), process.platform === 'win32' - ? process.env.APPDATA - ? path.join(process.env.APPDATA, 'npm') - : '' + ? path.join(getHomeDir(), 'AppData', 'Roaming', 'npm') : '/usr/local/bin', process.platform === 'win32' ? '' : '/opt/homebrew/bin', ].filter((candidate) => candidate.length > 0); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 402f5d08..932a5f07 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -196,10 +196,22 @@ async function readShellEnv(shellPath: string, args: string[]): Promise { timeoutHandle = null; child.kill(); - reject(new Error('shell env resolve timeout')); + // SIGKILL fallback if SIGTERM is ignored (e.g., shell stuck on .zshrc) + setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + /* already dead */ + } + }, 3000); + if (!settled) { + settled = true; + reject(new Error('shell env resolve timeout')); + } }, SHELL_ENV_TIMEOUT_MS); child.stdout?.on('data', (chunk: Buffer) => { @@ -210,13 +222,19 @@ async function readShellEnv(shellPath: string, args: string[]): Promise { if (timeoutHandle) { clearTimeout(timeoutHandle); } - resolve(Buffer.concat(chunks).toString('utf8')); + if (!settled) { + settled = true; + resolve(Buffer.concat(chunks).toString('utf8')); + } }); }); return parseNullSeparatedEnv(envDump); @@ -965,7 +983,7 @@ export class TeamProvisioningService { run.child = child; // Send provisioning prompt as first stream-json message (SDKUserMessage format) - if (child.stdin) { + if (child.stdin?.writable) { const message = JSON.stringify({ type: 'user', message: { @@ -1275,7 +1293,7 @@ export class TeamProvisioningService { run.child = child; // Send launch prompt - if (child.stdin) { + if (child.stdin?.writable) { const message = JSON.stringify({ type: 'user', message: { @@ -1982,7 +2000,9 @@ export class TeamProvisioningService { * Process stays alive for subsequent tasks. */ private async handleProvisioningTurnComplete(run: ProvisioningRun): Promise { - if (run.cancelRequested) return; + // Guard: must be set synchronously BEFORE any await to prevent + // double-invocation from filesystem monitor + stream-json racing. + if (run.provisioningComplete || run.cancelRequested) return; run.provisioningComplete = true; this.setLeadActivity(run, 'idle'); @@ -2058,6 +2078,11 @@ export class TeamProvisioningService { run.timeoutHandle = null; } this.stopFilesystemMonitor(run); + // Remove stream listeners to prevent data handlers firing on a cleaned-up run + if (run.child) { + run.child.stdout?.removeAllListeners('data'); + run.child.stderr?.removeAllListeners('data'); + } this.activeByTeam.delete(run.teamName); this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); @@ -2441,7 +2466,10 @@ export class TeamProvisioningService { private async buildProvisioningEnv(): Promise { const shellEnv = await resolveInteractiveShellEnv(); - const home = shellEnv.HOME?.trim() || process.env.HOME?.trim() || getHomeDir(); + // getHomeDir() uses Electron's app.getPath('home') which handles Unicode + // correctly on Windows. Prefer it over process.env which may be garbled. + const electronHome = getHomeDir(); + const home = shellEnv.HOME?.trim() || electronHome; const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username; const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh'; const xdgConfigHome = @@ -2455,6 +2483,7 @@ export class TeamProvisioningService { ...process.env, ...shellEnv, HOME: home, + USERPROFILE: home, USER: user, LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user, SHELL: shell, diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx index 3f8312ab..8cad8199 100644 --- a/src/renderer/components/common/UpdateDialog.tsx +++ b/src/renderer/components/common/UpdateDialog.tsx @@ -2,7 +2,7 @@ * UpdateDialog - Modal dialog shown when a new version is available. * * Prompts the user to download the update or dismiss it. - * Release notes may be HTML from the updater; we normalize to text and render as markdown. + * Release notes (markdown from GitHub) are rendered with ReactMarkdown. */ import { useEffect, useRef } from 'react'; @@ -14,31 +14,6 @@ import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { X } from 'lucide-react'; import remarkGfm from 'remark-gfm'; -/** - * Normalize release notes: strip HTML tags and convert block elements to newlines. - * Uses DOMParser for proper HTML entity decoding (handles all entities like —, ', etc.) - */ -function normalizeReleaseNotes(html: string): string { - if (!html?.trim()) return ''; - - // Convert block elements to newlines for better formatting - const processed = html - .replace(/<\/p>\s*/gi, '\n\n') - .replace(/\s*/gi, '\n') - .replace(/<\/div>\s*/gi, '\n') - .replace(/<\/li>\s*/gi, '\n') - .replace(/<\/h[1-6]>\s*/gi, '\n\n'); - - // Use DOMParser to decode HTML entities and strip remaining tags - // This properly handles all HTML entities ( , —, ', etc.) - const parser = new DOMParser(); - const doc = parser.parseFromString(processed, 'text/html'); - const text = doc.body.textContent || ''; - - // Normalize multiple newlines - return text.replace(/\n{3,}/g, '\n\n').trim(); -} - export const UpdateDialog = (): React.JSX.Element | null => { const showUpdateDialog = useStore((s) => s.showUpdateDialog); const availableVersion = useStore((s) => s.availableVersion); @@ -141,14 +116,14 @@ export const UpdateDialog = (): React.JSX.Element | null => { )} - {/* Release notes — normalize HTML then render as markdown */} + {/* Release notes */} {releaseNotes && (

{ rehypePlugins={REHYPE_PLUGINS} components={markdownComponents} > - {normalizeReleaseNotes(releaseNotes)} + {releaseNotes}
)} diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index d9aef8e4..7a51ee52 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -405,6 +405,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => { onExit={() => { void fetchCliStatus(); }} + autoCloseOnSuccessMs={4000} + successMessage="Login complete" + failureMessage="Login failed" /> )} diff --git a/src/renderer/components/terminal/TerminalModal.tsx b/src/renderer/components/terminal/TerminalModal.tsx index 7c7d033d..a7c7f4e7 100644 --- a/src/renderer/components/terminal/TerminalModal.tsx +++ b/src/renderer/components/terminal/TerminalModal.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; -import { Terminal, X } from 'lucide-react'; +import { CheckCircle, Terminal, X, XCircle } from 'lucide-react'; import { EmbeddedTerminal } from './EmbeddedTerminal'; @@ -18,6 +18,12 @@ interface TerminalModalProps { onClose: () => void; /** Called when the PTY process exits */ onExit?: (exitCode: number) => void; + /** Auto-close the modal after this many ms on success (exit code 0). 0 = disabled. */ + autoCloseOnSuccessMs?: number; + /** Custom message shown on exit code 0. Default: "Completed successfully" */ + successMessage?: string; + /** Custom message prefix for non-zero exit. Default: "Process failed" */ + failureMessage?: string; } export function TerminalModal({ @@ -27,28 +33,72 @@ export function TerminalModal({ cwd, onClose, onExit, + autoCloseOnSuccessMs = 0, + successMessage = 'Completed successfully', + failureMessage = 'Process failed', }: TerminalModalProps): React.JSX.Element { const [exited, setExited] = useState(null); + const [countdown, setCountdown] = useState(0); + const dialogRef = useRef(null); - const handleExit = (exitCode: number): void => { - setExited(exitCode); - onExit?.(exitCode); - }; + const handleExit = useCallback( + (exitCode: number): void => { + setExited(exitCode); + onExit?.(exitCode); + if (exitCode === 0 && autoCloseOnSuccessMs > 0) { + setCountdown(Math.ceil(autoCloseOnSuccessMs / 1000)); + } + }, + [onExit, autoCloseOnSuccessMs] + ); - const handleKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'Escape') { - e.stopPropagation(); - onClose(); - } - }; + const handleKeyDown = useCallback( + (e: React.KeyboardEvent): void => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }, + [onClose] + ); + + // Focus trap — focus dialog on mount + useEffect(() => { + dialogRef.current?.focus(); + }, []); + + // Countdown timer for auto-close + useEffect(() => { + if (countdown <= 0) return; + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + onClose(); + return 0; + } + return prev - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, [countdown, onClose]); + + const totalSeconds = autoCloseOnSuccessMs > 0 ? Math.ceil(autoCloseOnSuccessMs / 1000) : 0; + const progressPercent = totalSeconds > 0 ? (countdown / totalSeconds) * 100 : 0; return ReactDOM.createPortal( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- modal backdrop + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- modal backdrop handles Escape
-
+
{/* Header */}
@@ -57,28 +107,75 @@ export function TerminalModal({
- {/* Terminal area */} + {/* Terminal area — always visible, status bar overlaid at bottom */}
- {exited === null ? ( - - ) : ( -
-

- Process exited with code{' '} - {exited} -

- + + + {exited !== null && ( +
+
+ {exited === 0 ? ( +
+
+ ) : ( +
+
+ )} + +
+ + {/* Progress bar for auto-close countdown */} + {countdown > 0 && ( +
+
+
+ )}
)}