From 427c5478e1ec1e6fcea82953ecaf2895ccbf9b0c Mon Sep 17 00:00:00 2001
From: iliya
Date: Fri, 27 Feb 2026 15:37:37 +0200
Subject: [PATCH] improvement
---
README.md | 2 +-
.../infrastructure/CliInstallerService.ts | 39 ++++++++++++++++++-
.../infrastructure/PtyTerminalService.ts | 4 +-
src/main/utils/pathDecoder.ts | 37 +++++++++++++++++-
src/main/utils/pathValidation.ts | 9 ++---
5 files changed, 79 insertions(+), 12 deletions(-)
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));