diff --git a/README.md b/README.md index ac8a6d10..42c3464f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@

- 100% free, open source. No API keys. No configuration. Just download, open, and see everything Claude Code did. + 100% free, open source. No API keys. No configuration.


diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 35772582..9af488fa 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -58,6 +58,12 @@ const INSTALL_TIMEOUT_MS = 120_000; /** Max redirects to follow when fetching from GCS */ const MAX_REDIRECTS = 5; +/** Max retries for EBUSY (antivirus scanning the new binary) */ +const EBUSY_MAX_RETRIES = 3; + +/** Delay between EBUSY retries (multiplied by attempt number) */ +const EBUSY_RETRY_DELAY_MS = 2000; + // ============================================================================= // Helpers // ============================================================================= @@ -331,6 +337,12 @@ export class CliInstallerService { await fsp.chmod(tmpFilePath, 0o755); } + // On Windows, antivirus (Defender) scans new executables on first access. + // A brief pause lets the scan complete before we spawn, preventing EBUSY. + if (process.platform === 'win32') { + await new Promise((r) => setTimeout(r, 1000)); + } + this.sendProgress({ type: 'installing', detail: 'Starting shell integration...', @@ -411,7 +423,11 @@ export class CliInstallerService { }); res.on('end', () => { - fileStream.end(() => resolve(hash.digest('hex'))); + const digest = hash.digest('hex'); + fileStream.end(); + // Wait for 'close' (not just 'finish') — ensures file descriptor is fully released. + // On Windows, spawning the file before 'close' can cause EBUSY. + fileStream.on('close', () => resolve(digest)); }); res.on('error', (err) => { @@ -429,8 +445,9 @@ export class CliInstallerService { /** * Run `claude install` via spawn with streaming output. * Collects all output for error context. Non-zero exit tolerated if binary resolves. + * Retries on EBUSY (antivirus scanning the new binary). */ - private async runInstallWithStreaming(binaryPath: string): Promise { + private async runInstallWithStreaming(binaryPath: string, attempt = 1): Promise { return new Promise((resolve, reject) => { const child = spawn(binaryPath, ['install'], { env: { ...process.env, CLAUDE_SKIP_ANALYTICS: '1' }, @@ -491,6 +508,24 @@ export class CliInstallerService { child.on('error', (err) => { clearTimeout(timeout); + + // EBUSY: antivirus (Windows Defender / macOS Gatekeeper) may be scanning the binary — retry + const isEbusy = (err as NodeJS.ErrnoException).code === 'EBUSY'; + if (isEbusy && attempt < EBUSY_MAX_RETRIES) { + const delayMs = attempt * EBUSY_RETRY_DELAY_MS; + logger.warn( + `spawn EBUSY (attempt ${attempt}/${EBUSY_MAX_RETRIES}), retrying in ${delayMs}ms...` + ); + this.sendProgress({ + type: 'installing', + rawChunk: `\r\nā³ File busy (OS scan), retrying in ${delayMs / 1000}s...\r\n`, + }); + setTimeout(() => { + this.runInstallWithStreaming(binaryPath, attempt + 1).then(resolve, reject); + }, delayMs); + return; + } + reject(err); }); }); diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index f03bd429..55f02753 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -6,8 +6,8 @@ */ import crypto from 'node:crypto'; -import os from 'node:os'; +import { getHomeDir } from '@main/utils/pathDecoder'; // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; @@ -62,7 +62,7 @@ export class PtyTerminalService { name: 'xterm-256color', cols: options?.cols ?? 80, rows: options?.rows ?? 24, - cwd: options?.cwd ?? os.homedir(), + cwd: options?.cwd ?? getHomeDir(), env: { ...process.env, ...options?.env } as Record, }); diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 07478cca..4b37b158 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -223,9 +223,42 @@ export function buildTodoPath(claudeBasePath: string, sessionId: string): string // ============================================================================= /** - * Get the user's home directory. + * Try Electron's app.getPath('home') which correctly handles Unicode paths + * on Windows (Cyrillic, CJK, etc.) unlike Node's os.homedir() / env vars + * that can suffer from UTF-8 vs system codepage mismatches. + * + * Returns null when Electron app is unavailable (e.g. in tests). */ -function getHomeDir(): string { +function getElectronHome(): string | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Lazy require to avoid hard dependency on electron in test environments + const electron = require('electron') as { + app?: { getPath: (name: string) => string }; + }; + const app = electron.app; + if (app && typeof app.getPath === 'function') { + const home = app.getPath('home'); + if (home) return home; + } + } catch { + // Not in Electron context (tests, standalone builds, etc.) + } + return null; +} + +/** + * Get the user's home directory. + * + * Priority: + * 1. Electron app.getPath('home') — correct Unicode handling on all platforms + * 2. HOME env var (Unix) / USERPROFILE (Windows) + * 3. HOMEDRIVE + HOMEPATH (Windows fallback) + * 4. os.homedir() (Node.js built-in) + */ +export function getHomeDir(): string { + const electronHome = getElectronHome(); + if (electronHome) return electronHome; + const windowsHome = process.env.HOMEDRIVE && process.env.HOMEPATH ? `${process.env.HOMEDRIVE}${process.env.HOMEPATH}` diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts index dc9c6d34..4908e22e 100644 --- a/src/main/utils/pathValidation.ts +++ b/src/main/utils/pathValidation.ts @@ -6,10 +6,9 @@ */ import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; -import { getClaudeBasePath } from './pathDecoder'; +import { getClaudeBasePath, getHomeDir } from './pathDecoder'; /** * Sensitive file patterns that should never be accessible. @@ -149,7 +148,7 @@ export function validateFilePath( // Expand ~ to home directory const expandedPath = filePath.startsWith('~') - ? path.join(os.homedir(), filePath.slice(1)) + ? path.join(getHomeDir(), filePath.slice(1)) : filePath; // Must be absolute path @@ -212,7 +211,7 @@ export function validateOpenPathUserSelected(targetPath: string): PathValidation } const expandedPath = targetPath.startsWith('~') - ? path.join(os.homedir(), targetPath.slice(1)) + ? path.join(getHomeDir(), targetPath.slice(1)) : targetPath; const normalizedPath = path.resolve(path.normalize(expandedPath)); @@ -256,7 +255,7 @@ export function validateOpenPath( // Expand ~ to home directory const expandedPath = targetPath.startsWith('~') - ? path.join(os.homedir(), targetPath.slice(1)) + ? path.join(getHomeDir(), targetPath.slice(1)) : targetPath; const normalizedPath = path.resolve(path.normalize(expandedPath));