diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c4b89830..17f8ff9f 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -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 diff --git a/packages/core/src/cloud-sync/service.ts b/packages/core/src/cloud-sync/service.ts new file mode 100644 index 00000000..fa398fcc --- /dev/null +++ b/packages/core/src/cloud-sync/service.ts @@ -0,0 +1,1583 @@ +import { createCipheriv, createDecipheriv, createHash, pbkdf2Sync, randomBytes, randomUUID } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { hostname } from "node:os"; +import { dirname } from "node:path"; +import type { + AppConfig, + CloudSyncKeyFileResult, + CloudSyncConfig, + CloudSyncKeyInput, + CloudSyncLoginResult, + CloudSyncOperationResult, + CloudSyncPullRequest, + CloudSyncPushRequest, + CloudSyncSetupRequest, + CloudSyncStatus +} from "@ccr/core/contracts/app"; +import { CLOUD_SYNC_DEFAULT_BASE_URL } from "@ccr/core/contracts/app"; + +type CloudSyncKeyMaterial = { + key: Buffer; + keyId: string; + keyMode: CloudSyncConfig["keyMode"]; + keySalt: string; +}; + +type CloudSyncSnapshotConfig = Pick< + AppConfig, + | "Providers" + | "Router" + | "agent" + | "botConfigs" + | "botGateway" + | "mediaTools" + | "plugins" + | "preferredProvider" + | "profile" + | "providerPlugins" + | "toolHub" + | "virtualModelProfiles" +>; + +type CloudSyncSnapshot = { + config: CloudSyncSnapshotConfig; + exportedAt: string; + kind: "claude-code-router-cloud-sync-snapshot"; + version: 1; +}; + +type EncryptedPayload = { + algorithm: "aes-256-gcm"; + ciphertext: string; + encoding: "base64"; + keyId: string; + metadata: { + kdf: "pbkdf2-sha256"; + kdfIterations: number; + keySalt: string; + snapshotVersion?: number; + }; + nonce: string; + tag: string; +}; + +type CloudTokenResponse = { + accessToken?: string; + refreshToken?: string; + refreshTokenExpiresAt?: string; + tokenType?: string; +}; + +type CloudTokenRefreshPatch = { + accessToken: string; + refreshToken: string; + refreshTokenExpiresAt?: string; +}; + +type CloudUserResponse = { + avatarUrl?: string | null; + email?: string | null; + githubLogin?: string; + githubName?: string | null; + id?: string; +}; + +type CloudDeviceResponse = { + id?: string; + name?: string; +}; + +type CloudSyncAuthTokenInput = { + accessToken: string; + refreshToken: string; + refreshTokenExpiresAt?: string; + userAvatarUrl?: string; + userEmail?: string; + userId?: string; + userLogin?: string; + userName?: string; +}; + +type CloudDocument = { + encryptedSnapshot?: EncryptedPayload | null; + id?: string | null; + namespace: string; + revision: number; + snapshotHash?: string | null; + updatedAt?: string | null; +}; + +type CloudPullResponse = { + document: CloudDocument; + operations?: unknown[]; +}; + +type CloudPushResponse = { + conflict?: boolean; + document?: CloudDocument; + mergeRequired?: boolean; + snapshotAccepted?: boolean; + snapshotRejectedReason?: string; +}; + +type CloudRequestOptions = { + body?: unknown; + method?: "GET" | "POST"; +}; + +type CloudSyncMergeResult = { + conflicts: string[]; + snapshot: CloudSyncSnapshot; +}; + +type CloudSyncPushOptions = { + keyMaterial?: CloudSyncKeyMaterial; + mergeOnConflict?: boolean; + setupMessage?: boolean; +}; + +type CloudSyncLoginOptions = { + callbackUrl?: string; +}; + +const cloudSyncSnapshotKind = "claude-code-router-cloud-sync-snapshot"; +const defaultCloudSyncNamespace = "ccr"; +const keyFileKind = "claude-code-router-cloud-sync-key"; +const keyFileVersion = 1; +const keyDerivationIterations = 310_000; +const privateFileMode = 0o600; +const privateDirMode = 0o700; +const missingCloudSyncMergeValue = Symbol("missing-cloud-sync-merge-value"); +const cloudSyncAuthExpiredMessage = "Cloud sync login expired. Sign in again."; + +const cloudSyncKeyCache = new Map(); +const cloudTokenRefreshInFlight = new Map>(); +const cloudTokenRefreshRecent = new Map(); +const cloudTokenRefreshRecentMs = 30 * 60 * 1000; + +class CloudSyncAuthExpiredError extends Error { + readonly config: AppConfig; + + constructor(config: AppConfig, message = cloudSyncAuthExpiredMessage) { + super(message); + this.name = "CloudSyncAuthExpiredError"; + this.config = config; + } +} + +class CloudSyncRefreshTokenExpiredError extends Error { + constructor(message = cloudSyncAuthExpiredMessage) { + super(message); + this.name = "CloudSyncRefreshTokenExpiredError"; + } +} + +export function getCloudSyncStatus(config: AppConfig): CloudSyncStatus { + const cloudSync = normalizedCloudSyncConfig(config.cloudSync); + const unlocked = Boolean(cloudSync.keyId && cloudSyncKeyCache.has(cloudSync.keyId)); + + 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, + userAvatarUrl: cloudSync.userAvatarUrl, + userEmail: cloudSync.userEmail, + userId: cloudSync.userId, + userLogin: cloudSync.userLogin, + userName: cloudSync.userName + }; +} + +export function disableCloudSyncConfig(config: AppConfig): AppConfig { + if (config.cloudSync.keyId) { + cloudSyncKeyCache.delete(config.cloudSync.keyId); + } + + return { + ...config, + cloudSync: { + ...normalizedCloudSyncConfig(config.cloudSync), + accessToken: undefined, + enabled: false, + lastSyncError: undefined, + refreshToken: undefined, + refreshTokenExpiresAt: undefined, + userAvatarUrl: undefined, + userEmail: undefined, + userId: undefined, + userLogin: undefined, + userName: undefined + } + }; +} + +export function startCloudSyncLogin(config: AppConfig, options: CloudSyncLoginOptions = {}): CloudSyncLoginResult { + const nextConfig = prepareCloudSyncAuthConfig(config); + return { + config: nextConfig, + loginUrl: cloudSyncLoginUrl(options.callbackUrl), + message: "Open the browser to complete cloud sync login.", + status: getCloudSyncStatus(nextConfig) + }; +} + +export function applyCloudSyncAuthTokens(config: AppConfig, input: CloudSyncAuthTokenInput): AppConfig { + const accessToken = requiredString(input.accessToken, "Cloud access token"); + const refreshToken = requiredString(input.refreshToken, "Cloud refresh token"); + const cloudSync = normalizedCloudSyncConfig(config.cloudSync); + return { + ...config, + cloudSync: { + ...cloudSync, + accessToken, + baseUrl: CLOUD_SYNC_DEFAULT_BASE_URL, + deviceName: cloudSync.deviceName || defaultDeviceName(), + lastSyncError: undefined, + refreshToken, + refreshTokenExpiresAt: optionalString(input.refreshTokenExpiresAt), + ...cloudSyncUserProfilePatch(input) + } + }; +} + +export async function refreshCloudSyncUserProfile(config: AppConfig): Promise { + const response = await cloudRequest(config, "/auth/me"); + return withCloudSyncUserProfile(response.config, response.data); +} + +export async function ensureCloudSyncUserProfile(config: AppConfig): Promise { + const cloudSync = normalizedCloudSyncConfig(config.cloudSync); + if (!cloudSync.accessToken && !cloudSync.refreshToken) { + return { + ...config, + cloudSync + }; + } + if (cloudSync.userAvatarUrl || cloudSync.userEmail || cloudSync.userLogin || cloudSync.userName) { + return { + ...config, + cloudSync + }; + } + + try { + return await refreshCloudSyncUserProfile({ + ...config, + cloudSync + }); + } catch (error) { + if (isCloudSyncAuthExpiredError(error)) { + return error.config; + } + return { + ...config, + cloudSync: { + ...cloudSync, + lastSyncError: formatError(error) + } + }; + } +} + +export function createCloudSyncKeyFile(file: string): CloudSyncKeyFileResult { + const keyFilePath = requiredString(file, "Cloud sync key file path"); + writeCloudSyncKeyFile(keyFilePath); + return { + canceled: false, + file: keyFilePath + }; +} + +export async function setupCloudSyncConfig( + config: AppConfig, + request: CloudSyncSetupRequest +): Promise { + return withCloudSyncOperationAuthHandling(() => setupCloudSyncConfigInternal(config, request)); +} + +async function setupCloudSyncConfigInternal( + config: AppConfig, + request: CloudSyncSetupRequest +): Promise { + const baseUrl = CLOUD_SYNC_DEFAULT_BASE_URL; + const accessToken = requiredString(config.cloudSync.accessToken, "Cloud access token"); + const refreshToken = optionalString(config.cloudSync.refreshToken); + const deviceName = optionalString(config.cloudSync.deviceName) || defaultDeviceName(); + const existingCloudSync = normalizedCloudSyncConfig(config.cloudSync); + const preparedConfig: AppConfig = { + ...config, + cloudSync: { + ...existingCloudSync, + accessToken, + baseUrl, + deviceName, + enabled: true, + lastSyncError: undefined, + namespace: existingCloudSync.namespace || defaultCloudSyncNamespace, + refreshToken, + refreshTokenExpiresAt: optionalString(existingCloudSync.refreshTokenExpiresAt) + } + }; + let nextConfig: AppConfig = preparedConfig; + + nextConfig = await refreshCloudSyncUserProfile(nextConfig); + + const device = await cloudRequest(nextConfig, "/sync/devices", { + body: { + name: deviceName, + platform: `${process.platform}-${process.arch}` + }, + method: "POST" + }); + nextConfig = { + ...device.config, + cloudSync: { + ...device.config.cloudSync, + deviceId: device.data.id || device.config.cloudSync.deviceId, + userId: device.config.cloudSync.userId + } + }; + + const remote = await pullCloudDocument(nextConfig, 0); + nextConfig = remote.config; + if (remote.data.document.revision > 0) { + const encryptedSnapshot = remote.data.document.encryptedSnapshot; + if (!encryptedSnapshot) { + throw new Error("Cloud sync is configured, but the remote document has no decryptable snapshot."); + } + const keySalt = nextConfig.cloudSync.keySalt || keySaltFromEncryptedPayload(encryptedSnapshot); + const keyMaterial = resolveCloudSyncKey({ + ...nextConfig.cloudSync, + keySalt + }, request, { allowCreateSalt: false }); + nextConfig = { + ...nextConfig, + cloudSync: { + ...nextConfig.cloudSync, + keyId: keyMaterial.keyId, + keyMode: keyMaterial.keyMode, + keySalt: keyMaterial.keySalt + } + }; + const snapshot = decryptSnapshot(encryptedSnapshot, keyMaterial); + nextConfig = applyCloudSyncSnapshot(nextConfig, snapshot); + nextConfig = withCloudSyncSuccess(nextConfig, { + lastRevision: remote.data.document.revision, + snapshotHash: remote.data.document.snapshotHash || snapshotHash(snapshot) + }, snapshot); + return { + config: nextConfig, + message: "Cloud sync is enabled and the remote encrypted snapshot was restored on this device.", + remoteRevision: remote.data.document.revision, + snapshotApplied: true, + status: getCloudSyncStatus(nextConfig) + }; + } + + if (request.restoreOnly) { + nextConfig = { + ...nextConfig, + cloudSync: { + ...nextConfig.cloudSync, + enabled: false, + lastRevision: remote.data.document.revision, + lastSyncError: "Cloud sync has no remote snapshot yet.", + snapshotHash: remote.data.document.snapshotHash || undefined + } + }; + return { + config: nextConfig, + message: "Cloud sync has no remote snapshot yet.", + remoteRevision: remote.data.document.revision, + snapshotApplied: false, + status: getCloudSyncStatus(nextConfig) + }; + } + + const keyMaterial = resolveCloudSyncKey(nextConfig.cloudSync, request, { allowCreateSalt: true }); + nextConfig = { + ...nextConfig, + cloudSync: { + ...nextConfig.cloudSync, + keyId: keyMaterial.keyId, + keyMode: keyMaterial.keyMode, + keySalt: keyMaterial.keySalt + } + }; + return pushCloudSyncConfigInternal(nextConfig, {}, { keyMaterial, setupMessage: true }); +} + +export async function pushCloudSyncConfig( + config: AppConfig, + request: CloudSyncPushRequest = {} +): Promise { + return withCloudSyncOperationAuthHandling(() => pushCloudSyncConfigInternal(config, request, {})); +} + +export async function pullCloudSyncConfig( + config: AppConfig, + request: CloudSyncPullRequest = {} +): Promise { + return withCloudSyncOperationAuthHandling(() => pullCloudSyncConfigInternal(config, request)); +} + +async function pullCloudSyncConfigInternal( + config: AppConfig, + request: CloudSyncPullRequest = {} +): Promise { + const readyConfig = requireCloudSyncEnabled(config); + const keyMaterial = resolveCloudSyncKey(readyConfig.cloudSync, request, { allowCreateSalt: false }); + const remote = await pullCloudDocument(readyConfig, 0); + let nextConfig = remote.config; + const document = remote.data.document; + + if (!document.encryptedSnapshot || document.revision === 0) { + nextConfig = withCloudSyncSuccess(nextConfig, { + lastRevision: document.revision, + snapshotHash: document.snapshotHash || undefined + }); + return { + config: nextConfig, + message: "Cloud sync has no remote snapshot yet.", + remoteRevision: document.revision, + snapshotApplied: false, + status: getCloudSyncStatus(nextConfig) + }; + } + + const snapshot = decryptSnapshot(document.encryptedSnapshot, keyMaterial); + if (request.apply !== false) { + nextConfig = applyCloudSyncSnapshot(nextConfig, snapshot); + } + nextConfig = withCloudSyncSuccess(nextConfig, { + lastRevision: document.revision, + snapshotHash: document.snapshotHash || snapshotHash(snapshot) + }, snapshot); + + return { + config: nextConfig, + message: request.apply === false + ? "Cloud encrypted snapshot was decrypted successfully." + : "Cloud encrypted snapshot was pulled and applied.", + remoteRevision: document.revision, + snapshotApplied: request.apply !== false, + status: getCloudSyncStatus(nextConfig) + }; +} + +export async function autoPushCloudSyncConfig(config: AppConfig): Promise { + const cloudSync = normalizedCloudSyncConfig(config.cloudSync); + if (!cloudSync.enabled || !cloudSync.keyId || !cloudSyncKeyCache.has(cloudSync.keyId)) { + return config; + } + + try { + const result = await pushCloudSyncConfig(config); + return result.config ?? config; + } catch (error) { + if (isCloudSyncAuthExpiredError(error)) { + return error.config; + } + return { + ...config, + cloudSync: { + ...cloudSync, + lastSyncError: formatError(error) + } + }; + } +} + +export function createCloudSyncSnapshot(config: AppConfig, exportedAt = new Date().toISOString()): CloudSyncSnapshot { + return { + config: { + Providers: cloneJson(config.Providers), + Router: cloneJson(config.Router), + agent: cloneJson(config.agent), + botConfigs: cloneJson(config.botConfigs), + botGateway: cloneJson(config.botGateway), + mediaTools: cloneJson(config.mediaTools), + plugins: cloneJson(config.plugins), + preferredProvider: config.preferredProvider, + profile: cloneJson(config.profile), + providerPlugins: cloneJson(config.providerPlugins ?? []), + toolHub: cloneJson(config.toolHub), + virtualModelProfiles: cloneJson(config.virtualModelProfiles ?? []) + }, + exportedAt, + kind: cloudSyncSnapshotKind, + version: 1 + }; +} + +export function applyCloudSyncSnapshot(config: AppConfig, snapshot: CloudSyncSnapshot): AppConfig { + if (snapshot.kind !== cloudSyncSnapshotKind || snapshot.version !== 1 || !isObject(snapshot.config)) { + throw new Error("Cloud snapshot has an unsupported format."); + } + + return { + ...config, + Providers: cloneJson(snapshot.config.Providers ?? config.Providers), + Router: cloneJson(snapshot.config.Router ?? config.Router), + agent: cloneJson(snapshot.config.agent ?? config.agent), + botConfigs: cloneJson(snapshot.config.botConfigs ?? config.botConfigs), + botGateway: cloneJson(snapshot.config.botGateway ?? config.botGateway), + mediaTools: cloneJson(snapshot.config.mediaTools ?? config.mediaTools), + plugins: cloneJson(snapshot.config.plugins ?? config.plugins), + preferredProvider: typeof snapshot.config.preferredProvider === "string" + ? snapshot.config.preferredProvider + : config.preferredProvider, + profile: cloneJson(snapshot.config.profile ?? config.profile), + providerPlugins: cloneJson(snapshot.config.providerPlugins ?? config.providerPlugins ?? []), + toolHub: cloneJson(snapshot.config.toolHub ?? config.toolHub), + virtualModelProfiles: cloneJson(snapshot.config.virtualModelProfiles ?? config.virtualModelProfiles ?? []) + }; +} + +function mergeCloudSyncSnapshots( + base: CloudSyncSnapshot, + local: CloudSyncSnapshot, + remote: CloudSyncSnapshot +): CloudSyncMergeResult { + const conflicts: string[] = []; + const mergedConfig = mergeCloudSyncValue(base.config, local.config, remote.config, "config", conflicts); + if (isMissingCloudSyncMergeValue(mergedConfig) || !isObject(mergedConfig)) { + throw new Error("Cloud sync merge produced an invalid snapshot."); + } + + return { + conflicts, + snapshot: { + config: mergedConfig as CloudSyncSnapshotConfig, + exportedAt: new Date().toISOString(), + kind: cloudSyncSnapshotKind, + version: 1 + } + }; +} + +function mergeCloudSyncValue( + base: unknown, + local: unknown, + remote: unknown, + path: string, + conflicts: string[] +): unknown | typeof missingCloudSyncMergeValue { + if (sameCloudSyncValue(local, remote)) { + return cloneCloudSyncMergeValue(local); + } + if (sameCloudSyncValue(local, base)) { + return cloneCloudSyncMergeValue(remote); + } + if (sameCloudSyncValue(remote, base)) { + return cloneCloudSyncMergeValue(local); + } + + if (Array.isArray(local) && Array.isArray(remote) && (Array.isArray(base) || isMissingCloudSyncMergeValue(base))) { + const primitiveMerge = mergePrimitiveCloudSyncArray( + Array.isArray(base) ? base : [], + local, + remote + ); + if (primitiveMerge) { + return primitiveMerge; + } + + const identifiedMerge = mergeIdentifiedCloudSyncArray( + Array.isArray(base) ? base : [], + local, + remote, + path, + conflicts + ); + if (identifiedMerge) { + return identifiedMerge; + } + + conflicts.push(path); + return cloneJson(local); + } + + if (isObject(local) && isObject(remote) && (isObject(base) || isMissingCloudSyncMergeValue(base))) { + return mergeCloudSyncObject( + isObject(base) ? base : {}, + local, + remote, + path, + conflicts + ); + } + + conflicts.push(path); + return cloneCloudSyncMergeValue(local); +} + +function mergeCloudSyncObject( + base: Record, + local: Record, + remote: Record, + path: string, + conflicts: string[] +): Record { + const result: Record = {}; + const keys = new Set([ + ...Object.keys(base), + ...Object.keys(remote), + ...Object.keys(local) + ]); + + for (const key of keys) { + const merged = mergeCloudSyncValue( + Object.hasOwn(base, key) ? base[key] : missingCloudSyncMergeValue, + Object.hasOwn(local, key) ? local[key] : missingCloudSyncMergeValue, + Object.hasOwn(remote, key) ? remote[key] : missingCloudSyncMergeValue, + `${path}.${key}`, + conflicts + ); + if (!isMissingCloudSyncMergeValue(merged) && merged !== undefined) { + result[key] = merged; + } + } + + return result; +} + +function mergeIdentifiedCloudSyncArray( + base: unknown[], + local: unknown[], + remote: unknown[], + path: string, + conflicts: string[] +): unknown[] | undefined { + const baseMap = toIdentifiedCloudSyncMap(base, path); + const localMap = toIdentifiedCloudSyncMap(local, path); + const remoteMap = toIdentifiedCloudSyncMap(remote, path); + if (!baseMap || !localMap || !remoteMap) { + return undefined; + } + + const keys = uniqueStrings([ + ...localMap.keys(), + ...remoteMap.keys(), + ...baseMap.keys() + ]); + const result: unknown[] = []; + for (const key of keys) { + const merged = mergeCloudSyncValue( + baseMap.has(key) ? baseMap.get(key) : missingCloudSyncMergeValue, + localMap.has(key) ? localMap.get(key) : missingCloudSyncMergeValue, + remoteMap.has(key) ? remoteMap.get(key) : missingCloudSyncMergeValue, + `${path}[${key}]`, + conflicts + ); + if (!isMissingCloudSyncMergeValue(merged) && merged !== undefined) { + result.push(merged); + } + } + + return result; +} + +function mergePrimitiveCloudSyncArray(base: unknown[], local: unknown[], remote: unknown[]): unknown[] | undefined { + const baseMap = toPrimitiveCloudSyncMap(base); + const localMap = toPrimitiveCloudSyncMap(local); + const remoteMap = toPrimitiveCloudSyncMap(remote); + if (!baseMap || !localMap || !remoteMap) { + return undefined; + } + + const keys = uniqueStrings([ + ...localMap.keys(), + ...remoteMap.keys(), + ...baseMap.keys() + ]); + const result: unknown[] = []; + for (const key of keys) { + const hasBase = baseMap.has(key); + const hasLocal = localMap.has(key); + const hasRemote = remoteMap.has(key); + if (hasLocal && hasRemote) { + result.push(cloneJson(localMap.get(key))); + } else if (!hasBase && hasLocal) { + result.push(cloneJson(localMap.get(key))); + } else if (!hasBase && hasRemote) { + result.push(cloneJson(remoteMap.get(key))); + } + } + return result; +} + +function toIdentifiedCloudSyncMap(items: unknown[], path: string): Map | undefined { + const map = new Map(); + for (const item of items) { + const key = cloudSyncArrayIdentity(path, item); + if (!key || map.has(key)) { + return undefined; + } + map.set(key, item); + } + return map; +} + +function toPrimitiveCloudSyncMap(items: unknown[]): Map | undefined { + const map = new Map(); + for (const item of items) { + if (!isCloudSyncPrimitive(item)) { + return undefined; + } + const key = stableStringify(item); + if (map.has(key)) { + return undefined; + } + map.set(key, item); + } + return map; +} + +function cloudSyncArrayIdentity(path: string, value: unknown): string | undefined { + if (!isObject(value)) { + return undefined; + } + + const segment = path.split(".").at(-1) || path; + const candidatesBySegment: Record = { + Providers: ["id", "name"], + botConfigs: ["id", "name"], + capabilities: ["id", "type", "protocol"], + mcpServers: ["name", "id"], + plugins: ["id", "name"], + profiles: ["id", "name"], + providerPlugins: ["id", "name", "provider"], + routes: ["id", "name"], + rules: ["id", "name"], + virtualModelProfiles: ["id", "name", "key", "model"] + }; + const candidates = candidatesBySegment[segment]; + if (!candidates) { + return undefined; + } + + for (const candidate of candidates) { + const item = value[candidate]; + if (typeof item === "string" && item.trim()) { + return item.trim(); + } + } + return undefined; +} + +function isCloudSyncPrimitive(value: unknown): boolean { + return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +function sameCloudSyncValue(left: unknown, right: unknown): boolean { + if (isMissingCloudSyncMergeValue(left) || isMissingCloudSyncMergeValue(right)) { + return isMissingCloudSyncMergeValue(left) && isMissingCloudSyncMergeValue(right); + } + return stableStringify(left) === stableStringify(right); +} + +function cloneCloudSyncMergeValue(value: unknown): unknown | typeof missingCloudSyncMergeValue { + return isMissingCloudSyncMergeValue(value) ? missingCloudSyncMergeValue : cloneJson(value); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} + +export function encryptCloudSyncSnapshotForTest( + snapshot: CloudSyncSnapshot, + password: string, + salt = randomBase64(16) +): { encrypted: EncryptedPayload; keyId: string; keySalt: string } { + const cloudSync = normalizedCloudSyncConfig({ + baseUrl: "http://localhost:3000", + deviceName: "test", + enabled: true, + keySalt: salt, + lastRevision: 0, + namespace: "ccr" + }); + const keyMaterial = resolveCloudSyncKey(cloudSync, { password }, { allowCreateSalt: false }); + return { + encrypted: encryptJson(snapshot, keyMaterial), + keyId: keyMaterial.keyId, + keySalt: keyMaterial.keySalt + }; +} + +export function decryptCloudSyncSnapshotForTest( + encrypted: EncryptedPayload, + password: string, + keySalt: string +): CloudSyncSnapshot { + const cloudSync = normalizedCloudSyncConfig({ + baseUrl: "http://localhost:3000", + deviceName: "test", + enabled: true, + keySalt, + lastRevision: 0, + namespace: "ccr" + }); + const keyMaterial = resolveCloudSyncKey(cloudSync, { password }, { allowCreateSalt: false }); + return decryptSnapshot(encrypted, keyMaterial); +} + +export function mergeCloudSyncSnapshotsForTest( + base: CloudSyncSnapshot, + local: CloudSyncSnapshot, + remote: CloudSyncSnapshot +): CloudSyncMergeResult { + return mergeCloudSyncSnapshots(base, local, remote); +} + +async function pushCloudSyncConfigInternal( + config: AppConfig, + request: CloudSyncPushRequest, + options: CloudSyncPushOptions +): Promise { + let readyConfig = requireCloudSyncEnabled(config); + let keyMaterial = options.keyMaterial ?? resolveCloudSyncKey(readyConfig.cloudSync, request, { allowCreateSalt: false }); + if ( + keyMaterial.keyId !== readyConfig.cloudSync.keyId || + keyMaterial.keySalt !== readyConfig.cloudSync.keySalt || + keyMaterial.keyMode !== readyConfig.cloudSync.keyMode + ) { + readyConfig = { + ...readyConfig, + cloudSync: { + ...readyConfig.cloudSync, + keyId: keyMaterial.keyId, + keyMode: keyMaterial.keyMode, + keySalt: keyMaterial.keySalt + } + }; + } + + let baseRevision = readyConfig.cloudSync.lastRevision; + if (request.force) { + const remote = await pullCloudDocument(readyConfig, 0); + readyConfig = remote.config; + baseRevision = remote.data.document.revision; + keyMaterial = options.keyMaterial ?? keyMaterial; + } + + const snapshot = createCloudSyncSnapshot(readyConfig); + const encryptedSnapshot = encryptJson(snapshot, keyMaterial); + const encryptedOperation = encryptJson({ + kind: "replace-cloud-sync-snapshot", + snapshotHash: snapshotHash(snapshot), + snapshotVersion: snapshot.version, + updatedAt: snapshot.exportedAt + }, keyMaterial); + const response = await cloudRequest(readyConfig, "/sync/push", { + body: { + baseRevision, + clientOperationId: `${deviceOperationPrefix(readyConfig)}-${randomUUID()}`, + deviceId: readyConfig.cloudSync.deviceId, + encryptedOperation, + encryptedSnapshot, + namespace: readyConfig.cloudSync.namespace, + snapshotHash: snapshotHash(snapshot) + }, + method: "POST" + }); + + const document = response.data.document; + const remoteRevision = document?.revision ?? baseRevision; + if (response.data.conflict || response.data.mergeRequired || response.data.snapshotRejectedReason) { + if (!request.force && options.mergeOnConflict !== false) { + return mergeAndPushCloudSyncConflict({ + keyMaterial, + localSnapshot: snapshot, + remoteDocument: document, + remoteRevision, + responseConfig: response.config + }); + } + + const nextConfig = { + ...response.config, + cloudSync: { + ...response.config.cloudSync, + lastSyncError: "Cloud has a newer encrypted snapshot. Pull first or force push to replace it." + } + }; + return { + config: nextConfig, + conflict: true, + message: nextConfig.cloudSync.lastSyncError || "Cloud sync conflict.", + mergeApplied: false, + mergeConflicts: [], + remoteRevision, + snapshotPushed: false, + status: getCloudSyncStatus(nextConfig) + }; + } + + const nextConfig = withCloudSyncSuccess(response.config, { + lastRevision: remoteRevision, + snapshotHash: document?.snapshotHash || snapshotHash(snapshot) + }, snapshot); + + return { + config: nextConfig, + message: options.setupMessage + ? "Cloud sync is enabled and the local encrypted snapshot was uploaded." + : "Local configuration was encrypted and pushed to cloud sync.", + remoteRevision, + snapshotPushed: true, + status: getCloudSyncStatus(nextConfig) + }; +} + +async function mergeAndPushCloudSyncConflict({ + keyMaterial, + localSnapshot, + remoteDocument, + remoteRevision, + responseConfig +}: { + keyMaterial: CloudSyncKeyMaterial; + localSnapshot: CloudSyncSnapshot; + remoteDocument?: CloudDocument; + remoteRevision: number; + responseConfig: AppConfig; +}): Promise { + if (!remoteDocument?.encryptedSnapshot) { + return cloudSyncConflictResult( + responseConfig, + "Cloud has a newer encrypted snapshot, but it could not be loaded for automatic merge.", + remoteRevision + ); + } + + const baseSnapshot = cloudSyncSnapshotFromUnknown(responseConfig.cloudSync.lastSyncedSnapshot); + if (!baseSnapshot) { + return cloudSyncConflictResult( + responseConfig, + "Cloud has a newer encrypted snapshot, but this device has no merge base yet. Back up local config before pulling remote changes, or force push to replace cloud data.", + remoteRevision + ); + } + + let remoteSnapshot: CloudSyncSnapshot; + try { + remoteSnapshot = decryptSnapshot(remoteDocument.encryptedSnapshot, keyMaterial); + } catch (error) { + return cloudSyncConflictResult( + responseConfig, + `Cloud has a newer encrypted snapshot, but it could not be decrypted for automatic merge: ${formatError(error)}`, + remoteRevision + ); + } + + const merge = mergeCloudSyncSnapshots(baseSnapshot, localSnapshot, remoteSnapshot); + let mergedConfig = applyCloudSyncSnapshot(responseConfig, merge.snapshot); + mergedConfig = { + ...mergedConfig, + cloudSync: { + ...normalizedCloudSyncConfig(mergedConfig.cloudSync), + lastRevision: remoteDocument.revision, + lastSyncError: undefined, + lastSyncedSnapshot: cloneJson(remoteSnapshot), + snapshotHash: remoteDocument.snapshotHash || snapshotHash(remoteSnapshot) + } + }; + + const pushed = await pushCloudSyncConfigInternal(mergedConfig, {}, { + keyMaterial, + mergeOnConflict: false + }); + + return { + ...pushed, + conflict: true, + mergeApplied: pushed.snapshotPushed === true, + mergeConflicts: merge.conflicts, + message: pushed.snapshotPushed + ? merge.conflicts.length > 0 + ? `Cloud sync conflict was automatically merged and pushed. Local values were kept for ${merge.conflicts.length} conflicting path(s).` + : "Cloud sync conflict was automatically merged and pushed." + : pushed.message + }; +} + +function cloudSyncConflictResult( + config: AppConfig, + message: string, + remoteRevision: number, + mergeConflicts: string[] = [] +): CloudSyncOperationResult { + const nextConfig = { + ...config, + cloudSync: { + ...normalizedCloudSyncConfig(config.cloudSync), + lastSyncError: message + } + }; + + return { + config: nextConfig, + conflict: true, + mergeApplied: false, + mergeConflicts, + message, + remoteRevision, + snapshotPushed: false, + status: getCloudSyncStatus(nextConfig) + }; +} + +async function withCloudSyncOperationAuthHandling( + operation: () => Promise +): Promise { + try { + return await operation(); + } catch (error) { + if (!isCloudSyncAuthExpiredError(error)) { + throw error; + } + return { + authExpired: true, + config: error.config, + message: error.message, + snapshotApplied: false, + snapshotPushed: false, + status: getCloudSyncStatus(error.config) + }; + } +} + +function isCloudSyncAuthExpiredError(error: unknown): error is CloudSyncAuthExpiredError { + return error instanceof CloudSyncAuthExpiredError; +} + +function cloudSyncLoggedOutConfig(config: AppConfig): AppConfig { + const nextConfig = disableCloudSyncConfig(config); + return { + ...nextConfig, + cloudSync: { + ...normalizedCloudSyncConfig(nextConfig.cloudSync), + lastSyncError: cloudSyncAuthExpiredMessage + } + }; +} + +async function pullCloudDocument(config: AppConfig, sinceRevision: number): Promise<{ config: AppConfig; data: CloudPullResponse }> { + const cloudSync = normalizedCloudSyncConfig(config.cloudSync); + const search = new URLSearchParams({ + namespace: cloudSync.namespace, + sinceRevision: String(Math.max(0, Math.floor(sinceRevision))) + }); + return cloudRequest(config, `/sync/pull?${search.toString()}`); +} + +async function cloudRequest( + config: AppConfig, + path: string, + options: CloudRequestOptions = {}, + retry = true +): Promise<{ config: AppConfig; data: T }> { + const cloudSync = normalizedCloudSyncConfig(config.cloudSync); + const url = cloudSyncUrl(cloudSync.baseUrl, path); + const response = await fetch(url, { + body: options.body === undefined ? undefined : JSON.stringify(options.body), + headers: { + Accept: "application/json", + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + ...(cloudSync.accessToken ? { Authorization: `Bearer ${cloudSync.accessToken}` } : {}) + }, + method: options.method ?? "GET" + }); + + if (response.status === 401 && retry && cloudSync.refreshToken) { + const refreshed = await refreshCloudTokens(config); + return cloudRequest(refreshed, path, options, false); + } + if (response.status === 401) { + throw new CloudSyncAuthExpiredError(cloudSyncLoggedOutConfig(config)); + } + + const data = await readCloudResponse(response); + if (!response.ok) { + throw new Error(`Cloud sync request failed (${response.status}): ${cloudErrorMessage(data)}`); + } + + return { + config, + data: data as T + }; +} + +async function refreshCloudTokens(config: AppConfig): Promise { + const cloudSync = normalizedCloudSyncConfig(config.cloudSync); + const refreshToken = requiredString(cloudSync.refreshToken, "Cloud refresh token"); + try { + return applyCloudTokenRefreshPatch(config, await cloudTokenRefreshPatch(cloudSync, refreshToken)); + } catch (error) { + if (error instanceof CloudSyncRefreshTokenExpiredError) { + throw new CloudSyncAuthExpiredError(cloudSyncLoggedOutConfig(config)); + } + throw error; + } +} + +async function cloudTokenRefreshPatch( + cloudSync: CloudSyncConfig, + refreshToken: string +): Promise { + const recent = cloudTokenRefreshRecent.get(refreshToken); + if (recent) { + if (recent.expiresAt > Date.now()) { + return recent.patch; + } + cloudTokenRefreshRecent.delete(refreshToken); + } + + const inFlight = cloudTokenRefreshInFlight.get(refreshToken); + if (inFlight) { + return inFlight; + } + + const refresh = fetchCloudTokenRefreshPatch(cloudSync, refreshToken) + .then((patch) => { + rememberCloudTokenRefreshPatch(refreshToken, patch); + return patch; + }) + .finally(() => { + cloudTokenRefreshInFlight.delete(refreshToken); + }); + cloudTokenRefreshInFlight.set(refreshToken, refresh); + return refresh; +} + +async function fetchCloudTokenRefreshPatch( + cloudSync: CloudSyncConfig, + refreshToken: string +): Promise { + const response = await fetch(cloudSyncUrl(cloudSync.baseUrl, "/auth/refresh"), { + body: JSON.stringify({ refreshToken }), + headers: { + Accept: "application/json", + "Content-Type": "application/json" + }, + method: "POST" + }); + const data = await readCloudResponse(response); + if (!response.ok) { + if (response.status === 400 || response.status === 401) { + throw new CloudSyncRefreshTokenExpiredError(); + } + throw new Error(`Cloud token refresh failed (${response.status}): ${cloudErrorMessage(data)}`); + } + + const tokenResponse = data as CloudTokenResponse; + const accessToken = requiredString(tokenResponse.accessToken, "Cloud access token"); + const nextRefreshToken = optionalString(tokenResponse.refreshToken) || refreshToken; + + return { + accessToken, + refreshToken: nextRefreshToken, + refreshTokenExpiresAt: optionalString(tokenResponse.refreshTokenExpiresAt) + }; +} + +function rememberCloudTokenRefreshPatch(refreshToken: string, patch: CloudTokenRefreshPatch): void { + const entry = { + expiresAt: Date.now() + cloudTokenRefreshRecentMs, + patch + }; + cloudTokenRefreshRecent.set(refreshToken, entry); + if (patch.refreshToken !== refreshToken) { + cloudTokenRefreshRecent.set(patch.refreshToken, entry); + } +} + +function applyCloudTokenRefreshPatch(config: AppConfig, patch: CloudTokenRefreshPatch): AppConfig { + const cloudSync = normalizedCloudSyncConfig(config.cloudSync); + return { + ...config, + cloudSync: { + ...cloudSync, + accessToken: patch.accessToken, + refreshToken: patch.refreshToken, + refreshTokenExpiresAt: patch.refreshTokenExpiresAt || cloudSync.refreshTokenExpiresAt + } + }; +} + +async function readCloudResponse(response: Response): Promise { + const text = await response.text(); + if (!text) { + return {}; + } + try { + return JSON.parse(text) as unknown; + } catch { + return { message: text }; + } +} + +function cloudErrorMessage(data: unknown): string { + if (isObject(data)) { + const message = data.message; + if (Array.isArray(message)) { + return message.filter((item): item is string => typeof item === "string").join("; ") || "unknown error"; + } + if (typeof message === "string" && message.trim()) { + return message; + } + if (typeof data.error === "string" && data.error.trim()) { + return data.error; + } + } + return "unknown error"; +} + +function requireCloudSyncEnabled(config: AppConfig): AppConfig { + const cloudSync = normalizedCloudSyncConfig(config.cloudSync); + if (!cloudSync.enabled) { + throw new Error("Cloud sync is not enabled."); + } + if (!cloudSync.baseUrl) { + throw new Error("Cloud sync server URL is required."); + } + if (!cloudSync.accessToken) { + throw new Error("Cloud access token is required."); + } + if (!cloudSync.keyId || !cloudSync.keySalt) { + throw new Error("Set up an end-to-end encryption password or key file before using cloud sync."); + } + + return { + ...config, + cloudSync + }; +} + +function resolveCloudSyncKey( + cloudSync: CloudSyncConfig, + input: CloudSyncKeyInput, + options: { allowCreateSalt: boolean } +): CloudSyncKeyMaterial { + const cachedKeyId = cloudSync.keyId; + const cached = cachedKeyId ? cloudSyncKeyCache.get(cachedKeyId) : undefined; + if (cachedKeyId && cached && !input.password && !input.keyFilePath) { + return { + key: cached, + keyId: cachedKeyId, + keyMode: cloudSync.keyMode ?? "password", + keySalt: requiredString(cloudSync.keySalt, "Cloud sync key salt") + }; + } + + const secret = readSecretInput(input); + const keyMode: CloudSyncConfig["keyMode"] = input.keyFilePath ? "key-file" : "password"; + const keySalt = cloudSync.keySalt || (options.allowCreateSalt ? randomBase64(16) : ""); + if (!keySalt) { + throw new Error("Cloud sync key salt is missing. Run cloud sync setup again."); + } + const salt = Buffer.from(keySalt, "base64"); + if (salt.length < 16) { + throw new Error("Cloud sync key salt is invalid."); + } + + const key = pbkdf2Sync(secret, salt, keyDerivationIterations, 32, "sha256"); + const keyId = cloudSyncKeyId(key, salt); + if (cloudSync.keyId && keyId !== cloudSync.keyId) { + throw new Error("The cloud sync password or key file does not match this device's encryption key."); + } + cloudSyncKeyCache.set(keyId, key); + + return { + key, + keyId, + keyMode, + keySalt + }; +} + +function readSecretInput(input: CloudSyncKeyInput): Buffer { + if (input.keyFilePath) { + return readCloudSyncKeyFile(input.keyFilePath); + } + if (typeof input.password === "string" && input.password.length > 0) { + return Buffer.from(input.password, "utf8"); + } + throw new Error("Enter a cloud sync password or choose a key file."); +} + +function readCloudSyncKeyFile(file: string): Buffer { + if (!existsSync(file)) { + throw new Error("Cloud sync key file does not exist. Generate a key file first, or choose an existing one."); + } + + const content = readFileSync(file, "utf8").trim(); + if (!content) { + throw new Error("Cloud sync key file is empty."); + } + try { + const parsed = JSON.parse(content) as unknown; + if (isObject(parsed) && parsed.kind === keyFileKind && parsed.version === keyFileVersion && typeof parsed.key === "string") { + return Buffer.from(parsed.key, "base64"); + } + } catch { + return Buffer.from(content, "utf8"); + } + return Buffer.from(content, "utf8"); +} + +function writeCloudSyncKeyFile(file: string): void { + const key = randomBytes(32).toString("base64"); + mkdirSync(dirname(file), { mode: privateDirMode, recursive: true }); + writeFileSync(file, `${JSON.stringify({ kind: keyFileKind, key, version: keyFileVersion }, null, 2)}\n`, { + encoding: "utf8", + mode: privateFileMode + }); + chmodSync(file, privateFileMode); +} + +function encryptJson(value: unknown, keyMaterial: CloudSyncKeyMaterial): EncryptedPayload { + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", keyMaterial.key, nonce); + const ciphertext = Buffer.concat([ + cipher.update(stableStringify(value), "utf8"), + cipher.final() + ]); + const tag = cipher.getAuthTag(); + + return { + algorithm: "aes-256-gcm", + ciphertext: ciphertext.toString("base64"), + encoding: "base64", + keyId: keyMaterial.keyId, + metadata: { + kdf: "pbkdf2-sha256", + kdfIterations: keyDerivationIterations, + keySalt: keyMaterial.keySalt, + snapshotVersion: isObject(value) && typeof value.version === "number" ? value.version : undefined + }, + nonce: nonce.toString("base64"), + tag: tag.toString("base64") + }; +} + +function decryptSnapshot(encrypted: EncryptedPayload, keyMaterial: CloudSyncKeyMaterial): CloudSyncSnapshot { + if (encrypted.algorithm !== "aes-256-gcm") { + throw new Error(`Unsupported cloud sync encryption algorithm: ${encrypted.algorithm}`); + } + if (encrypted.keyId && encrypted.keyId !== keyMaterial.keyId) { + throw new Error("The cloud sync password or key file does not match the remote encrypted snapshot."); + } + const nonce = Buffer.from(encrypted.nonce, "base64"); + const tag = Buffer.from(encrypted.tag, "base64"); + const ciphertext = Buffer.from(encrypted.ciphertext, "base64"); + const decipher = createDecipheriv("aes-256-gcm", keyMaterial.key, nonce); + decipher.setAuthTag(tag); + const plaintext = Buffer.concat([ + decipher.update(ciphertext), + decipher.final() + ]).toString("utf8"); + const parsed = JSON.parse(plaintext) as unknown; + if (!isCloudSyncSnapshot(parsed)) { + throw new Error("Remote cloud sync snapshot has an unsupported format."); + } + return parsed; +} + +function keySaltFromEncryptedPayload(encrypted: EncryptedPayload): string { + const keySalt = optionalString(encrypted.metadata?.keySalt); + if (!keySalt) { + throw new Error("Remote cloud sync snapshot does not include key derivation metadata."); + } + return keySalt; +} + +function isCloudSyncSnapshot(value: unknown): value is CloudSyncSnapshot { + return isObject(value) && + value.kind === cloudSyncSnapshotKind && + value.version === 1 && + isObject(value.config); +} + +function cloudSyncSnapshotFromUnknown(value: unknown): CloudSyncSnapshot | undefined { + return isCloudSyncSnapshot(value) ? cloneJson(value) : undefined; +} + +function isMissingCloudSyncMergeValue(value: unknown): value is typeof missingCloudSyncMergeValue { + return value === missingCloudSyncMergeValue; +} + +function snapshotHash(snapshot: CloudSyncSnapshot): string { + return createHash("sha256").update(stableStringify(snapshot)).digest("base64url"); +} + +function cloudSyncKeyId(key: Buffer, salt: Buffer): string { + return `ccr-e2ee-v1-${createHash("sha256").update(salt).update(key).digest("base64url").slice(0, 32)}`; +} + +function withCloudSyncSuccess( + config: AppConfig, + patch: Pick & Partial>, + syncedSnapshot?: CloudSyncSnapshot +): AppConfig { + return { + ...config, + cloudSync: { + ...normalizedCloudSyncConfig(config.cloudSync), + lastRevision: patch.lastRevision, + lastSyncedSnapshot: syncedSnapshot ? cloneJson(syncedSnapshot) : normalizedCloudSyncConfig(config.cloudSync).lastSyncedSnapshot, + lastSyncAt: new Date().toISOString(), + lastSyncError: undefined, + snapshotHash: patch.snapshotHash + } + }; +} + +function withCloudSyncUserProfile(config: AppConfig, user: CloudUserResponse): AppConfig { + return { + ...config, + cloudSync: { + ...normalizedCloudSyncConfig(config.cloudSync), + ...cloudSyncUserProfilePatch(user) + } + }; +} + +function cloudSyncUserProfilePatch( + user: CloudUserResponse | CloudSyncAuthTokenInput +): Partial { + const id = optionalString("accessToken" in user ? user.userId : user.id); + const login = optionalString("accessToken" in user ? user.userLogin : user.githubLogin); + const name = optionalString("accessToken" in user ? user.userName : user.githubName); + const avatarUrl = optionalString("accessToken" in user ? user.userAvatarUrl : user.avatarUrl); + const email = optionalString("accessToken" in user ? user.userEmail : user.email); + + return { + ...(avatarUrl ? { userAvatarUrl: avatarUrl } : {}), + ...(email ? { userEmail: email } : {}), + ...(id ? { userId: id } : {}), + ...(login ? { userLogin: login } : {}), + ...(name ? { userName: name } : {}) + }; +} + +function prepareCloudSyncAuthConfig(config: AppConfig): AppConfig { + return { + ...config, + cloudSync: { + ...normalizedCloudSyncConfig(config.cloudSync), + baseUrl: CLOUD_SYNC_DEFAULT_BASE_URL, + deviceName: normalizedCloudSyncConfig(config.cloudSync).deviceName || defaultDeviceName(), + lastSyncError: undefined + } + }; +} + +function normalizedCloudSyncConfig(config: CloudSyncConfig): CloudSyncConfig { + return { + ...config, + baseUrl: CLOUD_SYNC_DEFAULT_BASE_URL, + deviceName: optionalString(config.deviceName) || defaultDeviceName(), + enabled: Boolean(config.enabled), + lastRevision: Number.isInteger(config.lastRevision) && config.lastRevision >= 0 ? config.lastRevision : 0, + lastSyncedSnapshot: cloudSyncSnapshotFromUnknown(config.lastSyncedSnapshot), + namespace: optionalString(config.namespace) || defaultCloudSyncNamespace + }; +} + +function normalizeBaseUrl(value: string | undefined): string { + const raw = requiredString(value, "Cloud sync server URL"); + const url = new URL(raw); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Cloud sync server URL must use http or https."); + } + url.pathname = url.pathname.replace(/\/+$/, ""); + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); +} + +function cloudSyncUrl(baseUrl: string, path: string): string { + return new URL(path, `${normalizeBaseUrl(baseUrl)}/`).toString(); +} + +function cloudSyncLoginUrl(callbackUrl: string | undefined): string { + const url = new URL(cloudSyncUrl(CLOUD_SYNC_DEFAULT_BASE_URL, "/auth/github/login")); + const normalizedCallbackUrl = optionalString(callbackUrl); + if (normalizedCallbackUrl) { + url.searchParams.set("redirect_uri", normalizedCallbackUrl); + } + return url.toString(); +} + +function deviceOperationPrefix(config: AppConfig): string { + return (config.cloudSync.deviceId || "local").replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 48) || "local"; +} + +function defaultDeviceName(): string { + return hostname().trim() || `${process.platform}-${process.arch}`; +} + +function requiredString(value: unknown, label: string): string { + const text = optionalString(value); + if (!text) { + throw new Error(`${label} is required.`); + } + return text; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function cloneJson(value: T): T { + if (value === undefined) { + return value; + } + return JSON.parse(JSON.stringify(value)) as T; +} + +function randomBase64(size: number): string { + return randomBytes(size).toString("base64"); +} + +function stableStringify(value: unknown): string { + return JSON.stringify(sortJson(value)) ?? "undefined"; +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJson); + } + if (!isObject(value)) { + return value; + } + return Object.keys(value).sort().reduce>((result, key) => { + const item = value[key]; + if (item !== undefined) { + result[key] = sortJson(item); + } + return result; + }, {}); +} + +function isObject(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 15b52ca2..10875c02 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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> handoff?: Partial; }; -type LoadedAppConfig = Partial> & { +type LoadedAppConfig = Partial> & { Router?: Partial; agent?: Partial; botConfigs?: BotGatewaySavedConfig[]; botGateway?: LoadedBotGatewayConfig; + cloudSync?: Partial; contextArchive?: Partial; gateway?: Partial; mediaTools?: Partial; @@ -145,6 +147,62 @@ function completeBotGatewayConfig(config: LoadedBotGatewayConfig | undefined): B }; } +function completeCloudSyncConfig(config: Partial | 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 { + 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 { }, 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): LoadedAppConfig { if (botGateway) { config.botGateway = botGateway; } + const cloudSync = parseCloudSync((value as Record).cloudSync ?? (value as Record).cloud_sync); + if (cloudSync) { + config.cloudSync = cloudSync; + } const botConfigs = parseBotGatewaySavedConfigs((value as Record).botConfigs ?? (value as Record).bot_configs); if (botConfigs) { config.botConfigs = botConfigs; @@ -831,6 +894,99 @@ function pickConfig(value: Partial): LoadedAppConfig { return config; } +function parseCloudSync(value: unknown): Partial | undefined { + if (!isObject(value)) { + return undefined; + } + + const cloudSync: Partial = {}; + 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 | undefined { if (!isObject(value)) { return undefined; diff --git a/packages/core/src/config/default-config.ts b/packages/core/src/config/default-config.ts index f2097d36..6b9e2ca9 100644 --- a/packages/core/src/config/default-config.ts +++ b/packages/core/src/config/default-config.ts @@ -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, diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index c026ce34..a79a9dc6 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -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; diff --git a/packages/core/src/contracts/ipc-channels.ts b/packages/core/src/contracts/ipc-channels.ts index 134718a9..36a01c55 100644 --- a/packages/core/src/contracts/ipc-channels.ts +++ b/packages/core/src/contracts/ipc-channels.ts @@ -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", diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index c24e9716..b3311102 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -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 = ' '; +const cloudSyncAuthCallbackPath = "/api/cloud-sync/auth/callback"; +const cloudSyncAuthSessionParam = "session"; +const cloudSyncAuthSessions = new Set(); export async function startWebManagementServer(options: WebManagementServerOptions = {}): Promise { @@ -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 { + 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 { + 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 = { 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 = { 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 = { waitBotGatewayQrLogin: (request) => waitBotGatewayQrLogin(request as BotGatewayQrLoginWaitRequest) }; +async function persistCloudSyncOperation(result: CloudSyncOperationResult): Promise { + 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 { 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 [ + "", + "", + "CCR Cloud Sync", + "", + "", + "

Cloud sync login complete

", + "

You can return to CCR and choose an end-to-end encryption method.

", + "", + "" + ].join(""); +} + +function cloudSyncAuthFailureHtml(message: string): string { + return [ + "", + "", + "CCR Cloud Sync", + "", + "", + "

Cloud sync login failed

", + `

${escapeHtml(message)}

`, + "", + "" + ].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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + function formatListenHost(host: string): string { return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; } diff --git a/packages/core/test/unit/config/cloud-sync-service.test.mjs b/packages/core/test/unit/config/cloud-sync-service.test.mjs new file mode 100644 index 00000000..d4d0d4a8 --- /dev/null +++ b/packages/core/test/unit/config/cloud-sync-service.test.mjs @@ -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" + ]); +}); diff --git a/packages/electron/src/main/deep-link.ts b/packages/electron/src/main/deep-link.ts index 7338ca84..c7f99000 100644 --- a/packages/electron/src/main/deep-link.ts +++ b/packages/electron/src/main/deep-link.ts @@ -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 { + try { + const savedConfig = await saveAppConfig(await refreshCloudSyncUserProfile(applyCloudSyncAuthTokens(await loadAppConfig(), input))); + logDeepLinkDiagnostic(`[deep-link] Cloud sync login completed for user ${savedConfig.cloudSync.userId || ""}`); + 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 { 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) { diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 476add08..737204c6 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -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(); const gatewayPluginPermissionIdSet = new Set(GATEWAY_PLUGIN_PERMISSION_IDS); const gatewayPluginSurfaceIdSet = new Set(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 => { + 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 => { + 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 { + 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((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 { + 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([ + "", + "", + "CCR Cloud Sync", + "", + "", + `

${escapeHtml(title)}

`, + `

${escapeHtml(message)}

`, + "", + "" + ].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 { 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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + function uniqueStrings(values: string[]): string[] { const seen = new Set(); const result: string[] = []; diff --git a/packages/electron/src/main/preload.ts b/packages/electron/src/main/preload.ts index 3f61517c..6973da9d 100644 --- a/packages/electron/src/main/preload.ts +++ b/packages/electron/src/main/preload.ts @@ -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, captureElementPng: (request: AppCaptureElementPngRequest) => invoke(IPC_CHANNELS.appCaptureElementPng, request) as Promise, checkProviderConnectivity: (request: GatewayProviderConnectivityCheckRequest) => invoke(IPC_CHANNELS.appCheckProviderConnectivity, request) as Promise, + cloudSyncDisable: () => invoke(IPC_CHANNELS.appCloudSyncDisable) as Promise, + cloudSyncGenerateKeyFile: (file?: string) => invoke(IPC_CHANNELS.appCloudSyncGenerateKeyFile, file) as Promise, + cloudSyncGetStatus: () => invoke(IPC_CHANNELS.appCloudSyncGetStatus) as Promise, + cloudSyncLogin: () => invoke(IPC_CHANNELS.appCloudSyncLogin) as Promise, + cloudSyncPull: (request?: CloudSyncPullRequest) => invoke(IPC_CHANNELS.appCloudSyncPull, request) as Promise, + cloudSyncPush: (request?: CloudSyncPushRequest) => invoke(IPC_CHANNELS.appCloudSyncPush, request) as Promise, + cloudSyncSetup: (request: CloudSyncSetupRequest) => invoke(IPC_CHANNELS.appCloudSyncSetup, request) as Promise, closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => invoke(IPC_CHANNELS.appBotGatewayQrWindowClose, request) as Promise, clearProxyNetworkCaptures: () => invoke(IPC_CHANNELS.appClearProxyNetworkCaptures) as Promise, closeTray: () => invoke(IPC_CHANNELS.appCloseTray) as Promise, @@ -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); diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 95fa069a..1cf57949 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -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, diff --git a/packages/ui/src/pages/home/components/onboarding.tsx b/packages/ui/src/pages/home/components/onboarding.tsx index 807ae0c2..85300f6a 100644 --- a/packages/ui/src/pages/home/components/onboarding.tsx +++ b/packages/ui/src/pages/home/components/onboarding.tsx @@ -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; onChangeProfile: (patch: Partial) => void; onChangeProvider: (patch: Partial, resetProbe?: boolean) => void; + onCloudConfigRestore?: (config: AppConfig) => void; + onCloudToast?: (message: string) => void; onComplete: () => void | Promise; onSelectStep: (step: OnboardingStepId) => void; onSubmitProfile: () => Promise; @@ -247,6 +251,18 @@ export function OnboardingView({ + { + onCloudConfigRestore?.(restoredConfig); + onSelectStep(getDefaultOnboardingStep(restoredConfig, { + ...readiness, + profileConfirmed: true + })); + }} + onToast={onCloudToast} + /> +
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(); + const [busy, setBusy] = useState<"" | "login" | "restore" | "status">(""); + const [error, setError] = useState(""); + const keyFileInputRef = useRef(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 ( +
+
+
+

{t("Restore from cloud")}

+

+ {t("Use an encrypted cloud snapshot instead of setting up providers manually.")} +

+ {authenticated ? ( +
+ {t("Signed in as")} {onboardingCloudAccountName(effectiveStatus)} +
+ ) : null} +
+ +
+ + {open && authenticated ? ( +
+
+
+ +
{t("Use the same password or key file that encrypted your cloud snapshot. Losing it makes cloud data unrecoverable.")}
+
+
+ + setKeyMode(value === "password" ? "password" : "key-file")} + value={keyMode} + > + + + + {t("Key file")} + + + + {t("Password")} + + + + + + setKeyFilePath(event.target.value)} placeholder={t("Choose an E2EE key file")} /> + + { + 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" + /> +
+ + +
+
+ + + + setPassword(event.target.value)} + placeholder={t("Required unless you use a key file")} + type="password" + value={password} + /> + +
+ +
+
+
+
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} +
+ ); +} + 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 (
void; + onCloudSyncConfigChange: (config: AppConfig) => void; onChangeLaunchAtLogin: (checked: boolean) => void; onChangeObservability: (patch: Partial) => void; onChangeProxy: (patch: Partial) => 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 ( + + ); + } if (activePage === "tray") { return ( setActivePage("general")} /> + setActivePage("cloud")} + /> 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(); + 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([]); + const [error, setError] = useState(""); + const [failedAvatarUrl, setFailedAvatarUrl] = useState(""); + const keyFileInputRef = useRef(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 ( +
+ {!authenticated ? ( + <> +
+ +
+ {error ? ( +
+ {error} +
+ ) : null} + + ) : ( + <> +
+
+

{t("Cloud Sync")}

+

+ {t("Sync providers, agent profiles, routing rules, plugins, ToolHub, and related user configuration as encrypted snapshots.")} +

+
+
+ + {syncControlsVisible ? ( + + ) : null} +
+
+ + + {syncControlsVisible && encryptionSettingsOpen ? ( + +
+ {!effectiveStatus.configured ? ( +
+
+ +
+
{t("End-to-end encryption is required before cloud sync can be enabled.")}
+
{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.")}
+
+
+
+ ) : null} + + setKeyMode(value === "key-file" ? "key-file" : "password")} + value={keyMode} + > + + + + {t("Password")} + + + + {t("Key file")} + + + + + + setPassword(event.target.value)} + placeholder={t("Required unless you use a key file")} + /> + + + + + + setKeyFilePath(event.target.value)} placeholder={t("Choose an E2EE key file")} /> + +
+ { + 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" + /> + + +
+
+
+ +
+ +
+
+
+ ) : null} +
+ +
+
+ {avatarUrl ? ( + setFailedAvatarUrl(avatarUrl)} + referrerPolicy="no-referrer" + src={avatarUrl} + /> + ) : ( +
+ {cloudSyncAccountInitial(effectiveStatus)} +
+ )} +
+
{cloudSyncAccountName(effectiveStatus, t)}
+
{cloudSyncAccountDetail(effectiveStatus)}
+
+
+ +
+ + {syncControlsVisible ? ( + <> +
+
+ + + + + + +
+ {effectiveStatus.lastSyncError ? ( +
+ {effectiveStatus.lastSyncError} +
+ ) : null} + {mergeConflicts.length > 0 ? ( +
+
{t("Local values were kept for these conflict paths:")}
+
{mergeConflicts.join(", ")}
+
+ ) : null} + {error ? ( +
+ {error} +
+ ) : null} +
+ +
+ + +
+ + ) : null} + + )} +
+ ); +} + +function CloudSyncStatusRow({ + label, + value +}: { + label: string; + value: string; +}) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +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, diff --git a/packages/ui/src/pages/home/index.html b/packages/ui/src/pages/home/index.html index 758a4aab..b3e4d64f 100644 --- a/packages/ui/src/pages/home/index.html +++ b/packages/ui/src/pages/home/index.html @@ -5,7 +5,7 @@ Claude Code Router diff --git a/packages/ui/src/pages/home/shared/config.ts b/packages/ui/src/pages/home/shared/config.ts index 15aa790b..9bedcb0a 100644 --- a/packages/ui/src/pages/home/shared/config.ts +++ b/packages/ui/src/pages/home/shared/config.ts @@ -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 | 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 { + return Boolean(value && typeof value === "object" && !Array.isArray(value)) && + (value as Record).kind === "claude-code-router-cloud-sync-snapshot" && + (value as Record).version === 1 && + Boolean((value as Record).config && typeof (value as Record).config === "object" && !Array.isArray((value as Record).config)); +} + export function normalizeContextArchiveConfig(config: Partial | undefined): AppConfig["contextArchive"] { return { enabled: Boolean(config?.enabled), diff --git a/packages/ui/src/pages/home/shared/external.tsx b/packages/ui/src/pages/home/shared/external.tsx index 8eac742e..2c6501f9 100644 --- a/packages/ui/src/pages/home/shared/external.tsx +++ b/packages/ui/src/pages/home/shared/external.tsx @@ -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, diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index e1a33373..07d9b3bf 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -284,6 +284,73 @@ export const appCopy: Record = { "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 = { "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": "默认模型", diff --git a/packages/ui/src/pages/home/shared/types.ts b/packages/ui/src/pages/home/shared/types.ts index 87c90cde..741356e7 100644 --- a/packages/ui/src/pages/home/shared/types.ts +++ b/packages/ui/src/pages/home/shared/types.ts @@ -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; export type TrayComponentOptionGroup = { key: keyof TrayComponentVariants; diff --git a/packages/ui/src/types/electron.d.ts b/packages/ui/src/types/electron.d.ts index cf4da2a6..605b4ff3 100644 --- a/packages/ui/src/types/electron.d.ts +++ b/packages/ui/src/types/electron.d.ts @@ -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; captureElementPng?: (request: AppCaptureElementPngRequest) => Promise; checkProviderConnectivity: (request: GatewayProviderConnectivityCheckRequest) => Promise; + cloudSyncDisable: () => Promise; + cloudSyncGenerateKeyFile: (file?: string) => Promise; + cloudSyncGetStatus: () => Promise; + cloudSyncLogin: () => Promise; + cloudSyncPull: (request?: CloudSyncPullRequest) => Promise; + cloudSyncPush: (request?: CloudSyncPushRequest) => Promise; + cloudSyncSetup: (request: CloudSyncSetupRequest) => Promise; closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => Promise; clearProxyNetworkCaptures: () => Promise; closeTray: () => Promise; @@ -157,6 +171,7 @@ declare global { validateRouteScript: (request: RouteScriptValidationRequest) => Promise; waitBotGatewayQrLogin: (request: BotGatewayQrLoginWaitRequest) => Promise; onBeforeQuit: (callback: () => void) => () => void; + onCloudSyncAuthChanged?: (callback: () => void) => () => void; onOpenSettingsRequest: (callback: () => void) => () => void; onOpenUpdateRequest: (callback: () => void) => () => void; onProviderDeepLink: (callback: (request: ProviderDeepLinkRequest) => void) => () => void; diff --git a/packages/ui/src/web-client-bridge.ts b/packages/ui/src/web-client-bridge.ts index 40c578f4..a1925bc9 100644 --- a/packages/ui/src/web-client-bridge.ts +++ b/packages/ui/src/web-client-bridge.ts @@ -109,6 +109,17 @@ const webClientBridge: CcrApi = { applyProfile: () => rpc("applyProfile") as ReturnType, cancelBotGatewayQrLogin: (request) => rpc("cancelBotGatewayQrLogin", [request]) as ReturnType, checkProviderConnectivity: (request) => rpc("checkProviderConnectivity", [request]) as ReturnType, + cloudSyncDisable: () => rpc("cloudSyncDisable") as ReturnType, + cloudSyncGenerateKeyFile: (file) => rpc("cloudSyncGenerateKeyFile", [file]) as ReturnType, + cloudSyncGetStatus: () => rpc("cloudSyncGetStatus") as ReturnType, + cloudSyncLogin: async () => { + const login = await rpc("cloudSyncLogin") as Awaited>; + window.open(login.loginUrl, "_blank", "noopener,noreferrer"); + return login; + }, + cloudSyncPull: (request) => rpc("cloudSyncPull", [request]) as ReturnType, + cloudSyncPush: (request) => rpc("cloudSyncPush", [request]) as ReturnType, + cloudSyncSetup: (request) => rpc("cloudSyncSetup", [request]) as ReturnType, clearProxyNetworkCaptures: () => rpc("clearProxyNetworkCaptures") as ReturnType, closeBotGatewayQrWindow: (request) => rpc("closeBotGatewayQrWindow", [request]) as ReturnType, closeTray: () => Promise.resolve(),