mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Refactor router config and provider handling
This commit is contained in:
parent
81882616b9
commit
1534059fd6
18 changed files with 1148 additions and 374 deletions
106
.github/workflows/release.yml
vendored
106
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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_<version>.dmg` or `.zip`
|
||||
- macOS Apple Silicon: `Claude-Code-Router_<version>-mac-Apple-Silicon-arm64.dmg` or `.zip`
|
||||
- macOS Intel: `Claude-Code-Router_<version>-mac-Intel-x64.dmg` or `.zip`
|
||||
- Windows: `Claude Code Router_<version>.exe`
|
||||
- Linux: `Claude Code Router_<version>.AppImage`
|
||||
3. Install and launch **Claude Code Router**.
|
||||
|
|
|
|||
|
|
@ -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_<version>.dmg` 或 `.zip`
|
||||
- macOS Apple 芯片:`Claude-Code-Router_<version>-mac-Apple-Silicon-arm64.dmg` 或 `.zip`
|
||||
- macOS Intel 芯片:`Claude-Code-Router_<version>-mac-Intel-x64.dmg` 或 `.zip`
|
||||
- Windows:`Claude Code Router_<version>.exe`
|
||||
- Linux:`Claude Code Router_<version>.AppImage`
|
||||
3. 安装并启动 **Claude Code Router**。
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
78
build/merge-macos-update-metadata.mjs
Normal file
78
build/merge-macos-update-metadata.mjs
Normal file
|
|
@ -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 <output> <latest-mac.yml> <latest-mac.yml> [...]");
|
||||
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;
|
||||
}
|
||||
65
src/main/app-paths.ts
Normal file
65
src/main/app-paths.ts
Normal file
|
|
@ -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<Record<RuntimePathName, string>>;
|
||||
|
||||
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");
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
|
||||
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<void> {
|
|||
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<void> {
|
|||
];
|
||||
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<void> {
|
|||
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<void> {
|
||||
const runtime = await startWebManagementServer({
|
||||
host: options.host,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
? []
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
246
src/main/main-app.ts
Normal file
246
src/main/main-app.ts
Normal file
|
|
@ -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<void> | undefined;
|
||||
let startServicesPromise: Promise<void> | undefined;
|
||||
let stopForQuitPromise: Promise<void> | 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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<typeof proxyService.getStatus>): 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);
|
||||
}
|
||||
219
src/main/main.ts
219
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<void> | undefined;
|
||||
let startServicesPromise: Promise<void> | undefined;
|
||||
let stopForQuitPromise: Promise<void> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<typeof proxyService.getStatus>): 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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeo
|
|||
dataDir: resolveClaudeAppProfileUserDataDir(CONFIGDIR, profile),
|
||||
refreshModelDiscoveryCache: true
|
||||
});
|
||||
await ensureGatewayConfigRunning(profileGatewayConfig, "Claude App");
|
||||
await ensureGatewayConfigRunning(profileGatewayConfig, profile, "Claude App");
|
||||
const entry = registerProfileApp(profile, "app", await launchClaudeAppProfile(CONFIGDIR, profile, profileGatewayConfig));
|
||||
const started = await waitForProfileAppStart(entry, 12000);
|
||||
if (!started) {
|
||||
|
|
@ -179,21 +179,316 @@ async function openClaudeAppProfile(config: AppConfig, profile: ReturnType<typeo
|
|||
};
|
||||
}
|
||||
|
||||
async function ensureProfileGateway(
|
||||
export async function ensureProfileGateway(
|
||||
config: AppConfig,
|
||||
profile: ReturnType<typeof findProfileForOpen>,
|
||||
appName: string
|
||||
appName: string,
|
||||
options: { reuseExisting?: boolean; startIfMissing?: boolean } = {}
|
||||
): Promise<AppConfig> {
|
||||
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<void> {
|
||||
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<typeof findProfileForOpen>,
|
||||
appName: string,
|
||||
options: { reuseExisting?: boolean; startIfMissing?: boolean } = {},
|
||||
candidateConfig: AppConfig = config
|
||||
): Promise<EnsureGatewayConfigRunningResult> {
|
||||
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<typeof findProfileForOpen>,
|
||||
candidateConfig: AppConfig = config
|
||||
): Promise<ExistingProfileGatewayProbe> {
|
||||
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<string, string> = {
|
||||
"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<ExistingGatewayHttpProbe> {
|
||||
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<unknown> {
|
||||
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<typeof findProfileForOpen>,
|
||||
candidateConfig: AppConfig
|
||||
): Array<string | undefined> {
|
||||
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<string>();
|
||||
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<typeof findProfileForOpen>): string[] {
|
||||
const file = path.join(CONFIGDIR, "bin", claudeCodeApiKeyHelperFilename(profile));
|
||||
const files = [
|
||||
file,
|
||||
...readBackupFiles(file)
|
||||
];
|
||||
return uniqueStrings(files.map(readClaudeCodeApiKeyHelperToken));
|
||||
}
|
||||
|
||||
function claudeCodeApiKeyHelperFilename(profile: ReturnType<typeof findProfileForOpen>): 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<typeof findProfileForOpen>): AppConfig {
|
||||
|
|
@ -201,6 +496,10 @@ function profileGatewayConfigFor(config: AppConfig, profile: ReturnType<typeof f
|
|||
if (!token) {
|
||||
throw new Error(`No CCR API key was found for profile "${profile.name || profile.id}". Re-save the profile and try again.`);
|
||||
}
|
||||
return profileGatewayConfigWithToken(config, profile, token);
|
||||
}
|
||||
|
||||
function profileGatewayConfigWithToken(config: AppConfig, profile: ReturnType<typeof findProfileForOpen>, token: string): AppConfig {
|
||||
return {
|
||||
...config,
|
||||
APIKEY: token,
|
||||
|
|
@ -779,7 +1078,7 @@ function commandProfileRef(config: AppConfig, profile: ReturnType<typeof findPro
|
|||
return duplicateName ? profile.id : name;
|
||||
}
|
||||
|
||||
function ensureCcrCliLauncher(): string {
|
||||
export function ensureCcrCliLauncher(): string {
|
||||
const binDir = path.join(CONFIGDIR, "bin");
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
|
||||
|
|
@ -819,38 +1118,73 @@ function findBundledCcrCliSource(): string {
|
|||
}
|
||||
|
||||
function posixCcrLauncher(runtimeFile: string): string {
|
||||
const nodePath = bundledNodePath();
|
||||
return [
|
||||
"#!/bin/sh",
|
||||
`CCR_CLI_NODE_PATH=${shQuote(nodePath)}`,
|
||||
'if [ -n "$NODE_PATH" ]; then',
|
||||
' export NODE_PATH="$CCR_CLI_NODE_PATH:$NODE_PATH"',
|
||||
"else",
|
||||
' export NODE_PATH="$CCR_CLI_NODE_PATH"',
|
||||
"fi",
|
||||
'if [ -n "$CCR_NODE_BIN" ]; then',
|
||||
` exec "$CCR_NODE_BIN" ${shQuote(runtimeFile)} "$@"`,
|
||||
"fi",
|
||||
"if command -v node >/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<string>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ export async function applyProfileConfig(config: AppConfig): Promise<ProfileAppl
|
|||
return result;
|
||||
}
|
||||
|
||||
export function applyProfileRuntimeConfig(config: AppConfig, profile: ProfileConfig, token: string): ProfileClientApplyStatus {
|
||||
const appliedAt = new Date().toISOString();
|
||||
return profile.agent === "claude-code"
|
||||
? applyClaudeCodeProfile(config, profile, token, appliedAt)
|
||||
: profile.agent === "zcode"
|
||||
? applyZcodeProfile(config, profile, token, appliedAt)
|
||||
: applyCodexProfile(config, profile, token, appliedAt);
|
||||
}
|
||||
|
||||
function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus {
|
||||
const settingsFile = resolveClaudeCodeSettingsFile(profile);
|
||||
if (!profile.enabled) {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { providerApiKeySafetyIssue } from "../../main/presets";
|
|||
import { normalizeProviderBaseUrl as normalizeProviderBaseUrlInput } from "../../shared/provider-url";
|
||||
import { backendService } from "../backend-service";
|
||||
import { RAW_TRACE_SPOOL_DIR } from "../../main/constants";
|
||||
import { loadPersistedApiKeys } from "../../main/api-key-store";
|
||||
import { codexDefaultBaseUrl, readCodexAuth } from "../../main/local-agent-provider-service";
|
||||
import { fetchWithSystemProxy, getSystemProxyUrlForProtocol } from "../../main/system-proxy-fetch";
|
||||
import { handleNetworkCaptureMcpRequest, isNetworkCaptureMcpPath } from "../mcp/network-capture-mcp";
|
||||
|
|
@ -112,6 +113,7 @@ type ManagedGatewayRuntimeMarker = {
|
|||
};
|
||||
|
||||
type ApiKeyWindowCounter = {
|
||||
expiresAt: number;
|
||||
value: number;
|
||||
windowStart: number;
|
||||
};
|
||||
|
|
@ -196,6 +198,7 @@ const responseHeaderDenyList = new Set(["connection", "content-encoding", "trans
|
|||
const maxUsageCaptureBytes = 8 * 1024 * 1024;
|
||||
const maxPendingRawTraceUpdates = 200;
|
||||
const pendingRawTraceMaxAgeMs = 5 * 60 * 1000;
|
||||
const apiKeyLimitCounterRetentionWindows = 2;
|
||||
const gatewayRuntimeMarkerFile = "gateway-runtime.json";
|
||||
const rawTraceSyncHeader = "x-ccr-raw-trace-token";
|
||||
let warnedMissingCursorOpenAICompatContext = false;
|
||||
|
|
@ -216,6 +219,8 @@ const gatewayProviderProtocolFallbackOrder: GatewayProviderProtocol[] = [
|
|||
];
|
||||
const privateDirMode = 0o700;
|
||||
const privateFileMode = 0o600;
|
||||
const persistedApiKeyCacheTtlMs = 1000;
|
||||
let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined;
|
||||
|
||||
class GatewayService {
|
||||
private child?: ChildProcess;
|
||||
|
|
@ -430,7 +435,7 @@ class GatewayService {
|
|||
}
|
||||
|
||||
if (path === ccrRemoteControlPathPrefix || path.startsWith(`${ccrRemoteControlPathPrefix}/`)) {
|
||||
const authorization = authorize(request, response, this.config);
|
||||
const authorization = await authorize(request, response, this.config);
|
||||
if (!authorization.ok) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -450,7 +455,7 @@ class GatewayService {
|
|||
sendJson(response, 404, { error: { message: "Network capture MCP is disabled." } });
|
||||
return;
|
||||
}
|
||||
const authorization = authorize(request, response, this.config);
|
||||
const authorization = await authorize(request, response, this.config);
|
||||
if (!authorization.ok) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -461,7 +466,7 @@ class GatewayService {
|
|||
const pluginRoute = pluginService.matchGatewayRoute(request.method, path);
|
||||
if (pluginRoute) {
|
||||
if (pluginRoute.auth !== "none") {
|
||||
const authorization = authorize(request, response, this.config);
|
||||
const authorization = await authorize(request, response, this.config);
|
||||
if (!authorization.ok) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -496,7 +501,7 @@ class GatewayService {
|
|||
return;
|
||||
}
|
||||
|
||||
const authorization = authorize(request, response, this.config);
|
||||
const authorization = await authorize(request, response, this.config);
|
||||
if (!authorization.ok) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1681,16 +1686,11 @@ function rewriteModelSelectorForProtocol(
|
|||
return model;
|
||||
}
|
||||
const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized;
|
||||
const separator = publicModel.indexOf("/");
|
||||
if (separator <= 0) {
|
||||
return publicModel;
|
||||
}
|
||||
|
||||
const providerName = publicModel.slice(0, separator).trim();
|
||||
const targetModel = publicModel.slice(separator + 1).trim();
|
||||
const provider = findProviderByPublicOrInternalName(config, providerName);
|
||||
const capability = provider ? providerCapabilityForClientProtocol(provider, protocol) : undefined;
|
||||
return provider && capability ? `${providerCapabilityInternalName(provider, capability.type)}/${targetModel}` : publicModel;
|
||||
const selector = resolveConfiguredProviderModelSelector(publicModel, config);
|
||||
const capability = selector ? providerCapabilityForClientProtocol(selector.provider, protocol) : undefined;
|
||||
return selector && capability
|
||||
? `${providerCapabilityInternalName(selector.provider, capability.type)}/${selector.model}`
|
||||
: publicModel;
|
||||
}
|
||||
|
||||
function providerCapabilityForClientProtocol(
|
||||
|
|
@ -1891,10 +1891,12 @@ function prepareUpstreamCredentialAttempt(input: {
|
|||
method: string;
|
||||
path: string;
|
||||
}): UpstreamAttempt {
|
||||
const normalizedBody = normalizeConfiguredProviderModelBody(input.attempt.body, input.config);
|
||||
const target = resolveProviderCredentialRoutingTarget(input.config, input.headers, input.path, input.attempt.body);
|
||||
if (!target) {
|
||||
return {
|
||||
...input.attempt,
|
||||
body: normalizedBody?.body ?? input.attempt.body,
|
||||
headers: input.headers
|
||||
};
|
||||
}
|
||||
|
|
@ -1903,6 +1905,7 @@ function prepareUpstreamCredentialAttempt(input: {
|
|||
if (credentials.length === 0) {
|
||||
return {
|
||||
...input.attempt,
|
||||
body: target.body ?? normalizedBody?.body ?? input.attempt.body,
|
||||
headers: input.headers
|
||||
};
|
||||
}
|
||||
|
|
@ -1912,6 +1915,7 @@ function prepareUpstreamCredentialAttempt(input: {
|
|||
if (selection.credentials.length === 0) {
|
||||
return {
|
||||
...input.attempt,
|
||||
body: target.body ?? normalizedBody?.body ?? input.attempt.body,
|
||||
headers: input.headers
|
||||
};
|
||||
}
|
||||
|
|
@ -1929,7 +1933,7 @@ function prepareUpstreamCredentialAttempt(input: {
|
|||
|
||||
return {
|
||||
...input.attempt,
|
||||
body: target.body ?? input.attempt.body,
|
||||
body: target.body ?? normalizedBody?.body ?? input.attempt.body,
|
||||
credentialChain: selection.credentials.map((candidate) => 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<string, string>,
|
||||
|
|
@ -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, string>): 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<ApiKeyAuthorizationResult> {
|
||||
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<ApiKeyConfig[]> {
|
||||
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<ApiKeyConfig[]> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<JsonRpcResponse |
|
|||
}
|
||||
|
||||
function appVersion(): string {
|
||||
return typeof electron.app?.getVersion === "function" ? electron.app.getVersion() : packageJson.version;
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
async function callTool(params: unknown): Promise<JsonValue> {
|
||||
|
|
|
|||
|
|
@ -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<boolean>;
|
||||
revealFile?: (file: string) => Promise<void>;
|
||||
};
|
||||
|
||||
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<string, Promise<MitmServer>>();
|
||||
|
|
@ -134,6 +140,10 @@ class ProxyService {
|
|||
};
|
||||
private upstreamProxy?: UpstreamProxyConfig;
|
||||
|
||||
setDesktopIntegration(integration: ProxyDesktopIntegration): void {
|
||||
this.desktopIntegration = integration;
|
||||
}
|
||||
|
||||
async start(config: AppConfig): Promise<ProxyStatus> {
|
||||
await this.stop();
|
||||
this.config = config;
|
||||
|
|
@ -375,7 +385,7 @@ class ProxyService {
|
|||
async installCertificate(): Promise<ProxyCertificateInstallResult> {
|
||||
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<boolean> {
|
||||
return await (this.desktopIntegration.requestMacosCertificateInstallPermission?.() ?? Promise.resolve(true));
|
||||
}
|
||||
|
||||
private async revealFile(file: string): Promise<void> {
|
||||
if (this.desktopIntegration.revealFile) {
|
||||
await this.desktopIntegration.revealFile(file);
|
||||
return;
|
||||
}
|
||||
await revealFileWithSystemCommand(file);
|
||||
}
|
||||
|
||||
async getCertificateStatus(): Promise<ProxyCertificateStatus> {
|
||||
const base = {
|
||||
caCertFile: proxyCaCertFile(),
|
||||
|
|
@ -1469,25 +1491,6 @@ function execFilePromise(file: string, args: string[]): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
async function requestMacosCertificateInstallPermission(): Promise<boolean> {
|
||||
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<string> {
|
||||
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<string> {
|
|||
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<void> {
|
||||
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue