feat: full Windows platform adaptation

- Add src/lib/platform.ts: cross-platform Claude CLI discovery
  (candidate paths, .cmd/.bat shell handling, where/which fallback)
- Refactor claude-status route and claude-client to use shared platform utils
- Electron main: Windows process spawning (windowsHide), PATH construction
  with AppData/npm dirs, titleBarOverlay, .ico icon support
- electron-builder.yml: add win/nsis build targets
- package.json: add electron:pack:win script
- FolderPicker: drive switcher dropdown for navigating between drives
- SkillsManager: fix duplicate key warning with filePath fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
xuxu777xuandClaude Opus 4.6 committed 2026-02-07 13:35:04 +08:00
1 parent 3ae44a7277
commit 0234240066
8 files changed
+251 -126

No files matched your search

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

+14
View File
@@ -30,6 +30,9 @@ extraResources:
- from: public/
to: standalone/public/
filter: ["**/*"]
- from: build/
to: .
filter: ["icon.*"]
asarUnpack:
- "**/*.node"
- "**/better-sqlite3/**"
@@ -39,3 +42,14 @@ mac:
target:
- target: dmg
arch: [arm64]
win:
icon: build/icon.ico
target:
- target: nsis
arch: [x64, arm64]
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
+64 -14
View File
@@ -18,6 +18,10 @@ const isDev = !app.isPackaged;
* and won't include vars from .zshrc/.bashrc (e.g. API keys).
*/
function loadUserShellEnv(): Record<string, string> {
// Only macOS needs login-shell env loading; Windows/Linux GUI apps inherit full env
if (process.platform !== 'darwin') {
return {};
}
try {
const shell = process.env.SHELL || '/bin/zsh';
const result = execFileSync(shell, ['-ilc', 'env'], {
@@ -106,12 +110,32 @@ function startServer(port: number): ChildProcess {
serverErrors = [];
const home = os.homedir();
const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`;
const shellPath = userShellEnv.PATH || process.env.PATH || '';
const sep = path.delimiter; // ';' on Windows, ':' on Unix
let constructedPath: string;
if (process.platform === 'win32') {
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
const winExtra = [
path.join(appData, 'npm'),
path.join(localAppData, 'npm'),
path.join(home, '.npm-global', 'bin'),
path.join(home, '.local', 'bin'),
path.join(home, '.claude', 'bin'),
];
const allParts = [shellPath, ...winExtra].join(sep).split(sep).filter(Boolean);
constructedPath = [...new Set(allParts)].join(sep);
} else {
const basePath = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`;
const raw = `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`;
const allParts = raw.split(':').filter(Boolean);
constructedPath = [...new Set(allParts)].join(':');
}
const env: Record<string, string> = {
...userShellEnv,
...process.env as Record<string, string>,
...(process.env as Record<string, string>),
// Ensure user shell env vars override (especially API keys)
...userShellEnv,
PORT: String(port),
@@ -119,17 +143,28 @@ function startServer(port: number): ChildProcess {
CLAUDE_GUI_DATA_DIR: path.join(home, '.codepilot'),
ELECTRON_RUN_AS_NODE: '1',
HOME: home,
PATH: `${basePath}:${home}/.npm-global/bin:${home}/.local/bin:${home}/.claude/bin:${shellPath}`,
USERPROFILE: home,
PATH: constructedPath,
};
// Spawn via /bin/sh to prevent the Electron binary from appearing
// as a separate Dock icon on macOS (even with ELECTRON_RUN_AS_NODE=1,
// macOS may show a Dock entry for the Electron Framework binary).
const child = spawn('/bin/sh', ['-c', `exec "${nodePath}" "${serverPath}"`], {
env,
stdio: 'pipe',
cwd: standaloneDir,
});
// On Windows, spawn Node.js directly with windowsHide to prevent console flash.
// On macOS, spawn via /bin/sh to prevent the Electron binary from appearing
// as a separate Dock icon (even with ELECTRON_RUN_AS_NODE=1).
let child: ChildProcess;
if (process.platform === 'win32') {
child = spawn(nodePath, [serverPath], {
env,
stdio: 'pipe',
cwd: standaloneDir,
windowsHide: true,
});
} else {
child = spawn('/bin/sh', ['-c', `exec "${nodePath}" "${serverPath}"`], {
env,
stdio: 'pipe',
cwd: standaloneDir,
});
}
child.stdout?.on('data', (data: Buffer) => {
const msg = data.toString().trim();
@@ -155,23 +190,38 @@ function getIconPath(): string {
if (isDev) {
return path.join(process.cwd(), 'build', 'icon.png');
}
if (process.platform === 'win32') {
return path.join(process.resourcesPath, 'icon.ico');
}
return path.join(process.resourcesPath, 'icon.icns');
}
function createWindow(port: number) {
mainWindow = new BrowserWindow({
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: 1280,
height: 860,
minWidth: 800,
minHeight: 600,
icon: getIconPath(),
titleBarStyle: 'hiddenInset',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
};
if (process.platform === 'darwin') {
windowOptions.titleBarStyle = 'hiddenInset';
} else if (process.platform === 'win32') {
windowOptions.titleBarStyle = 'hidden';
windowOptions.titleBarOverlay = {
color: '#00000000',
symbolColor: '#888888',
height: 44,
};
}
mainWindow = new BrowserWindow(windowOptions);
mainWindow.loadURL(`http://127.0.0.1:${port}`);
+3 -1
View File
@@ -10,7 +10,9 @@
"lint": "eslint",
"electron:dev": "concurrently -k \"next dev\" \"wait-on http://localhost:3000 && electron .\"",
"electron:build": "next build && node scripts/build-electron.mjs",
"electron:pack": "npm run electron:build && electron-builder --mac --config electron-builder.yml"
"electron:pack": "npm run electron:build && electron-builder --config electron-builder.yml",
"electron:pack:mac": "npm run electron:build && electron-builder --mac --config electron-builder.yml",
"electron:pack:win": "npm run electron:build && electron-builder --win --config electron-builder.yml"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.33",
+4 -66
View File
@@ -1,76 +1,14 @@
import { NextResponse } from 'next/server';
import { execFile, execFileSync } from 'child_process';
import { promisify } from 'util';
import os from 'os';
import path from 'path';
const execFileAsync = promisify(execFile);
function getExpandedPath(): string {
const home = os.homedir();
const extra = [
'/usr/local/bin',
'/opt/homebrew/bin',
'/usr/bin',
'/bin',
path.join(home, '.npm-global', 'bin'),
path.join(home, '.nvm', 'current', 'bin'),
path.join(home, '.local', 'bin'),
path.join(home, '.claude', 'bin'),
];
const current = process.env.PATH || '';
const parts = current.split(':');
for (const p of extra) {
if (!parts.includes(p)) parts.push(p);
}
return parts.join(':');
}
function findClaudePath(): string | null {
const home = os.homedir();
const candidates = [
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
path.join(home, '.npm-global', 'bin', 'claude'),
path.join(home, '.local', 'bin', 'claude'),
path.join(home, '.claude', 'bin', 'claude'),
];
for (const p of candidates) {
try {
execFileSync(p, ['--version'], { timeout: 3000, stdio: 'pipe' });
return p;
} catch {
// not found, try next
}
}
// Fallback: use `which claude` with expanded PATH
try {
const result = execFileSync('/usr/bin/which', ['claude'], {
timeout: 3000,
stdio: 'pipe',
env: { ...process.env, PATH: getExpandedPath() },
});
return result.toString().trim() || null;
} catch {
return null;
}
}
import { findClaudeBinary, getClaudeVersion } from '@/lib/platform';
export async function GET() {
try {
const claudePath = findClaudePath();
const claudePath = findClaudeBinary();
if (!claudePath) {
return NextResponse.json({ connected: false, version: null });
}
const { stdout } = await execFileAsync(claudePath, ['--version'], {
timeout: 5000,
env: { ...process.env, PATH: getExpandedPath() },
});
const version = stdout.trim();
return NextResponse.json({ connected: true, version });
const version = await getClaudeVersion(claudePath);
return NextResponse.json({ connected: !!version, version });
} catch {
return NextResponse.json({ connected: false, version: null });
}
+1 -1
View File
@@ -210,7 +210,7 @@ export function SkillsManager() {
</span>
{pluginSkills.map((skill) => (
<SkillListItem
key={`${skill.source}:${skill.name}`}
key={skill.filePath || `${skill.source}:${skill.name}`}
skill={skill}
selected={
selected?.name === skill.name &&
+9 -44
View File
@@ -15,52 +15,16 @@ import type {
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent } from '@/types';
import { registerPendingPermission } from './permission-registry';
import { getSetting } from './db';
import { execFileSync } from 'child_process';
import { findClaudeBinary, getExpandedPath } from './platform';
import os from 'os';
import path from 'path';
let cachedClaudePath: string | null | undefined;
function findClaudePath(): string | undefined {
if (cachedClaudePath !== undefined) return cachedClaudePath || undefined;
const home = os.homedir();
const candidates = [
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
path.join(home, '.npm-global', 'bin', 'claude'),
path.join(home, '.local', 'bin', 'claude'),
path.join(home, '.claude', 'bin', 'claude'),
];
for (const p of candidates) {
try {
execFileSync(p, ['--version'], { timeout: 3000, stdio: 'pipe' });
cachedClaudePath = p;
return p;
} catch {
// not found
}
}
// Fallback: which claude
const extra = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin',
path.join(home, '.npm-global', 'bin'), path.join(home, '.nvm', 'current', 'bin'),
path.join(home, '.local', 'bin'), path.join(home, '.claude', 'bin')];
const current = (process.env.PATH || '').split(':');
for (const p of extra) { if (!current.includes(p)) current.push(p); }
try {
const result = execFileSync('/usr/bin/which', ['claude'], {
timeout: 3000, stdio: 'pipe',
env: { ...process.env, PATH: current.join(':') },
});
const found = result.toString().trim();
if (found) { cachedClaudePath = found; return found; }
} catch { /* not found */ }
cachedClaudePath = null;
return undefined;
const found = findClaudeBinary();
cachedClaudePath = found ?? null;
return found;
}
/**
@@ -138,10 +102,11 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
// Then overlay any API config the user set in CodePilot settings (optional).
const sdkEnv: Record<string, string> = { ...process.env as Record<string, string> };
// Ensure HOME is explicitly set so Claude Code can find ~/.claude/commands/
if (!sdkEnv.HOME) {
sdkEnv.HOME = os.homedir();
}
// Ensure HOME/USERPROFILE are set so Claude Code can find ~/.claude/commands/
if (!sdkEnv.HOME) sdkEnv.HOME = os.homedir();
if (!sdkEnv.USERPROFILE) sdkEnv.USERPROFILE = os.homedir();
// Ensure SDK subprocess has expanded PATH (consistent with Electron mode)
sdkEnv.PATH = getExpandedPath();
const appToken = getSetting('anthropic_auth_token');
const appBaseUrl = getSetting('anthropic_base_url');
+156
View File
@@ -0,0 +1,156 @@
import { execFileSync, execFile } from 'child_process';
import { promisify } from 'util';
import os from 'os';
import path from 'path';
const execFileAsync = promisify(execFile);
export const isWindows = process.platform === 'win32';
export const isMac = process.platform === 'darwin';
/**
* Whether the given binary path requires shell execution.
* On Windows, .cmd/.bat files cannot be executed directly by execFileSync.
*/
function needsShell(binPath: string): boolean {
return isWindows && /\.(cmd|bat)$/i.test(binPath);
}
/**
* Extra PATH directories to search for Claude CLI and other tools.
*/
export function getExtraPathDirs(): string[] {
const home = os.homedir();
if (isWindows) {
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
return [
path.join(appData, 'npm'),
path.join(localAppData, 'npm'),
path.join(home, '.npm-global', 'bin'),
path.join(home, '.claude', 'bin'),
path.join(home, '.local', 'bin'),
path.join(home, '.nvm', 'current', 'bin'),
];
}
return [
'/usr/local/bin',
'/opt/homebrew/bin',
'/usr/bin',
'/bin',
path.join(home, '.npm-global', 'bin'),
path.join(home, '.nvm', 'current', 'bin'),
path.join(home, '.local', 'bin'),
path.join(home, '.claude', 'bin'),
];
}
/**
* Claude CLI candidate installation paths.
*/
export function getClaudeCandidatePaths(): string[] {
const home = os.homedir();
if (isWindows) {
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
return [
path.join(appData, 'npm', 'claude.cmd'),
path.join(localAppData, 'npm', 'claude.cmd'),
path.join(home, '.npm-global', 'bin', 'claude.cmd'),
path.join(home, '.claude', 'bin', 'claude.exe'),
path.join(home, '.local', 'bin', 'claude.cmd'),
];
}
return [
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
path.join(home, '.npm-global', 'bin', 'claude'),
path.join(home, '.local', 'bin', 'claude'),
path.join(home, '.claude', 'bin', 'claude'),
];
}
/**
* Build an expanded PATH string with extra directories, deduped and filtered.
*/
export function getExpandedPath(): string {
const current = process.env.PATH || '';
const parts = current.split(path.delimiter).filter(Boolean);
const seen = new Set(parts);
for (const p of getExtraPathDirs()) {
if (p && !seen.has(p)) {
parts.push(p);
seen.add(p);
}
}
return parts.join(path.delimiter);
}
/**
* Find and validate the Claude CLI binary.
* Tests each candidate with --version before returning.
*/
export function findClaudeBinary(): string | undefined {
// Try known candidate paths first
for (const p of getClaudeCandidatePaths()) {
try {
execFileSync(p, ['--version'], {
timeout: 3000,
stdio: 'pipe',
shell: needsShell(p),
});
return p;
} catch {
// not found, try next
}
}
// Fallback: use `where` (Windows) or `which` (Unix) with expanded PATH
try {
const cmd = isWindows ? 'where' : '/usr/bin/which';
const args = isWindows ? ['claude'] : ['claude'];
const result = execFileSync(cmd, args, {
timeout: 3000,
stdio: 'pipe',
env: { ...process.env, PATH: getExpandedPath() },
shell: isWindows,
});
// where.exe may return multiple lines; try each with --version validation
const lines = result.toString().trim().split(/\r?\n/);
for (const line of lines) {
const candidate = line.trim();
if (!candidate) continue;
try {
execFileSync(candidate, ['--version'], {
timeout: 3000,
stdio: 'pipe',
shell: needsShell(candidate),
});
return candidate;
} catch {
continue;
}
}
} catch {
// not found
}
return undefined;
}
/**
* Execute claude --version and return the version string.
* Handles .cmd shell execution on Windows.
*/
export async function getClaudeVersion(claudePath: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync(claudePath, ['--version'], {
timeout: 5000,
env: { ...process.env, PATH: getExpandedPath() },
shell: needsShell(claudePath),
});
return stdout.trim() || null;
} catch {
return null;
}
}