Fix Claude app launch on macOS

This commit is contained in:
musistudio 2026-07-03 15:35:07 +08:00
parent 6118d52df5
commit 2410530450
4 changed files with 136 additions and 9 deletions

View file

@ -46,12 +46,9 @@ const claudeAppDesignCdpConnectTimeoutMs = 15_000;
const claudeAppDesignCdpKeepAliveMs = 45_000;
const claudeAppDesignCdpPollIntervalMs = 250;
export function shouldEnableClaudeAppDesignCdp(enabledByConfig = false): boolean {
export function shouldEnableClaudeAppDesignCdp(_enabledByConfig = false): boolean {
const configured = process.env.CCR_CLAUDE_APP_DESIGN_CDP?.trim().toLowerCase();
if (configured === "false" || configured === "0" || configured === "off") {
return false;
}
return enabledByConfig || configured === "true" || configured === "1" || configured === "on";
return configured === "true" || configured === "1" || configured === "on";
}
export async function reserveClaudeAppCdpPort(logger: ClaudeAppCdpLogger = console, enabledByConfig = false): Promise<number | undefined> {

View file

@ -19,6 +19,7 @@ export type ClaudeAppLaunchResult = {
command: string;
cdpPort?: number;
claudeDesignProxy?: boolean;
pidIsLauncher?: boolean;
pid?: number;
userDataDir: string;
};
@ -52,8 +53,7 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
const shouldOpenDesign = shouldOpenClaudeAppDesign(config);
const cdpPort = await reserveClaudeAppCdpPort(console, shouldOpenDesign);
const env: NodeJS.ProcessEnv = {
...process.env,
const appEnv: Record<string, string> = {
...profileEnv(profile),
...claudeCodeModelEnv(profile),
...(config ? botGatewayProfileEnv(config, profile, "app") : {}),
@ -64,11 +64,16 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
ELECTRON_ENABLE_LOGGING: "1",
...claudeCodeUtcTimezoneEnvOverride()
};
const env: NodeJS.ProcessEnv = {
...process.env,
...appEnv
};
delete env.ELECTRON_RUN_AS_NODE;
const designUrl = claudeAppDesignUrl(config);
const proxyUrl = claudeAppProxyUrl(config);
const child = spawn(lookup.executable, claudeElectronArgs(userDataDir, cdpPort, proxyUrl), {
const launch = claudeAppLaunchCommand(lookup.executable, userDataDir, cdpPort, proxyUrl, appEnv);
const child = spawn(launch.command, launch.args, {
detached: true,
env,
stdio: "ignore"
@ -84,8 +89,9 @@ export async function launchClaudeAppProfile(configDir: string, profile: Profile
return {
child,
claudeDesignProxy: Boolean(proxyUrl),
command: lookup.executable,
command: launch.command,
...(cdpPort ? { cdpPort } : {}),
...(launch.pidIsLauncher ? { pidIsLauncher: launch.pidIsLauncher } : {}),
pid: child.pid,
userDataDir
};
@ -163,6 +169,52 @@ function claudeElectronArgs(userDataDir: string, cdpPort?: number, proxyUrl?: st
];
}
export function claudeAppLaunchCommand(
executable: string,
userDataDir: string,
cdpPort: number | undefined,
proxyUrl: string | undefined,
env: Record<string, string>
): { args: string[]; command: string; pidIsLauncher?: boolean } {
const args = claudeElectronArgs(userDataDir, cdpPort, proxyUrl);
const appBundle = process.platform === "darwin" ? macAppBundleFromExecutable(executable) : undefined;
if (appBundle) {
return {
args: [
"-W",
"-n",
...macOpenEnvArgs(env),
appBundle,
"--args",
...args
],
command: "/usr/bin/open",
pidIsLauncher: true
};
}
return {
args,
command: executable
};
}
function macAppBundleFromExecutable(executable: string): string | undefined {
const normalized = path.normalize(executable);
const marker = `${path.sep}Contents${path.sep}MacOS${path.sep}`;
const markerIndex = normalized.lastIndexOf(marker);
if (markerIndex <= 0) {
return undefined;
}
const appBundle = normalized.slice(0, markerIndex);
return appBundle.endsWith(".app") && isDirectory(appBundle) ? appBundle : undefined;
}
function macOpenEnvArgs(env: Record<string, string>): string[] {
return Object.entries(env)
.filter(([key, value]) => isEnvName(key) && typeof value === "string")
.flatMap(([key, value]) => ["--env", `${key}=${value}`]);
}
function claudeElectronUserDataDir(settingsDir: string, profile: ProfileConfig): string {
return path.join(
settingsDir,

View file

@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldEnableClaudeAppDesignCdp } from "../../src/main/claude-app-cdp.ts";
test("Claude App Design CDP is opt-in even when Claude Design is configured", (t) => {
const previous = process.env.CCR_CLAUDE_APP_DESIGN_CDP;
t.after(() => {
if (previous === undefined) {
delete process.env.CCR_CLAUDE_APP_DESIGN_CDP;
} else {
process.env.CCR_CLAUDE_APP_DESIGN_CDP = previous;
}
});
delete process.env.CCR_CLAUDE_APP_DESIGN_CDP;
assert.equal(shouldEnableClaudeAppDesignCdp(true), false);
assert.equal(shouldEnableClaudeAppDesignCdp(false), false);
process.env.CCR_CLAUDE_APP_DESIGN_CDP = "true";
assert.equal(shouldEnableClaudeAppDesignCdp(false), true);
process.env.CCR_CLAUDE_APP_DESIGN_CDP = "off";
assert.equal(shouldEnableClaudeAppDesignCdp(true), false);
});

View file

@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { claudeAppLaunchCommand } from "../../src/main/claude-app-launch.ts";
test("claudeAppLaunchCommand opens macOS app bundles through LaunchServices", (t) => {
const tempDir = mkdtempForTest();
t.after(() => rmSync(tempDir, { force: true, recursive: true }));
const appBundle = path.join(tempDir, "Claude.app");
const executable = path.join(appBundle, "Contents", "MacOS", "Claude");
mkdirSync(path.dirname(executable), { recursive: true });
writeFileSync(executable, "");
const launch = claudeAppLaunchCommand(
executable,
path.join(tempDir, "profile data"),
49152,
"http://127.0.0.1:3456",
{
"BAD-NAME": "ignored",
CLAUDE_CONFIG_DIR: path.join(tempDir, "config"),
CCR_PROFILE_SURFACE: "app"
}
);
if (process.platform !== "darwin") {
assert.equal(launch.command, executable);
assert.equal(launch.pidIsLauncher, undefined);
return;
}
assert.equal(launch.command, "/usr/bin/open");
assert.equal(launch.pidIsLauncher, true);
assert.deepEqual(launch.args.slice(0, 2), ["-W", "-n"]);
assert.ok(launch.args.includes("--env"));
assert.ok(launch.args.includes(`CLAUDE_CONFIG_DIR=${path.join(tempDir, "config")}`));
assert.ok(launch.args.includes("CCR_PROFILE_SURFACE=app"));
assert.equal(launch.args.some((arg) => arg.startsWith("BAD-NAME=")), false);
const appIndex = launch.args.indexOf(appBundle);
assert.ok(appIndex > 0);
assert.equal(launch.args[appIndex + 1], "--args");
assert.ok(launch.args.includes("--remote-debugging-port=49152"));
assert.ok(launch.args.includes("--remote-debugging-address=127.0.0.1"));
assert.ok(launch.args.includes("--proxy-server=http://127.0.0.1:3456"));
assert.ok(launch.args.includes(`--user-data-dir=${path.join(tempDir, "profile data")}`));
});
function mkdtempForTest() {
return mkdtempSync(path.join(os.tmpdir(), "ccr-claude-app-launch-"));
}