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:
1 parent
3ae44a7277
commit
0234240066
8 files changed
+251
-126
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user