Add app update support and release publishing

This commit is contained in:
musistudio 2026-06-19 15:49:12 +08:00
parent c480a33587
commit e3ec3cabde
19 changed files with 931 additions and 68 deletions

View file

@ -277,3 +277,9 @@ npm run build:app
`npm run build` packages the app for the current platform and writes installer artifacts to `release/`.
`npm run build:app` packages both macOS and Windows artifacts with `electron-builder --mac --win`. You can also run `npm run build:app:mac` or `npm run build:app:win` for a single platform. Cross-building Windows installers from macOS may require Wine; otherwise run the Windows build command on Windows.
## Online Updates
Packaged builds check GitHub Releases for updates through `electron-updater`. The builder config publishes update metadata for `musistudio/claude-code-router`; attach the generated installer artifacts and `latest*.yml` files to each release. macOS builds include both `dmg` and `zip` targets because the updater requires the zip artifact.
For local update feed testing, set `CCR_UPDATE_FEED_URL` to a generic electron-updater feed URL before starting the app. `CCR_UPDATE_ALLOW_PRERELEASE=1` enables prerelease updates.

View file

@ -3,6 +3,13 @@
"productName": "Claude Code Router",
"asar": true,
"npmRebuild": false,
"publish": [
{
"provider": "github",
"owner": "musistudio",
"repo": "claude-code-router"
}
],
"directories": {
"output": "release/${version}"
},
@ -18,7 +25,7 @@
],
"mac": {
"icon": "build/icon.icns",
"target": ["dmg"],
"target": ["dmg", "zip"],
"artifactName": "${productName}_${version}.${ext}"
},
"win": {

59
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@the-next-ai/ai-gateway": "file:../../next-ai/gateway",
"electron-updater": "^6.8.9",
"node-forge": "^1.4.0",
"sql.js": "^1.14.1",
"undici": "^7.27.2"
@ -2554,7 +2555,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/asn1js": {
@ -2895,7 +2895,6 @@
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
"integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
@ -3703,7 +3702,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -4086,6 +4084,34 @@
"mime": "^2.5.2"
}
},
"node_modules/electron-updater": {
"version": "6.8.9",
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz",
"integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==",
"license": "MIT",
"dependencies": {
"builder-util-runtime": "9.7.0",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
"lodash.escaperegexp": "^4.1.2",
"lodash.isequal": "^4.5.0",
"semver": "~7.7.3",
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-updater/node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/electron-winstaller": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
@ -4607,7 +4633,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
@ -4808,7 +4833,6 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/grid-index": {
@ -5187,7 +5211,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
"funding": [
{
"type": "github",
@ -5263,7 +5286,6 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
@ -5300,7 +5322,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/light-my-request": {
@ -5618,6 +5639,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -5949,7 +5983,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/murmurhash-js": {
@ -7213,7 +7246,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
@ -7644,6 +7676,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@ -7805,7 +7843,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"

View file

@ -22,6 +22,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@the-next-ai/ai-gateway": "file:../../next-ai/gateway",
"electron-updater": "^6.8.9",
"node-forge": "^1.4.0",
"sql.js": "^1.14.1",
"undici": "^7.27.2"

View file

@ -5,6 +5,7 @@ import path from "node:path";
import { randomBytes, randomUUID } from "node:crypto";
import { saveAppConfig } from "./config";
import { CONFIGDIR } from "./constants";
import { buildCodexModelCatalog } from "./codex-model-catalog";
import type { ApiKeyConfig, AppConfig, ClaudeAppGatewayApplyResult } from "../shared/app";
const CLAUDE_APP_CONFIG_ID = "8f69f2f1-3275-4ad8-9317-4aa7e972f311";
@ -86,12 +87,13 @@ export function applyClaudeAppGatewayConfig(config: AppConfig, options: ClaudeAp
const paths = getClaudeAppGatewayPaths(options.dataDir);
const endpoint = gatewayEndpoint(state.config);
const model = inferClaudeAppGatewayModel(state.config);
const models = buildClaudeAppGatewayModels(state.config, model);
const gatewayConfig: ClaudeAppGatewayConfig = {
inferenceCredentialKind: "static",
inferenceGatewayApiKey: state.apiKey,
inferenceGatewayAuthScheme: "x-api-key",
inferenceGatewayBaseUrl: endpoint,
inferenceModels: [{ name: model }],
inferenceModels: models.map((name) => ({ name })),
inferenceProvider: "gateway",
modelDiscoveryEnabled: false,
unstableDisableModelVerification: true
@ -306,6 +308,14 @@ function inferClaudeAppGatewayModel(config: AppConfig): string {
return CLAUDE_APP_FALLBACK_MODEL;
}
function buildClaudeAppGatewayModels(config: AppConfig, selectedModel: string): string[] {
const models = buildCodexModelCatalog(config, selectedModel);
if (models.length > 0) {
return models;
}
return [CLAUDE_APP_FALLBACK_MODEL];
}
function normalizeGatewayModelSelector(value: unknown): string {
if (typeof value !== "string") {
return "";

View file

@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { loadPersistedApiKeys, replacePersistedApiKeys } from "./api-key-store";
import { CONFIGDIR, CONFIG_FILE, GATEWAY_CONFIG_FILE } from "./constants";
import { DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS } from "../shared/app";
import { DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent } from "../shared/app";
import { findProviderPresetByBaseUrl, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "../shared/provider-presets";
import type {
AppConfig,
@ -193,7 +193,7 @@ export async function loadAppConfig(): Promise<AppConfig> {
const configFileApiKeys = normalizeApiKeys(picked.APIKEYS, picked.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey));
const persistedApiKeys = await loadPersistedApiKeys();
const apiKeys = uniqueApiKeyConfigs([...persistedApiKeys, ...configFileApiKeys]);
const config: AppConfig = {
const config: AppConfig = withSingleEnabledGlobalProfiles({
...DEFAULT_CONFIG,
...picked,
APIKEY: apiKeys[0]?.key ?? "",
@ -240,7 +240,7 @@ export async function loadAppConfig(): Promise<AppConfig> {
targets: picked.proxy?.targets?.length ? picked.proxy.targets : DEFAULT_CONFIG.proxy.targets
},
routerEndpoint: endpoint
};
});
if (hasConfigFileApiKeys(rawValue) || configFileApiKeys.length > 0) {
await replacePersistedApiKeys(apiKeys);
writeSanitizedConfig(config);
@ -261,18 +261,29 @@ export async function loadAppConfig(): Promise<AppConfig> {
}
export async function saveAppConfig(config: AppConfig): Promise<AppConfig> {
assertProviderApiKeysAreSafe(config);
const normalizedConfig = withSingleEnabledGlobalProfiles(config);
assertProviderApiKeysAreSafe(normalizedConfig);
mkdirSync(CONFIGDIR, { recursive: true });
const apiKeys = normalizeApiKeys(config.APIKEYS, config.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey));
const apiKeys = normalizeApiKeys(normalizedConfig.APIKEYS, normalizedConfig.APIKEY).filter((apiKey) => !isDefaultSeedApiKey(apiKey));
await replacePersistedApiKeys(apiKeys);
writeSanitizedConfig({
...config,
...normalizedConfig,
APIKEY: apiKeys[0]?.key ?? "",
APIKEYS: apiKeys
});
return loadAppConfig();
}
function withSingleEnabledGlobalProfiles(config: AppConfig): AppConfig {
return {
...config,
profile: {
...config.profile,
profiles: enforceSingleEnabledGlobalProfilePerAgent(config.profile.profiles)
}
};
}
function assertProviderApiKeysAreSafe(config: AppConfig): void {
for (const provider of config.Providers ?? []) {
const apiKey = providerApiKey(provider);

View file

@ -736,10 +736,10 @@ function writeCoreGatewayConfig(config: AppConfig, rawTraceSyncToken: string): v
...(config.providerPlugins ?? []),
...pluginService.getCoreProviderPlugins()
];
const virtualModelProfiles = withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
const virtualModelProfiles = withOptimisticVirtualModelStreams(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([
...(config.virtualModelProfiles ?? []),
...pluginService.getVirtualModelProfiles()
]));
])));
const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort);
const builtinToolArtifacts = fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint);
const providers = [
@ -928,6 +928,25 @@ function withCodexCompatibleVirtualModelProfiles(profiles: unknown[]): unknown[]
});
}
function withOptimisticVirtualModelStreams(profiles: unknown[]): unknown[] {
return profiles.map((profile) => {
if (!isRecord(profile) || profile.enabled === false) {
return profile;
}
const execution = isRecord(profile.execution) ? profile.execution : {};
if (execution.streamMode === "optimistic") {
return profile;
}
return {
...profile,
execution: {
...execution,
streamMode: "optimistic"
}
};
});
}
function fusionModelSelector(model: string): string {
const normalized = fusionModelNameFromSelector(model);
return normalized ? `${fusionModelProviderName}/${normalized}` : "";

View file

@ -19,6 +19,7 @@ import { proxyService } from "./proxy/service";
import { listMcpServerTools } from "./mcp/tool-discovery";
import { getAgentAnalysis, getRequestLogs } from "./request-log-store";
import trayController from "./tray-controller";
import { appUpdateService } from "./update-service";
import { getUsageStats } from "./usage-store";
import windowsManager from "./windows";
import type { AgentAnalysisFilter, ApiKeyConfig, AppConfig, AppInfo, GatewayMcpServerConfig, GatewayPluginAppConfig, GatewayProviderProbeRequest, GatewayStatus, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProfileApplyResult, ProfileOpenRequest, ProviderAccountTestRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, RequestLogListFilter, UsageStatsFilter, UsageStatsRange } from "../shared/app";
@ -79,6 +80,7 @@ ipcMain.handle(IPC_CHANNELS.appGetProxyNetworkCaptures, () => proxyService.getNe
ipcMain.handle(IPC_CHANNELS.appGetProxyStatus, () => proxyService.getStatus());
ipcMain.handle(IPC_CHANNELS.appGetPluginMarketplace, () => pluginMarketplace);
ipcMain.handle(IPC_CHANNELS.appGetRequestLogs, (_event, filter?: RequestLogListFilter) => getRequestLogs(filter));
ipcMain.handle(IPC_CHANNELS.appGetUpdateStatus, () => appUpdateService.getStatus());
ipcMain.handle(IPC_CHANNELS.appGetUsageStats, (_event, range?: UsageStatsRange, filter?: UsageStatsFilter) => getUsageStats(range, filter));
ipcMain.handle(IPC_CHANNELS.appFetchProviderManifest, (_event, request: ProviderManifestFetchRequest) => fetchProviderManifest(request));
ipcMain.handle(IPC_CHANNELS.appInstallProxyCertificate, () => proxyService.installCertificate());
@ -163,6 +165,9 @@ ipcMain.handle(IPC_CHANNELS.appProbeProvider, (_event, request: GatewayProviderP
ipcMain.handle(IPC_CHANNELS.appTestProviderAccountConnector, (_event, request: ProviderAccountTestRequest) => {
return testProviderAccountConnector(request);
});
ipcMain.handle(IPC_CHANNELS.appUpdateCheck, () => appUpdateService.checkForUpdates());
ipcMain.handle(IPC_CHANNELS.appUpdateDownload, () => appUpdateService.downloadUpdate());
ipcMain.handle(IPC_CHANNELS.appUpdateInstall, () => appUpdateService.installUpdate());
ipcMain.handle(IPC_CHANNELS.appQuit, () => {
app.quit();
});

View file

@ -7,6 +7,7 @@ import "./ipc";
import { applyProfileConfig } from "./profile-service";
import { proxyService } from "./proxy/service";
import trayController from "./tray-controller";
import { appUpdateService } from "./update-service";
import windowsManager from "./windows";
const gotTheLock = app.requestSingleInstanceLock();
@ -36,6 +37,8 @@ function startPrimaryInstance(): void {
app.whenReady().then(() => {
windowsManager.createMainWindow();
trayController.start();
appUpdateService.start();
appUpdateService.setInstallPreparation(prepareForUpdateInstall);
void startConfiguredServices("startup");
app.on("activate", () => {
@ -45,7 +48,7 @@ function startPrimaryInstance(): void {
});
app.on("before-quit", (event) => {
if (quitPrepared) {
if (quitPrepared || appUpdateService.isInstallingUpdate()) {
return;
}
event.preventDefault();
@ -53,7 +56,7 @@ function startPrimaryInstance(): void {
});
app.on("will-quit", (event) => {
if (quitPrepared) {
if (quitPrepared || appUpdateService.isInstallingUpdate()) {
return;
}
event.preventDefault();
@ -82,6 +85,14 @@ function prepareAndQuit(): void {
});
}
async function prepareForUpdateInstall(): Promise<void> {
if (!stoppingForQuit) {
stoppingForQuit = true;
}
await stopServicesForQuit();
quitPrepared = true;
}
function handleTerminationSignal(signal: NodeJS.Signals): void {
if (stoppingForQuit) {
return;

View file

@ -5,6 +5,7 @@ import type {
AgentAnalysisSnapshot,
AppConfig,
AppInfo,
AppUpdateStatus,
ApiKeyConfig,
ClaudeAppGatewayApplyResult,
GatewayMcpServerConfig,
@ -57,6 +58,7 @@ contextBridge.exposeInMainWorld("ccr", {
getProxyNetworkCaptures: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProxyNetworkCaptures) as Promise<ProxyNetworkSnapshot>,
getProxyStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProxyStatus) as Promise<ProxyStatus>,
getRequestLogs: (filter?: RequestLogListFilter) => ipcRenderer.invoke(IPC_CHANNELS.appGetRequestLogs, filter) as Promise<RequestLogPage>,
getUpdateStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetUpdateStatus) as Promise<AppUpdateStatus>,
getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => ipcRenderer.invoke(IPC_CHANNELS.appGetUsageStats, range, filter) as Promise<UsageStatsSnapshot>,
installProxyCertificate: () => ipcRenderer.invoke(IPC_CHANNELS.appInstallProxyCertificate) as Promise<ProxyCertificateInstallResult>,
listMcpServerTools: (server: GatewayMcpServerConfig) => ipcRenderer.invoke(IPC_CHANNELS.appListMcpServerTools, server) as Promise<GatewayMcpToolInfo[]>,
@ -78,6 +80,9 @@ contextBridge.exposeInMainWorld("ccr", {
startGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStartGateway) as Promise<GatewayStatus>,
stopGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStopGateway) as Promise<GatewayStatus>,
testProviderAccountConnector: (request: ProviderAccountTestRequest) => ipcRenderer.invoke(IPC_CHANNELS.appTestProviderAccountConnector, request) as Promise<ProviderAccountTestResult>,
updateCheck: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateCheck) as Promise<AppUpdateStatus>,
updateDownload: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateDownload) as Promise<AppUpdateStatus>,
updateInstall: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateInstall) as Promise<void>,
onBeforeQuit: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on(IPC_CHANNELS.appBeforeQuit, handler);
@ -87,5 +92,10 @@ contextBridge.exposeInMainWorld("ccr", {
const handler = (_event: Electron.IpcRendererEvent, request: ProviderDeepLinkRequest) => callback(request);
ipcRenderer.on(IPC_CHANNELS.appProviderDeepLink, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.appProviderDeepLink, handler);
},
onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => {
const handler = (_event: Electron.IpcRendererEvent, status: AppUpdateStatus) => callback(status);
ipcRenderer.on(IPC_CHANNELS.appUpdateStatusChanged, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.appUpdateStatusChanged, handler);
}
});

View file

@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { ApiKeyConfig, AppConfig, ProfileApplyResult, ProfileClientApplyStatus, ProfileConfig } from "../shared/app";
import { enforceSingleEnabledGlobalProfilePerAgent, type ApiKeyConfig, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileConfig } from "../shared/app";
import { replacePersistedApiKeys } from "./api-key-store";
import { codexCliMiddlewareRuntimeScript } from "./codex-cli-middleware-runtime";
import { codexModelCatalogBase64 } from "./codex-model-catalog";
@ -160,7 +160,7 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str
}
function profileEntries(config: AppConfig): ProfileConfig[] {
return config.profile.profiles;
return enforceSingleEnabledGlobalProfilePerAgent(config.profile.profiles);
}
async function ensureProfileApiKeys(config: AppConfig, profiles: ProfileConfig[]): Promise<Map<string, string>> {

345
src/main/update-service.ts Normal file
View file

@ -0,0 +1,345 @@
import { app } from "electron";
import { autoUpdater, type ProgressInfo, type UpdateDownloadedEvent, type UpdateInfo } from "electron-updater";
import { IPC_CHANNELS } from "./constants";
import windowsManager from "./windows";
import type { AppUpdateStatus } from "../shared/app";
type InstallPreparation = () => Promise<void>;
const startupCheckDelayMs = 12_000;
class AppUpdateService {
private initialized = false;
private installingUpdate = false;
private prepareInstall?: InstallPreparation;
private startupCheckTimer?: NodeJS.Timeout;
private status: AppUpdateStatus = this.deriveStatus({
canCheck: false,
canDownload: false,
canInstall: false,
currentVersion: app.getVersion(),
state: "idle",
supported: this.isUpdaterSupported()
});
start(): void {
if (this.initialized) {
return;
}
this.initialized = true;
this.configureUpdater();
this.registerUpdaterEvents();
this.publishStatus({ feedUrl: autoUpdater.getFeedURL() || undefined });
if (this.isUpdaterSupported()) {
this.queueStartupCheck();
}
}
getStatus(): AppUpdateStatus {
return this.status;
}
setInstallPreparation(callback: InstallPreparation): void {
this.prepareInstall = callback;
}
isInstallingUpdate(): boolean {
return this.installingUpdate;
}
async checkForUpdates(): Promise<AppUpdateStatus> {
this.start();
if (!this.isUpdaterSupported()) {
return this.publishStatus({
lastError: "Updates are only available in packaged builds.",
state: "error"
});
}
this.publishStatus({
lastError: undefined,
progress: undefined,
state: "checking"
});
try {
const result = await autoUpdater.checkForUpdates();
const checkedAt = new Date().toISOString();
if (!result) {
return this.publishStatus({
availableVersion: undefined,
downloadedAt: undefined,
lastCheckedAt: checkedAt,
progress: undefined,
releaseDate: undefined,
releaseName: undefined,
releaseNotes: undefined,
state: "not-available"
});
}
if (result.isUpdateAvailable) {
return this.publishUpdateInfo("available", result.updateInfo, {
downloadedAt: undefined,
lastCheckedAt: checkedAt,
progress: undefined
});
}
return this.publishUpdateInfo("not-available", result.updateInfo, {
availableVersion: undefined,
downloadedAt: undefined,
lastCheckedAt: checkedAt,
progress: undefined
});
} catch (error) {
return this.publishStatus({
lastError: formatError(error),
progress: undefined,
state: "error"
});
}
}
async downloadUpdate(): Promise<AppUpdateStatus> {
this.start();
if (!this.status.canDownload) {
const checkedStatus = await this.checkForUpdates();
if (checkedStatus.state === "downloaded" || checkedStatus.canDownload) {
return this.downloadUpdate();
}
return checkedStatus;
}
this.publishStatus({
lastError: undefined,
progress: undefined,
state: "downloading"
});
try {
await autoUpdater.downloadUpdate();
return this.status;
} catch (error) {
return this.publishStatus({
lastError: formatError(error),
state: "error"
});
}
}
async installUpdate(): Promise<void> {
this.start();
if (!this.status.canInstall) {
const status = this.publishStatus({
lastError: "No downloaded update is ready to install.",
state: "error"
});
throw new Error(status.lastError);
}
this.installingUpdate = true;
this.publishStatus({
lastError: undefined,
state: "installing"
});
try {
await this.prepareInstall?.();
autoUpdater.quitAndInstall(false, true);
} catch (error) {
this.installingUpdate = false;
this.publishStatus({
lastError: formatError(error),
state: "error"
});
throw error;
}
}
private configureUpdater(): void {
const feedUrl = readEnvString("CCR_UPDATE_FEED_URL");
if (feedUrl) {
autoUpdater.setFeedURL({
provider: "generic",
url: feedUrl
});
autoUpdater.forceDevUpdateConfig = true;
}
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.allowPrerelease = readEnvBoolean("CCR_UPDATE_ALLOW_PRERELEASE");
autoUpdater.fullChangelog = true;
autoUpdater.logger = {
debug: (message) => console.debug(`[update] ${message}`),
error: (message) => console.error(`[update] ${String(message)}`),
info: (message) => console.info(`[update] ${String(message)}`),
warn: (message) => console.warn(`[update] ${String(message)}`)
};
}
private registerUpdaterEvents(): void {
autoUpdater.on("checking-for-update", () => {
this.publishStatus({
lastError: undefined,
progress: undefined,
state: "checking"
});
});
autoUpdater.on("update-available", (info) => {
this.publishUpdateInfo("available", info, {
downloadedAt: undefined,
lastCheckedAt: new Date().toISOString(),
progress: undefined
});
});
autoUpdater.on("update-not-available", (info) => {
this.publishUpdateInfo("not-available", info, {
availableVersion: undefined,
downloadedAt: undefined,
lastCheckedAt: new Date().toISOString(),
progress: undefined
});
});
autoUpdater.on("download-progress", (progress) => {
this.publishStatus({
progress: normalizeProgress(progress),
state: "downloading"
});
});
autoUpdater.on("update-downloaded", (event) => {
this.publishUpdateInfo("downloaded", event, {
downloadedAt: new Date().toISOString(),
lastError: undefined,
progress: normalizeProgress({
bytesPerSecond: 0,
delta: 0,
percent: 100,
total: 0,
transferred: 0
})
});
});
autoUpdater.on("update-cancelled", (info) => {
this.publishUpdateInfo("available", info, {
lastError: "Update download was cancelled.",
progress: undefined
});
});
autoUpdater.on("error", (error) => {
this.publishStatus({
lastError: formatError(error),
progress: undefined,
state: "error"
});
});
}
private queueStartupCheck(): void {
if (this.startupCheckTimer) {
return;
}
this.startupCheckTimer = setTimeout(() => {
this.startupCheckTimer = undefined;
void this.checkForUpdates();
}, readEnvNumber("CCR_UPDATE_STARTUP_DELAY_MS") ?? startupCheckDelayMs);
this.startupCheckTimer.unref?.();
}
private publishUpdateInfo(
state: AppUpdateStatus["state"],
info: UpdateInfo | UpdateDownloadedEvent,
patch: Partial<AppUpdateStatus> = {}
): AppUpdateStatus {
return this.publishStatus({
availableVersion: info.version,
lastError: undefined,
releaseDate: info.releaseDate,
releaseName: info.releaseName || undefined,
releaseNotes: formatReleaseNotes(info.releaseNotes),
state,
...patch
});
}
private publishStatus(patch: Partial<AppUpdateStatus>): AppUpdateStatus {
this.status = this.deriveStatus({
...this.status,
...patch,
currentVersion: app.getVersion(),
feedUrl: autoUpdater.getFeedURL() || this.status.feedUrl,
supported: this.isUpdaterSupported()
});
windowsManager.broadcast(IPC_CHANNELS.appUpdateStatusChanged, this.status);
return this.status;
}
private deriveStatus(status: AppUpdateStatus): AppUpdateStatus {
const busy = status.state === "checking" || status.state === "downloading" || status.state === "installing";
return {
...status,
canCheck: status.supported && !busy,
canDownload: status.supported && status.state === "available",
canInstall: status.supported && status.state === "downloaded"
};
}
private isUpdaterSupported(): boolean {
return app.isPackaged || Boolean(readEnvString("CCR_UPDATE_FEED_URL"));
}
}
export const appUpdateService = new AppUpdateService();
function normalizeProgress(progress: ProgressInfo): AppUpdateStatus["progress"] {
return {
bytesPerSecond: finiteNumber(progress.bytesPerSecond),
percent: finiteNumber(progress.percent),
total: finiteNumber(progress.total),
transferred: finiteNumber(progress.transferred)
};
}
function finiteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function formatReleaseNotes(notes: UpdateInfo["releaseNotes"]): string | undefined {
if (typeof notes === "string") {
return notes.trim() || undefined;
}
if (!Array.isArray(notes)) {
return undefined;
}
return notes
.map((item) => [item.version, item.note].filter(Boolean).join("\n"))
.filter(Boolean)
.join("\n\n") || undefined;
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function readEnvString(name: string): string | undefined {
const value = process.env[name]?.trim();
return value || undefined;
}
function readEnvBoolean(name: string): boolean {
const value = readEnvString(name)?.toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
function readEnvNumber(name: string): number | undefined {
const value = readEnvString(name);
if (!value) {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
}

View file

@ -1,15 +1,16 @@
import {
AddApiKeyDraft, AddProfileDraft, AddProviderDraft, AddRoutingRuleDraft, AgentAnalysisSnapshot, AgentFilterValue,
ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo,
ApiKeyConfig, AppConfig, appCopy, AppI18nContext, AppInfo, AppUpdateStatus,
AppLanguagePreference, applyProviderProbeResult, AppToast, buildExtensionList, claudeDesignRoutingConfigFromDraft,
ClaudeDesignRoutingDraft, ClaudeDesignRoutingRuleDraft, cloneConfig, createApiKeyDraft, createApiKeyEditDraft,
createApiKeyList, createClaudeDesignRoutingDraft, createClaudeDesignRoutingRuleDraft, createCursorProxyRoutingDraft, createCursorProxyRoutingRuleDraft, createEmptyAgentAnalysis,
createEmptyRequestLogPage, createEmptyUsageStats, createExtensionInstallDraft, createGeneratedApiKey, createPluginSettingsDraft, createProfileDraft,
createProfileDraftFromProfile, createProviderConfigFromDeepLink, createProviderDraft, createProviderDraftFromProvider, createRoutingRuleDraft, createRoutingRuleDraftFromRule,
createVirtualModelDraft, createVirtualModelDraftFromProfile, DEFAULT_TRAY_WIDGETS, detectSystemLanguage, detectSystemTheme,
enforceSingleEnabledGlobalProfilePerAgent,
ExtensionConfigTarget, ExtensionDeleteTarget, ExtensionInstallDraft, ExtensionSource, fallbackAgentAnalysis, fallbackConfig,
fallbackGatewayStatus, fallbackInfo, fallbackProxyCertificateStatus, fallbackProxyNetworkSnapshot, fallbackProxyStatus, fallbackRequestLogPage,
fallbackUsageStats, findProviderDeepLinkReplacementIndex, firstProviderConnectivityModel, formatJson, formatProxyCertificateInstallMessage, GatewayProviderConfig,
fallbackUpdateStatus, fallbackUsageStats, findProviderDeepLinkReplacementIndex, firstProviderConnectivityModel, formatJson, formatProxyCertificateInstallMessage, GatewayProviderConfig,
fusionCustomMcpServerFromDraft, fusionCustomToolConfigFromProfile,
GatewayProviderProbeResult, gatewayServiceMessage, GatewayStatus, getDefaultOnboardingStep, isClaudeDesignPluginConfig, isClaudeDesignRoutingDraftValid,
isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady,
@ -41,6 +42,8 @@ type ProfileOpenDialogState = {
profile: ProfileConfig;
};
type UpdateActionBusy = "" | "check" | "download" | "install";
function App() {
const [activeView, setActiveView] = useState<ViewId>("onboarding");
const [onboardingStep, setOnboardingStep] = useState<OnboardingStepId>(() => getDefaultOnboardingStep(fallbackConfig));
@ -52,6 +55,9 @@ function App() {
const [proxyCertificateStatus, setProxyCertificateStatus] = useState<ProxyCertificateStatus>(fallbackProxyCertificateStatus);
const [proxyNetworkSnapshot, setProxyNetworkSnapshot] = useState<ProxyNetworkSnapshot>(fallbackProxyNetworkSnapshot);
const [proxyStatus, setProxyStatus] = useState<ProxyStatus>(fallbackProxyStatus);
const [updateStatus, setUpdateStatus] = useState<AppUpdateStatus>(fallbackUpdateStatus);
const [updateActionBusy, setUpdateActionBusy] = useState<UpdateActionBusy>("");
const [updateActionError, setUpdateActionError] = useState("");
const [actionBusy, setActionBusy] = useState<ServerActionBusy>("");
const [gatewayActionBusy, setGatewayActionBusy] = useState(false);
const [actionMessage, setActionMessage] = useState("");
@ -183,13 +189,18 @@ function App() {
.finally(() => setOnboardingStatusLoaded(true));
void window.ccr.getPluginMarketplace().then(setPluginMarketplace).catch(() => setPluginMarketplace([]));
void window.ccr.getProxyCertificateStatus().then(setProxyCertificateStatus);
void window.ccr.getUpdateStatus().then(setUpdateStatus).catch(() => setUpdateStatus(fallbackUpdateStatus));
const unsubscribeUpdateStatus = window.ccr.onUpdateStatusChanged(setUpdateStatus);
const refreshRuntimeStatus = () => {
void window.ccr?.getGatewayStatus().then(setGatewayStatus);
void window.ccr?.getProxyStatus().then(setProxyStatus);
};
refreshRuntimeStatus();
const timer = window.setInterval(refreshRuntimeStatus, 2000);
return () => window.clearInterval(timer);
return () => {
window.clearInterval(timer);
unsubscribeUpdateStatus();
};
}, []);
useEffect(() => {
@ -589,6 +600,53 @@ function App() {
}, 1800);
}
async function checkForAppUpdate() {
if (!window.ccr) {
setUpdateActionError(t("Updates are only available in packaged builds."));
return;
}
setUpdateActionBusy("check");
setUpdateActionError("");
try {
setUpdateStatus(await window.ccr.updateCheck());
} catch (error) {
setUpdateActionError(formatUnknownError(error));
} finally {
setUpdateActionBusy("");
}
}
async function downloadAppUpdate() {
if (!window.ccr) {
setUpdateActionError(t("Updates are only available in packaged builds."));
return;
}
setUpdateActionBusy("download");
setUpdateActionError("");
try {
setUpdateStatus(await window.ccr.updateDownload());
} catch (error) {
setUpdateActionError(formatUnknownError(error));
} finally {
setUpdateActionBusy("");
}
}
async function installAppUpdate() {
if (!window.ccr) {
setUpdateActionError(t("Updates are only available in packaged builds."));
return;
}
setUpdateActionBusy("install");
setUpdateActionError("");
try {
await window.ccr.updateInstall();
} catch (error) {
setUpdateActionError(formatUnknownError(error));
setUpdateActionBusy("");
}
}
function updateConfig(mutator: (config: AppConfig) => AppConfig) {
setDraftConfig((current) => {
const next = normalizeConfig(mutator(cloneConfig(current)));
@ -1915,7 +1973,10 @@ function App() {
profile: {
...config.profile,
enabled: true,
profiles: [...config.profile.profiles, profile]
profiles: enforceSingleEnabledGlobalProfilePerAgent(
[...config.profile.profiles, profile],
config.profile.profiles.length
)
}
}));
setConfigDraft(next);
@ -1962,7 +2023,7 @@ function App() {
...config,
profile: {
...config.profile,
profiles
profiles: enforceSingleEnabledGlobalProfilePerAgent(profiles, profileEditIndex)
}
};
});
@ -1992,7 +2053,7 @@ function App() {
...next,
profile: {
...next.profile,
profiles
profiles: enforceSingleEnabledGlobalProfilePerAgent(profiles, index)
}
};
});
@ -2323,18 +2384,24 @@ function App() {
copy,
isMac,
languagePreference,
onCheckUpdate: checkForAppUpdate,
onChangeLanguage: changeLanguagePreference,
onChangeTheme: changeThemePreference,
onChangeTrayIcon: changeTrayIconPreference,
onChangeTrayProgressTarget: changeTrayProgressTargetTokens,
onChangeTrayWidgets: changeTrayWidgets,
onClose: () => setSettingsOpen(false),
onDownloadUpdate: downloadAppUpdate,
onInstallUpdate: installAppUpdate,
systemLanguage,
systemTheme,
themePreference: draftConfig.theme || "system",
trayIconPreference: draftConfig.trayIcon || "random",
trayProgressTargetTokens: draftConfig.trayProgressTargetTokens || 100000,
trayWidgets: normalizeTrayWidgets(draftConfig.trayWidgets ?? DEFAULT_TRAY_WIDGETS, draftConfig.trayWindowModules, draftConfig.trayComponentVariants)
trayWidgets: normalizeTrayWidgets(draftConfig.trayWidgets ?? DEFAULT_TRAY_WIDGETS, draftConfig.trayWindowModules, draftConfig.trayComponentVariants),
updateActionBusy,
updateActionError,
updateStatus
} : undefined}
virtualModelUpsert={virtualModelDialogOpen ? {
canSubmit: canSubmitVirtualModel,
@ -2358,4 +2425,8 @@ function App() {
);
}
function formatUnknownError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export default App;

View file

@ -43,7 +43,7 @@ export function ServerView({
return (
<motion.div
animate={{ opacity: 1 }}
className="mx-auto w-full max-w-3xl"
className="w-full"
initial={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>

View file

@ -1,73 +1,106 @@
import {
Activity, AppConfig, AppCopy, AppLanguagePreference, Boxes, Button,
closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent,
Activity, AppConfig, AppCopy, AppLanguagePreference, AppUpdateStatus, Boxes, Button,
Check, CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent,
DialogFooter, DialogHeader, DialogTitle, Field, formatSystemOption, Gauge,
DndContext, DragEndEvent, Input, KeyboardSensor, languageDisplayName, Layers3, Palette,
DndContext, DragEndEvent, Input, KeyboardSensor, languageDisplayName, Layers3, LoaderCircle, Palette,
PanelLeftOpen, Power, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl,
PointerSensor, rectSortingStrategy, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName,
PointerSensor, rectSortingStrategy, RefreshCw, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName,
TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant,
trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, isTraySingletonWidgetType, normalizeTrayWidget, normalizeTrayWidgets, Switch, trayWidgetVariantOptions, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, useState,
X
} from "../shared";
type UpdateActionBusy = "" | "check" | "download" | "install";
export function AppSettingsDialog({
copy,
isMac,
languagePreference,
onCheckUpdate,
onChangeLanguage,
onChangeTheme,
onChangeTrayIcon,
onChangeTrayProgressTarget,
onChangeTrayWidgets,
onClose,
onDownloadUpdate,
onInstallUpdate,
systemLanguage,
systemTheme,
themePreference,
trayIconPreference,
trayProgressTargetTokens,
trayWidgets
trayWidgets,
updateActionBusy,
updateActionError,
updateStatus
}: {
copy: AppCopy;
isMac: boolean;
languagePreference: AppLanguagePreference;
onCheckUpdate: () => Promise<void>;
onChangeLanguage: (value: string) => void;
onChangeTheme: (value: string) => void;
onChangeTrayIcon: (value: string) => void;
onChangeTrayProgressTarget: (value: string) => void;
onChangeTrayWidgets: (widgets: TrayWidgetConfig[]) => void;
onClose: () => void;
onDownloadUpdate: () => Promise<void>;
onInstallUpdate: () => Promise<void>;
systemLanguage: ResolvedLanguage;
systemTheme: ResolvedTheme;
themePreference: AppConfig["theme"];
trayIconPreference: AppConfig["trayIcon"];
trayProgressTargetTokens: number;
trayWidgets: TrayWidgetConfig[];
updateActionBusy: UpdateActionBusy;
updateActionError: string;
updateStatus: AppUpdateStatus;
}) {
return (
<SettingsLayout
copy={copy}
isMac={isMac}
onClose={onClose}
renderPage={(activePage) => activePage === "appearance" ? (
<AppearanceSettingsPage
copy={copy}
languagePreference={languagePreference}
onChangeLanguage={onChangeLanguage}
onChangeTheme={onChangeTheme}
systemLanguage={systemLanguage}
systemTheme={systemTheme}
themePreference={themePreference}
/>
) : (
<TraySettingsPage
copy={copy}
onChangeTrayIcon={onChangeTrayIcon}
onChangeTrayProgressTarget={onChangeTrayProgressTarget}
onChangeTrayWidgets={onChangeTrayWidgets}
trayIconPreference={trayIconPreference}
trayProgressTargetTokens={trayProgressTargetTokens}
trayWidgets={trayWidgets}
/>
)}
renderPage={(activePage) => {
if (activePage === "appearance") {
return (
<AppearanceSettingsPage
copy={copy}
languagePreference={languagePreference}
onChangeLanguage={onChangeLanguage}
onChangeTheme={onChangeTheme}
systemLanguage={systemLanguage}
systemTheme={systemTheme}
themePreference={themePreference}
/>
);
}
if (activePage === "tray") {
return (
<TraySettingsPage
copy={copy}
onChangeTrayIcon={onChangeTrayIcon}
onChangeTrayProgressTarget={onChangeTrayProgressTarget}
onChangeTrayWidgets={onChangeTrayWidgets}
trayIconPreference={trayIconPreference}
trayProgressTargetTokens={trayProgressTargetTokens}
trayWidgets={trayWidgets}
/>
);
}
return (
<UpdateSettingsPage
actionBusy={updateActionBusy}
actionError={updateActionError}
copy={copy}
onCheck={onCheckUpdate}
onDownload={onDownloadUpdate}
onInstall={onInstallUpdate}
status={updateStatus}
/>
);
}}
/>
);
}
@ -84,7 +117,7 @@ function SettingsLayout({
renderPage: (activePage: SettingsPageId) => ReactNode;
}) {
const [activePage, setActivePage] = useState<SettingsPageId>("appearance");
const visiblePage = isMac ? activePage : "appearance";
const visiblePage = activePage === "tray" && !isMac ? "appearance" : activePage;
return (
<Dialog onOpenChange={(open) => !open && onClose()}>
@ -115,6 +148,13 @@ function SettingsLayout({
onClick={() => setActivePage("tray")}
/>
) : null}
<SettingsPageButton
active={visiblePage === "update"}
className="mt-1"
icon={RefreshCw}
label={copy.settings.update}
onClick={() => setActivePage("update")}
/>
</aside>
<section className="min-h-0 flex-1 overflow-auto p-5">
@ -212,6 +252,186 @@ function AppearanceSettingsPage({
);
}
function UpdateSettingsPage({
actionBusy,
actionError,
copy,
onCheck,
onDownload,
onInstall,
status
}: {
actionBusy: UpdateActionBusy;
actionError: string;
copy: AppCopy;
onCheck: () => Promise<void>;
onDownload: () => Promise<void>;
onInstall: () => Promise<void>;
status: AppUpdateStatus;
}) {
const t = (value: string) => copy.text[value] ?? value;
const busy = Boolean(actionBusy) || status.state === "checking" || status.state === "downloading" || status.state === "installing";
const progressPercent = clampPercent(status.progress?.percent);
const message = actionError || (status.lastError ? t(status.lastError) : "");
return (
<div className="mx-auto grid max-w-[620px] grid-cols-1 gap-5">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-[15px] font-semibold text-foreground">{copy.settings.update}</h3>
<div className="mt-1 text-[12px] text-muted-foreground">{t("Online updates")}</div>
</div>
<UpdateStateBadge copy={copy} status={status} />
</div>
<div className="grid grid-cols-1 gap-3 rounded-md border border-border bg-background p-3">
<div className="grid grid-cols-2 gap-3 max-[640px]:grid-cols-1">
<UpdateInfoRow label={t("Current version")} value={status.currentVersion} />
<UpdateInfoRow label={t("Available version")} value={status.availableVersion || "-"} />
<UpdateInfoRow label={t("Last checked")} value={formatUpdateDate(status.lastCheckedAt) || "-"} />
<UpdateInfoRow label={t("Feed URL")} value={status.feedUrl || "-"} />
</div>
{status.state === "downloading" ? (
<div className="grid gap-1.5">
<div className="flex items-center justify-between gap-3 text-[11px] font-medium text-muted-foreground">
<span>{t("Downloading update")}</span>
<span>{progressPercent !== undefined ? `${progressPercent.toFixed(0)}%` : ""}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width]"
style={{ width: `${progressPercent ?? 0}%` }}
/>
</div>
<div className="text-[11px] text-muted-foreground">
{formatDownloadProgress(status.progress)}
</div>
</div>
) : null}
{message ? (
<div className="flex gap-2 rounded-md border border-destructive/25 bg-destructive/10 px-3 py-2 text-[12px] text-destructive">
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 break-words">{message}</span>
</div>
) : null}
{!status.supported ? (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-[12px] text-muted-foreground">
{t("Updates are only available in packaged builds.")}
</div>
) : null}
{status.releaseNotes ? (
<div className="grid gap-1.5">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{t("Release notes")}</div>
<div className="max-h-[140px] overflow-auto whitespace-pre-wrap rounded-md border border-border bg-muted/25 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
{status.releaseNotes}
</div>
</div>
) : null}
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button disabled={busy || !status.canCheck} onClick={() => void onCheck()} type="button" variant="outline">
{actionBusy === "check" || status.state === "checking" ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{t("Check for updates")}
</Button>
<Button disabled={busy || !status.canDownload} onClick={() => void onDownload()} type="button" variant="outline">
{actionBusy === "download" || status.state === "downloading" ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{t("Download update")}
</Button>
<Button disabled={busy || !status.canInstall} onClick={() => void onInstall()} type="button">
{actionBusy === "install" || status.state === "installing" ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
{t("Install and restart")}
</Button>
</div>
</div>
);
}
function UpdateInfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-md border border-border/70 bg-muted/20 px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-1 min-w-0 truncate text-[12px] font-medium text-foreground" title={value}>{value}</div>
</div>
);
}
function UpdateStateBadge({ copy, status }: { copy: AppCopy; status: AppUpdateStatus }) {
const t = (value: string) => copy.text[value] ?? value;
const label = updateStateLabel(status, t);
const tone = status.state === "error"
? "border-destructive/25 bg-destructive/10 text-destructive"
: status.state === "available" || status.state === "downloaded"
? "border-primary/25 bg-primary/10 text-primary"
: "border-border bg-muted/40 text-muted-foreground";
return (
<span className={cn("inline-flex min-h-7 shrink-0 items-center rounded-md border px-2 text-[11px] font-medium", tone)}>
{label}
</span>
);
}
function updateStateLabel(status: AppUpdateStatus, t: (value: string) => string): string {
if (!status.supported) return t("Not configured");
if (status.state === "checking") return t("Checking for updates");
if (status.state === "available") return t("Update available");
if (status.state === "not-available") return t("No updates available");
if (status.state === "downloading") return t("Downloading update");
if (status.state === "downloaded") return t("Update ready to install");
if (status.state === "installing") return t("Install and restart");
if (status.state === "error") return t("Update failed");
return t("Idle");
}
function clampPercent(value: number | undefined): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
return Math.max(0, Math.min(100, value));
}
function formatDownloadProgress(progress: AppUpdateStatus["progress"]): string {
if (!progress) {
return "";
}
const transferred = formatBytes(progress.transferred);
const total = formatBytes(progress.total);
const speed = formatBytes(progress.bytesPerSecond);
return [transferred && total ? `${transferred} / ${total}` : transferred || total, speed ? `${speed}/s` : ""]
.filter(Boolean)
.join(" | ");
}
function formatBytes(value: number | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return "";
}
const units = ["B", "KB", "MB", "GB"];
let current = value;
let unitIndex = 0;
while (current >= 1024 && unitIndex < units.length - 1) {
current /= 1024;
unitIndex += 1;
}
return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`;
}
function formatUpdateDate(value: string | undefined): string {
if (!value) {
return "";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
function TraySettingsPage({
copy,
onChangeTrayIcon,

View file

@ -121,6 +121,8 @@ import {
DEFAULT_TRAY_COMPONENT_VARIANTS,
DEFAULT_TRAY_WIDGETS,
DEFAULT_TRAY_WINDOW_MODULES,
enforceSingleEnabledGlobalProfilePerAgent,
normalizeProfileScopeValue,
OVERVIEW_WIDGET_SIZE_VALUES,
TRAY_SINGLETON_WIDGET_TYPES,
TRAY_TOP_WIDGET_TYPES,
@ -132,6 +134,7 @@ import type {
AgentKind,
AppConfig,
AppInfo,
AppUpdateStatus,
ApiKeyConfig,
ApiKeyLimitConfig,
GatewayProviderConfig,
@ -238,13 +241,13 @@ export {
cn, claudeCodeLogoUrl, codexLogoUrl, onboardingMascotSpriteUrl, anthropicProviderIconUrl, bailianProviderIconUrl, deepseekProviderIconUrl,
geminiProviderIconUrl, mistralProviderIconUrl, moonshotProviderIconUrl, openaiProviderIconUrl, openrouterProviderIconUrl, siliconflowProviderIconUrl, zaiGlobalCodingProviderIconUrl,
zaiGlobalGeneralProviderIconUrl, zhipuCnCodingProviderIconUrl, zhipuCnGeneralProviderIconUrl, trayCyanIconUrl, trayOrangeIconUrl, trayVioletIconUrl, BUILTIN_FUSION_TOOL_SERVER_NAME,
BUILTIN_FUSION_VISION_TOOL_NAME, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS,
BUILTIN_FUSION_VISION_TOOL_NAME, BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME, DEFAULT_OVERVIEW_WIDGETS, DEFAULT_TRAY_COMPONENT_VARIANTS, DEFAULT_TRAY_WIDGETS, DEFAULT_TRAY_WINDOW_MODULES, enforceSingleEnabledGlobalProfilePerAgent, OVERVIEW_WIDGET_SIZE_VALUES, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS,
customProviderPresetId, defaultProviderAccountConfig, findProviderPreset, findProviderPresetByBaseUrl, primaryProviderPresetEndpoint, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey,
providerIdentitySafetyIssue, providerPresets, standardProviderAccountConfig, normalizeProviderBaseUrl, providerUrlWithDefaultScheme
};
export type {
HTMLAttributes, ReactPointerEvent, ReactNode, CollisionDetection, DragEndEvent, DragOverEvent, DragStartEvent,
LucideIcon, AgentAnalysisFilter, AgentAnalysisSnapshot, AgentKind, AppConfig, AppInfo, ApiKeyConfig,
LucideIcon, AgentAnalysisFilter, AgentAnalysisSnapshot, AgentKind, AppConfig, AppInfo, AppUpdateStatus, ApiKeyConfig,
ApiKeyLimitConfig, GatewayProviderConfig, GatewayProviderCapability, GatewayPluginAppConfig, GatewayProviderProbeResult, GatewayProviderProtocol, GatewayMcpServerConfig,
GatewayMcpServerTransport, GatewayMcpStdioMessageMode, GatewayMcpToolInfo, GatewayStatus, OverviewMetricKind, OverviewWidgetConfig, OverviewWidgetSize, OverviewWidgetType,
OverviewWidgetVariant, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProviderAccountConfig, ProviderAccountConnectorConfig, ProviderAccountHttpJsonConnectorConfig,
@ -263,7 +266,7 @@ export type OnboardingStepId = "provider" | "profile" | "enter";
export type AppLanguagePreference = "system" | "en" | "zh";
export type ResolvedLanguage = "en" | "zh";
export type ResolvedTheme = "light" | "dark";
export type SettingsPageId = "appearance" | "tray";
export type SettingsPageId = "appearance" | "tray" | "update";
export type TrayEditableModuleId = Exclude<TrayWindowModuleId, "footer">;
export type TrayComponentOptionGroup = {
key: keyof TrayComponentVariants;
@ -293,6 +296,7 @@ export type AppCopy = {
themeLight: string;
themeSystem: string;
tray: string;
update: string;
trayIcon: string;
trayIconCyan: string;
trayIconOrange: string;
@ -381,6 +385,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
themeLight: "Light",
themeSystem: "System",
tray: "Tray",
update: "Updates",
trayIcon: "Tray mascot",
trayIconCyan: "Cyan",
trayIconOrange: "Orange",
@ -627,6 +632,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
themeLight: "亮色",
themeSystem: "跟随系统",
tray: "Tray",
update: "更新",
trayIcon: "托盘小精灵",
trayIconCyan: "青色小精灵",
trayIconOrange: "橙色小精灵",
@ -729,6 +735,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Cache write": "缓存写入",
"Cancel": "取消",
"Check": "检查",
"Check for updates": "检查更新",
"Checking for updates": "正在检查更新",
"Capture network": "捕获网络",
"Connection verified": "连通性已验证",
"Check trust": "检查信任",
@ -830,6 +838,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Input": "输入",
"Input tokens": "输入令牌",
"Install": "安装",
"Install and restart": "安装并重启",
"App": "App",
"Install Extension": "安装扩展",
"Install extension": "安装扩展",
@ -838,6 +847,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Keep Claude Code default": "保持 Claude Code 默认值",
"Keep default": "保持默认值",
"Last apply": "上次应用",
"Last checked": "上次检查",
"Last request": "最近请求",
"Last seen": "最近活跃",
"Legacy profile table": "旧版配置档案表",
@ -1337,6 +1347,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"No subagent calls": "暂无 Subagent 调用",
"No tool calls": "暂无工具调用",
"No routing rules configured": "未配置路由规则",
"No updates available": "当前已是最新版本",
"Not installed": "未安装",
"Not set": "未设置",
"Pause capture": "暂停捕获",
@ -1395,6 +1406,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"API Service": "API 服务 (API Service)",
"Availability": "可用性",
"Current": "当前",
"Current version": "当前版本",
"Current model": "当前模型",
"Degraded": "降级",
"Gateway": "网关",
@ -1404,6 +1416,17 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Proxy Service": "代理服务",
"System status": "系统状态",
"System Proxy": "系统代理",
"Available version": "可用版本",
"Download update": "下载更新",
"Downloading update": "正在下载更新",
"Feed URL": "更新源",
"Online updates": "在线更新",
"Release notes": "更新说明",
"Update available": "发现新版本",
"Update downloaded": "更新已下载",
"Update failed": "更新失败",
"Update ready to install": "更新已准备安装",
"Updates are only available in packaged builds.": "在线更新仅在打包后的应用中可用。",
"This action is applied immediately to the draft config and will auto-save with other changes.": "此操作会立即应用到草稿配置,并随其他变更自动保存。",
"This provider link came from an external website. Review details before importing.": "这个供应商链接来自外部网站。导入前请确认下面的内容。",
"Welcome to CCR": "欢迎使用CCR",
@ -1773,6 +1796,15 @@ export const fallbackInfo: AppInfo = {
version: "0.1.0"
};
export const fallbackUpdateStatus: AppUpdateStatus = {
canCheck: false,
canDownload: false,
canInstall: false,
currentVersion: fallbackInfo.version,
state: "idle",
supported: false
};
export const fallbackConfig: AppConfig = {
APIKEY: "",
APIKEYS: [],
@ -2925,7 +2957,7 @@ export function normalizeCodexConfigFormat(_value: unknown): CodexProfileConfigF
}
export function normalizeProfileScope(value: unknown): ProfileScope {
return value === "ccr" || value === "custom" ? value : "global";
return normalizeProfileScopeValue(value);
}
export function normalizeProfileFormScope(value: unknown): ProfileScope {
@ -3030,9 +3062,9 @@ export function normalizeProfileItems(values: unknown): ProfileConfig[] {
if (!Array.isArray(values)) {
return fallbackConfig.profile.profiles;
}
return values
return enforceSingleEnabledGlobalProfilePerAgent(values
.map((value, index) => isPlainRecord(value) ? normalizeUnknownProfileItem(value, index) : undefined)
.filter((profile): profile is ProfileConfig => Boolean(profile));
.filter((profile): profile is ProfileConfig => Boolean(profile)));
}
export function legacyProfileItemsFromProfileConfig(profile: AppConfig["profile"]): ProfileConfig[] {
@ -5557,7 +5589,7 @@ export function virtualModelProfileFromDraft(
maxToolCalls: clampNumber(maxToolCalls || Math.max(tools.length, 1), 1, 50),
maxTurns: clampNumber(maxTurns || 6, 1, 50),
mode: "tool_loop",
streamMode: "buffered"
streamMode: "optimistic"
},
id,
key,

View file

@ -5,6 +5,7 @@ import type {
AgentAnalysisSnapshot,
AppConfig,
AppInfo,
AppUpdateStatus,
ApiKeyConfig,
ClaudeAppGatewayApplyResult,
GatewayMcpServerConfig,
@ -59,6 +60,7 @@ declare global {
getProxyNetworkCaptures: () => Promise<ProxyNetworkSnapshot>;
getProxyStatus: () => Promise<ProxyStatus>;
getRequestLogs: (filter?: RequestLogListFilter) => Promise<RequestLogPage>;
getUpdateStatus: () => Promise<AppUpdateStatus>;
getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => Promise<UsageStatsSnapshot>;
installProxyCertificate: () => Promise<ProxyCertificateInstallResult>;
listMcpServerTools: (server: GatewayMcpServerConfig) => Promise<GatewayMcpToolInfo[]>;
@ -80,8 +82,12 @@ declare global {
startGateway: () => Promise<GatewayStatus>;
stopGateway: () => Promise<GatewayStatus>;
testProviderAccountConnector: (request: ProviderAccountTestRequest) => Promise<ProviderAccountTestResult>;
updateCheck: () => Promise<AppUpdateStatus>;
updateDownload: () => Promise<AppUpdateStatus>;
updateInstall: () => Promise<void>;
onBeforeQuit: (callback: () => void) => () => void;
onProviderDeepLink: (callback: (request: ProviderDeepLinkRequest) => void) => () => void;
onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => () => void;
};
}
}

View file

@ -11,6 +11,41 @@ export type AppInfo = {
version: string;
};
export type AppUpdateState =
| "idle"
| "checking"
| "available"
| "not-available"
| "downloading"
| "downloaded"
| "installing"
| "error";
export type AppUpdateDownloadProgress = {
bytesPerSecond?: number;
percent?: number;
total?: number;
transferred?: number;
};
export type AppUpdateStatus = {
availableVersion?: string;
canCheck: boolean;
canDownload: boolean;
canInstall: boolean;
currentVersion: string;
downloadedAt?: string;
feedUrl?: string;
lastCheckedAt?: string;
lastError?: string;
progress?: AppUpdateDownloadProgress;
releaseDate?: string;
releaseName?: string;
releaseNotes?: string;
state: AppUpdateState;
supported: boolean;
};
export const BUILTIN_FUSION_TOOL_SERVER_NAME = "ccr-fusion-builtins";
export const BUILTIN_FUSION_VISION_TOOL_NAME = "vision_understand";
export const BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME = "web_search";
@ -407,7 +442,7 @@ export type VirtualModelExecutionConfig = {
maxToolCalls: number;
maxTurns: number;
mode: VirtualModelExecutionMode;
streamMode: "buffered";
streamMode: "buffered" | "optimistic";
};
export type VirtualModelMaterializationConfig = {
@ -732,6 +767,38 @@ export type ProfileRuntimeConfig = {
profiles: ProfileConfig[];
};
export function normalizeProfileScopeValue(value: unknown): ProfileScope {
return value === "ccr" || value === "custom" ? value : "global";
}
export function isEnabledGlobalProfile(profile: Pick<ProfileConfig, "enabled" | "scope">): boolean {
return profile.enabled && normalizeProfileScopeValue(profile.scope) === "global";
}
export function enforceSingleEnabledGlobalProfilePerAgent(
profiles: ProfileConfig[],
preferredIndex?: number
): ProfileConfig[] {
const activeGlobalProfileByAgent = new Map<ProfileClientKind, number>();
const preferredProfileIndex = typeof preferredIndex === "number" ? preferredIndex : undefined;
const preferredProfile = preferredProfileIndex !== undefined ? profiles[preferredProfileIndex] : undefined;
if (preferredProfileIndex !== undefined && preferredProfile && isEnabledGlobalProfile(preferredProfile)) {
activeGlobalProfileByAgent.set(preferredProfile.agent, preferredProfileIndex);
}
return profiles.map((profile, index) => {
if (!isEnabledGlobalProfile(profile)) {
return profile;
}
const activeIndex = activeGlobalProfileByAgent.get(profile.agent);
if (activeIndex === undefined) {
activeGlobalProfileByAgent.set(profile.agent, index);
return profile;
}
return activeIndex === index ? profile : { ...profile, enabled: false };
});
}
export type ProfileClientApplyStatus = {
appliedAt?: string;
backupFile?: string;

View file

@ -14,6 +14,7 @@ export const IPC_CHANNELS = {
appGetProxyNetworkCaptures: "ccr:app:get-proxy-network-captures",
appGetProxyStatus: "ccr:app:get-proxy-status",
appGetRequestLogs: "ccr:app:get-request-logs",
appGetUpdateStatus: "ccr:app:get-update-status",
appGetUsageStats: "ccr:app:get-usage-stats",
appFetchProviderManifest: "ccr:app:fetch-provider-manifest",
appInstallProxyCertificate: "ccr:app:install-proxy-certificate",
@ -41,6 +42,10 @@ export const IPC_CHANNELS = {
appSelectPluginDirectory: "ccr:app:select-plugin-directory",
appShowMainWindow: "ccr:app:show-main-window",
appTestProviderAccountConnector: "ccr:app:test-provider-account-connector",
appUpdateCheck: "ccr:app:update-check",
appUpdateDownload: "ccr:app:update-download",
appUpdateInstall: "ccr:app:update-install",
appUpdateStatusChanged: "ccr:app:update-status-changed",
browserBack: "ccr:browser:back",
browserCloseTab: "ccr:browser:close-tab",
browserForward: "ccr:browser:forward",