Expand application functionality and configuration

This commit is contained in:
musistudio
2026-07-28 14:57:36 +08:00
parent 2c89b212ef
commit 5a075c517b
21 changed files with 3626 additions and 16 deletions
+49
View File
@@ -167,6 +167,15 @@ server {
proxy_pass http://${CCR_WEB_HOST}:${CCR_WEB_PORT};
}
location ^~ /api/cloud-sync/auth/ {
proxy_http_version 1.1;
proxy_set_header Host ${CCR_WEB_HOST}:${CCR_WEB_PORT};
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_pass http://${CCR_WEB_HOST}:${CCR_WEB_PORT};
}
location = /health {
proxy_http_version 1.1;
proxy_set_header Host ${CCR_GATEWAY_HOST}:${CCR_GATEWAY_PORT};
@@ -196,6 +205,46 @@ server {
}
EOF
if [ "${CCR_DOCKER_CLOUD_SYNC_PROXY:-1}" != "0" ]; then
node - <<'NODE' &
const net = require("node:net");
const listenHost = process.env.CCR_DOCKER_CLOUD_SYNC_PROXY_LISTEN_HOST || "127.0.0.1";
const listenPort = Number(process.env.CCR_DOCKER_CLOUD_SYNC_PROXY_LISTEN_PORT || "3000");
const targetHost = process.env.CCR_DOCKER_CLOUD_SYNC_PROXY_TARGET_HOST || "host.docker.internal";
const targetPort = Number(process.env.CCR_DOCKER_CLOUD_SYNC_PROXY_TARGET_PORT || "3000");
function startProxy() {
const server = net.createServer((client) => {
const upstream = net.connect(targetPort, targetHost);
client.on("error", () => undefined);
upstream.on("error", () => client.destroy());
client.pipe(upstream);
upstream.pipe(client);
});
server.on("error", (error) => {
if (error && error.code !== "EADDRINUSE") {
console.error(`CCR cloud sync Docker proxy failed: ${error.message}`);
}
process.exit(0);
});
server.listen(listenPort, listenHost, () => {
console.log(`CCR cloud sync Docker proxy listening at ${listenHost}:${listenPort} -> ${targetHost}:${targetPort}`);
});
}
const probe = net.connect({ host: listenHost, port: listenPort });
probe.setTimeout(1000);
probe.once("connect", () => process.exit(0));
probe.once("timeout", () => {
probe.destroy();
startProxy();
});
probe.once("error", () => startProxy());
NODE
fi
if [ "$#" -gt 0 ]; then
exec "$@"
fi
File diff suppressed because it is too large Load Diff
+158 -2
View File
@@ -13,7 +13,7 @@ import { LEGACY_ACTIVE_CONFIG_FILE, LEGACY_CONFIG_FILE, LEGACY_WINDOWS_CONFIG_FI
import { normalizeCodexProviderAccountConfig } from "@ccr/core/agents/local-providers/codex";
import { normalizeGrokProviderAccountConfig, normalizeGrokProviderMediaCapabilities } from "@ccr/core/agents/local-providers/grok";
import { removeOpenCodeProviderAccountConfig } from "@ccr/core/agents/local-providers/opencode";
import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, DEFAULT_TRAY_COMPONENT_VARIANTS, GATEWAY_PLUGIN_PERMISSION_IDS, GATEWAY_PLUGIN_SURFACE_IDS, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, ROUTER_SCRIPT_API_VERSION, ROUTER_SCRIPT_DEFAULT_TIMEOUT_MS, ROUTER_SCRIPT_MAX_TIMEOUT_MS, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent, knownGatewayPluginDefaultApps, knownGatewayPluginDefaultPermissions, knownGatewayPluginDefaultSurfaces } from "@ccr/core/contracts/app";
import { CLAUDE_CODE_DEFAULT_ENV, CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, CLOUD_SYNC_DEFAULT_BASE_URL, DEFAULT_TRAY_COMPONENT_VARIANTS, GATEWAY_PLUGIN_PERMISSION_IDS, GATEWAY_PLUGIN_SURFACE_IDS, OVERVIEW_WIDGET_SIZE_VALUES, ROUTER_FALLBACK_MAX_RETRY_COUNT, ROUTER_SCRIPT_API_VERSION, ROUTER_SCRIPT_DEFAULT_TIMEOUT_MS, ROUTER_SCRIPT_MAX_TIMEOUT_MS, TRAY_SINGLETON_WIDGET_TYPES, TRAY_TOP_WIDGET_TYPES, TRAY_WINDOW_MODULE_IDS, enforceSingleEnabledGlobalProfilePerAgent, knownGatewayPluginDefaultApps, knownGatewayPluginDefaultPermissions, knownGatewayPluginDefaultSurfaces } from "@ccr/core/contracts/app";
import { createDefaultAppConfig } from "@ccr/core/config/default-config";
import { maxRequestLogBodyBytes } from "@ccr/core/observability/request-log-limits";
import { findProviderPresetByBaseUrl, primaryProviderPresetEndpoint, providerApiKeySafetyIssue, providerEndpointCanReceiveProviderApiKey } from "@ccr/core/providers/presets/index";
@@ -25,6 +25,7 @@ import type {
BotGatewayRuntimeConfig,
BotGatewaySavedConfig,
ClaudeCodeProfileConfig,
CloudSyncConfig,
CodexProfileConfig,
ContextArchiveConfig,
GatewayAgentConfig,
@@ -87,11 +88,12 @@ type LoadedBotGatewayConfig = Partial<Omit<BotGatewayRuntimeConfig, "handoff">>
handoff?: Partial<BotGatewayRuntimeConfig["handoff"]>;
};
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway" | "contextArchive" | "gateway" | "mediaTools" | "observability" | "profile" | "proxy" | "toolHub">> & {
type LoadedAppConfig = Partial<Omit<AppConfig, "Router" | "agent" | "botGateway" | "cloudSync" | "contextArchive" | "gateway" | "mediaTools" | "observability" | "profile" | "proxy" | "toolHub">> & {
Router?: Partial<RouterConfig>;
agent?: Partial<GatewayAgentConfig>;
botConfigs?: BotGatewaySavedConfig[];
botGateway?: LoadedBotGatewayConfig;
cloudSync?: Partial<CloudSyncConfig>;
contextArchive?: Partial<ContextArchiveConfig>;
gateway?: Partial<AppConfig["gateway"]>;
mediaTools?: Partial<MediaToolsConfig>;
@@ -145,6 +147,62 @@ function completeBotGatewayConfig(config: LoadedBotGatewayConfig | undefined): B
};
}
function completeCloudSyncConfig(config: Partial<CloudSyncConfig> | undefined): CloudSyncConfig {
const baseUrl = normalizeCloudSyncBaseUrl(config?.baseUrl);
const namespace = normalizeCloudSyncNamespace(config?.namespace);
const configuredLastRevision = config?.lastRevision;
const lastRevision = Number.isInteger(configuredLastRevision) && configuredLastRevision !== undefined && configuredLastRevision >= 0
? configuredLastRevision
: DEFAULT_CONFIG.cloudSync.lastRevision;
const keyMode = config?.keyMode === "key-file" || config?.keyMode === "password"
? config.keyMode
: undefined;
return {
baseUrl,
deviceId: nonEmptyString(config?.deviceId),
deviceName: nonEmptyString(config?.deviceName) || DEFAULT_CONFIG.cloudSync.deviceName,
enabled: Boolean(config?.enabled && baseUrl),
keyId: nonEmptyString(config?.keyId),
keyMode,
keySalt: nonEmptyString(config?.keySalt),
lastRevision,
lastSyncedSnapshot: isCloudSyncSnapshotLike(config?.lastSyncedSnapshot) ? config.lastSyncedSnapshot : undefined,
lastSyncAt: nonEmptyString(config?.lastSyncAt),
lastSyncError: nonEmptyString(config?.lastSyncError),
namespace,
accessToken: nonEmptyString(config?.accessToken),
refreshToken: nonEmptyString(config?.refreshToken),
refreshTokenExpiresAt: nonEmptyString(config?.refreshTokenExpiresAt),
snapshotHash: nonEmptyString(config?.snapshotHash),
userAvatarUrl: nonEmptyString(config?.userAvatarUrl),
userEmail: nonEmptyString(config?.userEmail),
userId: nonEmptyString(config?.userId),
userLogin: nonEmptyString(config?.userLogin),
userName: nonEmptyString(config?.userName)
};
}
function normalizeCloudSyncBaseUrl(_value: unknown): string {
return CLOUD_SYNC_DEFAULT_BASE_URL;
}
function normalizeCloudSyncNamespace(value: unknown): string {
const raw = nonEmptyString(value) || DEFAULT_CONFIG.cloudSync.namespace;
return /^[a-zA-Z0-9._-]{1,64}$/.test(raw) ? raw : DEFAULT_CONFIG.cloudSync.namespace;
}
function isCloudSyncSnapshotLike(value: unknown): value is Record<string, unknown> {
return isObject(value) &&
value.kind === "claude-code-router-cloud-sync-snapshot" &&
value.version === 1 &&
isObject(value.config);
}
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeBotGatewayPlatform(value: unknown): string {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (!normalized || normalized === "off" || normalized === "disabled") {
@@ -274,6 +332,7 @@ export async function loadAppConfig(): Promise<AppConfig> {
},
botConfigs: picked.botConfigs ?? DEFAULT_CONFIG.botConfigs,
botGateway: completeBotGatewayConfig(picked.botGateway),
cloudSync: completeCloudSyncConfig(picked.cloudSync),
contextArchive: {
enabled: picked.contextArchive?.enabled ?? DEFAULT_CONFIG.contextArchive.enabled,
maxBytes: picked.contextArchive?.maxBytes ?? DEFAULT_CONFIG.contextArchive.maxBytes,
@@ -731,6 +790,10 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
if (botGateway) {
config.botGateway = botGateway;
}
const cloudSync = parseCloudSync((value as Record<string, unknown>).cloudSync ?? (value as Record<string, unknown>).cloud_sync);
if (cloudSync) {
config.cloudSync = cloudSync;
}
const botConfigs = parseBotGatewaySavedConfigs((value as Record<string, unknown>).botConfigs ?? (value as Record<string, unknown>).bot_configs);
if (botConfigs) {
config.botConfigs = botConfigs;
@@ -831,6 +894,99 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
return config;
}
function parseCloudSync(value: unknown): Partial<CloudSyncConfig> | undefined {
if (!isObject(value)) {
return undefined;
}
const cloudSync: Partial<CloudSyncConfig> = {};
if (typeof value.enabled === "boolean") {
cloudSync.enabled = value.enabled;
}
const baseUrl = readString(value.baseUrl ?? value.base_url);
if (baseUrl !== undefined) {
cloudSync.baseUrl = baseUrl;
}
const namespace = readString(value.namespace);
if (namespace !== undefined) {
cloudSync.namespace = namespace;
}
const deviceId = readString(value.deviceId ?? value.device_id);
if (deviceId !== undefined) {
cloudSync.deviceId = deviceId;
}
const deviceName = readString(value.deviceName ?? value.device_name);
if (deviceName !== undefined) {
cloudSync.deviceName = deviceName;
}
const keyId = readString(value.keyId ?? value.key_id);
if (keyId !== undefined) {
cloudSync.keyId = keyId;
}
const keySalt = readString(value.keySalt ?? value.key_salt);
if (keySalt !== undefined) {
cloudSync.keySalt = keySalt;
}
const keyMode = readString(value.keyMode ?? value.key_mode);
if (keyMode === "password" || keyMode === "key-file") {
cloudSync.keyMode = keyMode;
}
const accessToken = readString(value.accessToken ?? value.access_token);
if (accessToken !== undefined) {
cloudSync.accessToken = accessToken;
}
const refreshToken = readString(value.refreshToken ?? value.refresh_token);
if (refreshToken !== undefined) {
cloudSync.refreshToken = refreshToken;
}
const refreshTokenExpiresAt = readString(value.refreshTokenExpiresAt ?? value.refresh_token_expires_at);
if (refreshTokenExpiresAt !== undefined) {
cloudSync.refreshTokenExpiresAt = refreshTokenExpiresAt;
}
const snapshotHash = readString(value.snapshotHash ?? value.snapshot_hash);
if (snapshotHash !== undefined) {
cloudSync.snapshotHash = snapshotHash;
}
const userId = readString(value.userId ?? value.user_id);
if (userId !== undefined) {
cloudSync.userId = userId;
}
const userLogin = readString(value.userLogin ?? value.user_login ?? value.githubLogin ?? value.github_login);
if (userLogin !== undefined) {
cloudSync.userLogin = userLogin;
}
const userName = readString(value.userName ?? value.user_name ?? value.githubName ?? value.github_name);
if (userName !== undefined) {
cloudSync.userName = userName;
}
const userAvatarUrl = readString(value.userAvatarUrl ?? value.user_avatar_url ?? value.avatarUrl ?? value.avatar_url);
if (userAvatarUrl !== undefined) {
cloudSync.userAvatarUrl = userAvatarUrl;
}
const userEmail = readString(value.userEmail ?? value.user_email ?? value.email);
if (userEmail !== undefined) {
cloudSync.userEmail = userEmail;
}
const lastRevision = readNumber(value.lastRevision ?? value.last_revision);
if (lastRevision !== undefined) {
cloudSync.lastRevision = Math.max(0, Math.floor(lastRevision));
}
const lastSyncedSnapshot = value.lastSyncedSnapshot ?? value.last_synced_snapshot;
if (isCloudSyncSnapshotLike(lastSyncedSnapshot)) {
cloudSync.lastSyncedSnapshot = lastSyncedSnapshot;
}
const lastSyncAt = readString(value.lastSyncAt ?? value.last_sync_at);
if (lastSyncAt !== undefined) {
cloudSync.lastSyncAt = lastSyncAt;
}
const lastSyncError = readString(value.lastSyncError ?? value.last_sync_error);
if (lastSyncError !== undefined) {
cloudSync.lastSyncError = lastSyncError;
}
return cloudSync;
}
function parseContextArchive(value: unknown): Partial<ContextArchiveConfig> | undefined {
if (!isObject(value)) {
return undefined;
@@ -1,5 +1,6 @@
import {
CLAUDE_CODE_DEFAULT_ENV,
CLOUD_SYNC_DEFAULT_BASE_URL,
DEFAULT_OVERVIEW_WIDGETS,
DEFAULT_TRAY_COMPONENT_VARIANTS,
DEFAULT_TRAY_WIDGETS,
@@ -90,6 +91,13 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions = {}): A
streamReplies: true,
tenantId: "ccr"
},
cloudSync: {
baseUrl: CLOUD_SYNC_DEFAULT_BASE_URL,
deviceName: "",
enabled: false,
lastRevision: 0,
namespace: "ccr"
},
contextArchive: {
enabled: false,
maxBytes: 512 * 1024 * 1024,
+91
View File
@@ -19,6 +19,96 @@ export type AppDataExportResult = {
file?: string;
};
export const CLOUD_SYNC_DEFAULT_BASE_URL = "http://127.0.0.1:3000";
export type CloudSyncConfig = {
accessToken?: string;
baseUrl: string;
deviceId?: string;
deviceName: string;
enabled: boolean;
keyId?: string;
keyMode?: "key-file" | "password";
keySalt?: string;
lastRevision: number;
lastSyncedSnapshot?: unknown;
lastSyncAt?: string;
lastSyncError?: string;
namespace: string;
refreshToken?: string;
refreshTokenExpiresAt?: string;
snapshotHash?: string;
userAvatarUrl?: string;
userEmail?: string;
userId?: string;
userLogin?: string;
userName?: string;
};
export type CloudSyncKeyInput = {
keyFilePath?: string;
password?: string;
};
export type CloudSyncSetupRequest = CloudSyncKeyInput & {
restoreOnly?: boolean;
};
export type CloudSyncLoginResult = {
config?: AppConfig;
loginUrl: string;
message: string;
status: CloudSyncStatus;
};
export type CloudSyncPushRequest = CloudSyncKeyInput & {
force?: boolean;
};
export type CloudSyncPullRequest = CloudSyncKeyInput & {
apply?: boolean;
};
export type CloudSyncStatus = {
authenticated: boolean;
baseUrl: string;
configured: boolean;
deviceId?: string;
deviceName: string;
enabled: boolean;
keyId?: string;
keyMode?: "key-file" | "password";
lastRevision: number;
lastSyncAt?: string;
lastSyncError?: string;
namespace: string;
snapshotHash?: string;
unlocked: boolean;
userAvatarUrl?: string;
userEmail?: string;
userId?: string;
userLogin?: string;
userName?: string;
};
export type CloudSyncKeyFileResult = {
canceled: boolean;
file?: string;
};
export type CloudSyncOperationResult = {
authExpired?: boolean;
config?: AppConfig;
conflict?: boolean;
message: string;
mergeApplied?: boolean;
mergeConflicts?: string[];
remoteRevision?: number;
snapshotApplied?: boolean;
snapshotPushed?: boolean;
status: CloudSyncStatus;
};
export type AppCaptureElementPngRequest = {
borderRadius?: number;
exportId?: string;
@@ -1726,6 +1816,7 @@ export type AppConfig = {
autoStart: boolean;
botConfigs: BotGatewaySavedConfig[];
botGateway: BotGatewayRuntimeConfig;
cloudSync: CloudSyncConfig;
contextArchive: ContextArchiveConfig;
gateway: GatewayRuntimeConfig;
mediaTools: MediaToolsConfig;
@@ -1,6 +1,14 @@
export const IPC_CHANNELS = {
appBeforeQuit: "ccr:app:before-quit",
appCaptureElementPng: "ccr:app:capture-element-png",
appCloudSyncDisable: "ccr:app:cloud-sync-disable",
appCloudSyncAuthChanged: "ccr:app:cloud-sync-auth-changed",
appCloudSyncGenerateKeyFile: "ccr:app:cloud-sync-generate-key-file",
appCloudSyncGetStatus: "ccr:app:cloud-sync-get-status",
appCloudSyncLogin: "ccr:app:cloud-sync-login",
appCloudSyncPull: "ccr:app:cloud-sync-pull",
appCloudSyncPush: "ccr:app:cloud-sync-push",
appCloudSyncSetup: "ccr:app:cloud-sync-setup",
appCloseTray: "ccr:app:close-tray",
appDetectProviderIcon: "ccr:app:detect-provider-icon",
appExportData: "ccr:app:export-data",
+189
View File
@@ -12,6 +12,7 @@ import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin
import { syncClaudeAppGatewayConfig, restoreClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch";
import { findInstalledOpenCodeAppExecutable } from "@ccr/core/agents/opencode/app-launch";
import { applyCloudSyncAuthTokens, autoPushCloudSyncConfig, createCloudSyncKeyFile, disableCloudSyncConfig, ensureCloudSyncUserProfile, getCloudSyncStatus, pullCloudSyncConfig, pushCloudSyncConfig, refreshCloudSyncUserProfile, setupCloudSyncConfig, startCloudSyncLogin } from "@ccr/core/cloud-sync/service";
import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config";
import {
APP_CONFIG_DB_FILE,
@@ -57,6 +58,12 @@ import type {
BotGatewayQrLoginStartRequest,
BotGatewayQrLoginWaitRequest,
BotGatewayQrWindowOpenRequest,
CloudSyncOperationResult,
CloudSyncKeyFileResult,
CloudSyncLoginResult,
CloudSyncPullRequest,
CloudSyncPushRequest,
CloudSyncSetupRequest,
GatewayPluginAppConfig,
GatewayPluginPermission,
GatewayPluginSurface,
@@ -125,6 +132,9 @@ const staticRoot = path.resolve(__dirname, "..", "renderer");
const homeHtmlFile = path.join(staticRoot, "pages", "home", "index.html");
const rendererAssetsRoot = path.join(staticRoot, "assets");
const webBridgeScriptTag = ' <script src="../../assets/web-client-bridge.js"></script>';
const cloudSyncAuthCallbackPath = "/api/cloud-sync/auth/callback";
const cloudSyncAuthSessionParam = "session";
const cloudSyncAuthSessions = new Set<string>();
export async function startWebManagementServer(options: WebManagementServerOptions = {}): Promise<WebManagementServerRuntime> {
@@ -176,6 +186,10 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse,
}
const url = requestUrl(request);
if (url.pathname === cloudSyncAuthCallbackPath) {
await handleCloudSyncAuthCallbackRequest(request, response);
return;
}
if (url.pathname === "/api/ccr/rpc") {
await handleRpcRequest(request, response, security);
return;
@@ -222,6 +236,18 @@ async function handleRpcRequest(request: IncomingMessage, response: ServerRespon
}
const method = typeof payload.method === "string" ? payload.method.trim() : "";
if (method === "cloudSyncLogin") {
try {
sendJson(response, 200, {
ok: true,
value: await handleCloudSyncLoginRpc(request)
});
} catch (error) {
sendJson(response, 500, { error: { message: formatError(error) }, ok: false });
}
return;
}
const handler = rpcHandlers[method];
if (!method || !handler) {
sendJson(response, 404, { error: { message: `Unknown CCR web RPC method: ${method || "(empty)"}` }, ok: false });
@@ -236,6 +262,48 @@ async function handleRpcRequest(request: IncomingMessage, response: ServerRespon
}
}
async function handleCloudSyncLoginRpc(
request: IncomingMessage
): Promise<CloudSyncLoginResult> {
const session = randomBytes(24).toString("base64url");
cloudSyncAuthSessions.add(session);
setTimeout(() => cloudSyncAuthSessions.delete(session), 10 * 60 * 1000).unref();
const login = startCloudSyncLogin(await loadAppConfig(), {
callbackUrl: cloudSyncCallbackUrl(request, session)
});
const savedConfig = login.config ? await saveAppConfig(login.config) : await loadAppConfig();
return {
...login,
config: savedConfig,
status: getCloudSyncStatus(savedConfig)
};
}
async function handleCloudSyncAuthCallbackRequest(
request: IncomingMessage,
response: ServerResponse
): Promise<void> {
if (request.method !== "GET" && request.method !== "HEAD") {
sendText(response, 405, "Method not allowed");
return;
}
const url = requestUrl(request);
const session = url.searchParams.get(cloudSyncAuthSessionParam)?.trim();
if (!session || !cloudSyncAuthSessions.delete(session)) {
sendText(response, 401, "Invalid or expired cloud sync login session");
return;
}
try {
await saveAppConfig(await refreshCloudSyncUserProfile(applyCloudSyncAuthTokens(await loadAppConfig(), cloudSyncAuthTokensFromUrl(url))));
sendHtml(response, 200, cloudSyncAuthSuccessHtml(), request.method === "HEAD");
} catch (error) {
sendHtml(response, 500, cloudSyncAuthFailureHtml(formatError(error)), request.method === "HEAD");
}
}
const unsupportedUpdateStatus: AppUpdateStatus = {
canCheck: false,
canDownload: false,
@@ -273,6 +341,28 @@ const rpcHandlers: Record<string, RpcHandler> = {
applyProfile: async () => applyProfileConfig(await loadAppConfig()),
cancelBotGatewayQrLogin: (request) => cancelBotGatewayQrLogin(request as BotGatewayQrLoginCancelRequest),
checkProviderConnectivity: (request) => checkGatewayProviderConnectivity(request as GatewayProviderConnectivityCheckRequest),
cloudSyncDisable: async () => {
const savedConfig = await saveAppConfig(disableCloudSyncConfig(await loadAppConfig()));
return {
config: savedConfig,
message: "Cloud sync is disabled on this device.",
status: getCloudSyncStatus(savedConfig)
} satisfies CloudSyncOperationResult;
},
cloudSyncGenerateKeyFile: (file): CloudSyncKeyFileResult => {
const keyFilePath = typeof file === "string" ? file.trim() : "";
if (!keyFilePath) {
throw new Error("Enter a key file path before generating a cloud sync key file.");
}
return createCloudSyncKeyFile(keyFilePath);
},
cloudSyncGetStatus: async () => {
const savedConfig = await saveAppConfig(await ensureCloudSyncUserProfile(await loadAppConfig()));
return getCloudSyncStatus(savedConfig);
},
cloudSyncPull: async (request) => persistCloudSyncOperation(await pullCloudSyncConfig(await loadAppConfig(), request as CloudSyncPullRequest | undefined)),
cloudSyncPush: async (request) => persistCloudSyncOperation(await pushCloudSyncConfig(await loadAppConfig(), request as CloudSyncPushRequest | undefined)),
cloudSyncSetup: async (request) => persistCloudSyncOperation(await setupCloudSyncConfig(await loadAppConfig(), request as CloudSyncSetupRequest)),
clearProxyNetworkCaptures: () => proxyService.clearNetworkCaptures(),
closeBotGatewayQrWindow: (_request) => ({ closed: false }),
detectProviderIcon: (request) => detectProviderIcon(request as ProviderIconDetectionRequest),
@@ -402,6 +492,10 @@ const rpcHandlers: Record<string, RpcHandler> = {
await applyProfileIfServiceRunning(savedConfig, runtimeStatus);
}
invalidateProviderAccountSnapshotCache();
const cloudSyncedConfig = await autoPushCloudSyncConfig(savedConfig);
if (cloudSyncedConfig !== savedConfig) {
savedConfig = await saveAppConfig(cloudSyncedConfig);
}
return savedConfig;
},
scanBotHandoffBluetoothTargets: () => scanBotHandoffBluetoothTargets(),
@@ -433,6 +527,21 @@ const rpcHandlers: Record<string, RpcHandler> = {
waitBotGatewayQrLogin: (request) => waitBotGatewayQrLogin(request as BotGatewayQrLoginWaitRequest)
};
async function persistCloudSyncOperation(result: CloudSyncOperationResult): Promise<CloudSyncOperationResult> {
if (!result.config) {
return result;
}
const savedConfig = await saveAppConfig(result.config);
await gatewayService.updateConfig(savedConfig);
await applyProfileIfServiceRunning(savedConfig, gatewayService.getStatus());
invalidateProviderAccountSnapshotCache();
return {
...result,
config: savedConfig,
status: getCloudSyncStatus(savedConfig)
};
}
async function startConfiguredServices(reason: string): Promise<void> {
try {
let config = await loadAppConfig();
@@ -518,6 +627,77 @@ function sendHomeHtml(response: ServerResponse, headOnly: boolean): void {
sendBuffer(response, 200, Buffer.from(html, "utf8"), "text/html; charset=utf-8", headOnly);
}
function sendHtml(response: ServerResponse, status: number, html: string, headOnly: boolean): void {
sendBuffer(response, status, Buffer.from(html, "utf8"), "text/html; charset=utf-8", headOnly);
}
function cloudSyncCallbackUrl(request: IncomingMessage, session: string): string {
const publicBaseUrl = readEnvString("CCR_PUBLIC_BASE_URL");
const url = publicBaseUrl
? new URL(cloudSyncAuthCallbackPath, `${publicBaseUrl.replace(/\/+$/, "")}/`)
: requestUrl(request);
url.pathname = cloudSyncAuthCallbackPath;
url.search = "";
url.hash = "";
url.searchParams.set(cloudSyncAuthSessionParam, session);
return url.toString();
}
function cloudSyncAuthTokensFromUrl(url: URL): {
accessToken: string;
refreshToken: string;
refreshTokenExpiresAt?: string;
userId?: string;
} {
return {
accessToken: requiredQueryParam(url, "access_token"),
refreshToken: requiredQueryParam(url, "refresh_token"),
refreshTokenExpiresAt: optionalQueryParam(url, "refresh_token_expires_at"),
userId: optionalQueryParam(url, "user_id")
};
}
function requiredQueryParam(url: URL, key: string): string {
const value = optionalQueryParam(url, key);
if (!value) {
throw new Error(`Cloud sync callback is missing ${key}.`);
}
return value;
}
function optionalQueryParam(url: URL, key: string): string | undefined {
const value = url.searchParams.get(key)?.trim();
return value || undefined;
}
function cloudSyncAuthSuccessHtml(): string {
return [
"<!doctype html>",
"<html>",
"<head><meta charset=\"utf-8\"><meta name=\"referrer\" content=\"no-referrer\"><title>CCR Cloud Sync</title></head>",
"<body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px; line-height: 1.5;\">",
"<script>try{history.replaceState(null,'','/api/cloud-sync/auth/complete')}catch{}</script>",
"<h1>Cloud sync login complete</h1>",
"<p>You can return to CCR and choose an end-to-end encryption method.</p>",
"</body>",
"</html>"
].join("");
}
function cloudSyncAuthFailureHtml(message: string): string {
return [
"<!doctype html>",
"<html>",
"<head><meta charset=\"utf-8\"><meta name=\"referrer\" content=\"no-referrer\"><title>CCR Cloud Sync</title></head>",
"<body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px; line-height: 1.5;\">",
"<script>try{history.replaceState(null,'','/api/cloud-sync/auth/complete')}catch{}</script>",
"<h1>Cloud sync login failed</h1>",
`<p>${escapeHtml(message)}</p>`,
"</body>",
"</html>"
].join("");
}
function sendStaticFile(response: ServerResponse, root: string, relativePath: string, headOnly: boolean): void {
const normalizedRelativePath = relativePath.replace(/^[/\\]+/, "");
const file = path.resolve(root, normalizedRelativePath);
@@ -1479,6 +1659,15 @@ function fileSafeTimestamp(value: string): string {
return value.replace(/[:.]/g, "-");
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
function formatListenHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
applyCloudSyncSnapshot,
createCloudSyncSnapshot,
decryptCloudSyncSnapshotForTest,
encryptCloudSyncSnapshotForTest,
mergeCloudSyncSnapshotsForTest
} from "@ccr/core/cloud-sync/service.ts";
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
test("cloud sync snapshot contains user sync data but excludes cloud sync secrets", () => {
const config = createDefaultAppConfig();
config.Providers = [{
api_key: "provider-secret",
api_base_url: "https://example.test/v1",
models: ["model-a"],
name: "Example"
}];
config.Router.rules = [{
condition: { left: "body.model", operator: "equals", right: "model-a" },
enabled: true,
fallback: { mode: "off", models: [], retryCount: 1 },
id: "route-a",
name: "Route A",
type: "conditional"
}];
config.cloudSync = {
accessToken: "cloud-access-token",
baseUrl: "https://cloud.example.test",
deviceName: "test-device",
enabled: true,
keyId: "local-key",
keySalt: "local-salt",
lastRevision: 7,
namespace: "ccr",
refreshToken: "cloud-refresh-token"
};
const snapshot = createCloudSyncSnapshot(config, "2026-07-28T00:00:00.000Z");
assert.equal(snapshot.kind, "claude-code-router-cloud-sync-snapshot");
assert.equal(snapshot.config.Providers[0].api_key, "provider-secret");
assert.equal(Object.hasOwn(snapshot.config, "cloudSync"), false);
assert.equal(JSON.stringify(snapshot).includes("cloud-access-token"), false);
assert.equal(JSON.stringify(snapshot).includes("cloud-refresh-token"), false);
});
test("cloud sync snapshots decrypt with the matching password only", () => {
const config = createDefaultAppConfig();
config.profile.profiles[0].model = "provider,model-a";
const snapshot = createCloudSyncSnapshot(config, "2026-07-28T00:00:00.000Z");
const encrypted = encryptCloudSyncSnapshotForTest(snapshot, "correct horse battery staple");
const decrypted = decryptCloudSyncSnapshotForTest(
encrypted.encrypted,
"correct horse battery staple",
encrypted.keySalt
);
assert.deepEqual(decrypted, snapshot);
assert.throws(
() => decryptCloudSyncSnapshotForTest(encrypted.encrypted, "wrong password", encrypted.keySalt),
/does not match|Unsupported state|unable to authenticate/i
);
});
test("cloud sync snapshots apply the synced config fields onto a local config", () => {
const localConfig = createDefaultAppConfig();
const remoteConfig = createDefaultAppConfig();
remoteConfig.preferredProvider = "Example";
remoteConfig.Providers = [{
api_key: "remote-secret",
api_base_url: "https://example.test/v1",
models: ["remote-model"],
name: "Example"
}];
const merged = applyCloudSyncSnapshot(localConfig, createCloudSyncSnapshot(remoteConfig));
assert.equal(merged.preferredProvider, "Example");
assert.equal(merged.Providers[0].name, "Example");
assert.equal(merged.Providers[0].api_key, "remote-secret");
assert.deepEqual(merged.cloudSync, localConfig.cloudSync);
});
test("cloud sync conflict merge combines independent additions", () => {
const baseConfig = createDefaultAppConfig();
const localConfig = createDefaultAppConfig();
const remoteConfig = createDefaultAppConfig();
localConfig.Providers = [{
api_key: "local-secret",
api_base_url: "https://local.example.test/v1",
models: ["local-model"],
name: "Local"
}];
remoteConfig.Providers = [{
api_key: "remote-secret",
api_base_url: "https://remote.example.test/v1",
models: ["remote-model"],
name: "Remote"
}];
localConfig.Router.rules = [{
condition: { left: "body.model", operator: "equals", right: "local-model" },
enabled: true,
fallback: { mode: "off", models: [], retryCount: 1 },
id: "local-route",
name: "Local route",
type: "condition"
}];
remoteConfig.Router.rules = [{
condition: { left: "body.model", operator: "equals", right: "remote-model" },
enabled: true,
fallback: { mode: "off", models: [], retryCount: 1 },
id: "remote-route",
name: "Remote route",
type: "condition"
}];
const merged = mergeCloudSyncSnapshotsForTest(
createCloudSyncSnapshot(baseConfig),
createCloudSyncSnapshot(localConfig),
createCloudSyncSnapshot(remoteConfig)
);
assert.deepEqual(merged.conflicts, []);
assert.deepEqual(merged.snapshot.config.Providers.map((provider) => provider.name).sort(), ["Local", "Remote"]);
assert.deepEqual(merged.snapshot.config.Router.rules.map((rule) => rule.id).sort(), ["local-route", "remote-route"]);
});
test("cloud sync conflict merge combines primitive list changes", () => {
const baseConfig = createDefaultAppConfig();
const localConfig = createDefaultAppConfig();
const remoteConfig = createDefaultAppConfig();
const provider = {
api_key: "secret",
api_base_url: "https://example.test/v1",
models: ["base-model"],
name: "Example"
};
baseConfig.Providers = [provider];
localConfig.Providers = [{ ...provider, models: ["base-model", "local-model"] }];
remoteConfig.Providers = [{ ...provider, models: ["base-model", "remote-model"] }];
const merged = mergeCloudSyncSnapshotsForTest(
createCloudSyncSnapshot(baseConfig),
createCloudSyncSnapshot(localConfig),
createCloudSyncSnapshot(remoteConfig)
);
assert.deepEqual(merged.conflicts, []);
assert.deepEqual(merged.snapshot.config.Providers[0].models, ["base-model", "local-model", "remote-model"]);
});
test("cloud sync conflict merge keeps local values for same-field conflicts", () => {
const baseConfig = createDefaultAppConfig();
const localConfig = createDefaultAppConfig();
const remoteConfig = createDefaultAppConfig();
const provider = {
api_key: "base-secret",
api_base_url: "https://example.test/v1",
models: ["model"],
name: "Example"
};
baseConfig.preferredProvider = "Base";
localConfig.preferredProvider = "Local";
remoteConfig.preferredProvider = "Remote";
baseConfig.Providers = [provider];
localConfig.Providers = [{ ...provider, api_key: "local-secret" }];
remoteConfig.Providers = [{ ...provider, api_key: "remote-secret" }];
const merged = mergeCloudSyncSnapshotsForTest(
createCloudSyncSnapshot(baseConfig),
createCloudSyncSnapshot(localConfig),
createCloudSyncSnapshot(remoteConfig)
);
assert.equal(merged.snapshot.config.preferredProvider, "Local");
assert.equal(merged.snapshot.config.Providers[0].api_key, "local-secret");
assert.deepEqual(merged.conflicts, [
"config.Providers[Example].api_key",
"config.preferredProvider"
]);
});
+79 -1
View File
@@ -1,9 +1,10 @@
import { app, dialog } from "electron";
import path from "node:path";
import { appDeepLinkProtocol, createProviderDeepLinkRequest as createSharedProviderDeepLinkRequest, isAppDeepLinkUrl } from "@ccr/core/contracts/deep-link";
import { applyCloudSyncAuthTokens, refreshCloudSyncUserProfile } from "@ccr/core/cloud-sync/service";
import { CLAUDE_DESIGN_PLUGIN_ID, CLAUDE_SHIP_PLUGIN_ID, knownGatewayPluginDefaultApps, type AppConfig, type GatewayPluginAppConfig, type ProviderDeepLinkRequest } from "@ccr/core/contracts/app";
import { IPC_CHANNELS } from "@ccr/core/config/constants";
import { loadAppConfig } from "@ccr/core/config/config";
import { loadAppConfig, saveAppConfig } from "@ccr/core/config/config";
import { syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
import { gatewayService } from "@ccr/core/gateway/service";
import { providerIdentitySafetyIssue } from "@ccr/core/providers/presets/index";
@@ -17,6 +18,13 @@ type PluginDeepLinkRequest = {
rawUrl: string;
};
type CloudSyncAuthDeepLink = {
accessToken: string;
refreshToken: string;
refreshTokenExpiresAt?: string;
userId?: string;
};
const pluginAppExistingGatewayProbeTimeoutMs = 2_000;
const pluginAppStartupProbeTimeoutMs = 12_000;
const pluginAppProbeIntervalMs = 250;
@@ -77,6 +85,12 @@ class DeepLinkService {
return;
}
const cloudSyncAuth = parseCloudSyncAuthDeepLink(url);
if (cloudSyncAuth) {
void this.handleCloudSyncAuth(cloudSyncAuth);
return;
}
const request = createProviderDeepLinkRequest(url);
this.pendingProviderRequests.push(request);
if (this.pendingProviderRequests.length > 20) {
@@ -117,6 +131,27 @@ class DeepLinkService {
}
}
private async handleCloudSyncAuth(input: CloudSyncAuthDeepLink): Promise<void> {
try {
const savedConfig = await saveAppConfig(await refreshCloudSyncUserProfile(applyCloudSyncAuthTokens(await loadAppConfig(), input)));
logDeepLinkDiagnostic(`[deep-link] Cloud sync login completed for user ${savedConfig.cloudSync.userId || "<unknown>"}`);
if (app.isReady()) {
windowsManager.showMainWindow();
windowsManager.broadcast(IPC_CHANNELS.appCloudSyncAuthChanged);
}
} catch (error) {
const detail = formatError(error);
console.error(`[deep-link] Failed to store cloud sync auth tokens: ${detail}`);
if (app.isReady()) {
try {
dialog.showErrorBox("Cloud sync login failed", detail);
} catch {
// The console error above remains available when the dialog API is unavailable.
}
}
}
}
private async openPluginRequest(request: PluginDeepLinkRequest): Promise<void> {
const requestKey = `${request.pluginId}:${request.appId || ""}`;
if (this.openingPluginRequests.has(requestKey)) {
@@ -333,6 +368,49 @@ function parsePluginDeepLinkRequest(rawUrl: string): PluginDeepLinkRequest | und
};
}
function parseCloudSyncAuthDeepLink(rawUrl: string): CloudSyncAuthDeepLink | undefined {
const url = new URL(rawUrl.trim());
if (url.protocol !== `${appDeepLinkProtocol}:`) {
return undefined;
}
const host = url.hostname.toLowerCase();
const pathSegments = url.pathname.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment).toLowerCase());
const cloudSyncTarget = host === "cloud-sync" && pathSegments[0] === "auth" ||
host === "auth" && pathSegments[0] === "cloud-sync" ||
pathSegments[0] === "cloud-sync" && pathSegments[1] === "auth";
if (!cloudSyncTarget) {
return undefined;
}
const params = new URLSearchParams(url.search);
if (url.hash.length > 1) {
for (const [key, value] of new URLSearchParams(url.hash.slice(1))) {
params.set(key, value);
}
}
return {
accessToken: requiredDeepLinkParam(params, "access_token"),
refreshToken: requiredDeepLinkParam(params, "refresh_token"),
refreshTokenExpiresAt: optionalDeepLinkParam(params, "refresh_token_expires_at"),
userId: optionalDeepLinkParam(params, "user_id")
};
}
function requiredDeepLinkParam(params: URLSearchParams, key: string): string {
const value = optionalDeepLinkParam(params, key);
if (!value) {
throw new Error(`Cloud sync auth link is missing ${key}.`);
}
return value;
}
function optionalDeepLinkParam(params: URLSearchParams, key: string): string | undefined {
const value = params.get(key)?.trim();
return value || undefined;
}
function boundedPluginId(value: string | null | undefined, optional = false): string {
const normalized = value?.trim();
if (!normalized) {
+187 -1
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell, type OpenDialogOptions, type Rectangle, type SaveDialogOptions } from "electron";
import { randomUUID } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { deflateSync, inflateSync } from "node:zlib";
@@ -11,6 +12,7 @@ import { closeBotGatewayQrWindow, openBotGatewayQrWindow } from "./bot-gateway-q
import { syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service";
import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch";
import { findInstalledOpenCodeAppExecutable } from "@ccr/core/agents/opencode/app-launch";
import { applyCloudSyncAuthTokens, autoPushCloudSyncConfig, createCloudSyncKeyFile, disableCloudSyncConfig, ensureCloudSyncUserProfile, getCloudSyncStatus, pullCloudSyncConfig, pushCloudSyncConfig, refreshCloudSyncUserProfile, setupCloudSyncConfig, startCloudSyncLogin } from "@ccr/core/cloud-sync/service";
import { loadAppConfig, saveApiKeysConfig, saveAppConfig, saveAppThemePreference, withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config";
import {
APP_CONFIG_DB_FILE,
@@ -51,7 +53,7 @@ import { appUpdateService } from "./update-service";
import { getUsageStats } from "@ccr/core/usage/store";
import { applyNativeThemePreference } from "./native-theme";
import windowsManager from "./windows";
import { CLAUDE_DESIGN_PLUGIN_ID, GATEWAY_PLUGIN_PERMISSION_IDS, GATEWAY_PLUGIN_SURFACE_IDS, type AgentAnalysisFilter, type AgentAnalysisTracePayloadRequest, type ApiKeyConfig, type AppCaptureElementPngRequest, type AppCaptureElementPngResult, type AppConfig, type AppDataExportResult, type AppImageExportTargetRequest, type AppImageExportTargetResult, type AppInfo, type AppRenderHtmlPngRequest, type AppRenderHtmlPngResult, type AppSaveConfigOptions, type BotGatewayQrLoginCancelRequest, type BotGatewayQrLoginStartRequest, type BotGatewayQrLoginWaitRequest, type BotGatewayQrWindowCloseRequest, type BotGatewayQrWindowOpenRequest, type GatewayPluginAppConfig, type GatewayPluginPermission, type GatewayPluginSurface, type GatewayProviderConnectivityCheckRequest, type GatewayProviderProbeCandidatesRequest, type GatewayProviderProbeRequest, type GatewayStatus, type LocalAgentProviderImportRequest, type PluginDependency, type PluginDirectorySelection, type ProfileApplyResult, type ProfileOpenRequest, type ProfileOpenResult, type ProviderAccountResetRequest, type ProviderAccountSnapshotRequestOptions, type ProviderAccountTestRequest, type ProviderCatalogModelsRequest, type ProviderIconDetectionRequest, type ProviderManifestFetchRequest, type RequestLogListFilter, type RouteScriptTestRequest, type RouteScriptValidationRequest, type UsageStatsFilter, type UsageStatsRange } from "@ccr/core/contracts/app";
import { CLAUDE_DESIGN_PLUGIN_ID, GATEWAY_PLUGIN_PERMISSION_IDS, GATEWAY_PLUGIN_SURFACE_IDS, type AgentAnalysisFilter, type AgentAnalysisTracePayloadRequest, type ApiKeyConfig, type AppCaptureElementPngRequest, type AppCaptureElementPngResult, type AppConfig, type AppDataExportResult, type AppImageExportTargetRequest, type AppImageExportTargetResult, type AppInfo, type AppRenderHtmlPngRequest, type AppRenderHtmlPngResult, type AppSaveConfigOptions, type BotGatewayQrLoginCancelRequest, type BotGatewayQrLoginStartRequest, type BotGatewayQrLoginWaitRequest, type BotGatewayQrWindowCloseRequest, type BotGatewayQrWindowOpenRequest, type CloudSyncKeyFileResult, type CloudSyncLoginResult, type CloudSyncOperationResult, type CloudSyncPullRequest, type CloudSyncPushRequest, type CloudSyncSetupRequest, type GatewayPluginAppConfig, type GatewayPluginPermission, type GatewayPluginSurface, type GatewayProviderConnectivityCheckRequest, type GatewayProviderProbeCandidatesRequest, type GatewayProviderProbeRequest, type GatewayStatus, type LocalAgentProviderImportRequest, type PluginDependency, type PluginDirectorySelection, type ProfileApplyResult, type ProfileOpenRequest, type ProfileOpenResult, type ProviderAccountResetRequest, type ProviderAccountSnapshotRequestOptions, type ProviderAccountTestRequest, type ProviderCatalogModelsRequest, type ProviderIconDetectionRequest, type ProviderManifestFetchRequest, type RequestLogListFilter, type RouteScriptTestRequest, type RouteScriptValidationRequest, type UsageStatsFilter, type UsageStatsRange } from "@ccr/core/contracts/app";
const imageExportTargets = new Map<string, string>();
const gatewayPluginPermissionIdSet = new Set<string>(GATEWAY_PLUGIN_PERMISSION_IDS);
const gatewayPluginSurfaceIdSet = new Set<string>(GATEWAY_PLUGIN_SURFACE_IDS);
@@ -94,6 +96,52 @@ ipcMain.handle(IPC_CHANNELS.appRenderHtmlPng, async (event, request: AppRenderHt
});
ipcMain.handle(IPC_CHANNELS.appGetConfig, () => loadAppConfig());
ipcMain.handle(IPC_CHANNELS.appCloudSyncGetStatus, async () => {
const savedConfig = await saveAppConfig(await ensureCloudSyncUserProfile(await loadAppConfig()));
return getCloudSyncStatus(savedConfig);
});
ipcMain.handle(IPC_CHANNELS.appCloudSyncLogin, async (): Promise<CloudSyncLoginResult> => {
const callback = await startCloudSyncDesktopAuthCallback();
const login = startCloudSyncLogin(await loadAppConfig(), { callbackUrl: callback.url });
const savedConfig = login.config ? await saveAppConfig(login.config) : await loadAppConfig();
await shell.openExternal(login.loginUrl);
return {
...login,
config: savedConfig,
status: getCloudSyncStatus(savedConfig)
};
});
ipcMain.handle(IPC_CHANNELS.appCloudSyncGenerateKeyFile, async (event, file?: string): Promise<CloudSyncKeyFileResult> => {
const requestedFile = typeof file === "string" ? file.trim() : "";
if (requestedFile) {
return createCloudSyncKeyFile(requestedFile);
}
const window = BrowserWindow.fromWebContents(event.sender);
const result = window
? await dialog.showSaveDialog(window, cloudSyncKeyFileSaveDialogOptions())
: await dialog.showSaveDialog(cloudSyncKeyFileSaveDialogOptions());
if (result.canceled || !result.filePath) {
return { canceled: true };
}
return createCloudSyncKeyFile(result.filePath);
});
ipcMain.handle(IPC_CHANNELS.appCloudSyncSetup, async (_event, request: CloudSyncSetupRequest) => {
return persistCloudSyncOperation(await setupCloudSyncConfig(await loadAppConfig(), request));
});
ipcMain.handle(IPC_CHANNELS.appCloudSyncPush, async (_event, request?: CloudSyncPushRequest) => {
return persistCloudSyncOperation(await pushCloudSyncConfig(await loadAppConfig(), request));
});
ipcMain.handle(IPC_CHANNELS.appCloudSyncPull, async (_event, request?: CloudSyncPullRequest) => {
return persistCloudSyncOperation(await pullCloudSyncConfig(await loadAppConfig(), request));
});
ipcMain.handle(IPC_CHANNELS.appCloudSyncDisable, async () => {
const savedConfig = await saveAppConfig(disableCloudSyncConfig(await loadAppConfig()));
return {
config: savedConfig,
message: "Cloud sync is disabled on this device.",
status: getCloudSyncStatus(savedConfig)
} satisfies CloudSyncOperationResult;
});
ipcMain.handle(IPC_CHANNELS.appGetOnboardingFinished, () => loadOnboardingFinished());
ipcMain.handle(IPC_CHANNELS.appGetPendingProviderDeepLinks, () => deepLinkService.consumePendingProviderRequests());
ipcMain.handle(IPC_CHANNELS.appGetLocalAgentProviderCandidates, () => getLocalAgentProviderCandidates());
@@ -321,6 +369,10 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt
await builtInBrowserService.syncProxy(savedConfig);
await trayController.refreshIconFromConfig(savedConfig);
invalidateProviderAccountSnapshotCache();
const cloudSyncedConfig = await autoPushCloudSyncConfig(savedConfig);
if (cloudSyncedConfig !== savedConfig) {
savedConfig = await saveAppConfig(cloudSyncedConfig);
}
return savedConfig;
});
ipcMain.handle(IPC_CHANNELS.appSetThemePreference, async (_event, theme: unknown) => {
@@ -383,6 +435,119 @@ ipcMain.handle(IPC_CHANNELS.appRestartProxy, async () => {
return proxyService.getStatus();
});
async function persistCloudSyncOperation(result: CloudSyncOperationResult): Promise<CloudSyncOperationResult> {
if (!result.config) {
return result;
}
const savedConfig = await saveAppConfig(result.config);
await gatewayService.updateConfig(savedConfig);
await applyProfileIfServiceRunning(savedConfig, gatewayService.getStatus());
await builtInBrowserService.syncProxy(savedConfig);
await trayController.refreshIconFromConfig(savedConfig);
invalidateProviderAccountSnapshotCache();
return {
...result,
config: savedConfig,
status: getCloudSyncStatus(savedConfig)
};
}
async function startCloudSyncDesktopAuthCallback(): Promise<{ url: string }> {
const session = randomUUID();
const server = createServer((request, response) => {
void handleCloudSyncDesktopAuthCallback(request, response, server, session).catch((error) => {
sendCloudSyncDesktopAuthHtml(response, 500, "Cloud sync login failed", formatError(error));
closeCloudSyncDesktopAuthServer(server);
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
setTimeout(() => closeCloudSyncDesktopAuthServer(server), 10 * 60 * 1000).unref();
const address = server.address();
if (!address || typeof address !== "object") {
closeCloudSyncDesktopAuthServer(server);
throw new Error("Failed to start cloud sync login callback server.");
}
const url = new URL(`http://127.0.0.1:${address.port}/cloud-sync/auth/callback`);
url.searchParams.set("session", session);
return { url: url.toString() };
}
async function handleCloudSyncDesktopAuthCallback(
request: IncomingMessage,
response: ServerResponse,
server: Server,
session: string
): Promise<void> {
const url = new URL(request.url || "/", "http://127.0.0.1");
if (url.pathname !== "/cloud-sync/auth/callback") {
sendCloudSyncDesktopAuthHtml(response, 404, "Cloud sync login failed", "Unknown callback path.");
return;
}
if (url.searchParams.get("session") !== session) {
sendCloudSyncDesktopAuthHtml(response, 401, "Cloud sync login failed", "Invalid or expired login session.");
return;
}
const authenticatedConfig = applyCloudSyncAuthTokens(await loadAppConfig(), {
accessToken: requiredCloudSyncCallbackParam(url, "access_token"),
refreshToken: requiredCloudSyncCallbackParam(url, "refresh_token"),
refreshTokenExpiresAt: optionalCloudSyncCallbackParam(url, "refresh_token_expires_at"),
userId: optionalCloudSyncCallbackParam(url, "user_id")
});
const savedConfig = await saveAppConfig(await refreshCloudSyncUserProfile(authenticatedConfig));
windowsManager.showMainWindow();
windowsManager.broadcast(IPC_CHANNELS.appCloudSyncAuthChanged);
await trayController.refreshIconFromConfig(savedConfig);
sendCloudSyncDesktopAuthHtml(response, 200, "Cloud sync login complete", "You can return to CCR and choose an end-to-end encryption method.");
closeCloudSyncDesktopAuthServer(server);
}
function closeCloudSyncDesktopAuthServer(server: Server): void {
if (server.listening) {
server.close();
}
}
function requiredCloudSyncCallbackParam(url: URL, key: string): string {
const value = optionalCloudSyncCallbackParam(url, key);
if (!value) {
throw new Error(`Cloud sync callback is missing ${key}.`);
}
return value;
}
function optionalCloudSyncCallbackParam(url: URL, key: string): string | undefined {
const value = url.searchParams.get(key)?.trim();
return value || undefined;
}
function sendCloudSyncDesktopAuthHtml(response: ServerResponse, status: number, title: string, message: string): void {
const body = Buffer.from([
"<!doctype html>",
"<html>",
"<head><meta charset=\"utf-8\"><meta name=\"referrer\" content=\"no-referrer\"><title>CCR Cloud Sync</title></head>",
"<body style=\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px; line-height: 1.5;\">",
"<script>try{history.replaceState(null,'','/cloud-sync/auth/complete')}catch{}</script>",
`<h1>${escapeHtml(title)}</h1>`,
`<p>${escapeHtml(message)}</p>`,
"</body>",
"</html>"
].join(""), "utf8");
response.writeHead(status, {
"cache-control": "no-store",
"content-length": body.length,
"content-type": "text/html; charset=utf-8",
"x-content-type-options": "nosniff"
});
response.end(body);
}
async function applyProfileIfServiceRunning(config: AppConfig, status: GatewayStatus): Promise<void> {
if (status.state !== "running") {
return;
@@ -545,6 +710,18 @@ function dataExportSaveDialogOptions(exportedAt: string): SaveDialogOptions {
};
}
function cloudSyncKeyFileSaveDialogOptions(): SaveDialogOptions {
return {
buttonLabel: "Generate key file",
defaultPath: path.join(app.getPath("documents"), "ccr-cloud-sync-key.json"),
filters: [
{ extensions: ["json"], name: "JSON key file" },
{ extensions: ["*"], name: "All files" }
],
title: "Generate cloud sync key file"
};
}
function shareCardSaveDialogOptions(fileName: string): SaveDialogOptions {
return {
buttonLabel: "Save image",
@@ -966,6 +1143,15 @@ function fileSafeTimestamp(value: string): string {
return value.replace(/[:.]/g, "-");
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
+19
View File
@@ -30,6 +30,13 @@ import type {
BotGatewayQrWindowOpenResult,
BotHandoffScanTarget,
ClaudeAppGatewayApplyResult,
CloudSyncOperationResult,
CloudSyncKeyFileResult,
CloudSyncLoginResult,
CloudSyncPullRequest,
CloudSyncPushRequest,
CloudSyncSetupRequest,
CloudSyncStatus,
GatewayMcpToolInfo,
GatewayProviderConnectivityCheckReport,
GatewayProviderConnectivityCheckRequest,
@@ -102,6 +109,13 @@ contextBridge.exposeInMainWorld("ccr", {
cancelBotGatewayQrLogin: (request: BotGatewayQrLoginCancelRequest) => invoke(IPC_CHANNELS.appBotGatewayQrLoginCancel, request) as Promise<BotGatewayQrLoginCancelResult>,
captureElementPng: (request: AppCaptureElementPngRequest) => invoke(IPC_CHANNELS.appCaptureElementPng, request) as Promise<AppCaptureElementPngResult>,
checkProviderConnectivity: (request: GatewayProviderConnectivityCheckRequest) => invoke(IPC_CHANNELS.appCheckProviderConnectivity, request) as Promise<GatewayProviderConnectivityCheckReport>,
cloudSyncDisable: () => invoke(IPC_CHANNELS.appCloudSyncDisable) as Promise<CloudSyncOperationResult>,
cloudSyncGenerateKeyFile: (file?: string) => invoke(IPC_CHANNELS.appCloudSyncGenerateKeyFile, file) as Promise<CloudSyncKeyFileResult>,
cloudSyncGetStatus: () => invoke(IPC_CHANNELS.appCloudSyncGetStatus) as Promise<CloudSyncStatus>,
cloudSyncLogin: () => invoke(IPC_CHANNELS.appCloudSyncLogin) as Promise<CloudSyncLoginResult>,
cloudSyncPull: (request?: CloudSyncPullRequest) => invoke(IPC_CHANNELS.appCloudSyncPull, request) as Promise<CloudSyncOperationResult>,
cloudSyncPush: (request?: CloudSyncPushRequest) => invoke(IPC_CHANNELS.appCloudSyncPush, request) as Promise<CloudSyncOperationResult>,
cloudSyncSetup: (request: CloudSyncSetupRequest) => invoke(IPC_CHANNELS.appCloudSyncSetup, request) as Promise<CloudSyncOperationResult>,
closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => invoke(IPC_CHANNELS.appBotGatewayQrWindowClose, request) as Promise<BotGatewayQrWindowCloseResult>,
clearProxyNetworkCaptures: () => invoke(IPC_CHANNELS.appClearProxyNetworkCaptures) as Promise<ProxyNetworkSnapshot>,
closeTray: () => invoke(IPC_CHANNELS.appCloseTray) as Promise<void>,
@@ -174,6 +188,11 @@ contextBridge.exposeInMainWorld("ccr", {
ipcRenderer.on(IPC_CHANNELS.appBeforeQuit, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.appBeforeQuit, handler);
},
onCloudSyncAuthChanged: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on(IPC_CHANNELS.appCloudSyncAuthChanged, handler);
return () => ipcRenderer.removeListener(IPC_CHANNELS.appCloudSyncAuthChanged, handler);
},
onProviderDeepLink: (callback: (request: ProviderDeepLinkRequest) => void) => {
const handler = (_event: Electron.IpcRendererEvent, request: ProviderDeepLinkRequest) => callback(request);
ipcRenderer.on(IPC_CHANNELS.appProviderDeepLink, handler);
+9
View File
@@ -915,6 +915,11 @@ function App() {
setThemePreference(normalized.theme || "system");
}
function restoreOnboardingCloudConfig(config: AppConfig) {
syncConfigState(config);
setOnboardingProfileConfirmed(true);
}
function showToast(message: string) {
if (toastTimer.current !== undefined) {
window.clearTimeout(toastTimer.current);
@@ -2990,6 +2995,8 @@ function App() {
onComplete: completeOnboarding,
onChangeProfile: updateProfileDraft,
onChangeProvider: updateProviderDraft,
onCloudConfigRestore: restoreOnboardingCloudConfig,
onCloudToast: showToast,
onSelectStep: setOnboardingStep,
onSubmitProfile: submitProfileDraft,
onSubmitProvider: submitProviderDraft,
@@ -3354,6 +3361,7 @@ function App() {
languagePreference,
launchAtLogin: Boolean(draftConfig.launchAtLogin),
onChangeBotConfigs: changeBotConfigs,
onCloudSyncConfigChange: syncConfigState,
onChangeLaunchAtLogin: changeLaunchAtLogin,
onChangeLanguage: changeLanguagePreference,
onChangeObservability: changeObservabilityConfig,
@@ -3364,6 +3372,7 @@ function App() {
onChangeTrayIcon: changeTrayIconPreference,
onChangeTrayWidgets: changeTrayWidgets,
onClose: () => setSettingsOpen(false),
onToast: showToast,
observability: draftConfig.observability,
profiles: draftConfig.profile.profiles,
proxy: draftConfig.proxy,
@@ -1,9 +1,9 @@
import {
AddProfileDraft, AddProviderDraft, AppConfig, Button, Check, ChevronLeft,
ChevronRight, cn, findProviderPreset, GatewayProviderProbeResult, GatewayStatus, Gauge, getNextOnboardingStep,
isOnboardingProfileReady, isOnboardingProviderReady, Layers3, LucideIcon, mergeProviderModelLists, motion, motionEase,
LoaderCircle, onboardingMascotSpriteUrl, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, type ProfileAgentOption, providerDraftHasReadyCredentialPool, ProviderConnectivityCheckReport, reducedMotionTransition, splitLines, useAppText, useEffect, useReducedMotion,
useState,
AddProfileDraft, AddProviderDraft, AppConfig, ArrowDown, Button, Check, ChevronLeft,
ChevronRight, CircleAlert, CloudSyncOperationResult, CloudSyncStatus, cn, Field, findProviderPreset, GatewayProviderProbeResult, GatewayStatus, Gauge, getDefaultOnboardingStep, getNextOnboardingStep,
Globe, Input, isOnboardingProfileReady, isOnboardingProviderReady, KeyRound, Layers3, LucideIcon, mergeProviderModelLists, motion, motionEase,
LoaderCircle, onboardingMascotSpriteUrl, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, type ProfileAgentOption, providerDraftHasReadyCredentialPool, ProviderConnectivityCheckReport, reducedMotionTransition, splitLines, Tabs, TabsContent, TabsList, TabsTrigger, useAppErrorText, useAppText, useEffect, useReducedMotion,
useRef, useState,
UserRound, X
} from "../shared/index";
import { AddProviderForm, providerSetupStepIds, type ProviderSetupStepId } from "./providers";
@@ -48,6 +48,8 @@ export function OnboardingView({
onCheckProvider,
onChangeProfile,
onChangeProvider,
onCloudConfigRestore,
onCloudToast,
onComplete,
onSelectStep,
onSubmitProfile,
@@ -72,6 +74,8 @@ export function OnboardingView({
onCheckProvider: () => Promise<ProviderConnectivityCheckReport>;
onChangeProfile: (patch: Partial<AddProfileDraft>) => void;
onChangeProvider: (patch: Partial<AddProviderDraft>, resetProbe?: boolean) => void;
onCloudConfigRestore?: (config: AppConfig) => void;
onCloudToast?: (message: string) => void;
onComplete: () => void | Promise<void>;
onSelectStep: (step: OnboardingStepId) => void;
onSubmitProfile: () => Promise<boolean>;
@@ -247,6 +251,18 @@ export function OnboardingView({
</div>
</div>
<OnboardingCloudRestorePanel
config={config}
onConfigRestore={(restoredConfig) => {
onCloudConfigRestore?.(restoredConfig);
onSelectStep(getDefaultOnboardingStep(restoredConfig, {
...readiness,
profileConfirmed: true
}));
}}
onToast={onCloudToast}
/>
<div className="onboarding-step-panels mt-5 min-h-0 flex-1 overflow-hidden">
<div
aria-hidden={activeStep !== "provider"}
@@ -334,6 +350,274 @@ export function OnboardingView({
);
}
function OnboardingCloudRestorePanel({
config,
onConfigRestore,
onToast
}: {
config: AppConfig;
onConfigRestore?: (config: AppConfig) => void;
onToast?: (message: string) => void;
}) {
const t = useAppText();
const formatError = useAppErrorText();
const [open, setOpen] = useState(Boolean(config.cloudSync?.accessToken || config.cloudSync?.refreshToken));
const [keyMode, setKeyMode] = useState<"key-file" | "password">(config.cloudSync?.keyMode ?? "key-file");
const [password, setPassword] = useState("");
const [keyFilePath, setKeyFilePath] = useState("");
const [status, setStatus] = useState<CloudSyncStatus>();
const [busy, setBusy] = useState<"" | "login" | "restore" | "status">("");
const [error, setError] = useState("");
const keyFileInputRef = useRef<HTMLInputElement>(null);
const effectiveStatus = status ?? onboardingCloudStatusFromConfig(config);
const authenticated = effectiveStatus.authenticated;
const restoreReady = keyMode === "key-file" ? Boolean(keyFilePath.trim()) : Boolean(password);
useEffect(() => {
setStatus(onboardingCloudStatusFromConfig(config));
setKeyMode(config.cloudSync?.keyMode ?? "key-file");
}, [
config.cloudSync?.accessToken,
config.cloudSync?.deviceId,
config.cloudSync?.enabled,
config.cloudSync?.keyId,
config.cloudSync?.keyMode,
config.cloudSync?.lastRevision,
config.cloudSync?.lastSyncAt,
config.cloudSync?.refreshToken,
config.cloudSync?.userAvatarUrl,
config.cloudSync?.userEmail,
config.cloudSync?.userId,
config.cloudSync?.userLogin,
config.cloudSync?.userName
]);
useEffect(() => {
if (typeof window === "undefined" || !window.ccr?.cloudSyncGetStatus) {
return;
}
setBusy((current) => current || "status");
void window.ccr.cloudSyncGetStatus()
.then((nextStatus) => {
setStatus(nextStatus);
if (nextStatus.authenticated) {
setOpen(true);
}
})
.catch(() => undefined)
.finally(() => setBusy((current) => current === "status" ? "" : current));
}, []);
useEffect(() => {
if (typeof window === "undefined" || !window.ccr?.onCloudSyncAuthChanged) {
return undefined;
}
return window.ccr.onCloudSyncAuthChanged(() => {
void refreshCloudSyncAfterAuth();
});
}, []);
function keyInput() {
return keyMode === "key-file"
? { keyFilePath: keyFilePath.trim(), restoreOnly: true }
: { password, restoreOnly: true };
}
function applyStatusResult(result: CloudSyncOperationResult) {
setStatus(result.status);
const authExpired = Boolean(result.authExpired);
if (authExpired) {
setError(t(result.message));
return;
}
if (result.snapshotApplied && result.config) {
onConfigRestore?.(result.config);
onToast?.(t("Cloud configuration restored. Review it before entering CCR."));
setPassword("");
setError("");
return;
}
setError(t(result.message));
}
async function refreshCloudSyncAfterAuth() {
if (typeof window === "undefined" || !window.ccr) {
return;
}
try {
const nextStatus = await window.ccr.cloudSyncGetStatus();
setStatus(nextStatus);
setOpen(true);
setError("");
onToast?.(t("Cloud login completed. Choose the encryption method used by your cloud snapshot."));
} catch (authError) {
setError(formatError(authError));
}
}
async function pollCloudSyncAuthCompletion(deadline = Date.now() + 2 * 60 * 1000) {
if (typeof window === "undefined" || !window.ccr) {
return;
}
try {
const nextStatus = await window.ccr.cloudSyncGetStatus();
if (nextStatus.authenticated) {
setStatus(nextStatus);
setOpen(true);
setError("");
onToast?.(t("Cloud login completed. Choose the encryption method used by your cloud snapshot."));
return;
}
} catch {
// Continue polling until the browser login finishes or the deadline expires.
}
if (Date.now() < deadline) {
window.setTimeout(() => void pollCloudSyncAuthCompletion(deadline), 2000);
}
}
async function runCloudLogin() {
if (typeof window === "undefined" || !window.ccr?.cloudSyncLogin) {
setError(t("Cloud sync login is only available in the desktop app."));
return;
}
setBusy("login");
setError("");
try {
const login = await window.ccr.cloudSyncLogin();
setStatus(login.status);
setOpen(true);
onToast?.(t("Complete login in the browser, then return to CCR."));
void pollCloudSyncAuthCompletion();
} catch (loginError) {
setError(formatError(loginError));
} finally {
setBusy("");
}
}
async function restoreFromCloud() {
if (typeof window === "undefined" || !window.ccr?.cloudSyncSetup) {
setError(t("Cloud sync is only available in the Electron app."));
return;
}
setBusy("restore");
setError("");
try {
applyStatusResult(await window.ccr.cloudSyncSetup(keyInput()));
} catch (restoreError) {
setError(formatError(restoreError));
} finally {
setBusy("");
}
}
return (
<section className="mx-auto mt-4 grid w-full max-w-[780px] gap-3 rounded-md border border-border bg-background/75 p-3 text-left">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-[13px] font-semibold text-foreground">{t("Restore from cloud")}</h3>
<p className="mt-1 text-[12px] leading-5 text-muted-foreground">
{t("Use an encrypted cloud snapshot instead of setting up providers manually.")}
</p>
{authenticated ? (
<div className="mt-1 truncate text-[11px] text-muted-foreground">
{t("Signed in as")} {onboardingCloudAccountName(effectiveStatus)}
</div>
) : null}
</div>
<Button disabled={Boolean(busy)} onClick={() => authenticated ? setOpen((value) => !value) : void runCloudLogin()} size="sm" type="button" variant={authenticated ? "outline" : "default"}>
{busy === "login" || busy === "status" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Globe className="h-4 w-4" />}
{authenticated ? t(open ? "Hide restore options" : "Restore from cloud") : t("Sign in with GitHub")}
</Button>
</div>
{open && authenticated ? (
<div className="grid grid-cols-1 gap-3 border-t border-border/60 pt-3">
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] leading-5 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex items-start gap-2">
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0" />
<div>{t("Use the same password or key file that encrypted your cloud snapshot. Losing it makes cloud data unrecoverable.")}</div>
</div>
</div>
<Tabs
className="grid grid-cols-1 gap-3"
onValueChange={(value) => setKeyMode(value === "password" ? "password" : "key-file")}
value={keyMode}
>
<TabsList aria-label={t("Encryption method")} className="w-fit border border-border bg-muted/20">
<TabsTrigger className="gap-2" value="key-file">
<KeyRound className="h-4 w-4" />
{t("Key file")}
</TabsTrigger>
<TabsTrigger className="gap-2" value="password">
<KeyRound className="h-4 w-4" />
{t("Password")}
</TabsTrigger>
</TabsList>
<TabsContent className="grid grid-cols-1 gap-3" value="key-file">
<Field label={t("Key file path")}>
<Input value={keyFilePath} onChange={(event) => setKeyFilePath(event.target.value)} placeholder={t("Choose an E2EE key file")} />
</Field>
<input
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
const filePath = file && window.ccr?.getFilePath ? window.ccr.getFilePath(file) : "";
if (filePath) {
setKeyFilePath(filePath);
}
if (keyFileInputRef.current) {
keyFileInputRef.current.value = "";
}
}}
ref={keyFileInputRef}
type="file"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<Button onClick={() => keyFileInputRef.current?.click()} size="sm" type="button" variant="outline">
<KeyRound className="h-4 w-4" />
{t("Choose key file")}
</Button>
<Button disabled={Boolean(busy) || !restoreReady} onClick={() => void restoreFromCloud()} size="sm" type="button">
{busy === "restore" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <ArrowDown className="h-4 w-4" />}
{t("Pull cloud config")}
</Button>
</div>
</TabsContent>
<TabsContent className="grid grid-cols-1 gap-3" value="password">
<Field label={t("Encryption password")}>
<Input
autoComplete="current-password"
onChange={(event) => setPassword(event.target.value)}
placeholder={t("Required unless you use a key file")}
type="password"
value={password}
/>
</Field>
<div className="flex justify-end">
<Button disabled={Boolean(busy) || !restoreReady} onClick={() => void restoreFromCloud()} size="sm" type="button">
{busy === "restore" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <ArrowDown className="h-4 w-4" />}
{t("Pull cloud config")}
</Button>
</div>
</TabsContent>
</Tabs>
</div>
) : null}
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
{error}
</div>
) : null}
</section>
);
}
const onboardingProgressItems: Array<{ key: ProviderSetupStepId | "profile" | "enter"; label: string }> = [
{ key: "provider", label: "Choose provider" },
{ key: "credentials", label: "Add credentials" },
@@ -405,6 +689,47 @@ function OnboardingDetailRow({ label, value }: { label: string; value: string })
);
}
function onboardingCloudStatusFromConfig(config: AppConfig): CloudSyncStatus {
const cloudSync = config.cloudSync ?? {
baseUrl: "",
deviceName: "",
enabled: false,
lastRevision: 0,
namespace: "ccr"
};
return {
authenticated: Boolean(cloudSync.accessToken || cloudSync.refreshToken),
baseUrl: cloudSync.baseUrl,
configured: Boolean(cloudSync.baseUrl && cloudSync.keyId && cloudSync.keySalt),
deviceId: cloudSync.deviceId,
deviceName: cloudSync.deviceName,
enabled: cloudSync.enabled,
keyId: cloudSync.keyId,
keyMode: cloudSync.keyMode,
lastRevision: cloudSync.lastRevision,
lastSyncAt: cloudSync.lastSyncAt,
lastSyncError: cloudSync.lastSyncError,
namespace: cloudSync.namespace,
snapshotHash: cloudSync.snapshotHash,
unlocked: false,
userAvatarUrl: cloudSync.userAvatarUrl,
userEmail: cloudSync.userEmail,
userId: cloudSync.userId,
userLogin: cloudSync.userLogin,
userName: cloudSync.userName
};
}
function onboardingCloudAccountName(status: CloudSyncStatus): string {
if (status.userName && status.userLogin) {
return `${status.userName} @${status.userLogin}`;
}
if (status.userLogin) {
return `@${status.userLogin}`;
}
return status.userName || status.userEmail || status.userId || "";
}
function OnboardingMascotSprite({ activeStep }: { activeStep: OnboardingStepId }) {
return (
<div
@@ -1,5 +1,5 @@
import {
Activity, AppConfig, AppCopy, AppInfo, AppLanguagePreference, Boxes, BotGatewayConfigDraft, botGatewayAuthSpecsForPlatform,
Activity, AnimatedDisclosure, AnimatePresence, AppConfig, AppCopy, AppInfo, AppLanguagePreference, ArrowDown, ArrowUp, Boxes, BotGatewayConfigDraft, botGatewayAuthSpecsForPlatform,
botGatewayDefaultAuthType, botGatewayFieldsForAuth, botGatewayPickAuthFields, botGatewayPlatformLabel, botGatewayPlatformOptions,
botGatewaySavedConfigFromDraft, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitResult, BotGatewayQrWindowOpenResult, BotGatewaySavedConfig, Button,
CircleAlert, closestCenter, cn, CSS, Database, Dialog, DialogBody, DialogContent,
@@ -7,8 +7,9 @@ import {
Globe,
createBotGatewayConfigDraft, createMcpServerDraft, createMcpServerDraftFromConfig, createMcpServerDraftFromUnknown, DndContext, DragEndEvent, GatewayMcpServerConfig, GatewayProviderConfig, Input, isBotGatewayConfigDraftSubmittable, KeyboardSensor, KeyRound, KeyValueRowsControl, languageDisplayName, Layers3, LoaderCircle,
mcpServerConfigFromDraft, mcpServerEndpointSummary, mcpServerTransportOptions, mcpStdioMessageModeOptions, McpServerDraft, normalizeBotGatewayAuthType, normalizeBotGatewayPlatform, normalizeProviderModelSelector, normalizeProxyUpstreamConfig, normalizeToolHubConfig, numberValue, Palette, Pencil, Plus, ProfileConfig, profileAgentLabel,
PanelLeftOpen, Power, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl,
CloudSyncOperationResult, CloudSyncStatus, PanelLeftOpen, Power, ProviderAccountMeter, ProviderAccountSnapshot, ReactNode, ResolvedLanguage, ResolvedTheme, Select, SelectControl,
PointerSensor, rectSortingStrategy, Settings, SettingsPageId, SortableContext, sortableKeyboardCoordinates, themeDisplayName,
Tabs, TabsContent, TabsList, TabsTrigger,
translateOptions, TrayBalanceProgressConfig, TrayComponentVariants, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant,
appLogoUrl, trayMascotIconUrls, arrayMove, defaultTrayWidgetVariant, isTraySingletonWidgetType, normalizeTrayWidget, normalizeTrayWidgets, Switch, Textarea, Trash2, trayWidgetVariantOptions, useAppText, useEffect, useMemo, useRef, useSensor, useSensors, useSortable, useState, validateMcpServerDraft,
X
@@ -27,6 +28,7 @@ export function AppSettingsDialog({
languagePreference,
launchAtLogin,
onChangeBotConfigs,
onCloudSyncConfigChange,
onChangeLaunchAtLogin,
onChangeObservability,
onChangeProxy,
@@ -37,6 +39,7 @@ export function AppSettingsDialog({
onChangeTrayIcon,
onChangeTrayWidgets,
onClose,
onToast,
observability,
profiles,
proxy,
@@ -61,6 +64,7 @@ export function AppSettingsDialog({
languagePreference: AppLanguagePreference;
launchAtLogin: boolean;
onChangeBotConfigs: (configs: BotGatewaySavedConfig[]) => void;
onCloudSyncConfigChange: (config: AppConfig) => void;
onChangeLaunchAtLogin: (checked: boolean) => void;
onChangeObservability: (patch: Partial<AppConfig["observability"]>) => void;
onChangeProxy: (patch: Partial<AppConfig["proxy"]>) => void;
@@ -71,6 +75,7 @@ export function AppSettingsDialog({
onChangeTrayIcon: (value: string) => void;
onChangeTrayWidgets: (widgets: TrayWidgetConfig[]) => void;
onClose: () => void;
onToast: (message: string) => void;
observability: AppConfig["observability"];
profiles: ProfileConfig[];
proxy: AppConfig["proxy"];
@@ -120,6 +125,16 @@ export function AppSettingsDialog({
/>
);
}
if (activePage === "cloud") {
return (
<CloudSyncSettingsPage
config={config}
copy={copy}
onConfigChange={onCloudSyncConfigChange}
onToast={onToast}
/>
);
}
if (activePage === "tray") {
return (
<TraySettingsPage
@@ -218,6 +233,13 @@ function SettingsLayout({
label={copy.settings.general}
onClick={() => setActivePage("general")}
/>
<SettingsPageButton
active={visiblePage === "cloud"}
className="mt-1"
icon={Database}
label={copy.text["Cloud Sync"] ?? "Cloud Sync"}
onClick={() => setActivePage("cloud")}
/>
<SettingsPageButton
active={visiblePage === "observability"}
className="mt-1"
@@ -339,6 +361,510 @@ function AppearanceSettingsPage({
);
}
function CloudSyncSettingsPage({
config,
copy,
onConfigChange,
onToast
}: {
config: AppConfig;
copy: AppCopy;
onConfigChange: (config: AppConfig) => void;
onToast: (message: string) => void;
}) {
const t = (value: string) => copy.text[value] ?? value;
const [keyMode, setKeyMode] = useState<"key-file" | "password">(config.cloudSync.keyMode ?? "password");
const [password, setPassword] = useState("");
const [keyFilePath, setKeyFilePath] = useState("");
const [status, setStatus] = useState<CloudSyncStatus>();
const [busy, setBusy] = useState<"" | "setup" | "push" | "pull" | "disable" | "logout" | "status" | "login" | "key-file">("");
const [syncPanelOpen, setSyncPanelOpen] = useState(Boolean(config.cloudSync.enabled));
const [encryptionSettingsOpen, setEncryptionSettingsOpen] = useState(!config.cloudSync.keyId);
const [mergeConflicts, setMergeConflicts] = useState<string[]>([]);
const [error, setError] = useState("");
const [failedAvatarUrl, setFailedAvatarUrl] = useState("");
const keyFileInputRef = useRef<HTMLInputElement>(null);
const effectiveStatus = status ?? cloudSyncStatusFromConfig(config);
const authenticated = effectiveStatus.authenticated;
const syncControlsVisible = effectiveStatus.enabled || syncPanelOpen;
const setupReady = keyMode === "key-file" ? Boolean(keyFilePath.trim()) : Boolean(password);
const avatarUrl = effectiveStatus.userAvatarUrl && effectiveStatus.userAvatarUrl !== failedAvatarUrl
? effectiveStatus.userAvatarUrl
: "";
useEffect(() => {
setKeyMode(config.cloudSync.keyMode ?? "password");
setStatus(cloudSyncStatusFromConfig(config));
}, [
config.cloudSync.deviceId,
config.cloudSync.enabled,
config.cloudSync.keyId,
config.cloudSync.keyMode,
config.cloudSync.lastRevision,
config.cloudSync.lastSyncAt,
config.cloudSync.lastSyncError,
config.cloudSync.refreshToken,
config.cloudSync.userAvatarUrl,
config.cloudSync.userEmail,
config.cloudSync.userId,
config.cloudSync.userLogin,
config.cloudSync.userName
]);
useEffect(() => {
if (config.cloudSync.enabled) {
setSyncPanelOpen(true);
}
}, [config.cloudSync.enabled]);
useEffect(() => {
if (!window.ccr?.cloudSyncGetStatus) {
return;
}
setBusy((current) => current || "status");
void window.ccr.cloudSyncGetStatus()
.then(setStatus)
.catch(() => undefined)
.finally(() => setBusy((current) => current === "status" ? "" : current));
}, []);
useEffect(() => {
if (!window.ccr?.onCloudSyncAuthChanged) {
return undefined;
}
return window.ccr.onCloudSyncAuthChanged(() => {
void refreshCloudSyncAfterAuth();
});
}, []);
function keyInput() {
return keyMode === "key-file"
? { keyFilePath: keyFilePath.trim() }
: { password };
}
function applyCloudResult(result: CloudSyncOperationResult | { config?: AppConfig; message: string; status: CloudSyncStatus }) {
if (result.config) {
onConfigChange(result.config);
}
setStatus(result.status);
const authExpired = "authExpired" in result && result.authExpired;
if (!authExpired && result.message) {
onToast(t(result.message));
}
setMergeConflicts("mergeApplied" in result && result.mergeApplied ? result.mergeConflicts ?? [] : []);
setError(authExpired ? t(result.message) : "");
setPassword("");
}
async function refreshCloudSyncAfterAuth() {
if (!window.ccr) {
return;
}
try {
const [nextConfig, nextStatus] = await Promise.all([
window.ccr.getConfig(),
window.ccr.cloudSyncGetStatus()
]);
onConfigChange(nextConfig);
setStatus(nextStatus);
setSyncPanelOpen(true);
setEncryptionSettingsOpen(!nextStatus.configured);
onToast(t("Cloud login completed. Choose an encryption method to enable sync."));
setError("");
} catch (authError) {
setError(formatAppError(copy, authError));
}
}
async function pollCloudSyncAuthCompletion(deadline = Date.now() + 2 * 60 * 1000) {
if (!window.ccr) {
return;
}
try {
const [nextConfig, nextStatus] = await Promise.all([
window.ccr.getConfig(),
window.ccr.cloudSyncGetStatus()
]);
if (nextStatus.authenticated) {
onConfigChange(nextConfig);
setStatus(nextStatus);
setSyncPanelOpen(true);
setEncryptionSettingsOpen(!nextStatus.configured);
onToast(t("Cloud login completed. Choose an encryption method to enable sync."));
setError("");
return;
}
} catch {
// Keep polling until the browser login either completes or the window expires.
}
if (Date.now() < deadline) {
window.setTimeout(() => void pollCloudSyncAuthCompletion(deadline), 2000);
}
}
async function runCloudLogin() {
if (!window.ccr?.cloudSyncLogin) {
setError(t("Cloud sync login is only available in the desktop app."));
return;
}
setBusy("login");
setError("");
setMergeConflicts([]);
try {
applyCloudResult(await window.ccr.cloudSyncLogin());
onToast(t("Complete login in the browser, then return to CCR."));
void pollCloudSyncAuthCompletion();
} catch (loginError) {
setError(formatAppError(copy, loginError));
} finally {
setBusy("");
}
}
async function generateKeyFile() {
if (!window.ccr?.cloudSyncGenerateKeyFile) {
setError(t("Cloud sync key file generation is only available in the desktop app."));
return;
}
setBusy("key-file");
setError("");
try {
const result = await window.ccr.cloudSyncGenerateKeyFile(keyFilePath.trim() || undefined);
if (!result.canceled && result.file) {
setKeyMode("key-file");
setKeyFilePath(result.file);
onToast(t("Key file generated. Keep it safe; losing it can make cloud data unrecoverable."));
}
} catch (keyFileError) {
setError(formatAppError(copy, keyFileError));
} finally {
setBusy("");
}
}
async function runCloudAction(action: "setup" | "push" | "pull" | "disable" | "logout") {
if (!window.ccr) {
setError(t("Cloud sync is only available in the Electron app."));
return;
}
setBusy(action);
setError("");
setMergeConflicts([]);
try {
if (action === "setup") {
const result = await window.ccr.cloudSyncSetup(keyInput());
applyCloudResult(result);
const authExpired = "authExpired" in result && result.authExpired;
setSyncPanelOpen(!authExpired);
setEncryptionSettingsOpen(authExpired || !result.status.configured);
} else if (action === "push") {
applyCloudResult(await window.ccr.cloudSyncPush(keyInput()));
} else if (action === "pull") {
applyCloudResult(await window.ccr.cloudSyncPull(keyInput()));
} else if (action === "disable" || action === "logout") {
applyCloudResult(await window.ccr.cloudSyncDisable());
setSyncPanelOpen(false);
setEncryptionSettingsOpen(false);
}
} catch (cloudError) {
setError(formatAppError(copy, cloudError));
} finally {
setBusy("");
}
}
function toggleCloudSyncPanel(checked: boolean) {
if (checked) {
setSyncPanelOpen(true);
setEncryptionSettingsOpen(!effectiveStatus.configured);
setError("");
return;
}
if (effectiveStatus.enabled) {
void runCloudAction("disable");
return;
}
setSyncPanelOpen(false);
setEncryptionSettingsOpen(false);
setError("");
setMergeConflicts([]);
}
return (
<div className={cn(settingsPageContentWidthClassName, "grid grid-cols-1 gap-5")}>
{!authenticated ? (
<>
<div className="flex justify-start">
<Button disabled={Boolean(busy)} onClick={() => void runCloudLogin()} size="sm" type="button">
{busy === "login" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Globe className="h-4 w-4" />}
{t("Sign in with GitHub")}
</Button>
</div>
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
{error}
</div>
) : null}
</>
) : (
<>
<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">{t("Cloud Sync")}</h3>
<p className="mt-1 text-[12px] leading-5 text-muted-foreground">
{t("Sync providers, agent profiles, routing rules, plugins, ToolHub, and related user configuration as encrypted snapshots.")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 pt-0.5">
<Switch
aria-label={t("Cloud Sync")}
checked={syncControlsVisible}
disabled={Boolean(busy)}
onCheckedChange={toggleCloudSyncPanel}
/>
{syncControlsVisible ? (
<Button
aria-expanded={encryptionSettingsOpen}
aria-label={t("Encryption method")}
disabled={Boolean(busy)}
onClick={() => setEncryptionSettingsOpen((open) => !open)}
size="iconSm"
title={t("Encryption method")}
type="button"
variant={encryptionSettingsOpen ? "default" : "outline"}
>
<Settings className="h-4 w-4" />
</Button>
) : null}
</div>
</div>
<AnimatePresence initial={false}>
{syncControlsVisible && encryptionSettingsOpen ? (
<AnimatedDisclosure key="cloud-sync-encryption-settings">
<section className="grid grid-cols-1 gap-3 rounded-md border border-border bg-background p-3">
{!effectiveStatus.configured ? (
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-[12px] leading-5 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex items-start gap-2">
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-semibold">{t("End-to-end encryption is required before cloud sync can be enabled.")}</div>
<div>{t("Your password or key file is never uploaded. If you forget the password or lose the key file, cloud data cannot be decrypted and may be permanently lost.")}</div>
</div>
</div>
</div>
) : null}
<Tabs
className="grid grid-cols-1 gap-3"
onValueChange={(value) => setKeyMode(value === "key-file" ? "key-file" : "password")}
value={keyMode}
>
<TabsList aria-label={t("Encryption method")} className="w-fit border border-border bg-muted/20">
<TabsTrigger className="gap-2" value="password">
<KeyRound className="h-4 w-4" />
{t("Password")}
</TabsTrigger>
<TabsTrigger className="gap-2" value="key-file">
<KeyRound className="h-4 w-4" />
{t("Key file")}
</TabsTrigger>
</TabsList>
<TabsContent value="password">
<Field label={t("Encryption password")}>
<Input
autoComplete="new-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={t("Required unless you use a key file")}
/>
</Field>
</TabsContent>
<TabsContent className="grid grid-cols-1 gap-3" value="key-file">
<Field label={t("Key file path")}>
<Input value={keyFilePath} onChange={(event) => setKeyFilePath(event.target.value)} placeholder={t("Choose an E2EE key file")} />
</Field>
<div className="flex flex-wrap items-center gap-2">
<input
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
const filePath = file && window.ccr?.getFilePath ? window.ccr.getFilePath(file) : "";
if (filePath) {
setKeyFilePath(filePath);
}
if (keyFileInputRef.current) {
keyFileInputRef.current.value = "";
}
}}
ref={keyFileInputRef}
type="file"
/>
<Button onClick={() => keyFileInputRef.current?.click()} size="sm" type="button" variant="outline">
<KeyRound className="h-4 w-4" />
{t("Choose key file")}
</Button>
<Button disabled={Boolean(busy)} onClick={() => void generateKeyFile()} size="sm" type="button" variant="outline">
{busy === "key-file" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
{t("Generate key file")}
</Button>
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end">
<Button
disabled={Boolean(busy) || !setupReady}
onClick={() => void runCloudAction("setup")}
size="sm"
type="button"
>
{busy === "setup" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
{effectiveStatus.enabled ? t("Update setup") : t("Enable sync")}
</Button>
</div>
</section>
</AnimatedDisclosure>
) : null}
</AnimatePresence>
<section className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-border bg-card/70 px-3 py-3">
<div className="flex min-w-0 items-center gap-3">
{avatarUrl ? (
<img
alt=""
className="h-10 w-10 shrink-0 rounded-full border border-border bg-muted object-cover"
onError={() => setFailedAvatarUrl(avatarUrl)}
referrerPolicy="no-referrer"
src={avatarUrl}
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[13px] font-semibold text-muted-foreground">
{cloudSyncAccountInitial(effectiveStatus)}
</div>
)}
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-foreground">{cloudSyncAccountName(effectiveStatus, t)}</div>
<div className="truncate text-[11px] text-muted-foreground">{cloudSyncAccountDetail(effectiveStatus)}</div>
</div>
</div>
<Button disabled={Boolean(busy)} onClick={() => void runCloudAction("logout")} size="sm" type="button" variant="outline">
{busy === "logout" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <Power className="h-4 w-4" />}
{t("Sign out")}
</Button>
</section>
{syncControlsVisible ? (
<>
<section className="grid grid-cols-1 gap-3 rounded-md border border-border bg-muted/20 p-3">
<div className="grid grid-cols-1 gap-2 text-[12px] sm:grid-cols-2">
<CloudSyncStatusRow label={t("State")} value={effectiveStatus.enabled ? t("Enabled") : t("Disabled")} />
<CloudSyncStatusRow label={t("Unlocked")} value={effectiveStatus.unlocked ? t("Yes") : t("No")} />
<CloudSyncStatusRow label={t("Revision")} value={String(effectiveStatus.lastRevision)} />
<CloudSyncStatusRow label={t("Key mode")} value={effectiveStatus.keyMode ? t(effectiveStatus.keyMode === "key-file" ? "Key file" : "Password") : t("Not set")} />
<CloudSyncStatusRow label={t("Device ID")} value={effectiveStatus.deviceId || t("Not registered")} />
<CloudSyncStatusRow label={t("Last sync")} value={effectiveStatus.lastSyncAt || t("Never")} />
</div>
{effectiveStatus.lastSyncError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
{effectiveStatus.lastSyncError}
</div>
) : null}
{mergeConflicts.length > 0 ? (
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] leading-5 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
<div className="font-medium">{t("Local values were kept for these conflict paths:")}</div>
<div className="mt-1 break-all font-mono">{mergeConflicts.join(", ")}</div>
</div>
) : null}
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
{error}
</div>
) : null}
</section>
<div className="flex flex-wrap justify-end gap-2">
<Button disabled={Boolean(busy) || !effectiveStatus.enabled} onClick={() => void runCloudAction("push")} size="sm" type="button" variant="outline">
{busy === "push" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
{t("Push")}
</Button>
<Button disabled={Boolean(busy) || !effectiveStatus.enabled} onClick={() => void runCloudAction("pull")} size="sm" type="button" variant="outline">
{busy === "pull" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <ArrowDown className="h-4 w-4" />}
{t("Pull")}
</Button>
</div>
</>
) : null}
</>
)}
</div>
);
}
function CloudSyncStatusRow({
label,
value
}: {
label: string;
value: string;
}) {
return (
<div className="grid min-w-0 gap-1 rounded-md bg-background/70 px-2 py-2 sm:grid-cols-[110px_minmax(0,1fr)] sm:items-center">
<div className="text-[11px] font-medium text-muted-foreground">{label}</div>
<div className="min-w-0 break-all text-[11px] text-foreground">{value}</div>
</div>
);
}
function cloudSyncStatusFromConfig(config: AppConfig): CloudSyncStatus {
return {
authenticated: Boolean(config.cloudSync.accessToken || config.cloudSync.refreshToken),
baseUrl: config.cloudSync.baseUrl,
configured: Boolean(config.cloudSync.baseUrl && config.cloudSync.keyId && config.cloudSync.keySalt),
deviceId: config.cloudSync.deviceId,
deviceName: config.cloudSync.deviceName,
enabled: config.cloudSync.enabled,
keyId: config.cloudSync.keyId,
keyMode: config.cloudSync.keyMode,
lastRevision: config.cloudSync.lastRevision,
lastSyncAt: config.cloudSync.lastSyncAt,
lastSyncError: config.cloudSync.lastSyncError,
namespace: config.cloudSync.namespace,
snapshotHash: config.cloudSync.snapshotHash,
unlocked: false,
userAvatarUrl: config.cloudSync.userAvatarUrl,
userEmail: config.cloudSync.userEmail,
userId: config.cloudSync.userId,
userLogin: config.cloudSync.userLogin,
userName: config.cloudSync.userName
};
}
function cloudSyncAccountName(status: CloudSyncStatus, t: (value: string) => string): string {
if (status.userName && status.userLogin) {
return `${status.userName} @${status.userLogin}`;
}
if (status.userLogin) {
return `@${status.userLogin}`;
}
return status.userName || status.userEmail || status.userId || t("Cloud account");
}
function cloudSyncAccountDetail(status: CloudSyncStatus): string {
return status.userEmail || status.userId || "";
}
function cloudSyncAccountInitial(status: CloudSyncStatus): string {
const value = status.userName || status.userLogin || status.userEmail || status.userId || "C";
return value.trim().slice(0, 1).toUpperCase();
}
function GeneralSettingsPage({
appInfo,
config,
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file:; font-src 'self' data:; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; object-src 'none'; base-uri 'none'; form-action 'none'"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://avatars.githubusercontent.com https://*.githubusercontent.com; font-src 'self' data:; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; object-src 'none'; base-uri 'none'; form-action 'none'"
/>
<title>Claude Code Router</title>
</head>
+42 -2
View File
@@ -1,5 +1,6 @@
import type {
AppConfig
import {
CLOUD_SYNC_DEFAULT_BASE_URL,
type AppConfig
} from "@ccr/core/contracts/app";
import {
fallbackConfig
@@ -35,6 +36,7 @@ export function normalizeConfig(config: AppConfig): AppConfig {
},
botConfigs: normalizeBotGatewaySavedConfigs(config.botConfigs),
botGateway: normalizeBotGatewayRuntimeConfig(config.botGateway) ?? fallbackConfig.botGateway,
cloudSync: normalizeCloudSyncConfig(config.cloudSync),
contextArchive: normalizeContextArchiveConfig(config.contextArchive),
gateway: {
...fallbackConfig.gateway,
@@ -78,6 +80,44 @@ export function normalizeConfig(config: AppConfig): AppConfig {
};
}
export function normalizeCloudSyncConfig(config: Partial<AppConfig["cloudSync"]> | undefined): AppConfig["cloudSync"] {
const lastRevision = typeof config?.lastRevision === "number" && Number.isFinite(config.lastRevision)
? Math.max(0, Math.floor(config.lastRevision))
: fallbackConfig.cloudSync.lastRevision;
return {
...fallbackConfig.cloudSync,
...(config || {}),
accessToken: typeof config?.accessToken === "string" ? config.accessToken : undefined,
baseUrl: CLOUD_SYNC_DEFAULT_BASE_URL,
deviceId: typeof config?.deviceId === "string" ? config.deviceId.trim() || undefined : undefined,
deviceName: typeof config?.deviceName === "string" ? config.deviceName.trim() : fallbackConfig.cloudSync.deviceName,
enabled: Boolean(config?.enabled),
keyId: typeof config?.keyId === "string" ? config.keyId.trim() || undefined : undefined,
keyMode: config?.keyMode === "password" || config?.keyMode === "key-file" ? config.keyMode : undefined,
keySalt: typeof config?.keySalt === "string" ? config.keySalt.trim() || undefined : undefined,
lastRevision,
lastSyncedSnapshot: isCloudSyncSnapshotLike(config?.lastSyncedSnapshot) ? config.lastSyncedSnapshot : undefined,
lastSyncAt: typeof config?.lastSyncAt === "string" ? config.lastSyncAt.trim() || undefined : undefined,
lastSyncError: typeof config?.lastSyncError === "string" ? config.lastSyncError.trim() || undefined : undefined,
namespace: typeof config?.namespace === "string" && config.namespace.trim() ? config.namespace.trim() : fallbackConfig.cloudSync.namespace,
refreshToken: typeof config?.refreshToken === "string" ? config.refreshToken : undefined,
refreshTokenExpiresAt: typeof config?.refreshTokenExpiresAt === "string" ? config.refreshTokenExpiresAt.trim() || undefined : undefined,
snapshotHash: typeof config?.snapshotHash === "string" ? config.snapshotHash.trim() || undefined : undefined,
userAvatarUrl: typeof config?.userAvatarUrl === "string" ? config.userAvatarUrl.trim() || undefined : undefined,
userEmail: typeof config?.userEmail === "string" ? config.userEmail.trim() || undefined : undefined,
userId: typeof config?.userId === "string" ? config.userId.trim() || undefined : undefined,
userLogin: typeof config?.userLogin === "string" ? config.userLogin.trim() || undefined : undefined,
userName: typeof config?.userName === "string" ? config.userName.trim() || undefined : undefined
};
}
function isCloudSyncSnapshotLike(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value)) &&
(value as Record<string, unknown>).kind === "claude-code-router-cloud-sync-snapshot" &&
(value as Record<string, unknown>).version === 1 &&
Boolean((value as Record<string, unknown>).config && typeof (value as Record<string, unknown>).config === "object" && !Array.isArray((value as Record<string, unknown>).config));
}
export function normalizeContextArchiveConfig(config: Partial<AppConfig["contextArchive"]> | undefined): AppConfig["contextArchive"] {
return {
enabled: Boolean(config?.enabled),
@@ -164,6 +164,8 @@ import type {
BotGatewayRuntimeConfig,
BotGatewaySavedConfig,
BotHandoffScanTarget,
CloudSyncOperationResult,
CloudSyncStatus,
GatewayProviderConfig,
GatewayProviderCapability,
GatewayPluginAppConfig,
@@ -564,7 +566,7 @@ export type {
HTMLAttributes, ReactDragEvent, ReactPointerEvent, ReactNode, CollisionDetection, DragEndEvent, DragOverEvent, DragStartEvent,
LucideIcon, AgentAnalysisFilter, AgentAnalysisSessionSelection, AgentAnalysisSnapshot, AgentAnalysisTrace, AgentAnalysisTracePayloadFullResult, AgentAnalysisTracePayloadRequest, AgentAnalysisTraceRun, AgentAnalysisTraceRunKind, AgentKind, AppConfig, AppInfo, AppSaveConfigOptions, AppUpdateStatus, ApiKeyConfig,
ApiKeyLimitConfig, BotGatewayQrLoginCancelRequest, BotGatewayQrLoginCancelResult, BotGatewayQrLoginStartRequest, BotGatewayQrLoginStartResult, BotGatewayQrLoginWaitRequest, BotGatewayQrLoginWaitResult, BotGatewayQrWindowOpenResult, BotGatewayRuntimeConfig, BotGatewaySavedConfig, BotHandoffScanTarget, GatewayProviderConfig, GatewayProviderCapability, GatewayPluginAppConfig, GatewayProviderProbeResult, GatewayProviderProtocol, GatewayMcpServerConfig,
GatewayMcpServerTransport, GatewayMcpStdioMessageMode, GatewayMcpToolInfo, GatewayStatus, OverviewMetricKind, OverviewWidgetConfig, OverviewWidgetSize, OverviewWidgetType,
CloudSyncOperationResult, CloudSyncStatus, GatewayMcpServerTransport, GatewayMcpStdioMessageMode, GatewayMcpToolInfo, GatewayStatus, OverviewMetricKind, OverviewWidgetConfig, OverviewWidgetSize, OverviewWidgetType,
OverviewWidgetVariant, PluginDependency, PluginDirectorySelection, PluginMarketplaceEntry, ProviderAccountConfig, ProviderAccountConnectorConfig, ProviderAccountHttpJsonConnectorConfig,
ProviderAccountMeter, ProviderAccountStandardConnectorConfig, ProviderAccountSnapshot, ProviderAccountTestPath, ProviderAccountTestResult, ProviderDeepLinkPayload, ProviderDeepLinkRequest,
ProviderCredentialConfig,
+130
View File
@@ -284,6 +284,73 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Configure a provider before creating an agent profile.": "Configure a provider before creating an agent profile.",
"Configure at least one enabled provider model before saving an agent profile.": "Configure at least one enabled provider model before saving an agent profile.",
"Configuration": "Configuration",
"Access token": "Access token",
"After login, copy the returned tokens into the fields above.": "After login, copy the returned tokens into the fields above.",
"Cloud server URL": "Cloud server URL",
"Cloud Sync": "Cloud Sync",
"Cloud account": "Cloud account",
"Cloud encrypted snapshot was decrypted successfully.": "Cloud encrypted snapshot was decrypted successfully.",
"Cloud encrypted snapshot was pulled and applied.": "Cloud encrypted snapshot was pulled and applied.",
"Cloud sync has no remote snapshot yet.": "Cloud sync has no remote snapshot yet.",
"Cloud sync is disabled on this device.": "Cloud sync is disabled on this device.",
"Cloud sync is enabled and the local encrypted snapshot was uploaded.": "Cloud sync is enabled and the local encrypted snapshot was uploaded.",
"Cloud sync is enabled and the remote encrypted snapshot was restored on this device.": "Cloud sync is enabled and the remote encrypted snapshot was restored on this device.",
"Cloud sync is only available in the Electron app.": "Cloud sync is only available in the Electron app.",
"Cloud sync key file generation is only available in the desktop app.": "Cloud sync key file generation is only available in the desktop app.",
"Cloud sync login is only available in the desktop app.": "Cloud sync login is only available in the desktop app.",
"Cloud sync login expired. Sign in again.": "Cloud sync login expired. Sign in again.",
"Cloud configuration restored. Review it before entering CCR.": "Cloud configuration restored. Review it before entering CCR.",
"Cloud login completed. Choose the encryption method used by your cloud snapshot.": "Cloud login completed. Choose the encryption method used by your cloud snapshot.",
"Cloud login completed. Choose an encryption method to enable sync.": "Cloud login completed. Choose an encryption method to enable sync.",
"Choose an E2EE key file": "Choose an E2EE key file",
"Choose key file": "Choose key file",
"Complete login in the browser, then return to CCR.": "Complete login in the browser, then return to CCR.",
"Create the key file if it does not exist": "Create the key file if it does not exist",
"Device ID": "Device ID",
"Device name": "Device name",
"Disable": "Disable",
"Disabled": "Disabled",
"Enabled": "Enabled",
"Enable sync": "Enable sync",
"Encryption password": "Encryption password",
"Encryption method": "Encryption method",
"End-to-end encryption is required before cloud sync can be enabled.": "End-to-end encryption is required before cloud sync can be enabled.",
"Key file": "Key file",
"Key file generated. Keep it safe; losing it can make cloud data unrecoverable.": "Key file generated. Keep it safe; losing it can make cloud data unrecoverable.",
"Key file path": "Key file path",
"Key mode": "Key mode",
"Last sync": "Last sync",
"Local configuration was encrypted and pushed to cloud sync.": "Local configuration was encrypted and pushed to cloud sync.",
"Local values were kept for these conflict paths:": "Local values were kept for these conflict paths:",
"Never": "Never",
"Not registered": "Not registered",
"Not set": "Not set",
"Open GitHub login": "Open GitHub login",
"Open the browser to complete cloud sync login.": "Open the browser to complete cloud sync login.",
"Optional path to an E2EE key file": "Optional path to an E2EE key file",
"Password": "Password",
"Paste the GitHub OAuth access token returned by CCR Cloud.": "Paste the GitHub OAuth access token returned by CCR Cloud.",
"Paste the refresh token to allow automatic token rotation.": "Paste the refresh token to allow automatic token rotation.",
"Pull": "Pull",
"Pull cloud config": "Pull cloud config",
"Push": "Push",
"Refresh token": "Refresh token",
"Generate key file": "Generate key file",
"Required unless you use a key file": "Required unless you use a key file",
"Restore from cloud": "Restore from cloud",
"Hide restore options": "Hide restore options",
"Revision": "Revision",
"Sign in with GitHub": "Sign in with GitHub",
"Sign out": "Sign out",
"Signed in as": "Signed in as",
"Sync providers, agent profiles, routing rules, plugins, ToolHub, and related user configuration as encrypted snapshots.": "Sync providers, agent profiles, routing rules, plugins, ToolHub, and related user configuration as encrypted snapshots.",
"This device": "This device",
"Unlocked": "Unlocked",
"Update setup": "Update setup",
"Use an encrypted cloud snapshot instead of setting up providers manually.": "Use an encrypted cloud snapshot instead of setting up providers manually.",
"Use the same password or key file that encrypted your cloud snapshot. Losing it makes cloud data unrecoverable.": "Use the same password or key file that encrypted your cloud snapshot. Losing it makes cloud data unrecoverable.",
"Using key file": "Using key file",
"Your password or key file is never uploaded. If you forget the password or lose the key file, cloud data cannot be decrypted and may be permanently lost.": "Your password or key file is never uploaded. If you forget the password or lose the key file, cloud data cannot be decrypted and may be permanently lost.",
"Credential method": "Credential method",
"Connection verified": "Connection verified",
"CCR is checking which API protocols this endpoint supports.": "CCR is checking which API protocols this endpoint supports.",
@@ -1147,15 +1214,78 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Manage bots used by agent profiles.": "管理 Agent 配置中可选择的 Bot。",
"API key database": "API 密钥数据库",
"App surface": "App 运行面",
"Access token": "访问令牌",
"After login, copy the returned tokens into the fields above.": "登录后,将返回的 token 复制到上方字段。",
"Cloud server URL": "云端服务地址",
"Cloud Sync": "云同步",
"Cloud account": "云端账号",
"Cloud encrypted snapshot was decrypted successfully.": "云端加密快照已成功解密。",
"Cloud encrypted snapshot was pulled and applied.": "云端加密快照已拉取并应用。",
"Cloud sync has no remote snapshot yet.": "云端还没有可同步的快照。",
"Cloud sync is disabled on this device.": "这台设备上的云同步已禁用。",
"Cloud sync is enabled and the local encrypted snapshot was uploaded.": "云同步已启用,本地加密快照已上传。",
"Cloud sync is enabled and the remote encrypted snapshot was restored on this device.": "云同步已启用,云端加密快照已恢复到这台设备。",
"Cloud sync is only available in the Electron app.": "云同步仅在 Electron 应用中可用。",
"Cloud sync key file generation is only available in the desktop app.": "云同步 key 文件生成仅在桌面应用中可用。",
"Cloud sync login is only available in the desktop app.": "云同步登录仅在桌面应用中可用。",
"Cloud sync login expired. Sign in again.": "云同步登录已过期,请重新登录。",
"Cloud configuration restored. Review it before entering CCR.": "云端配置已恢复。进入 CCR 前请确认配置。",
"Cloud login completed. Choose the encryption method used by your cloud snapshot.": "云端登录已完成。请选择云端快照使用的加密方式。",
"Cloud login completed. Choose an encryption method to enable sync.": "云端登录已完成。请选择加密方式以启用同步。",
"Choose an E2EE key file": "选择端到端加密 key 文件",
"Choose key file": "选择 key 文件",
"Complete login in the browser, then return to CCR.": "请在浏览器中完成登录,然后回到 CCR。",
"Config database": "配置数据库",
"Configuration is stored in SQLite. The legacy JSON file is only read once for migration.": "配置已存储在 SQLite 中,旧 JSON 文件只会在迁移时读取一次。",
"Create the key file if it does not exist": "如果 key 文件不存在则创建",
"Data export is only available in the Electron app.": "数据导出仅在 Electron 应用中可用。",
"Device ID": "设备 ID",
"Device name": "设备名称",
"Disable": "禁用",
"Enable sync": "启用同步",
"Encryption password": "加密密码",
"Encryption method": "加密方式",
"End-to-end encryption is required before cloud sync can be enabled.": "启用云同步前必须先设置端到端加密。",
"Export": "导出",
"Export data": "导出数据",
"Export current configuration and SQLite data into a single JSON backup file. The export contains API keys and should be stored securely.": "将当前配置和 SQLite 数据导出为单个 JSON 备份文件。导出内容包含 API 密钥,请妥善保存。",
"Exported": "已导出",
"Exporting": "正在导出",
"Key file": "key 文件",
"Key file generated. Keep it safe; losing it can make cloud data unrecoverable.": "key 文件已生成。请妥善保存;丢失后云端数据可能无法恢复。",
"Key file path": "key 文件路径",
"Key mode": "密钥模式",
"Last sync": "上次同步",
"Local configuration was encrypted and pushed to cloud sync.": "本地配置已加密并推送到云同步。",
"Local values were kept for these conflict paths:": "以下冲突路径已保留本地值:",
"Not registered": "未注册",
"Open GitHub login": "打开 GitHub 登录",
"Open the browser to complete cloud sync login.": "请打开浏览器完成云同步登录。",
"Optional path to an E2EE key file": "可选的端到端加密 key 文件路径",
"Paste the GitHub OAuth access token returned by CCR Cloud.": "粘贴 CCR Cloud 返回的 GitHub OAuth access token。",
"Paste the refresh token to allow automatic token rotation.": "粘贴 refresh token,以便自动轮换访问令牌。",
"Pull": "拉取",
"Pull cloud config": "拉取云端配置",
"Push": "推送",
"Refresh token": "刷新令牌",
"Generate key file": "生成 key 文件",
"Required unless you use a key file": "不使用 key 文件时必填",
"Request log database": "请求日志数据库",
"Restore from cloud": "从云端恢复",
"Hide restore options": "收起恢复选项",
"Revision": "版本",
"State": "状态",
"Sign in with GitHub": "使用 GitHub 登录",
"Sign out": "退出登录",
"Signed in as": "当前登录",
"Sync providers, agent profiles, routing rules, plugins, ToolHub, and related user configuration as encrypted snapshots.": "将 Providers、Agent Profiles、路由规则、插件、ToolHub 等用户配置作为加密快照同步。",
"This device": "这台设备",
"Unlocked": "已解锁",
"Update setup": "更新设置",
"Use an encrypted cloud snapshot instead of setting up providers manually.": "使用云端加密快照恢复配置,无需手动设置 Provider。",
"Use the same password or key file that encrypted your cloud snapshot. Losing it makes cloud data unrecoverable.": "请使用加密云端快照时的同一个密码或 key 文件。丢失后云端数据将无法恢复。",
"Using key file": "正在使用 key 文件",
"Your password or key file is never uploaded. If you forget the password or lose the key file, cloud data cannot be decrypted and may be permanently lost.": "密码或 key 文件不会上传。忘记密码或丢失 key 文件后,云端数据将无法解密,并可能永久丢失。",
"Method": "方法",
"Model": "模型",
"Default model": "默认模型",
+1 -1
View File
@@ -52,7 +52,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 = "general" | "appearance" | "toolhub" | "observability" | "bots" | "tray";
export type SettingsPageId = "general" | "appearance" | "cloud" | "toolhub" | "observability" | "bots" | "tray";
export type TrayEditableModuleId = Exclude<TrayWindowModuleId, "footer">;
export type TrayComponentOptionGroup = {
key: keyof TrayComponentVariants;
+15
View File
@@ -29,6 +29,13 @@ import type {
BotGatewayQrWindowOpenResult,
BotHandoffScanTarget,
ClaudeAppGatewayApplyResult,
CloudSyncOperationResult,
CloudSyncKeyFileResult,
CloudSyncLoginResult,
CloudSyncPullRequest,
CloudSyncPushRequest,
CloudSyncSetupRequest,
CloudSyncStatus,
GatewayMcpToolInfo,
GatewayProviderConnectivityCheckReport,
GatewayProviderConnectivityCheckRequest,
@@ -89,6 +96,13 @@ declare global {
cancelBotGatewayQrLogin: (request: BotGatewayQrLoginCancelRequest) => Promise<BotGatewayQrLoginCancelResult>;
captureElementPng?: (request: AppCaptureElementPngRequest) => Promise<AppCaptureElementPngResult>;
checkProviderConnectivity: (request: GatewayProviderConnectivityCheckRequest) => Promise<GatewayProviderConnectivityCheckReport>;
cloudSyncDisable: () => Promise<CloudSyncOperationResult>;
cloudSyncGenerateKeyFile: (file?: string) => Promise<CloudSyncKeyFileResult>;
cloudSyncGetStatus: () => Promise<CloudSyncStatus>;
cloudSyncLogin: () => Promise<CloudSyncLoginResult>;
cloudSyncPull: (request?: CloudSyncPullRequest) => Promise<CloudSyncOperationResult>;
cloudSyncPush: (request?: CloudSyncPushRequest) => Promise<CloudSyncOperationResult>;
cloudSyncSetup: (request: CloudSyncSetupRequest) => Promise<CloudSyncOperationResult>;
closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => Promise<BotGatewayQrWindowCloseResult>;
clearProxyNetworkCaptures: () => Promise<ProxyNetworkSnapshot>;
closeTray: () => Promise<void>;
@@ -157,6 +171,7 @@ declare global {
validateRouteScript: (request: RouteScriptValidationRequest) => Promise<RouteScriptValidationResult>;
waitBotGatewayQrLogin: (request: BotGatewayQrLoginWaitRequest) => Promise<BotGatewayQrLoginWaitResult>;
onBeforeQuit: (callback: () => void) => () => void;
onCloudSyncAuthChanged?: (callback: () => void) => () => void;
onOpenSettingsRequest: (callback: () => void) => () => void;
onOpenUpdateRequest: (callback: () => void) => () => void;
onProviderDeepLink: (callback: (request: ProviderDeepLinkRequest) => void) => () => void;
+11
View File
@@ -109,6 +109,17 @@ const webClientBridge: CcrApi = {
applyProfile: () => rpc("applyProfile") as ReturnType<CcrApi["applyProfile"]>,
cancelBotGatewayQrLogin: (request) => rpc("cancelBotGatewayQrLogin", [request]) as ReturnType<CcrApi["cancelBotGatewayQrLogin"]>,
checkProviderConnectivity: (request) => rpc("checkProviderConnectivity", [request]) as ReturnType<CcrApi["checkProviderConnectivity"]>,
cloudSyncDisable: () => rpc("cloudSyncDisable") as ReturnType<CcrApi["cloudSyncDisable"]>,
cloudSyncGenerateKeyFile: (file) => rpc("cloudSyncGenerateKeyFile", [file]) as ReturnType<CcrApi["cloudSyncGenerateKeyFile"]>,
cloudSyncGetStatus: () => rpc("cloudSyncGetStatus") as ReturnType<CcrApi["cloudSyncGetStatus"]>,
cloudSyncLogin: async () => {
const login = await rpc("cloudSyncLogin") as Awaited<ReturnType<CcrApi["cloudSyncLogin"]>>;
window.open(login.loginUrl, "_blank", "noopener,noreferrer");
return login;
},
cloudSyncPull: (request) => rpc("cloudSyncPull", [request]) as ReturnType<CcrApi["cloudSyncPull"]>,
cloudSyncPush: (request) => rpc("cloudSyncPush", [request]) as ReturnType<CcrApi["cloudSyncPush"]>,
cloudSyncSetup: (request) => rpc("cloudSyncSetup", [request]) as ReturnType<CcrApi["cloudSyncSetup"]>,
clearProxyNetworkCaptures: () => rpc("clearProxyNetworkCaptures") as ReturnType<CcrApi["clearProxyNetworkCaptures"]>,
closeBotGatewayQrWindow: (request) => rpc("closeBotGatewayQrWindow", [request]) as ReturnType<CcrApi["closeBotGatewayQrWindow"]>,
closeTray: () => Promise.resolve(),