mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Add app update support and release publishing
This commit is contained in:
parent
c480a33587
commit
e3ec3cabde
19 changed files with 931 additions and 68 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
59
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}` : "";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
345
src/main/update-service.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
6
src/renderer/types/electron.d.ts
vendored
6
src/renderer/types/electron.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue