diff --git a/README.md b/README.md index 8771ab3..878e60f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/electron-builder.json b/electron-builder.json index dd8c825..329ff6f 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -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": { diff --git a/package-lock.json b/package-lock.json index a72d736..0398213 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 1599dfa..acc521e 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/main/claude-app-gateway-service.ts b/src/main/claude-app-gateway-service.ts index 15bf43e..17fe571 100644 --- a/src/main/claude-app-gateway-service.ts +++ b/src/main/claude-app-gateway-service.ts @@ -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 ""; diff --git a/src/main/config.ts b/src/main/config.ts index 38c0f64..59519c2 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -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 { 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 { 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 { } export async function saveAppConfig(config: AppConfig): Promise { - 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); diff --git a/src/main/gateway/service.ts b/src/main/gateway/service.ts index 63efce0..4a82146 100644 --- a/src/main/gateway/service.ts +++ b/src/main/gateway/service.ts @@ -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}` : ""; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index ed38dae..3fdedfd 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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(); }); diff --git a/src/main/main.ts b/src/main/main.ts index b9fec42..96b4abc 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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 { + if (!stoppingForQuit) { + stoppingForQuit = true; + } + await stopServicesForQuit(); + quitPrepared = true; +} + function handleTerminationSignal(signal: NodeJS.Signals): void { if (stoppingForQuit) { return; diff --git a/src/main/preload.ts b/src/main/preload.ts index 2433ae7..8543d1e 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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, getProxyStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetProxyStatus) as Promise, getRequestLogs: (filter?: RequestLogListFilter) => ipcRenderer.invoke(IPC_CHANNELS.appGetRequestLogs, filter) as Promise, + getUpdateStatus: () => ipcRenderer.invoke(IPC_CHANNELS.appGetUpdateStatus) as Promise, getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => ipcRenderer.invoke(IPC_CHANNELS.appGetUsageStats, range, filter) as Promise, installProxyCertificate: () => ipcRenderer.invoke(IPC_CHANNELS.appInstallProxyCertificate) as Promise, listMcpServerTools: (server: GatewayMcpServerConfig) => ipcRenderer.invoke(IPC_CHANNELS.appListMcpServerTools, server) as Promise, @@ -78,6 +80,9 @@ contextBridge.exposeInMainWorld("ccr", { startGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStartGateway) as Promise, stopGateway: () => ipcRenderer.invoke(IPC_CHANNELS.appStopGateway) as Promise, testProviderAccountConnector: (request: ProviderAccountTestRequest) => ipcRenderer.invoke(IPC_CHANNELS.appTestProviderAccountConnector, request) as Promise, + updateCheck: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateCheck) as Promise, + updateDownload: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateDownload) as Promise, + updateInstall: () => ipcRenderer.invoke(IPC_CHANNELS.appUpdateInstall) as Promise, 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); } }); diff --git a/src/main/profile-service.ts b/src/main/profile-service.ts index 9637e6e..6e3112f 100644 --- a/src/main/profile-service.ts +++ b/src/main/profile-service.ts @@ -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> { diff --git a/src/main/update-service.ts b/src/main/update-service.ts new file mode 100644 index 0000000..b5d5dc6 --- /dev/null +++ b/src/main/update-service.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index 3c35abc..2f7e16e 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -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("onboarding"); const [onboardingStep, setOnboardingStep] = useState(() => getDefaultOnboardingStep(fallbackConfig)); @@ -52,6 +55,9 @@ function App() { const [proxyCertificateStatus, setProxyCertificateStatus] = useState(fallbackProxyCertificateStatus); const [proxyNetworkSnapshot, setProxyNetworkSnapshot] = useState(fallbackProxyNetworkSnapshot); const [proxyStatus, setProxyStatus] = useState(fallbackProxyStatus); + const [updateStatus, setUpdateStatus] = useState(fallbackUpdateStatus); + const [updateActionBusy, setUpdateActionBusy] = useState(""); + const [updateActionError, setUpdateActionError] = useState(""); const [actionBusy, setActionBusy] = useState(""); 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; diff --git a/src/renderer/pages/home/components/server.tsx b/src/renderer/pages/home/components/server.tsx index a1714e4..202c915 100644 --- a/src/renderer/pages/home/components/server.tsx +++ b/src/renderer/pages/home/components/server.tsx @@ -43,7 +43,7 @@ export function ServerView({ return ( diff --git a/src/renderer/pages/home/components/settings.tsx b/src/renderer/pages/home/components/settings.tsx index b34e959..7877db1 100644 --- a/src/renderer/pages/home/components/settings.tsx +++ b/src/renderer/pages/home/components/settings.tsx @@ -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; onChangeLanguage: (value: string) => void; onChangeTheme: (value: string) => void; onChangeTrayIcon: (value: string) => void; onChangeTrayProgressTarget: (value: string) => void; onChangeTrayWidgets: (widgets: TrayWidgetConfig[]) => void; onClose: () => void; + onDownloadUpdate: () => Promise; + onInstallUpdate: () => Promise; systemLanguage: ResolvedLanguage; systemTheme: ResolvedTheme; themePreference: AppConfig["theme"]; trayIconPreference: AppConfig["trayIcon"]; trayProgressTargetTokens: number; trayWidgets: TrayWidgetConfig[]; + updateActionBusy: UpdateActionBusy; + updateActionError: string; + updateStatus: AppUpdateStatus; }) { return ( activePage === "appearance" ? ( - - ) : ( - - )} + renderPage={(activePage) => { + if (activePage === "appearance") { + return ( + + ); + } + if (activePage === "tray") { + return ( + + ); + } + return ( + + ); + }} /> ); } @@ -84,7 +117,7 @@ function SettingsLayout({ renderPage: (activePage: SettingsPageId) => ReactNode; }) { const [activePage, setActivePage] = useState("appearance"); - const visiblePage = isMac ? activePage : "appearance"; + const visiblePage = activePage === "tray" && !isMac ? "appearance" : activePage; return ( !open && onClose()}> @@ -115,6 +148,13 @@ function SettingsLayout({ onClick={() => setActivePage("tray")} /> ) : null} + setActivePage("update")} + />
@@ -212,6 +252,186 @@ function AppearanceSettingsPage({ ); } +function UpdateSettingsPage({ + actionBusy, + actionError, + copy, + onCheck, + onDownload, + onInstall, + status +}: { + actionBusy: UpdateActionBusy; + actionError: string; + copy: AppCopy; + onCheck: () => Promise; + onDownload: () => Promise; + onInstall: () => Promise; + 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 ( +
+
+
+

{copy.settings.update}

+
{t("Online updates")}
+
+ +
+ +
+
+ + + + +
+ + {status.state === "downloading" ? ( +
+
+ {t("Downloading update")} + {progressPercent !== undefined ? `${progressPercent.toFixed(0)}%` : ""} +
+
+
+
+
+ {formatDownloadProgress(status.progress)} +
+
+ ) : null} + + {message ? ( +
+ + {message} +
+ ) : null} + + {!status.supported ? ( +
+ {t("Updates are only available in packaged builds.")} +
+ ) : null} + + {status.releaseNotes ? ( +
+
{t("Release notes")}
+
+ {status.releaseNotes} +
+
+ ) : null} +
+ +
+ + + +
+
+ ); +} + +function UpdateInfoRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +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 ( + + {label} + + ); +} + +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, diff --git a/src/renderer/pages/home/shared.tsx b/src/renderer/pages/home/shared.tsx index c58426a..d8afde5 100644 --- a/src/renderer/pages/home/shared.tsx +++ b/src/renderer/pages/home/shared.tsx @@ -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; 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 = { themeLight: "Light", themeSystem: "System", tray: "Tray", + update: "Updates", trayIcon: "Tray mascot", trayIconCyan: "Cyan", trayIconOrange: "Orange", @@ -627,6 +632,7 @@ export const appCopy: Record = { themeLight: "亮色", themeSystem: "跟随系统", tray: "Tray", + update: "更新", trayIcon: "托盘小精灵", trayIconCyan: "青色小精灵", trayIconOrange: "橙色小精灵", @@ -729,6 +735,8 @@ export const appCopy: Record = { "Cache write": "缓存写入", "Cancel": "取消", "Check": "检查", + "Check for updates": "检查更新", + "Checking for updates": "正在检查更新", "Capture network": "捕获网络", "Connection verified": "连通性已验证", "Check trust": "检查信任", @@ -830,6 +838,7 @@ export const appCopy: Record = { "Input": "输入", "Input tokens": "输入令牌", "Install": "安装", + "Install and restart": "安装并重启", "App": "App", "Install Extension": "安装扩展", "Install extension": "安装扩展", @@ -838,6 +847,7 @@ export const appCopy: Record = { "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 = { "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 = { "API Service": "API 服务 (API Service)", "Availability": "可用性", "Current": "当前", + "Current version": "当前版本", "Current model": "当前模型", "Degraded": "降级", "Gateway": "网关", @@ -1404,6 +1416,17 @@ export const appCopy: Record = { "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, diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index d8f3bf2..ec24c45 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -5,6 +5,7 @@ import type { AgentAnalysisSnapshot, AppConfig, AppInfo, + AppUpdateStatus, ApiKeyConfig, ClaudeAppGatewayApplyResult, GatewayMcpServerConfig, @@ -59,6 +60,7 @@ declare global { getProxyNetworkCaptures: () => Promise; getProxyStatus: () => Promise; getRequestLogs: (filter?: RequestLogListFilter) => Promise; + getUpdateStatus: () => Promise; getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => Promise; installProxyCertificate: () => Promise; listMcpServerTools: (server: GatewayMcpServerConfig) => Promise; @@ -80,8 +82,12 @@ declare global { startGateway: () => Promise; stopGateway: () => Promise; testProviderAccountConnector: (request: ProviderAccountTestRequest) => Promise; + updateCheck: () => Promise; + updateDownload: () => Promise; + updateInstall: () => Promise; onBeforeQuit: (callback: () => void) => () => void; onProviderDeepLink: (callback: (request: ProviderDeepLinkRequest) => void) => () => void; + onUpdateStatusChanged: (callback: (status: AppUpdateStatus) => void) => () => void; }; } } diff --git a/src/shared/app.ts b/src/shared/app.ts index f79a5ae..e80cea0 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -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): boolean { + return profile.enabled && normalizeProfileScopeValue(profile.scope) === "global"; +} + +export function enforceSingleEnabledGlobalProfilePerAgent( + profiles: ProfileConfig[], + preferredIndex?: number +): ProfileConfig[] { + const activeGlobalProfileByAgent = new Map(); + 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; diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index cbd2f97..a1be281 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -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",