diff --git a/src/main/claude-app-cdp.ts b/src/main/claude-app-cdp.ts index df2ca2f..9ced811 100644 --- a/src/main/claude-app-cdp.ts +++ b/src/main/claude-app-cdp.ts @@ -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 { diff --git a/src/main/claude-app-launch.ts b/src/main/claude-app-launch.ts index dc67f1b..f4f6ce9 100644 --- a/src/main/claude-app-launch.ts +++ b/src/main/claude-app-launch.ts @@ -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 = { ...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 +): { 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[] { + 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, diff --git a/tests/main/claude-app-cdp.test.mjs b/tests/main/claude-app-cdp.test.mjs new file mode 100644 index 0000000..d61252f --- /dev/null +++ b/tests/main/claude-app-cdp.test.mjs @@ -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); +}); diff --git a/tests/main/claude-app-launch.test.mjs b/tests/main/claude-app-launch.test.mjs new file mode 100644 index 0000000..8ad9ee6 --- /dev/null +++ b/tests/main/claude-app-launch.test.mjs @@ -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-")); +}