diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f2cbbd..409612d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,24 @@ concurrency: jobs: macos: - name: macOS - runs-on: macos-14 + name: macOS (${{ matrix.display_name }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: arm64 + arch_flag: --arm64 + runner: macos-14 + display_name: Apple Silicon + release_label: Apple-Silicon + artifact_name: macos-apple-silicon-arm64 + - arch: x64 + arch_flag: --x64 + runner: macos-15-intel + display_name: Intel + release_label: Intel + artifact_name: macos-intel-x64 steps: - name: Check out repository uses: actions/checkout@v4 @@ -39,16 +55,92 @@ jobs: exit 1 fi - - name: Build and publish ad-hoc macOS artifacts + - name: Build ad-hoc macOS artifacts + run: | + npm run build:assets + npx electron-builder \ + --config build/electron-builder.local.cjs \ + --mac ${{ matrix.arch_flag }} \ + --publish never \ + -c.mac.artifactName='Claude-Code-Router_${version}-mac-${{ matrix.release_label }}-${arch}.${ext}' + + - name: Upload macOS release files + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: | + release-local/Claude-Code-Router_*-mac-${{ matrix.release_label }}-${{ matrix.arch }}.* + release-local/latest-mac.yml + if-no-files-found: error + + publish-macos: + name: Publish macOS + needs: macos + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install metadata merge dependencies + run: npm ci --ignore-scripts + + - name: Download macOS release files + uses: actions/download-artifact@v4 + with: + pattern: macos-* + path: macos-release-artifacts + + - name: Merge macOS update metadata + run: node build/merge-macos-update-metadata.mjs latest-mac.yml macos-release-artifacts/*/latest-mac.yml + + - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npm run build:assets - npx electron-builder --config build/electron-builder.local.cjs --mac --publish always + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || \ + gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes + + - name: Upload macOS release assets + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + shopt -s nullglob + assets=(macos-release-artifacts/*/Claude-Code-Router_*-mac-*.*) + if (( ${#assets[@]} == 0 )); then + echo "No macOS release assets were found." + exit 1 + fi + + upload_args=() + for asset in "${assets[@]}"; do + name="$(basename "$asset")" + case "$name" in + *Apple-Silicon-arm64.dmg) label="macOS Apple Silicon DMG (arm64)" ;; + *Apple-Silicon-arm64.zip) label="macOS Apple Silicon ZIP (arm64)" ;; + *Apple-Silicon-arm64.dmg.blockmap) label="macOS Apple Silicon DMG blockmap (arm64)" ;; + *Apple-Silicon-arm64.zip.blockmap) label="macOS Apple Silicon ZIP blockmap (arm64)" ;; + *Intel-x64.dmg) label="macOS Intel DMG (x64)" ;; + *Intel-x64.zip) label="macOS Intel ZIP (x64)" ;; + *Intel-x64.dmg.blockmap) label="macOS Intel DMG blockmap (x64)" ;; + *Intel-x64.zip.blockmap) label="macOS Intel ZIP blockmap (x64)" ;; + *) label="$name" ;; + esac + upload_args+=("${asset}#${label}") + done + upload_args+=("latest-mac.yml#macOS update metadata") + + gh release upload "$GITHUB_REF_NAME" "${upload_args[@]}" --clobber windows: name: Windows - needs: macos + needs: publish-macos runs-on: windows-latest steps: - name: Check out repository @@ -82,7 +174,7 @@ jobs: linux: name: Linux - needs: macos + needs: publish-macos runs-on: ubuntu-latest steps: - name: Check out repository diff --git a/README.md b/README.md index c123f22..1e4bde0 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,8 @@ The web management UI listens on `http://127.0.0.1:3458` by default. Use `ccr st 1. Open the [GitHub Releases page](https://github.com/musistudio/claude-code-router/releases). 2. Download the package for your platform: - - macOS: `Claude Code Router_.dmg` or `.zip` + - macOS Apple Silicon: `Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` or `.zip` + - macOS Intel: `Claude-Code-Router_-mac-Intel-x64.dmg` or `.zip` - Windows: `Claude Code Router_.exe` - Linux: `Claude Code Router_.AppImage` 3. Install and launch **Claude Code Router**. diff --git a/README_zh.md b/README_zh.md index 45f98cf..7c94376 100644 --- a/README_zh.md +++ b/README_zh.md @@ -62,7 +62,8 @@ Web 管理端默认监听 `http://127.0.0.1:3458`。可以用 `ccr start --host 1. 打开 [GitHub Releases 页面](https://github.com/musistudio/claude-code-router/releases)。 2. 按系统下载对应安装包: - - macOS:`Claude Code Router_.dmg` 或 `.zip` + - macOS Apple 芯片:`Claude-Code-Router_-mac-Apple-Silicon-arm64.dmg` 或 `.zip` + - macOS Intel 芯片:`Claude-Code-Router_-mac-Intel-x64.dmg` 或 `.zip` - Windows:`Claude Code Router_.exe` - Linux:`Claude Code Router_.AppImage` 3. 安装并启动 **Claude Code Router**。 diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index 3283c23..9fc975f 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -140,7 +140,7 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {} minify: mode === "production", outdir: mainOutDir, platform: "node", - plugins: [electronNodeShimPlugin(), ...plugins], + plugins: [forbidCliElectronPlugin(), ...plugins], sourcemap: mode !== "production", target: "node22" }; @@ -292,12 +292,18 @@ function rendererAliasPlugin() { }; } -function electronNodeShimPlugin() { +function forbidCliElectronPlugin() { return { - name: "electron-node-shim", + name: "forbid-cli-electron", setup(build) { build.onResolve({ filter: /^electron$/ }, () => { - return { path: path.join(projectRoot, "src", "main", "electron-node-shim.ts") }; + return { + errors: [ + { + text: "CLI bundle must not import electron. Move the dependency behind a desktop-only boundary." + } + ] + }; }); } }; diff --git a/build/merge-macos-update-metadata.mjs b/build/merge-macos-update-metadata.mjs new file mode 100644 index 0000000..d1e2da0 --- /dev/null +++ b/build/merge-macos-update-metadata.mjs @@ -0,0 +1,78 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import yaml from "js-yaml"; + +const [outputFile, ...inputFiles] = process.argv.slice(2); + +if (!outputFile || inputFiles.length < 2) { + console.error("Usage: node build/merge-macos-update-metadata.mjs [...]"); + process.exit(1); +} + +const updateInfos = inputFiles.map((file) => { + const info = yaml.load(readFileSync(file, "utf8")); + if (!info || typeof info !== "object") { + throw new Error(`${file} is not a valid update metadata object`); + } + if (!Array.isArray(info.files) || info.files.length === 0) { + throw new Error(`${file} does not contain any update files`); + } + return { file, info }; +}); + +const version = updateInfos[0].info.version; +if (!version || updateInfos.some(({ info }) => info.version !== version)) { + throw new Error("All macOS update metadata files must have the same version"); +} + +const files = uniqueByUrl( + updateInfos + .flatMap(({ info }) => info.files) + .filter((file) => file && typeof file.url === "string") + .sort(compareMacUpdateFile) +); + +const defaultZip = files.find((file) => file.url.endsWith(".zip") && file.url.includes("arm64")) ?? files.find((file) => file.url.endsWith(".zip")); +if (!defaultZip?.sha512) { + throw new Error("Merged macOS update metadata must include at least one ZIP with sha512"); +} + +const releaseDate = updateInfos + .map(({ info }) => info.releaseDate) + .filter(Boolean) + .sort() + .at(-1); + +const { files: _files, path: _path, sha512: _sha512, releaseDate: _releaseDate, ...baseInfo } = updateInfos[0].info; +const mergedInfo = { + ...baseInfo, + version, + files, + path: defaultZip.url, + sha512: defaultZip.sha512, + ...(releaseDate ? { releaseDate } : {}) +}; + +writeFileSync(outputFile, yaml.dump(mergedInfo, { lineWidth: 120, noRefs: true }), "utf8"); +console.log(`Wrote ${path.relative(process.cwd(), outputFile)} with ${files.length} macOS artifacts.`); + +function uniqueByUrl(files) { + const seen = new Set(); + return files.filter((file) => { + if (seen.has(file.url)) { + return false; + } + seen.add(file.url); + return true; + }); +} + +function compareMacUpdateFile(left, right) { + return fileRank(left) - fileRank(right) || left.url.localeCompare(right.url); +} + +function fileRank(file) { + const isZip = file.url.endsWith(".zip") ? 0 : 10; + const isAppleSilicon = file.url.includes("arm64") ? 0 : 1; + return isZip + isAppleSilicon; +} diff --git a/src/main/app-paths.ts b/src/main/app-paths.ts new file mode 100644 index 0000000..1b0a299 --- /dev/null +++ b/src/main/app-paths.ts @@ -0,0 +1,65 @@ +import os from "node:os"; +import path from "node:path"; + +export const APP_NAME = "Claude Code Router"; +export const LEGACY_CONFIGDIR = path.join(os.homedir(), ".claude-code-router"); + +const homeDirEnv = "CCR_INTERNAL_HOME_DIR"; +const appDataDirEnv = "CCR_INTERNAL_APP_DATA_DIR"; +const userDataDirEnv = "CCR_INTERNAL_USER_DATA_DIR"; + +type RuntimePathName = "appData" | "home" | "userData"; + +export type RuntimeAppPaths = Partial>; + +export function setRuntimeAppPaths(paths: RuntimeAppPaths): void { + setPathEnv(homeDirEnv, paths.home); + setPathEnv(appDataDirEnv, paths.appData); + setPathEnv(userDataDirEnv, paths.userData); +} + +export function resolveRuntimeAppPath(name: RuntimePathName): string { + const configured = readConfiguredPath(name); + if (configured) { + return configured; + } + if (name === "home") { + return os.homedir(); + } + if (name === "appData") { + return fallbackAppDataDir(); + } + return fallbackUserDataDir(); +} + +function readConfiguredPath(name: RuntimePathName): string | undefined { + const key = name === "home" + ? homeDirEnv + : name === "appData" + ? appDataDirEnv + : userDataDirEnv; + const value = process.env[key]?.trim(); + return value || undefined; +} + +function setPathEnv(key: string, value: string | undefined): void { + if (value?.trim()) { + process.env[key] = value; + } +} + +function fallbackAppDataDir(): string { + if (process.platform === "win32") { + return process.env.APPDATA || + process.env.LOCALAPPDATA || + (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), "AppData", "Roaming")); + } + return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); +} + +function fallbackUserDataDir(): string { + if (process.platform === "win32") { + return path.join(fallbackAppDataDir(), APP_NAME); + } + return path.join(LEGACY_CONFIGDIR, "app-data"); +} diff --git a/src/main/claude-app-gateway-service.ts b/src/main/claude-app-gateway-service.ts index 66da1af..3a56652 100644 --- a/src/main/claude-app-gateway-service.ts +++ b/src/main/claude-app-gateway-service.ts @@ -1,8 +1,8 @@ -import * as electron from "electron"; import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { resolveRuntimeAppPath } from "./app-paths"; import { saveAppConfig } from "./config"; import { CONFIGDIR } from "./constants"; import { @@ -174,6 +174,33 @@ export function restoreClaudeAppGatewayConfig(): void { rmSync(CLAUDE_APP_GATEWAY_BACKUP_FILE, { force: true }); } +export function readClaudeAppGatewayApiKeyCandidates(): string[] { + return uniqueStrings([ + readClaudeAppGatewayApiKey(getClaudeAppGatewayPaths().configLibraryFile) + ]); +} + +function readClaudeAppGatewayApiKey(file: string): string { + const config = readJsonRecord(file); + return typeof config?.inferenceGatewayApiKey === "string" + ? config.inferenceGatewayApiKey.trim() + : ""; +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + return result; +} + function ensureClaudeAppGatewayState(config: AppConfig): ClaudeAppApplyState { const currentApiKey = findReusableApiKey(config); const gatewayEnabledConfig = config.gateway.enabled @@ -246,20 +273,7 @@ function getClaudeApp3pDataDir(): string { } function appPath(name: "appData" | "home"): string { - const electronApp = electronAppOrUndefined(); - if (electronApp) { - return electronApp.getPath(name); - } - if (name === "home") { - return os.homedir(); - } - return process.env.APPDATA || - process.env.LOCALAPPDATA || - (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), ".config")); -} - -function electronAppOrUndefined(): Electron.App | undefined { - return typeof electron.app?.getPath === "function" ? electron.app : undefined; + return resolveRuntimeAppPath(name); } function backupClaudeAppGatewayConfig(paths: ClaudeAppGatewayPaths): void { diff --git a/src/main/cli.ts b/src/main/cli.ts index 3d745ca..225b667 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -7,6 +7,8 @@ import { botGatewayProfileEnv } from "./bot-gateway-env"; import { launchCodexAppProfile, launchZcodeAppProfile } from "./codex-app-launch"; import { loadAppConfig } from "./config"; import { CONFIGDIR } from "./constants"; +import { applyProfileConfig, applyProfileRuntimeConfig } from "./profile-service"; +import { ensureProfileGateway } from "./profile-launch-service"; import { buildProfileLaunchPlan, findProfileForOpen, profileLaunchSpawnCommand, resolveProfileOpenSurface } from "./profile-launch-core"; import { startWebManagementServer } from "./web-management-server"; @@ -88,9 +90,19 @@ async function main(): Promise { const configDir = CONFIGDIR; const config = await loadAppConfig(); + await applyProfileConfig(config); const profile = findProfileForOpen(config, profileOptions.profileRef); const surface = profileOptions.surface ?? (profile.agent === "zcode" || profile.surface === "app" ? "app" : "cli"); const resolvedSurface = resolveProfileOpenSurface(profile, surface); + const launchConfig = resolvedSurface === "cli" + ? await ensureProfileGateway(config, profile, profile.name || profile.id || "profile", { reuseExisting: true, startIfMissing: false }) + : config; + if (resolvedSurface === "cli") { + const runtimeResult = applyProfileRuntimeConfig(launchConfig, profile, launchConfig.APIKEY); + if (!runtimeResult.ok) { + throw new Error(runtimeResult.message); + } + } if (profile.agent === "zcode" && profileOptions.agentArgs.length > 0) { throw new Error("ZCode profiles can only open the app; agent arguments are not supported."); } @@ -122,7 +134,7 @@ async function main(): Promise { const childEnv = { ...process.env, ...plan.env, - ...botGatewayProfileEnv(config, profile, resolvedSurface) + ...botGatewayProfileEnv(launchConfig, profile, resolvedSurface) }; delete childEnv.ELECTRON_RUN_AS_NODE; @@ -275,10 +287,7 @@ async function startService(options: WebCliOptions): Promise { ]; const child = spawn(process.execPath, childArgs, { detached: true, - env: { - ...process.env, - ELECTRON_RUN_AS_NODE: undefined - }, + env: serviceChildEnv(), stdio: "ignore", windowsHide: true }); @@ -295,6 +304,16 @@ async function startService(options: WebCliOptions): Promise { process.stdout.write(`CCR service started at ${state.url} (pid ${state.pid}).\n`); } +function serviceChildEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + if (process.versions.electron) { + env.ELECTRON_RUN_AS_NODE = "1"; + } else { + delete env.ELECTRON_RUN_AS_NODE; + } + return env; +} + async function runWebServer(options: WebCliOptions): Promise { const runtime = await startWebManagementServer({ host: options.host, diff --git a/src/main/codex-cli-middleware-runtime.ts b/src/main/codex-cli-middleware-runtime.ts index fcd0a11..0813c76 100644 --- a/src/main/codex-cli-middleware-runtime.ts +++ b/src/main/codex-cli-middleware-runtime.ts @@ -58,6 +58,7 @@ async function main() { async function runClaudeCodeCliWrapper(args) { const realCli = expandHome(nonEmptyEnv("CCR_REAL_CLAUDE_CODE_BIN") || nonEmptyEnv("CCR_CLAUDE_CODE_BIN") || nonEmptyEnv("CODEXL_CLAUDE_CODE_BIN") || "claude"); log("claude_code_wrapper_start", { realCli, args }); + const captureStdout = shouldCaptureClaudeCodeCliStdout(args); const remoteSync = createRemoteSyncClient({ args, cwd: process.cwd(), @@ -67,7 +68,7 @@ async function runClaudeCodeCliWrapper(args) { const injectRemoteStdin = boolEnv("CCR_REMOTE_SYNC_INJECT_STDIN"); const child = childProcess.spawn(realCli, args, { env: withoutKeys(process.env, ["CCR_CLAUDE_CODE_WRAPPER", "CCR_REAL_CLAUDE_CODE_BIN"]), - stdio: [injectRemoteStdin ? "pipe" : "inherit", "pipe", "inherit"] + stdio: [injectRemoteStdin ? "pipe" : "inherit", captureStdout ? "pipe" : "inherit", "inherit"] }); if (injectRemoteStdin && child.stdin) { process.stdin.pipe(child.stdin); @@ -88,18 +89,20 @@ async function runClaudeCodeCliWrapper(args) { remoteSync.postEvent("claude.spawn.error", { error: formatError(error) }, { direction: "system" }); }); let pending = ""; - child.stdout.on("data", (chunk) => { - process.stdout.write(chunk); - pending += chunk.toString("utf8"); - const lines = pending.split(/\r?\n/g); - pending = lines.pop() || ""; - for (const line of lines) { - botBridge().handleClaudeCliLine(line); - remoteSync.postEvent("claude.stdout", { line }, { text: line }); - } - }); + if (captureStdout && child.stdout) { + child.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + pending += chunk.toString("utf8"); + const lines = pending.split(/\r?\n/g); + pending = lines.pop() || ""; + for (const line of lines) { + botBridge().handleClaudeCliLine(line); + remoteSync.postEvent("claude.stdout", { line }, { text: line }); + } + }); + } const code = await waitForChild(child); - if (pending.trim()) { + if (captureStdout && pending.trim()) { botBridge().handleClaudeCliLine(pending); remoteSync.postEvent("claude.stdout", { line: pending }, { text: pending }); } @@ -109,6 +112,26 @@ async function runClaudeCodeCliWrapper(args) { process.exitCode = code; } +function shouldCaptureClaudeCodeCliStdout(args) { + if (boolEnv("CCR_CLAUDE_CODE_CAPTURE_STDOUT") || boolEnv("CODEXL_CLAUDE_CODE_CAPTURE_STDOUT")) { + return true; + } + if (botGatewayCliCaptureEnabled()) { + return true; + } + return claudeCodeArgsUsePrintMode(args); +} + +function botGatewayCliCaptureEnabled() { + const enabled = boolEnv("CCR_BOT_GATEWAY_ENABLED") || boolEnv("CODEXL_BOT_GATEWAY_ENABLED"); + const platform = normalizeBotGatewayPlatform(nonEmptyEnv("CCR_BOT_GATEWAY_PLATFORM") || nonEmptyEnv("CODEXL_BOT_GATEWAY_PLATFORM") || "none"); + return enabled && platform !== "none"; +} + +function claudeCodeArgsUsePrintMode(args) { + return args.some((arg) => arg === "--print" || arg === "-p"); +} + function defaultCodexArgs() { return normalizeProfileSurface(nonEmptyEnv("CCR_PROFILE_SURFACE") || nonEmptyEnv("CODEXL_PROFILE_SURFACE")) === "cli" ? [] diff --git a/src/main/constants.ts b/src/main/constants.ts index ba05680..a7b0bf8 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -1,35 +1,17 @@ -import * as electron from "electron"; -import os from "node:os"; import path from "node:path"; +import { APP_NAME, LEGACY_CONFIGDIR, resolveRuntimeAppPath } from "./app-paths"; + export { IPC_CHANNELS } from "../shared/ipc-channels"; - -export const APP_NAME = "Claude Code Router"; - -const electronApp = typeof electron.app?.getPath === "function" ? electron.app : undefined; -export const LEGACY_CONFIGDIR = path.join(os.homedir(), ".claude-code-router"); export const LEGACY_CONFIG_FILE = path.join(LEGACY_CONFIGDIR, "config.json"); -function appPath(name: "appData" | "home" | "userData"): string { - if (electronApp) { - return electronApp.getPath(name); - } - if (name === "home") { - return os.homedir(); - } - if (name === "appData") { - return process.env.APPDATA || - process.env.LOCALAPPDATA || - (process.env.USERPROFILE ? path.join(process.env.USERPROFILE, "AppData", "Roaming") : path.join(os.homedir(), "AppData", "Roaming")); - } - return path.join(LEGACY_CONFIGDIR, "app-data"); -} +export { APP_NAME, LEGACY_CONFIGDIR }; export const CONFIGDIR = process.platform === "win32" - ? path.join(appPath("appData"), APP_NAME) + ? path.join(resolveRuntimeAppPath("appData"), APP_NAME) : LEGACY_CONFIGDIR; export const CONFIG_FILE = path.join(CONFIGDIR, "config.json"); export const ONBOARDING_FINISHED_FILE = path.join(CONFIGDIR, ".onboard_finished"); -export const DATADIR = appPath("userData"); +export const DATADIR = resolveRuntimeAppPath("userData"); export const APP_CONFIG_DB_FILE = path.join(CONFIGDIR, "config.sqlite"); export const API_KEYS_DB_FILE = path.join(DATADIR, "api-keys.sqlite"); export const CERTDIR = path.join(DATADIR, "certs"); diff --git a/src/main/electron-node-shim.ts b/src/main/electron-node-shim.ts deleted file mode 100644 index 212ed6a..0000000 --- a/src/main/electron-node-shim.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const app = undefined; -export const BrowserWindow = undefined; -export const clipboard = undefined; -export const contextBridge = undefined; -export const dialog = undefined; -export const ipcMain = undefined; -export const ipcRenderer = undefined; -export const Menu = undefined; -export const nativeImage = undefined; -export const screen = undefined; -export const session = undefined; -export const shell = undefined; -export const Tray = undefined; -export const WebContentsView = undefined; diff --git a/src/main/main-app.ts b/src/main/main-app.ts new file mode 100644 index 0000000..72f63d5 --- /dev/null +++ b/src/main/main-app.ts @@ -0,0 +1,246 @@ +import { app, BrowserWindow, dialog, shell } from "electron"; +import { setupApplicationMenu } from "./app-menu"; +import { loadAppConfig } from "./config"; +import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "./claude-app-gateway-service"; +import { deepLinkService } from "./deep-link"; +import { gatewayService } from "../server/gateway/service"; +import "./ipc"; +import { applyProfileConfig } from "./profile-service"; +import { ensureCcrCliLauncher } from "./profile-launch-service"; +import { proxyService } from "../server/proxy/service"; +import trayController from "./tray-controller"; +import { appUpdateService } from "./update-service"; +import windowsManager from "./windows"; + +const gotTheLock = app.requestSingleInstanceLock(); +const quitProxyRestoreTimeoutMs = 30_000; +let quitPrepared = false; +let stoppingForQuit = false; +let ensureProxyModePromise: Promise | undefined; +let startServicesPromise: Promise | undefined; +let stopForQuitPromise: Promise | undefined; + +if (!gotTheLock) { + app.quit(); +} else { + startPrimaryInstance(); +} + +function startPrimaryInstance(): void { + deepLinkService.register(); + deepLinkService.handleArgv(process.argv); + + app.on("second-instance", (_event, argv) => { + windowsManager.showMainWindow(); + deepLinkService.handleArgv(argv); + queueEnsureConfiguredProxyModeActive("second-instance"); + }); + + app.whenReady().then(() => { + configureProxyDesktopIntegration(); + try { + ensureCcrCliLauncher(); + } catch (error) { + console.error(`Failed to install ccr CLI launcher: ${formatError(error)}`); + } + setupApplicationMenu(); + windowsManager.createMainWindow(); + trayController.start(); + appUpdateService.start(); + appUpdateService.setInstallPreparation(prepareForUpdateInstall); + void startConfiguredServices("startup"); + + app.on("activate", () => { + windowsManager.showMainWindow(); + queueEnsureConfiguredProxyModeActive("activate"); + }); + }); + + app.on("before-quit", (event) => { + if (quitPrepared || appUpdateService.isInstallingUpdate()) { + return; + } + event.preventDefault(); + prepareAndQuit(); + }); + + app.on("will-quit", (event) => { + if (quitPrepared || appUpdateService.isInstallingUpdate()) { + return; + } + event.preventDefault(); + prepareAndQuit(); + }); + + app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } + }); + + process.once("SIGINT", () => handleTerminationSignal("SIGINT")); + process.once("SIGTERM", () => handleTerminationSignal("SIGTERM")); +} + +function configureProxyDesktopIntegration(): void { + proxyService.setDesktopIntegration({ + requestMacosCertificateInstallPermission, + revealFile: async (file) => { + shell.showItemInFolder(file); + } + }); +} + +async function requestMacosCertificateInstallPermission(): Promise { + const window = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + const options = { + buttons: ["Continue", "Cancel"], + cancelId: 1, + defaultId: 0, + detail: + "macOS will ask for administrator credentials. This is required so Chrome can trust HTTPS certificates generated by CCR proxy mode.", + message: "Install CCR Proxy CA into the macOS System keychain?", + noLink: true, + type: "warning" as const + }; + const result = window ? await dialog.showMessageBox(window, options) : await dialog.showMessageBox(options); + return result.response === 0; +} + +function prepareAndQuit(): void { + if (stoppingForQuit) { + return; + } + + stoppingForQuit = true; + void stopServicesForQuit().finally(() => { + quitPrepared = true; + app.quit(); + }); +} + +async function prepareForUpdateInstall(): Promise { + if (!stoppingForQuit) { + stoppingForQuit = true; + } + await stopServicesForQuit(); + quitPrepared = true; +} + +function handleTerminationSignal(signal: NodeJS.Signals): void { + if (stoppingForQuit) { + return; + } + + stoppingForQuit = true; + void stopServicesForQuit().finally(() => { + quitPrepared = true; + app.exit(signal === "SIGINT" ? 130 : 143); + }); +} + +function stopServicesForQuit(): Promise { + if (!stopForQuitPromise) { + stopForQuitPromise = gatewayService + .stop({ proxyRestoreTimeoutMs: quitProxyRestoreTimeoutMs }) + .then(() => undefined) + .catch((error) => { + console.error(`Failed to stop services before quit: ${formatError(error)}`); + }) + .finally(() => { + try { + restoreClaudeAppGatewayConfig(); + } catch (error) { + console.error(`Failed to restore Claude App gateway config before quit: ${formatError(error)}`); + } + trayController.destroy(); + }); + } + return stopForQuitPromise; +} + +function startConfiguredServices(reason: string): Promise { + if (!startServicesPromise) { + startServicesPromise = loadAppConfig() + .then(async (config) => { + try { + config = (await syncClaudeAppGatewayConfig(config)).config; + } catch (error) { + console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`); + } + const status = await gatewayService.start(config); + if (status.state === "error") { + console.error(`Failed to start gateway during ${reason}: ${status.lastError}`); + } + if (status.state === "running") { + const profileResult = await applyProfileConfig(config); + for (const client of profileResult.clients) { + if (!client.ok) { + console.error(`Failed to apply ${client.client} profile during ${reason}: ${client.message}`); + } + } + } + if (config.proxy.enabled && config.proxy.systemProxy) { + const proxyStatus = await proxyService.ensureSystemProxyActive(); + logProxySystemProxyIssue(reason, proxyStatus); + } + }) + .catch((error) => { + console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`); + }) + .finally(() => { + trayController.refreshUsageTitle(); + startServicesPromise = undefined; + }); + } + return startServicesPromise; +} + +function queueEnsureConfiguredProxyModeActive(reason: string): void { + void (startServicesPromise ?? Promise.resolve()) + .then(() => ensureConfiguredProxyModeActive(reason)) + .catch((error) => { + console.error(`Failed to ensure proxy mode during ${reason}: ${formatError(error)}`); + }); +} + +function ensureConfiguredProxyModeActive(reason: string): Promise { + if (stoppingForQuit) { + return Promise.resolve(); + } + + if (!ensureProxyModePromise) { + ensureProxyModePromise = loadAppConfig() + .then(async (config) => { + if (!config.proxy.enabled || !config.proxy.systemProxy) { + return; + } + + const proxyStatus = proxyService.getStatus(); + if (proxyStatus.state !== "running") { + await startConfiguredServices(reason); + return; + } + + const ensuredStatus = await proxyService.ensureSystemProxyActive(); + logProxySystemProxyIssue(reason, ensuredStatus); + }) + .finally(() => { + ensureProxyModePromise = undefined; + }); + } + return ensureProxyModePromise; +} + +function logProxySystemProxyIssue(reason: string, status: ReturnType): void { + if (status.systemProxy.state === "active") { + return; + } + + const details = status.systemProxy.lastError ? `: ${status.systemProxy.lastError}` : ""; + console.error(`Proxy mode is enabled, but system proxy is ${status.systemProxy.state} during ${reason}${details}`); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/main/main.ts b/src/main/main.ts index 0e96bce..44f233b 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,214 +1,13 @@ import { app } from "electron"; -import { setupApplicationMenu } from "./app-menu"; -import { loadAppConfig } from "./config"; -import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "./claude-app-gateway-service"; -import { deepLinkService } from "./deep-link"; -import { gatewayService } from "../server/gateway/service"; -import "./ipc"; -import { applyProfileConfig } from "./profile-service"; -import { proxyService } from "../server/proxy/service"; -import trayController from "./tray-controller"; -import { appUpdateService } from "./update-service"; -import windowsManager from "./windows"; +import { setRuntimeAppPaths } from "./app-paths"; -const gotTheLock = app.requestSingleInstanceLock(); -const quitProxyRestoreTimeoutMs = 30_000; -let quitPrepared = false; -let stoppingForQuit = false; -let ensureProxyModePromise: Promise | undefined; -let startServicesPromise: Promise | undefined; -let stopForQuitPromise: Promise | undefined; +setRuntimeAppPaths({ + appData: app.getPath("appData"), + home: app.getPath("home"), + userData: app.getPath("userData") +}); -if (!gotTheLock) { +void import("./main-app.js").catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); app.quit(); -} else { - startPrimaryInstance(); -} - -function startPrimaryInstance(): void { - deepLinkService.register(); - deepLinkService.handleArgv(process.argv); - - app.on("second-instance", (_event, argv) => { - windowsManager.showMainWindow(); - deepLinkService.handleArgv(argv); - queueEnsureConfiguredProxyModeActive("second-instance"); - }); - - app.whenReady().then(() => { - setupApplicationMenu(); - windowsManager.createMainWindow(); - trayController.start(); - appUpdateService.start(); - appUpdateService.setInstallPreparation(prepareForUpdateInstall); - void startConfiguredServices("startup"); - - app.on("activate", () => { - windowsManager.showMainWindow(); - queueEnsureConfiguredProxyModeActive("activate"); - }); - }); - - app.on("before-quit", (event) => { - if (quitPrepared || appUpdateService.isInstallingUpdate()) { - return; - } - event.preventDefault(); - prepareAndQuit(); - }); - - app.on("will-quit", (event) => { - if (quitPrepared || appUpdateService.isInstallingUpdate()) { - return; - } - event.preventDefault(); - prepareAndQuit(); - }); - - app.on("window-all-closed", () => { - if (process.platform !== "darwin") { - app.quit(); - } - }); - - process.once("SIGINT", () => handleTerminationSignal("SIGINT")); - process.once("SIGTERM", () => handleTerminationSignal("SIGTERM")); -} - -function prepareAndQuit(): void { - if (stoppingForQuit) { - return; - } - - stoppingForQuit = true; - void stopServicesForQuit().finally(() => { - quitPrepared = true; - app.quit(); - }); -} - -async function prepareForUpdateInstall(): Promise { - if (!stoppingForQuit) { - stoppingForQuit = true; - } - await stopServicesForQuit(); - quitPrepared = true; -} - -function handleTerminationSignal(signal: NodeJS.Signals): void { - if (stoppingForQuit) { - return; - } - - stoppingForQuit = true; - void stopServicesForQuit().finally(() => { - quitPrepared = true; - app.exit(signal === "SIGINT" ? 130 : 143); - }); -} - -function stopServicesForQuit(): Promise { - if (!stopForQuitPromise) { - stopForQuitPromise = gatewayService - .stop({ proxyRestoreTimeoutMs: quitProxyRestoreTimeoutMs }) - .then(() => undefined) - .catch((error) => { - console.error(`Failed to stop services before quit: ${formatError(error)}`); - }) - .finally(() => { - try { - restoreClaudeAppGatewayConfig(); - } catch (error) { - console.error(`Failed to restore Claude App gateway config before quit: ${formatError(error)}`); - } - trayController.destroy(); - }); - } - return stopForQuitPromise; -} - -function startConfiguredServices(reason: string): Promise { - if (!startServicesPromise) { - startServicesPromise = loadAppConfig() - .then(async (config) => { - try { - config = (await syncClaudeAppGatewayConfig(config)).config; - } catch (error) { - console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`); - } - const status = await gatewayService.start(config); - if (status.state === "error") { - console.error(`Failed to start gateway during ${reason}: ${status.lastError}`); - } - if (status.state === "running") { - const profileResult = await applyProfileConfig(config); - for (const client of profileResult.clients) { - if (!client.ok) { - console.error(`Failed to apply ${client.client} profile during ${reason}: ${client.message}`); - } - } - } - if (config.proxy.enabled && config.proxy.systemProxy) { - const proxyStatus = await proxyService.ensureSystemProxyActive(); - logProxySystemProxyIssue(reason, proxyStatus); - } - }) - .catch((error) => { - console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`); - }) - .finally(() => { - trayController.refreshUsageTitle(); - startServicesPromise = undefined; - }); - } - return startServicesPromise; -} - -function queueEnsureConfiguredProxyModeActive(reason: string): void { - void (startServicesPromise ?? Promise.resolve()) - .then(() => ensureConfiguredProxyModeActive(reason)) - .catch((error) => { - console.error(`Failed to ensure proxy mode during ${reason}: ${formatError(error)}`); - }); -} - -function ensureConfiguredProxyModeActive(reason: string): Promise { - if (stoppingForQuit) { - return Promise.resolve(); - } - - if (!ensureProxyModePromise) { - ensureProxyModePromise = loadAppConfig() - .then(async (config) => { - if (!config.proxy.enabled || !config.proxy.systemProxy) { - return; - } - - const proxyStatus = proxyService.getStatus(); - if (proxyStatus.state !== "running") { - await startConfiguredServices(reason); - return; - } - - const ensuredStatus = await proxyService.ensureSystemProxyActive(); - logProxySystemProxyIssue(reason, ensuredStatus); - }) - .finally(() => { - ensureProxyModePromise = undefined; - }); - } - return ensureProxyModePromise; -} - -function logProxySystemProxyIssue(reason: string, status: ReturnType): void { - if (status.systemProxy.state === "active") { - return; - } - - const details = status.systemProxy.lastError ? `: ${status.systemProxy.lastError}` : ""; - console.error(`Proxy mode is enabled, but system proxy is ${status.systemProxy.state} during ${reason}${details}`); -} - -function formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} +}); diff --git a/src/main/profile-launch-service.ts b/src/main/profile-launch-service.ts index e1965e8..ed18cdf 100644 --- a/src/main/profile-launch-service.ts +++ b/src/main/profile-launch-service.ts @@ -1,10 +1,10 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import type { AppConfig, ProfileOpenCommandResult, ProfileOpenRequest, ProfileOpenResult, ProfileRuntimeEntry, ProfileRuntimeStatus, ProfileStopResult } from "../shared/app"; import { botGatewayProfileEnv } from "./bot-gateway-env"; -import { applyClaudeAppGatewayConfig } from "./claude-app-gateway-service"; +import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "./claude-app-gateway-service"; import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "./claude-app-launch"; import { launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "./codex-app-launch"; import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime"; @@ -156,7 +156,7 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType, - appName: string + appName: string, + options: { reuseExisting?: boolean; startIfMissing?: boolean } = {} ): Promise { const profileGatewayConfig = profileGatewayConfigFor(config, profile); - await ensureGatewayConfigRunning(profileGatewayConfig, appName); - return profileGatewayConfig; + const result = await ensureGatewayConfigRunning(profileGatewayConfig, profile, appName, options, config); + return result.acceptedApiKey && result.acceptedApiKey !== result.config.APIKEY + ? profileGatewayConfigWithToken(result.config, profile, result.acceptedApiKey) + : result.config; } -async function ensureGatewayConfigRunning(config: AppConfig, appName: string): Promise { - const startedStatus = await gatewayService.start(config); - if (startedStatus.state !== "running") { - throw new Error(startedStatus.lastError || `CCR gateway did not start for ${appName}.`); +type EnsureGatewayConfigRunningResult = { + acceptedApiKey?: string; + config: AppConfig; +}; + +async function ensureGatewayConfigRunning( + config: AppConfig, + profile: ReturnType, + appName: string, + options: { reuseExisting?: boolean; startIfMissing?: boolean } = {}, + candidateConfig: AppConfig = config +): Promise { + const startIfMissing = options.startIfMissing !== false; + if (options.reuseExisting) { + const existingGateway = await probeExistingProfileGateway(config, profile, candidateConfig); + if (existingGateway.state === "usable") { + return { acceptedApiKey: existingGateway.apiKey, config }; + } + if (existingGateway.state === "unavailable") { + if (!startIfMissing) { + throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`); + } + } else { + throw new Error(existingGatewayConflictMessage(existingGateway, appName)); + } } + + if (!startIfMissing) { + throw new Error(`CCR gateway is not running at ${profileGatewayEndpoint(config)}. Start CCR Desktop or run ccr start before opening ${appName}.`); + } + + const startedStatus = await gatewayService.start(config); + if (startedStatus.state === "running") { + return { config }; + } + + if (options.reuseExisting && isAddressInUseError(startedStatus.lastError)) { + const existingGateway = await probeExistingProfileGateway(config, profile, candidateConfig); + if (existingGateway.state === "usable") { + return { acceptedApiKey: existingGateway.apiKey, config }; + } + throw new Error(existingGatewayConflictMessage(existingGateway, appName)); + } + + throw new Error(startedStatus.lastError || `CCR gateway did not start for ${appName}.`); +} + +type ExistingProfileGatewayProbe = + | { endpoint: string; reason?: string; state: "unavailable" } + | { endpoint: string; status?: number; state: "not-ccr" } + | { endpoint: string; message?: string; status: number; state: "unauthorized" } + | { endpoint: string; status: number; state: "unusable" } + | { apiKey?: string; endpoint: string; state: "usable" }; + +type ExistingGatewayHttpProbe = { + payload?: unknown; + reason?: string; + status?: number; +}; + +async function probeExistingProfileGateway( + config: AppConfig, + profile: ReturnType, + candidateConfig: AppConfig = config +): Promise { + const endpoint = profileGatewayEndpoint(config); + const root = await fetchExistingGateway(endpoint, "/"); + if (root.status === undefined) { + return { endpoint, reason: root.reason, state: "unavailable" }; + } + + let ccrGateway = isCcrGatewayRoot(root.payload); + if (!ccrGateway) { + const health = await fetchExistingGateway(endpoint, "/health"); + ccrGateway = isCcrGatewayHealth(health.payload); + } + if (!ccrGateway) { + return { endpoint, status: root.status, state: "not-ccr" }; + } + + let lastUnauthorized: ExistingGatewayHttpProbe | undefined; + for (const apiKey of existingGatewayApiKeyCandidates(config, profile, candidateConfig)) { + const headers: Record = { + "user-agent": "Claude Code" + }; + if (apiKey) { + headers.authorization = `Bearer ${apiKey}`; + } + const models = await fetchExistingGateway(endpoint, "/v1/models", { headers }); + if (models.status === 200) { + return { apiKey, endpoint, state: "usable" }; + } + if (models.status === 401 || models.status === 403) { + lastUnauthorized = models; + continue; + } + return { endpoint, status: models.status ?? 0, state: "unusable" }; + } + + if (lastUnauthorized?.status === 401 || lastUnauthorized?.status === 403) { + return { + endpoint, + message: readGatewayErrorMessage(lastUnauthorized.payload), + status: lastUnauthorized.status, + state: "unauthorized" + }; + } + return { endpoint, status: 0, state: "unusable" }; +} + +async function fetchExistingGateway( + endpoint: string, + pathname: string, + init: RequestInit = {} +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1200); + try { + const response = await fetch(new URL(pathname, endpoint).toString(), { + ...init, + signal: controller.signal + }); + return { + payload: await readResponseJson(response), + status: response.status + }; + } catch (error) { + return { reason: formatError(error) }; + } finally { + clearTimeout(timeout); + } +} + +async function readResponseJson(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) { + return undefined; + } + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +function isCcrGatewayRoot(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return value.name === "claude-code-router" || value.plugin === "claude-code-router"; +} + +function isCcrGatewayHealth(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return typeof value.core === "string" && typeof value.status === "string" && typeof value.timestamp === "string"; +} + +function readGatewayErrorMessage(value: unknown): string | undefined { + if (!isRecord(value) || !isRecord(value.error)) { + return undefined; + } + return typeof value.error.message === "string" ? value.error.message : undefined; +} + +function existingGatewayApiKeyCandidates( + config: AppConfig, + profile: ReturnType, + candidateConfig: AppConfig +): Array { + const values = [ + config.APIKEY, + ...(Array.isArray(config.APIKEYS) ? config.APIKEYS.map((apiKey) => apiKey.key) : []), + candidateConfig.APIKEY, + ...(Array.isArray(candidateConfig.APIKEYS) ? candidateConfig.APIKEYS.map((apiKey) => apiKey.key) : []), + ...readClaudeAppGatewayApiKeyCandidates(), + ...readClaudeCodeApiKeyHelperCandidates(profile) + ]; + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const key = value?.trim(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + result.push(key); + } + return result.length > 0 ? result : [undefined]; +} + +function readClaudeCodeApiKeyHelperCandidates(profile: ReturnType): string[] { + const file = path.join(CONFIGDIR, "bin", claudeCodeApiKeyHelperFilename(profile)); + const files = [ + file, + ...readBackupFiles(file) + ]; + return uniqueStrings(files.map(readClaudeCodeApiKeyHelperToken)); +} + +function claudeCodeApiKeyHelperFilename(profile: ReturnType): string { + const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code"; + return process.platform === "win32" + ? `ccr-claude-code-api-key-${slug}.cmd` + : `ccr-claude-code-api-key-${slug}`; +} + +function readBackupFiles(file: string): string[] { + const dir = path.dirname(file); + const prefix = `${path.basename(file)}.ccr-backup-`; + try { + return readdirSync(dir) + .filter((entry) => entry.startsWith(prefix)) + .sort() + .reverse() + .map((entry) => path.join(dir, entry)); + } catch { + return []; + } +} + +function readClaudeCodeApiKeyHelperToken(file: string): string { + if (!existsSync(file)) { + return ""; + } + try { + const content = readFileSync(file, "utf8"); + for (const line of content.split(/\r?\n/g)) { + const token = parseClaudeCodeApiKeyHelperLine(line); + if (token) { + return token; + } + } + } catch { + return ""; + } + return ""; +} + +function parseClaudeCodeApiKeyHelperLine(line: string): string { + const trimmed = line.trim(); + const shellPrefix = "printf '%s\\n' "; + if (trimmed.startsWith(shellPrefix)) { + return unquoteShellValue(trimmed.slice(shellPrefix.length).trim()); + } + if (/^echo\s+/i.test(trimmed)) { + return trimmed.replace(/^echo\s+/i, "").trim().replace(/^"|"$/g, ""); + } + return ""; +} + +function unquoteShellValue(value: string): string { + if (value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1).replace(/'\\''/g, "'"); + } + return value.replace(/^"|"$/g, ""); +} + +function existingGatewayConflictMessage(probe: ExistingProfileGatewayProbe, appName: string): string { + if (probe.state === "unauthorized") { + const details = probe.message ? ` ${probe.message}` : ""; + return `CCR gateway is already running at ${probe.endpoint}, but it does not accept the API key for ${appName}.${details} Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`; + } + if (probe.state === "unusable") { + return `CCR gateway is already running at ${probe.endpoint}, but it cannot serve ${appName} right now (HTTP ${probe.status}). Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`; + } + if (probe.state === "not-ccr") { + return `Port ${probe.endpoint} is already in use by a non-CCR service. Stop that process or change the CCR gateway port.`; + } + if (probe.state === "unavailable") { + return `CCR gateway is not reachable at ${probe.endpoint}${probe.reason ? `: ${probe.reason}` : ""}.`; + } + return `CCR gateway is already running at ${probe.endpoint}.`; +} + +function isAddressInUseError(message: string | undefined): boolean { + return /\bEADDRINUSE\b/i.test(message || ""); +} + +function profileGatewayEndpoint(config: AppConfig): string { + const host = probeGatewayHost(config.gateway.host); + return `http://${formatEndpointHost(host)}:${config.gateway.port}/`; +} + +function probeGatewayHost(host: string): string { + if (!host || host === "0.0.0.0") { + return "127.0.0.1"; + } + if (host === "::") { + return "::1"; + } + return host; +} + +function formatEndpointHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; } function profileGatewayConfigFor(config: AppConfig, profile: ReturnType): AppConfig { @@ -201,6 +496,10 @@ function profileGatewayConfigFor(config: AppConfig, profile: ReturnType, token: string): AppConfig { return { ...config, APIKEY: token, @@ -779,7 +1078,7 @@ function commandProfileRef(config: AppConfig, profile: ReturnType/dev/null 2>&1; then", - ` exec node ${shQuote(runtimeFile)} "$@"`, - "fi", `ELECTRON_RUN_AS_NODE=1 exec ${shQuote(process.execPath)} ${shQuote(runtimeFile)} "$@"` ].join("\n") + "\n"; } function windowsCcrLauncher(runtimeFile: string): string { + const nodePath = bundledNodePath(); return [ "@echo off", "setlocal", `set "CCR_CLI_RUNTIME=${cmdEnvValue(runtimeFile)}"`, + `set "CCR_CLI_NODE_PATH=${cmdEnvValue(nodePath)}"`, + "if defined NODE_PATH (", + " set \"NODE_PATH=%CCR_CLI_NODE_PATH%;%NODE_PATH%\"", + ") else (", + " set \"NODE_PATH=%CCR_CLI_NODE_PATH%\"", + ")", "if defined CCR_NODE_BIN (", ' "%CCR_NODE_BIN%" "%CCR_CLI_RUNTIME%" %*', " exit /b %ERRORLEVEL%", ")", - "where node >nul 2>nul", - "if %ERRORLEVEL%==0 (", - ' node "%CCR_CLI_RUNTIME%" %*', - " exit /b %ERRORLEVEL%", - ")", "set \"ELECTRON_RUN_AS_NODE=1\"", `${cmdQuote(process.execPath)} "%CCR_CLI_RUNTIME%" %*`, "exit /b %ERRORLEVEL%" ].join("\r\n") + "\r\n"; } +function bundledNodePath(): string { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + const candidates = [ + path.join(__dirname, "..", "..", "node_modules"), + ...(resourcesPath + ? [ + path.join(resourcesPath, "app.asar", "node_modules"), + path.join(resourcesPath, "app.asar.unpacked", "node_modules"), + path.join(resourcesPath, "app", "node_modules") + ] + : []), + path.join(process.cwd(), "node_modules") + ]; + return uniqueStrings(candidates).join(path.delimiter); +} + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + return result; +} + function writeFileIfChanged(file: string, content: string): void { if (existsSync(file) && readFileSync(file, "utf8") === content) { return; diff --git a/src/main/profile-service.ts b/src/main/profile-service.ts index f0df909..3a4521b 100644 --- a/src/main/profile-service.ts +++ b/src/main/profile-service.ts @@ -44,6 +44,15 @@ export async function applyProfileConfig(config: AppConfig): Promise candidate.internalName), credentialIds: selection.credentials.map((candidate) => candidate.credentialId), credentialProtocol: target.protocol, @@ -1938,6 +1942,22 @@ function prepareUpstreamCredentialAttempt(input: { }; } +function normalizeConfiguredProviderModelBody( + body: Buffer | undefined, + config: AppConfig +): { body: Buffer; model: string } | undefined { + const parsedBody = parseJsonObjectSafe(body); + const model = stringValue(parsedBody?.model); + const selector = resolveConfiguredProviderModelSelector(model, config); + if (!parsedBody || !selector || selector.model === model) { + return undefined; + } + return { + body: serializeJsonBodyWithModel(parsedBody, selector.model), + model: selector.model + }; +} + function resolveProviderCredentialRoutingTarget( config: AppConfig, headers: Record, @@ -1951,14 +1971,14 @@ function resolveProviderCredentialRoutingTarget( const parsedBody = parseJsonObjectSafe(body); const bodyModel = stringValue(parsedBody?.model); - const parsedModel = parseProviderModelSelector(bodyModel); - if (parsedModel) { - const provider = findProviderByPublicOrInternalName(config, parsedModel.provider); + const modelSelector = resolveConfiguredProviderModelSelector(bodyModel, config); + if (modelSelector) { + const provider = modelSelector.provider; const providerProtocol = provider ? providerProtocolForClientProtocol(provider, protocol) : undefined; if (provider && providerProtocol && activeProviderCredentials(provider).length > 0) { return { - body: parsedBody ? serializeJsonBodyWithModel(parsedBody, parsedModel.model) : body, - model: parsedModel.model, + body: parsedBody ? serializeJsonBodyWithModel(parsedBody, modelSelector.model) : body, + model: modelSelector.model, provider, protocol: providerProtocol }; @@ -2001,6 +2021,44 @@ function parseProviderModelSelector(value: string | undefined): { model: string; return provider && model ? { model, provider } : undefined; } +function resolveConfiguredProviderModelSelector( + value: string | undefined, + config: AppConfig +): { model: string; provider: GatewayProviderConfig } | undefined { + let current = normalizeRouteSelector(value); + if (!current) { + return undefined; + } + + let selectedProvider: GatewayProviderConfig | undefined; + for (let depth = 0; depth < 4; depth += 1) { + const parsed = parseProviderModelSelector(current); + if (!parsed) { + break; + } + + const provider = findProviderByPublicOrInternalName(config, parsed.provider); + if (!provider) { + break; + } + + selectedProvider = provider; + current = parsed.model; + + const nested = parseProviderModelSelector(current); + if (!nested) { + return current ? { model: current, provider } : undefined; + } + + const nestedProvider = findProviderByPublicOrInternalName(config, nested.provider); + if (!nestedProvider || providerRuntimeId(nestedProvider) !== providerRuntimeId(provider)) { + return current ? { model: current, provider } : undefined; + } + } + + return selectedProvider && current ? { model: current, provider: selectedProvider } : undefined; +} + function firstTargetProviderHeader(headers: Record): string | undefined { const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"]; if (provider?.trim()) { @@ -2707,6 +2765,15 @@ function normalizeBindHost(host: string): string { return host.trim().replace(/^\[|\]$/g, "").toLowerCase(); } +function isLoopbackBindHost(host: string): boolean { + const normalized = normalizeBindHost(host).replace(/\.$/, ""); + return normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "0:0:0:0:0:0:0:1" || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized); +} + function isWildcardBindHost(host: string): boolean { return host === "" || host === "0.0.0.0" || host === "::" || host === "::0"; } @@ -2945,14 +3012,26 @@ function applyCors(response: ServerResponse, config?: AppConfig): void { response.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id"); } -function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): ApiKeyAuthorizationResult { - const apiKeys = configuredApiKeys(config); +async function authorize(request: IncomingMessage, response: ServerResponse, config: AppConfig): Promise { + let apiKeys = await configuredApiKeys(config); if (apiKeys.length === 0) { - return { ok: true }; + if (isLoopbackBindHost(config.gateway.host)) { + return { ok: true }; + } + sendJson(response, 403, { + error: { + message: "Configure at least one CCR API key before listening on a non-loopback gateway host." + } + }); + return { ok: false }; } const token = readAuthToken(request.headers) || readRemoteControlQueryAuthToken(request); - const apiKey = token ? apiKeys.find((item) => item.key === token) : undefined; + let apiKey = token ? apiKeys.find((item) => item.key === token) : undefined; + if (!apiKey && token) { + apiKeys = await configuredApiKeys(config, { refresh: true }); + apiKey = apiKeys.find((item) => item.key === token); + } if (apiKey) { if (isApiKeyExpired(apiKey)) { sendJson(response, 401, { error: { message: "API key is expired." } }); @@ -2965,8 +3044,10 @@ function authorize(request: IncomingMessage, response: ServerResponse, config: A return { ok: false }; } -function configuredApiKeys(config: AppConfig): ApiKeyConfig[] { +async function configuredApiKeys(config: AppConfig, options: { refresh?: boolean } = {}): Promise { + const persistedApiKeys = await loadPersistedApiKeysCached(options); const values = [ + ...persistedApiKeys, ...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []), ...(config.APIKEY ? [{ createdAt: new Date(0).toISOString(), id: "legacy", key: config.APIKEY }] : []) ]; @@ -2983,6 +3064,24 @@ function configuredApiKeys(config: AppConfig): ApiKeyConfig[] { return result; } +async function loadPersistedApiKeysCached(options: { refresh?: boolean } = {}): Promise { + const now = Date.now(); + if (!options.refresh && persistedApiKeyCache && now - persistedApiKeyCache.loadedAt < persistedApiKeyCacheTtlMs) { + return persistedApiKeyCache.values; + } + try { + const values = await loadPersistedApiKeys(); + persistedApiKeyCache = { + loadedAt: now, + values + }; + return values; + } catch (error) { + console.warn(`[gateway] Failed to load persisted API keys: ${formatError(error)}`); + return []; + } +} + function isApiKeyExpired(apiKey: ApiKeyConfig): boolean { if (!apiKey.expiresAt) { return false; @@ -3009,7 +3108,7 @@ function reserveApiKeyLimits(apiKey: ApiKeyConfig | undefined, request: Incoming }); for (const check of checks) { - const counter = readApiKeyWindowCounter(check.counterKey, check.windowStart); + const counter = readApiKeyWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now); if (counter.value + check.rule.requested > check.rule.limit) { sendJson(response, 429, { error: { @@ -3030,7 +3129,7 @@ function reserveApiKeyLimits(apiKey: ApiKeyConfig | undefined, request: Incoming } for (const check of checks) { - readApiKeyWindowCounter(check.counterKey, check.windowStart).value += check.rule.requested; + readApiKeyWindowCounter(check.counterKey, check.windowStart, check.rule.windowMs, now).value += check.rule.requested; } return true; } @@ -3076,7 +3175,7 @@ function providerCredentialLimitState( let utilization = 0; for (const rule of rules) { const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; - const counter = readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart); + const counter = readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now); blocked = blocked || counter.value + rule.requested > rule.limit; utilization = Math.max(utilization, (counter.value + rule.requested) / rule.limit); } @@ -3141,7 +3240,7 @@ function incrementProviderCredentialCounters( const now = Date.now(); for (const rule of rules) { const windowStart = Math.floor(now / rule.windowMs) * rule.windowMs; - readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart).value += rule.requested; + readApiKeyWindowCounter(providerCredentialCounterKey(provider, credential, rule, windowStart), windowStart, rule.windowMs, now).value += rule.requested; } } @@ -3202,16 +3301,29 @@ function addApiKeyLimitRule( }); } -function readApiKeyWindowCounter(key: string, windowStart: number): ApiKeyWindowCounter { +function readApiKeyWindowCounter(key: string, windowStart: number, windowMs: number, now = Date.now()): ApiKeyWindowCounter { + pruneExpiredApiKeyLimitCounters(now); const existing = apiKeyLimitCounters.get(key); if (existing && existing.windowStart === windowStart) { return existing; } - const fresh = { value: 0, windowStart }; + const fresh = { + expiresAt: windowStart + windowMs * apiKeyLimitCounterRetentionWindows, + value: 0, + windowStart + }; apiKeyLimitCounters.set(key, fresh); return fresh; } +function pruneExpiredApiKeyLimitCounters(now: number): void { + for (const [key, counter] of apiKeyLimitCounters) { + if (counter.expiresAt <= now) { + apiKeyLimitCounters.delete(key); + } + } +} + function estimateApiKeyLimitUsage(request: IncomingMessage, requestBody: Buffer): ApiKeyLimitUsage { return estimateLimitUsage(request.method ?? "GET", requestBody); } diff --git a/src/server/mcp/network-capture-mcp.ts b/src/server/mcp/network-capture-mcp.ts index 21759ce..2c6e0b7 100644 --- a/src/server/mcp/network-capture-mcp.ts +++ b/src/server/mcp/network-capture-mcp.ts @@ -1,4 +1,3 @@ -import * as electron from "electron"; import packageJson from "../../../package.json"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { ProxyNetworkExchange } from "../../shared/app"; @@ -170,7 +169,7 @@ async function handleJsonRpcRequest(payload: unknown): Promise { diff --git a/src/server/proxy/service.ts b/src/server/proxy/service.ts index eb8f031..4db90ff 100644 --- a/src/server/proxy/service.ts +++ b/src/server/proxy/service.ts @@ -1,6 +1,5 @@ import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import * as electron from "electron"; import { chmodSync, writeFileSync } from "node:fs"; import http, { type ClientRequest, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import https from "node:https"; @@ -9,6 +8,7 @@ import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import tls from "node:tls"; +import { pathToFileURL } from "node:url"; import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync } from "node:zlib"; import type { AppConfig, @@ -85,6 +85,11 @@ type ActiveProxyNetworkCapture = { setResponse: (statusCode: number, headers: CapturedHeaders | IncomingHttpHeaders) => void; }; +export type ProxyDesktopIntegration = { + requestMacosCertificateInstallPermission?: () => Promise; + revealFile?: (file: string) => Promise; +}; + const requestHopByHopHeaders = new Set([ "connection", "keep-alive", @@ -119,6 +124,7 @@ class ProxyService { private authority?: CertificateAuthority; private attachedServer?: AttachedServer; private config?: AppConfig; + private desktopIntegration: ProxyDesktopIntegration = {}; private networkCaptureEnabled = false; private networkCaptures: ProxyNetworkCaptureRecord[] = []; private secureServers = new Map>(); @@ -134,6 +140,10 @@ class ProxyService { }; private upstreamProxy?: UpstreamProxyConfig; + setDesktopIntegration(integration: ProxyDesktopIntegration): void { + this.desktopIntegration = integration; + } + async start(config: AppConfig): Promise { await this.stop(); this.config = config; @@ -375,7 +385,7 @@ class ProxyService { async installCertificate(): Promise { ensureProxyCertificateAuthority(); if (process.platform === "darwin") { - const approved = await requestMacosCertificateInstallPermission(); + const approved = await this.requestMacosCertificateInstallPermission(); if (!approved) { const status = await this.getCertificateStatus(); return { @@ -398,7 +408,7 @@ class ProxyService { const installerFile = await openMacosTerminalCertificateInstaller(); terminalMessage = ` Opened Terminal installer: ${installerFile}`; } catch (terminalError) { - electron.shell?.showItemInFolder?.(PROXY_CA_CERT_FILE); + await this.revealFile(PROXY_CA_CERT_FILE).catch(() => undefined); terminalMessage = ` Could not open Terminal installer: ${formatError(terminalError)}`; } const status = await this.getCertificateStatus(); @@ -455,6 +465,18 @@ class ProxyService { }; } + private async requestMacosCertificateInstallPermission(): Promise { + return await (this.desktopIntegration.requestMacosCertificateInstallPermission?.() ?? Promise.resolve(true)); + } + + private async revealFile(file: string): Promise { + if (this.desktopIntegration.revealFile) { + await this.desktopIntegration.revealFile(file); + return; + } + await revealFileWithSystemCommand(file); + } + async getCertificateStatus(): Promise { const base = { caCertFile: proxyCaCertFile(), @@ -1469,25 +1491,6 @@ function execFilePromise(file: string, args: string[]): Promise { }); } -async function requestMacosCertificateInstallPermission(): Promise { - if (!electron.dialog || !electron.BrowserWindow) { - return true; - } - const window = electron.BrowserWindow.getFocusedWindow() ?? electron.BrowserWindow.getAllWindows()[0]; - const options = { - buttons: ["Continue", "Cancel"], - cancelId: 1, - defaultId: 0, - detail: - "macOS will ask for administrator credentials. This is required so Chrome can trust HTTPS certificates generated by CCR proxy mode.", - message: "Install CCR Proxy CA into the macOS System keychain?", - noLink: true, - type: "warning" as const - }; - const result = window ? await electron.dialog.showMessageBox(window, options) : await electron.dialog.showMessageBox(options); - return result.response === 0; -} - function execFileText(file: string, args: string[]): Promise { return new Promise((resolve, reject) => { execFile(file, args, { maxBuffer: 8 * 1024 * 1024, windowsHide: process.platform === "win32" }, (error, stdout, stderr) => { @@ -1555,17 +1558,22 @@ async function openMacosTerminalCertificateInstaller(): Promise { const installerFile = path.join(os.tmpdir(), `ccr-install-proxy-ca-${randomUUID()}.command`); writeFileSync(installerFile, `${macosTerminalCertificateInstallScript()}\n`, "utf8"); chmodSync(installerFile, 0o700); - if (electron.shell?.openPath) { - const errorMessage = await electron.shell.openPath(installerFile); - if (errorMessage) { - throw new Error(errorMessage); - } - return installerFile; - } await execFilePromise("/usr/bin/open", [installerFile]); return installerFile; } +async function revealFileWithSystemCommand(file: string): Promise { + if (process.platform === "darwin") { + await execFilePromise("/usr/bin/open", ["-R", file]); + return; + } + if (process.platform === "win32") { + await execFilePromise("explorer.exe", ["/select,", file]); + return; + } + await execFilePromise("xdg-open", [pathToFileURL(path.dirname(file)).toString()]); +} + function macosTerminalCertificateInstallScript(): string { return [ "#!/bin/zsh",