diff --git a/src/main/claude-app-gateway-service.ts b/src/main/claude-app-gateway-service.ts new file mode 100644 index 00000000..8c515856 --- /dev/null +++ b/src/main/claude-app-gateway-service.ts @@ -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 { + 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 | 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 { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 2e3a1bb0..4ce320c7 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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); diff --git a/src/main/main.ts b/src/main/main.ts index 51964150..83069783 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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 { 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 { 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}`); diff --git a/src/main/preload.ts b/src/main/preload.ts index c195ef33..2bbb1397 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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, applyProfile: () => ipcRenderer.invoke(IPC_CHANNELS.appApplyProfile) as Promise, clearProxyNetworkCaptures: () => ipcRenderer.invoke(IPC_CHANNELS.appClearProxyNetworkCaptures) as Promise, closeTray: () => ipcRenderer.invoke(IPC_CHANNELS.appCloseTray) as Promise, diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index 3765db13..7cd4b413 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -453,7 +453,9 @@ const appCopy: Record = { "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("onboarding"); const [onboardingStep, setOnboardingStep] = useState(() => getDefaultOnboardingStep(fallbackConfig)); @@ -1704,7 +1708,7 @@ function App() { const [proxyCertificateStatus, setProxyCertificateStatus] = useState(fallbackProxyCertificateStatus); const [proxyNetworkSnapshot, setProxyNetworkSnapshot] = useState(fallbackProxyNetworkSnapshot); const [proxyStatus, setProxyStatus] = useState(fallbackProxyStatus); - const [actionBusy, setActionBusy] = useState<"" | "browser" | "cert" | "proxy">(""); + const [actionBusy, setActionBusy] = useState(""); 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 ( +
+
+
{t("Claude App Gateway")}
+
{claudeAppEndpoint}
+
+ +
+ {config.proxy.enabled || !proxyCertificateStatus.trusted ? (
@@ -6459,14 +6506,15 @@ function ServerView({ ) : null}
- {actionError || actionMessage ? ( -
- {actionError || actionMessage} -
- ) : null} +
+ ) : null} + + {actionError || actionMessage ? ( +
+ {actionError || actionMessage}
) : null} diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index f2598d92..85667ebb 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -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; applyProfile: () => Promise; clearProxyNetworkCaptures: () => Promise; closeTray: () => Promise; diff --git a/src/shared/app.ts b/src/shared/app.ts index 8276e049..a48dadf9 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -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; diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 8e0b17f1..db5e1bc6 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -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",