mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
Add Claude App gateway setup flow
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import { app } from "electron";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { saveAppConfig } from "./config";
|
||||
import { CONFIGDIR } from "./constants";
|
||||
import type { ApiKeyConfig, AppConfig, ClaudeAppGatewayApplyResult } from "../shared/app";
|
||||
|
||||
const CLAUDE_APP_CONFIG_ID = "8f69f2f1-3275-4ad8-9317-4aa7e972f311";
|
||||
const CLAUDE_APP_CONFIG_NAME = "Claude Code Router";
|
||||
const CLAUDE_APP_CONFIG_FILE = "claude_desktop_config.json";
|
||||
const CLAUDE_APP_CONFIG_LIBRARY_DIR = "configLibrary";
|
||||
const CLAUDE_APP_CONFIG_META_FILE = "_meta.json";
|
||||
const CLAUDE_APP_FALLBACK_MODEL = "claude-sonnet-4-5";
|
||||
const CLAUDE_APP_GATEWAY_BACKUP_FILE = path.join(CONFIGDIR, "claude-app-gateway-backup.json");
|
||||
|
||||
type ClaudeAppGatewayConfig = {
|
||||
inferenceCredentialKind: "static";
|
||||
inferenceGatewayApiKey: string;
|
||||
inferenceGatewayAuthScheme: "x-api-key";
|
||||
inferenceGatewayBaseUrl: string;
|
||||
inferenceModels: Array<{ name: string }>;
|
||||
inferenceProvider: "gateway";
|
||||
modelDiscoveryEnabled: false;
|
||||
unstableDisableModelVerification: true;
|
||||
};
|
||||
|
||||
type ClaudeAppApplyState = {
|
||||
apiKey: string;
|
||||
apiKeyGenerated: boolean;
|
||||
config: AppConfig;
|
||||
};
|
||||
|
||||
type ClaudeAppGatewayPaths = {
|
||||
configLibraryFile: string;
|
||||
dataDir: string;
|
||||
libraryDir: string;
|
||||
metaFile: string;
|
||||
rootConfigFile: string;
|
||||
};
|
||||
|
||||
type ClaudeAppGatewayFileSnapshot = {
|
||||
content?: string;
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
type ClaudeAppGatewayBackup = {
|
||||
configLibraryFile: ClaudeAppGatewayFileSnapshot;
|
||||
createdAt: string;
|
||||
metaFile: ClaudeAppGatewayFileSnapshot;
|
||||
rootConfigFile: ClaudeAppGatewayFileSnapshot;
|
||||
version: 1;
|
||||
};
|
||||
|
||||
export type ClaudeAppGatewaySyncResult = {
|
||||
config: AppConfig;
|
||||
configChanged: boolean;
|
||||
result: ClaudeAppGatewayApplyResult;
|
||||
};
|
||||
|
||||
export async function syncClaudeAppGatewayConfig(config: AppConfig): Promise<ClaudeAppGatewaySyncResult> {
|
||||
const applied = applyClaudeAppGatewayConfig(config);
|
||||
if (applied.config === config) {
|
||||
return {
|
||||
config,
|
||||
configChanged: false,
|
||||
result: applied.result
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
config: await saveAppConfig(applied.config),
|
||||
configChanged: true,
|
||||
result: applied.result
|
||||
};
|
||||
}
|
||||
|
||||
export function applyClaudeAppGatewayConfig(config: AppConfig): { config: AppConfig; result: ClaudeAppGatewayApplyResult } {
|
||||
const state = ensureClaudeAppGatewayState(config);
|
||||
const paths = getClaudeAppGatewayPaths();
|
||||
const endpoint = gatewayEndpoint(state.config);
|
||||
const model = inferClaudeAppGatewayModel(state.config);
|
||||
const gatewayConfig: ClaudeAppGatewayConfig = {
|
||||
inferenceCredentialKind: "static",
|
||||
inferenceGatewayApiKey: state.apiKey,
|
||||
inferenceGatewayAuthScheme: "x-api-key",
|
||||
inferenceGatewayBaseUrl: endpoint,
|
||||
inferenceModels: [{ name: model }],
|
||||
inferenceProvider: "gateway",
|
||||
modelDiscoveryEnabled: false,
|
||||
unstableDisableModelVerification: true
|
||||
};
|
||||
|
||||
backupClaudeAppGatewayConfig(paths);
|
||||
mkdirSync(paths.libraryDir, { mode: 0o700, recursive: true });
|
||||
writeJsonFile(paths.configLibraryFile, gatewayConfig);
|
||||
applyClaudeAppConfigMeta(paths.metaFile);
|
||||
applyClaudeAppDeploymentMode(paths.rootConfigFile);
|
||||
|
||||
return {
|
||||
config: state.config,
|
||||
result: {
|
||||
apiKeyGenerated: state.apiKeyGenerated,
|
||||
configFile: paths.rootConfigFile,
|
||||
configLibraryFile: paths.configLibraryFile,
|
||||
dataDir: paths.dataDir,
|
||||
endpoint,
|
||||
message: `Claude App is configured for CCR gateway at ${endpoint}. Restart Claude App if it is already open.`,
|
||||
model,
|
||||
requiresRestart: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreClaudeAppGatewayConfig(): void {
|
||||
const backup = readClaudeAppGatewayBackup();
|
||||
if (!backup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = getClaudeAppGatewayPaths();
|
||||
restoreFileSnapshot(paths.rootConfigFile, backup.rootConfigFile);
|
||||
restoreFileSnapshot(paths.metaFile, backup.metaFile);
|
||||
restoreFileSnapshot(paths.configLibraryFile, backup.configLibraryFile);
|
||||
rmSync(CLAUDE_APP_GATEWAY_BACKUP_FILE, { force: true });
|
||||
}
|
||||
|
||||
function ensureClaudeAppGatewayState(config: AppConfig): ClaudeAppApplyState {
|
||||
const currentApiKey = findReusableApiKey(config);
|
||||
const gatewayEnabledConfig = config.gateway.enabled
|
||||
? config
|
||||
: {
|
||||
...config,
|
||||
gateway: {
|
||||
...config.gateway,
|
||||
enabled: true
|
||||
}
|
||||
};
|
||||
|
||||
if (currentApiKey) {
|
||||
return {
|
||||
apiKey: currentApiKey,
|
||||
apiKeyGenerated: false,
|
||||
config: gatewayEnabledConfig
|
||||
};
|
||||
}
|
||||
|
||||
const generatedApiKey: ApiKeyConfig = {
|
||||
createdAt: new Date().toISOString(),
|
||||
id: randomUUID(),
|
||||
key: `ccr-${randomBytes(24).toString("hex")}`,
|
||||
name: "Claude App"
|
||||
};
|
||||
|
||||
return {
|
||||
apiKey: generatedApiKey.key,
|
||||
apiKeyGenerated: true,
|
||||
config: {
|
||||
...gatewayEnabledConfig,
|
||||
APIKEY: generatedApiKey.key,
|
||||
APIKEYS: [...(Array.isArray(gatewayEnabledConfig.APIKEYS) ? gatewayEnabledConfig.APIKEYS : []), generatedApiKey]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function findReusableApiKey(config: AppConfig): string {
|
||||
const apiKeys = Array.isArray(config.APIKEYS) ? config.APIKEYS : [];
|
||||
for (const apiKey of apiKeys) {
|
||||
const key = apiKey.key.trim();
|
||||
if (key) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return config.APIKEY.trim();
|
||||
}
|
||||
|
||||
function getClaudeAppGatewayPaths(): ClaudeAppGatewayPaths {
|
||||
const dataDir = getClaudeApp3pDataDir();
|
||||
const libraryDir = path.join(dataDir, CLAUDE_APP_CONFIG_LIBRARY_DIR);
|
||||
return {
|
||||
configLibraryFile: path.join(libraryDir, `${CLAUDE_APP_CONFIG_ID}.json`),
|
||||
dataDir,
|
||||
libraryDir,
|
||||
metaFile: path.join(libraryDir, CLAUDE_APP_CONFIG_META_FILE),
|
||||
rootConfigFile: path.join(dataDir, CLAUDE_APP_CONFIG_FILE)
|
||||
};
|
||||
}
|
||||
|
||||
function getClaudeApp3pDataDir(): string {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(app.getPath("home"), "Library", "Application Support", "Claude-3p");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(app.getPath("appData"), "..", "Local");
|
||||
return path.join(localAppData, "Claude-3p");
|
||||
}
|
||||
return path.join(app.getPath("appData") || os.homedir(), "Claude-3p");
|
||||
}
|
||||
|
||||
function backupClaudeAppGatewayConfig(paths: ClaudeAppGatewayPaths): void {
|
||||
if (existsSync(CLAUDE_APP_GATEWAY_BACKUP_FILE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backup: ClaudeAppGatewayBackup = {
|
||||
configLibraryFile: readFileSnapshot(paths.configLibraryFile),
|
||||
createdAt: new Date().toISOString(),
|
||||
metaFile: readFileSnapshot(paths.metaFile),
|
||||
rootConfigFile: readFileSnapshot(paths.rootConfigFile),
|
||||
version: 1
|
||||
};
|
||||
writeJsonFile(CLAUDE_APP_GATEWAY_BACKUP_FILE, backup);
|
||||
}
|
||||
|
||||
function readClaudeAppGatewayBackup(): ClaudeAppGatewayBackup | undefined {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(CLAUDE_APP_GATEWAY_BACKUP_FILE, "utf8"));
|
||||
if (!isPlainRecord(parsed) || parsed.version !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
const rootConfigFile = normalizeFileSnapshot(parsed.rootConfigFile);
|
||||
const metaFile = normalizeFileSnapshot(parsed.metaFile);
|
||||
const configLibraryFile = normalizeFileSnapshot(parsed.configLibraryFile);
|
||||
if (!rootConfigFile || !metaFile || !configLibraryFile) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
configLibraryFile,
|
||||
createdAt: stringValue(parsed.createdAt) || new Date(0).toISOString(),
|
||||
metaFile,
|
||||
rootConfigFile,
|
||||
version: 1
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readFileSnapshot(file: string): ClaudeAppGatewayFileSnapshot {
|
||||
if (!existsSync(file)) {
|
||||
return { exists: false };
|
||||
}
|
||||
return {
|
||||
content: readFileSync(file, "utf8"),
|
||||
exists: true
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileSnapshot(value: unknown): ClaudeAppGatewayFileSnapshot | undefined {
|
||||
if (!isPlainRecord(value) || typeof value.exists !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
if (!value.exists) {
|
||||
return { exists: false };
|
||||
}
|
||||
return typeof value.content === "string"
|
||||
? { content: value.content, exists: true }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function restoreFileSnapshot(file: string, snapshot: ClaudeAppGatewayFileSnapshot): void {
|
||||
if (!snapshot.exists) {
|
||||
rmSync(file, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, snapshot.content ?? "", { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
chmodSync(file, 0o600);
|
||||
} catch {
|
||||
// File permissions are best-effort across platforms.
|
||||
}
|
||||
}
|
||||
|
||||
function gatewayEndpoint(config: AppConfig): string {
|
||||
const rawHost = config.gateway.host || config.HOST || "127.0.0.1";
|
||||
const host = rawHost.trim() === "0.0.0.0" || rawHost.trim() === "::" ? "127.0.0.1" : rawHost.trim() || "127.0.0.1";
|
||||
const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
const port = Number.isInteger(config.gateway.port) && config.gateway.port > 0 ? config.gateway.port : config.PORT;
|
||||
return `http://${formattedHost}:${port}`;
|
||||
}
|
||||
|
||||
function inferClaudeAppGatewayModel(config: AppConfig): string {
|
||||
const routerModel = normalizeGatewayModelSelector(config.Router.default);
|
||||
if (routerModel) {
|
||||
return routerModel;
|
||||
}
|
||||
|
||||
for (const provider of Array.isArray(config.Providers) ? config.Providers : []) {
|
||||
for (const model of Array.isArray(provider.models) ? provider.models : []) {
|
||||
const modelName = stringValue(model);
|
||||
if (modelName) {
|
||||
return provider.name ? `${provider.name}/${modelName}` : modelName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CLAUDE_APP_FALLBACK_MODEL;
|
||||
}
|
||||
|
||||
function normalizeGatewayModelSelector(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const commaIndex = trimmed.indexOf(",");
|
||||
if (commaIndex > 0 && commaIndex < trimmed.length - 1) {
|
||||
const provider = trimmed.slice(0, commaIndex).trim();
|
||||
const model = trimmed.slice(commaIndex + 1).trim();
|
||||
return provider && model ? `${provider}/${model}` : "";
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function applyClaudeAppConfigMeta(metaFile: string): void {
|
||||
const current = readJsonRecord(metaFile);
|
||||
const entries = normalizeMetaEntries(current?.entries).filter((entry) => entry.id !== CLAUDE_APP_CONFIG_ID);
|
||||
entries.push({ id: CLAUDE_APP_CONFIG_ID, name: CLAUDE_APP_CONFIG_NAME });
|
||||
|
||||
writeJsonFile(metaFile, {
|
||||
...(current ?? {}),
|
||||
appliedId: CLAUDE_APP_CONFIG_ID,
|
||||
entries
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMetaEntries(value: unknown): Array<{ id: string; name: string }> {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const entries: Array<{ id: string; name: string }> = [];
|
||||
for (const item of value) {
|
||||
if (!isPlainRecord(item)) {
|
||||
continue;
|
||||
}
|
||||
const id = stringValue(item.id);
|
||||
const name = stringValue(item.name);
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
entries.push({ id, name: name || "Unnamed" });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function applyClaudeAppDeploymentMode(rootConfigFile: string): void {
|
||||
const current = readJsonRecord(rootConfigFile);
|
||||
writeJsonFile(rootConfigFile, {
|
||||
...(current ?? {}),
|
||||
deploymentMode: "3p"
|
||||
});
|
||||
}
|
||||
|
||||
function readJsonRecord(file: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
|
||||
return isPlainRecord(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonFile(file: string, value: unknown): void {
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
||||
try {
|
||||
chmodSync(file, 0o600);
|
||||
} catch {
|
||||
// File permissions are best-effort across platforms.
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
+41
-8
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { builtInBrowserService } from "./built-in-browser";
|
||||
import { syncClaudeAppGatewayConfig } from "./claude-app-gateway-service";
|
||||
import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "./config";
|
||||
import { API_KEYS_DB_FILE, APP_NAME, CONFIGDIR, CONFIG_FILE, DATADIR, GATEWAY_CONFIG_FILE, IPC_CHANNELS, ONBOARDING_FINISHED_FILE, PROXY_CA_CERT_FILE, REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "./constants";
|
||||
import { deepLinkService } from "./deep-link";
|
||||
@@ -109,6 +110,31 @@ ipcMain.handle(IPC_CHANNELS.appOpenExternal, async (_event, url: string) => {
|
||||
}
|
||||
await shell.openExternal(parsed.toString());
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appApplyClaudeAppGateway, async (_event, config?: AppConfig) => {
|
||||
const previousConfig = await loadAppConfig();
|
||||
const baseConfig = config ? await saveAppConfig(config) : previousConfig;
|
||||
const synced = await syncClaudeAppGatewayConfig(baseConfig);
|
||||
const savedConfig = synced.config;
|
||||
let runtimeStatus = gatewayService.getStatus();
|
||||
|
||||
if (synced.configChanged || shouldRestartForRuntimeChange(previousConfig, savedConfig) || runtimeStatus.state !== "running") {
|
||||
runtimeStatus = await gatewayService.start(savedConfig);
|
||||
} else {
|
||||
gatewayService.updateConfig(savedConfig);
|
||||
}
|
||||
|
||||
await builtInBrowserService.syncProxy(savedConfig);
|
||||
await trayController.refreshIconFromConfig(savedConfig);
|
||||
|
||||
const gatewayDetail = runtimeStatus.state === "running"
|
||||
? "CCR gateway is running."
|
||||
: `CCR gateway did not start: ${runtimeStatus.lastError || "unknown error"}`;
|
||||
const apiKeyDetail = synced.result.apiKeyGenerated ? "Generated a Claude App API key." : "Reused an existing CCR API key.";
|
||||
return {
|
||||
...synced.result,
|
||||
message: `${synced.result.message}\n${gatewayDetail}\n${apiKeyDetail}`
|
||||
};
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appApplyProfile, async () => {
|
||||
const config = await loadAppConfig();
|
||||
return applyProfileConfig(config);
|
||||
@@ -131,9 +157,11 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig) =>
|
||||
throw new Error(certificateStatus.message);
|
||||
}
|
||||
}
|
||||
const savedConfig = await saveAppConfig(config);
|
||||
let savedConfig = await saveAppConfig(config);
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig);
|
||||
savedConfig = syncedClaudeAppConfig.config;
|
||||
let runtimeStatus = gatewayService.getStatus();
|
||||
if (shouldRestartForRuntimeChange(previousConfig, savedConfig)) {
|
||||
if (syncedClaudeAppConfig.configChanged || shouldRestartForRuntimeChange(previousConfig, savedConfig)) {
|
||||
runtimeStatus = await gatewayService.start(savedConfig);
|
||||
} else {
|
||||
gatewayService.updateConfig(savedConfig);
|
||||
@@ -145,9 +173,11 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig) =>
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appSaveApiKeys, async (_event, apiKeys: ApiKeyConfig[]) => {
|
||||
const savedConfig = await saveApiKeysConfig(apiKeys);
|
||||
gatewayService.updateConfig(savedConfig);
|
||||
logProfileApplyResult(await applyProfileConfig(savedConfig));
|
||||
return savedConfig;
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig);
|
||||
const nextConfig = syncedClaudeAppConfig.config;
|
||||
gatewayService.updateConfig(nextConfig);
|
||||
logProfileApplyResult(await applyProfileConfig(nextConfig));
|
||||
return nextConfig;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appSetOnboardingFinished, () => {
|
||||
mkdirSync(CONFIGDIR, { recursive: true });
|
||||
@@ -155,14 +185,16 @@ ipcMain.handle(IPC_CHANNELS.appSetOnboardingFinished, () => {
|
||||
return true;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appRestartGateway, async () => {
|
||||
const config = await loadAppConfig();
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
await applyProfileIfServiceRunning(config, status);
|
||||
await builtInBrowserService.syncProxy(config);
|
||||
return status;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appStartGateway, async () => {
|
||||
const config = await loadAppConfig();
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
await applyProfileIfServiceRunning(config, status);
|
||||
await builtInBrowserService.syncProxy(config);
|
||||
@@ -181,7 +213,8 @@ ipcMain.handle(IPC_CHANNELS.appShowMainWindow, () => {
|
||||
windowsManager.showMainWindow();
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.appRestartProxy, async () => {
|
||||
const config = await loadAppConfig();
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig());
|
||||
const config = syncedClaudeAppConfig.config;
|
||||
const status = await gatewayService.start(config);
|
||||
await applyProfileIfServiceRunning(config, status);
|
||||
await builtInBrowserService.syncProxy(config);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app } from "electron";
|
||||
import { loadAppConfig } from "./config";
|
||||
import { restoreClaudeAppGatewayConfig, syncClaudeAppGatewayConfig } from "./claude-app-gateway-service";
|
||||
import { deepLinkService } from "./deep-link";
|
||||
import { gatewayService } from "./gateway/service";
|
||||
import "./ipc";
|
||||
@@ -99,6 +100,11 @@ function stopServicesForQuit(): Promise<void> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -109,6 +115,11 @@ 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}`);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AppConfig,
|
||||
AppInfo,
|
||||
ApiKeyConfig,
|
||||
ClaudeAppGatewayApplyResult,
|
||||
GatewayProviderProbeRequest,
|
||||
GatewayProviderProbeResult,
|
||||
GatewayStatus,
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
} from "../shared/app";
|
||||
|
||||
contextBridge.exposeInMainWorld("ccr", {
|
||||
applyClaudeAppGateway: (config?: AppConfig) => ipcRenderer.invoke(IPC_CHANNELS.appApplyClaudeAppGateway, config) as Promise<ClaudeAppGatewayApplyResult>,
|
||||
applyProfile: () => ipcRenderer.invoke(IPC_CHANNELS.appApplyProfile) as Promise<ProfileApplyResult>,
|
||||
clearProxyNetworkCaptures: () => ipcRenderer.invoke(IPC_CHANNELS.appClearProxyNetworkCaptures) as Promise<ProxyNetworkSnapshot>,
|
||||
closeTray: () => ipcRenderer.invoke(IPC_CHANNELS.appCloseTray) as Promise<void>,
|
||||
|
||||
@@ -453,7 +453,9 @@ const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Claude Design": "Claude Design",
|
||||
"Claude Design model": "Claude Design 模型",
|
||||
"Claude Design routes": "Claude Design 路由",
|
||||
"Claude App Gateway": "Claude App 网关",
|
||||
"Configure": "配置",
|
||||
"Configure Claude App": "配置 Claude App",
|
||||
"Configure provider": "配置供应商",
|
||||
"Configure Extension": "配置扩展",
|
||||
"Configure extension": "配置扩展",
|
||||
@@ -1693,6 +1695,8 @@ type AppToast = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type ServerActionBusy = "" | "browser" | "cert" | "proxy" | "claude-app";
|
||||
|
||||
function App() {
|
||||
const [activeView, setActiveView] = useState<ViewId>("onboarding");
|
||||
const [onboardingStep, setOnboardingStep] = useState<OnboardingStepId>(() => getDefaultOnboardingStep(fallbackConfig));
|
||||
@@ -1704,7 +1708,7 @@ function App() {
|
||||
const [proxyCertificateStatus, setProxyCertificateStatus] = useState<ProxyCertificateStatus>(fallbackProxyCertificateStatus);
|
||||
const [proxyNetworkSnapshot, setProxyNetworkSnapshot] = useState<ProxyNetworkSnapshot>(fallbackProxyNetworkSnapshot);
|
||||
const [proxyStatus, setProxyStatus] = useState<ProxyStatus>(fallbackProxyStatus);
|
||||
const [actionBusy, setActionBusy] = useState<"" | "browser" | "cert" | "proxy">("");
|
||||
const [actionBusy, setActionBusy] = useState<ServerActionBusy>("");
|
||||
const [gatewayActionBusy, setGatewayActionBusy] = useState(false);
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
@@ -3215,6 +3219,34 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyClaudeAppGateway() {
|
||||
if (!window.ccr?.applyClaudeAppGateway) {
|
||||
setActionError("Claude App setup is available in the Electron app.");
|
||||
return;
|
||||
}
|
||||
|
||||
autoSaveRequestId.current += 1;
|
||||
setActionBusy("claude-app");
|
||||
setActionError("");
|
||||
setActionMessage("");
|
||||
try {
|
||||
const result = await window.ccr.applyClaudeAppGateway(draftConfig);
|
||||
const [saved, status, nextProxyStatus] = await Promise.all([
|
||||
window.ccr.getConfig(),
|
||||
window.ccr.getGatewayStatus(),
|
||||
window.ccr.getProxyStatus()
|
||||
]);
|
||||
syncConfigState(saved);
|
||||
setGatewayStatus(status);
|
||||
setProxyStatus(nextProxyStatus);
|
||||
setActionMessage(result.message);
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function completeOnboarding() {
|
||||
if (window.ccr) {
|
||||
try {
|
||||
@@ -3747,6 +3779,7 @@ function App() {
|
||||
actionBusy={actionBusy}
|
||||
actionError={actionError}
|
||||
actionMessage={actionMessage}
|
||||
applyClaudeAppGateway={() => void applyClaudeAppGateway()}
|
||||
config={draftConfig}
|
||||
installProxyCertificate={installProxyCertificate}
|
||||
onProxyEnabledChange={(checked) => void setProxyEnabled(checked)}
|
||||
@@ -6313,6 +6346,7 @@ function ServerView({
|
||||
actionBusy,
|
||||
actionError,
|
||||
actionMessage,
|
||||
applyClaudeAppGateway,
|
||||
config,
|
||||
installProxyCertificate,
|
||||
onProxyEnabledChange,
|
||||
@@ -6325,9 +6359,10 @@ function ServerView({
|
||||
restartProxy,
|
||||
updateConfig
|
||||
}: {
|
||||
actionBusy: "" | "browser" | "cert" | "proxy";
|
||||
actionBusy: ServerActionBusy;
|
||||
actionError: string;
|
||||
actionMessage: string;
|
||||
applyClaudeAppGateway: () => void;
|
||||
config: AppConfig;
|
||||
installProxyCertificate: () => void;
|
||||
onProxyEnabledChange: (checked: boolean) => void;
|
||||
@@ -6342,6 +6377,7 @@ function ServerView({
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const trustSteps = proxyCertificateTrustSteps(proxyCertificateStatus);
|
||||
const claudeAppEndpoint = endpointFromHostPort(config.gateway.host || config.HOST, config.gateway.port || config.PORT);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -6415,6 +6451,17 @@ function ServerView({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-medium">{t("Claude App Gateway")}</div>
|
||||
<div className="break-all font-mono text-[11px] text-muted-foreground">{claudeAppEndpoint}</div>
|
||||
</div>
|
||||
<Button disabled={Boolean(actionBusy)} onClick={applyClaudeAppGateway} size="sm" type="button" variant="outline">
|
||||
{actionBusy === "claude-app" ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <Route className="h-3.5 w-3.5" />}
|
||||
{t("Configure Claude App")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{config.proxy.enabled || !proxyCertificateStatus.trusted ? (
|
||||
<div className="space-y-3 rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -6459,14 +6506,15 @@ function ServerView({
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{actionError || actionMessage ? (
|
||||
<div className={cn(
|
||||
"whitespace-pre-wrap rounded-lg border px-3 py-2 text-[12px]",
|
||||
actionError ? "border-destructive/30 bg-destructive/5 text-destructive" : "border-border/60 bg-background/80 text-muted-foreground"
|
||||
)}>
|
||||
{actionError || actionMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError || actionMessage ? (
|
||||
<div className={cn(
|
||||
"whitespace-pre-wrap rounded-lg border px-3 py-2 text-[12px]",
|
||||
actionError ? "border-destructive/30 bg-destructive/5 text-destructive" : "border-border/60 bg-background/80 text-muted-foreground"
|
||||
)}>
|
||||
{actionError || actionMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
Vendored
+2
@@ -6,6 +6,7 @@ import type {
|
||||
AppConfig,
|
||||
AppInfo,
|
||||
ApiKeyConfig,
|
||||
ClaudeAppGatewayApplyResult,
|
||||
GatewayProviderProbeRequest,
|
||||
GatewayProviderProbeResult,
|
||||
GatewayStatus,
|
||||
@@ -30,6 +31,7 @@ import type {
|
||||
declare global {
|
||||
interface Window {
|
||||
ccr?: {
|
||||
applyClaudeAppGateway: (config?: AppConfig) => Promise<ClaudeAppGatewayApplyResult>;
|
||||
applyProfile: () => Promise<ProfileApplyResult>;
|
||||
clearProxyNetworkCaptures: () => Promise<ProxyNetworkSnapshot>;
|
||||
closeTray: () => Promise<void>;
|
||||
|
||||
@@ -598,6 +598,17 @@ export type AppConfig = {
|
||||
virtualModelProfiles?: VirtualModelProfileConfig[];
|
||||
};
|
||||
|
||||
export type ClaudeAppGatewayApplyResult = {
|
||||
apiKeyGenerated: boolean;
|
||||
configFile: string;
|
||||
configLibraryFile: string;
|
||||
dataDir: string;
|
||||
endpoint: string;
|
||||
message: string;
|
||||
model: string;
|
||||
requiresRestart: boolean;
|
||||
};
|
||||
|
||||
export type GatewayStatus = {
|
||||
coreEndpoint: string;
|
||||
coreManagedExternally?: boolean;
|
||||
|
||||
@@ -17,6 +17,7 @@ export const IPC_CHANNELS = {
|
||||
appInstallProxyCertificate: "ccr:app:install-proxy-certificate",
|
||||
appOpenBuiltInBrowser: "ccr:app:open-built-in-browser",
|
||||
appOpenExternal: "ccr:app:open-external",
|
||||
appApplyClaudeAppGateway: "ccr:app:apply-claude-app-gateway",
|
||||
appApplyProfile: "ccr:app:apply-profile",
|
||||
appProbeProvider: "ccr:app:probe-provider",
|
||||
appProviderDeepLink: "ccr:app:provider-deep-link",
|
||||
|
||||
Reference in New Issue
Block a user