Add Claude App gateway setup flow

This commit is contained in:
musistudio
2026-06-17 20:24:44 +08:00
parent c61efcac99
commit d052a4c952
8 changed files with 510 additions and 18 deletions
+384
View File
@@ -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
View File
@@ -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);
+11
View File
@@ -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}`);
+2
View File
@@ -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>,
+58 -10
View File
@@ -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>
+2
View File
@@ -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>;
+11
View File
@@ -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;
+1
View File
@@ -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",