diff --git a/artifacts/cloud-sync-manual-conflict-dialog.jpg b/artifacts/cloud-sync-manual-conflict-dialog.jpg new file mode 100644 index 00000000..17ae58cb Binary files /dev/null and b/artifacts/cloud-sync-manual-conflict-dialog.jpg differ diff --git a/packages/core/src/cloud-sync/service.ts b/packages/core/src/cloud-sync/service.ts index fa398fcc..5b0ae7a5 100644 --- a/packages/core/src/cloud-sync/service.ts +++ b/packages/core/src/cloud-sync/service.ts @@ -4,6 +4,9 @@ import { hostname } from "node:os"; import { dirname } from "node:path"; import type { AppConfig, + CloudSyncConflictField, + CloudSyncConflictFieldResolution, + CloudSyncConflictResolution, CloudSyncKeyFileResult, CloudSyncConfig, CloudSyncKeyInput, @@ -11,6 +14,7 @@ import type { CloudSyncOperationResult, CloudSyncPullRequest, CloudSyncPushRequest, + CloudSyncResolveConflictRequest, CloudSyncSetupRequest, CloudSyncStatus } from "@ccr/core/contracts/app"; @@ -66,6 +70,7 @@ type CloudTokenResponse = { refreshToken?: string; refreshTokenExpiresAt?: string; tokenType?: string; + user?: CloudUserResponse; }; type CloudTokenRefreshPatch = { @@ -104,18 +109,42 @@ type CloudDocument = { namespace: string; revision: number; snapshotHash?: string | null; + snapshotRevision?: number | null; updatedAt?: string | null; }; +type CloudOperation = { + baseRevision?: number; + clientOperationId?: string; + encryptedPayload?: EncryptedPayload | null; + id?: string; + revision?: number; +}; + +type CloudPullPagination = { + excludeOperationId?: string | null; + hasMore?: boolean; + limit?: number; + nextRevision?: number | null; +}; + type CloudPullResponse = { document: CloudDocument; - operations?: unknown[]; + operations?: CloudOperation[]; + pagination?: CloudPullPagination; }; type CloudPushResponse = { + accepted?: boolean; conflict?: boolean; document?: CloudDocument; + idempotent?: boolean; mergeRequired?: boolean; + missingOperations?: CloudOperation[]; + missingOperationsExcludeOperationId?: string; + missingOperationsHasMore?: boolean; + nextMissingRevision?: number | null; + operation?: CloudOperation; snapshotAccepted?: boolean; snapshotRejectedReason?: string; }; @@ -126,10 +155,32 @@ type CloudRequestOptions = { }; type CloudSyncMergeResult = { + conflictFields: CloudSyncConflictField[]; conflicts: string[]; snapshot: CloudSyncSnapshot; }; +type CloudSyncConflictPreference = NonNullable; +type CloudSyncConflictResultMap = Map< + string, + unknown | typeof missingCloudSyncMergeValue +>; + +type CloudSyncSnapshotOperation = { + kind: "replace-cloud-sync-snapshot"; + snapshot: CloudSyncSnapshot; + snapshotHash: string; + snapshotVersion: number; + updatedAt: string; + version: 2; +}; + +type CloudOperationCollection = { + config: AppConfig; + document?: CloudDocument; + operations: CloudOperation[]; +}; + type CloudSyncPushOptions = { keyMaterial?: CloudSyncKeyMaterial; mergeOnConflict?: boolean; @@ -140,6 +191,21 @@ type CloudSyncLoginOptions = { callbackUrl?: string; }; +type PendingCloudSyncAuth = { + expiresAt: number; + verifier: string; +}; + +type PendingCloudSyncConflict = { + baseSnapshot?: CloudSyncSnapshot; + descriptor: CloudSyncConflictResolution; + identity: string; + keyMaterial: CloudSyncKeyMaterial; + localSnapshot: CloudSyncSnapshot; + remoteSnapshot: CloudSyncSnapshot; + remoteSnapshotHash?: string; +}; + const cloudSyncSnapshotKind = "claude-code-router-cloud-sync-snapshot"; const defaultCloudSyncNamespace = "ccr"; const keyFileKind = "claude-code-router-cloud-sync-key"; @@ -149,8 +215,13 @@ const privateFileMode = 0o600; const privateDirMode = 0o700; const missingCloudSyncMergeValue = Symbol("missing-cloud-sync-merge-value"); const cloudSyncAuthExpiredMessage = "Cloud sync login expired. Sign in again."; +const cloudSyncAuthAttemptMs = 10 * 60 * 1000; +const cloudSyncConflictResolutionMs = 30 * 60 * 1000; const cloudSyncKeyCache = new Map(); +const pendingCloudSyncAuth = new Map(); +const pendingCloudSyncConflicts = new Map(); +const pendingCloudSyncConflictIds = new Map(); const cloudTokenRefreshInFlight = new Map>(); const cloudTokenRefreshRecent = new Map(); const cloudTokenRefreshRecentMs = 30 * 60 * 1000; @@ -175,6 +246,7 @@ class CloudSyncRefreshTokenExpiredError extends Error { export function getCloudSyncStatus(config: AppConfig): CloudSyncStatus { const cloudSync = normalizedCloudSyncConfig(config.cloudSync); const unlocked = Boolean(cloudSync.keyId && cloudSyncKeyCache.has(cloudSync.keyId)); + const pendingConflict = pendingCloudSyncConflictForConfig(config)?.descriptor; return { authenticated: Boolean(cloudSync.accessToken || cloudSync.refreshToken), @@ -189,6 +261,7 @@ export function getCloudSyncStatus(config: AppConfig): CloudSyncStatus { lastSyncAt: cloudSync.lastSyncAt, lastSyncError: cloudSync.lastSyncError, namespace: cloudSync.namespace, + pendingConflict, snapshotHash: cloudSync.snapshotHash, unlocked, userAvatarUrl: cloudSync.userAvatarUrl, @@ -200,6 +273,7 @@ export function getCloudSyncStatus(config: AppConfig): CloudSyncStatus { } export function disableCloudSyncConfig(config: AppConfig): AppConfig { + clearPendingCloudSyncConflict(config); if (config.cloudSync.keyId) { cloudSyncKeyCache.delete(config.cloudSync.keyId); } @@ -232,6 +306,55 @@ export function startCloudSyncLogin(config: AppConfig, options: CloudSyncLoginOp }; } +export async function completeCloudSyncLogin(config: AppConfig, rawCallbackUrl: string): Promise { + const callbackUrl = new URL(requiredString(rawCallbackUrl, "Cloud sync callback URL")); + const code = requiredString(callbackUrl.searchParams.get("code"), "Cloud sync handoff code"); + const handoffExpiresAt = optionalString(callbackUrl.searchParams.get("expires_at")); + if (handoffExpiresAt) { + const expiresAt = Date.parse(handoffExpiresAt); + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + throw new Error("Cloud sync login handoff expired. Sign in again."); + } + } + + const callbackKey = cloudSyncAuthCallbackKey(callbackUrl); + const pending = pendingCloudSyncAuth.get(callbackKey); + pendingCloudSyncAuth.delete(callbackKey); + if (!pending || pending.expiresAt <= Date.now()) { + throw new Error("Cloud sync login session is invalid or expired. Sign in again."); + } + + const cloudSync = normalizedCloudSyncConfig(config.cloudSync); + const response = await fetch(cloudSyncUrl(cloudSync.baseUrl, "/auth/handoff"), { + body: JSON.stringify({ + code, + codeVerifier: pending.verifier + }), + headers: { + Accept: "application/json", + "Content-Type": "application/json" + }, + method: "POST" + }); + const data = await readCloudResponse(response); + if (!response.ok) { + throw new Error(`Cloud login handoff failed (${response.status}): ${cloudErrorMessage(data)}`); + } + + const tokenResponse = data as CloudTokenResponse; + const user = tokenResponse.user; + return applyCloudSyncAuthTokens(config, { + accessToken: requiredString(tokenResponse.accessToken, "Cloud access token"), + refreshToken: requiredString(tokenResponse.refreshToken, "Cloud refresh token"), + refreshTokenExpiresAt: optionalString(tokenResponse.refreshTokenExpiresAt), + userAvatarUrl: optionalString(user?.avatarUrl), + userEmail: optionalString(user?.email), + userId: optionalString(user?.id), + userLogin: optionalString(user?.githubLogin), + userName: optionalString(user?.githubName) + }); +} + export function applyCloudSyncAuthTokens(config: AppConfig, input: CloudSyncAuthTokenInput): AppConfig { const accessToken = requiredString(input.accessToken, "Cloud access token"); const refreshToken = requiredString(input.refreshToken, "Cloud refresh token"); @@ -349,7 +472,13 @@ async function setupCloudSyncConfigInternal( } }; - const remote = await pullCloudDocument(nextConfig, 0); + let remote = await pullCloudDocument(nextConfig, 0); + if ( + remote.data.document.encryptedSnapshot && + remote.data.document.snapshotRevision === null + ) { + remote = await pullAllCloudDocuments(remote.config, 0); + } nextConfig = remote.config; if (remote.data.document.revision > 0) { const encryptedSnapshot = remote.data.document.encryptedSnapshot; @@ -370,7 +499,11 @@ async function setupCloudSyncConfigInternal( keySalt: keyMaterial.keySalt } }; - const snapshot = decryptSnapshot(encryptedSnapshot, keyMaterial); + const snapshot = resolveCloudDocumentSnapshot( + remote.data.document, + remote.data.operations ?? [], + keyMaterial + ).snapshot; nextConfig = applyCloudSyncSnapshot(nextConfig, snapshot); nextConfig = withCloudSyncSuccess(nextConfig, { lastRevision: remote.data.document.revision, @@ -432,13 +565,206 @@ export async function pullCloudSyncConfig( return withCloudSyncOperationAuthHandling(() => pullCloudSyncConfigInternal(config, request)); } +export async function resolveCloudSyncConflict( + config: AppConfig, + request: CloudSyncResolveConflictRequest +): Promise { + return withCloudSyncOperationAuthHandling( + () => resolveCloudSyncConflictInternal(config, request) + ); +} + +async function resolveCloudSyncConflictInternal( + config: AppConfig, + request: CloudSyncResolveConflictRequest +): Promise { + const conflictId = requiredString(request.conflictId, "Cloud sync conflict ID"); + const pending = pendingCloudSyncConflictById(conflictId); + if (!pending || pending.identity !== cloudSyncConflictIdentity(config)) { + throw new Error("Cloud sync conflict expired or is no longer available. Sync again to refresh it."); + } + const conflictResults = cloudSyncConflictResultsFromRequest(request, pending.descriptor); + const preference = request.preference ?? "local"; + if ( + request.preference !== undefined && + request.preference !== "local" && + request.preference !== "remote" + ) { + throw new Error("Cloud sync conflict preference must be local or remote."); + } + if (!conflictResults && request.preference === undefined) { + throw new Error("Cloud sync conflict preference or reviewed resolutions are required."); + } + const currentSnapshot = createCloudSyncSnapshot(config); + if (!sameCloudSyncValue(currentSnapshot.config, pending.localSnapshot.config)) { + removePendingCloudSyncConflict(conflictId); + return cloudSyncConflictResult( + config, + "Local configuration changed after the conflict was detected. Sync again before choosing a version.", + pending.descriptor.remoteRevision, + pending.descriptor.paths + ); + } + const latestRemote = await pullCloudDocument(config, pending.descriptor.remoteRevision); + if (latestRemote.data.document.revision !== pending.descriptor.remoteRevision) { + removePendingCloudSyncConflict(conflictId); + return pullCloudSyncConfigInternal(latestRemote.config); + } + config = latestRemote.config; + + const merge = pending.baseSnapshot + ? mergeCloudSyncSnapshots( + pending.baseSnapshot, + pending.localSnapshot, + pending.remoteSnapshot, + preference, + conflictResults + ) + : resolveCloudSyncConflictWithoutBase(pending, preference, conflictResults); + let mergedConfig = applyCloudSyncSnapshot(config, merge.snapshot); + mergedConfig = { + ...mergedConfig, + cloudSync: { + ...normalizedCloudSyncConfig(mergedConfig.cloudSync), + lastRevision: pending.descriptor.remoteRevision, + lastSyncError: undefined, + lastSyncedSnapshot: cloneJson(pending.remoteSnapshot), + snapshotHash: pending.remoteSnapshotHash || snapshotHash(pending.remoteSnapshot) + } + }; + + if (sameCloudSyncValue(merge.snapshot.config, pending.remoteSnapshot.config)) { + removePendingCloudSyncConflict(conflictId); + mergedConfig = withCloudSyncSuccess(mergedConfig, { + lastRevision: pending.descriptor.remoteRevision, + snapshotHash: pending.remoteSnapshotHash || snapshotHash(pending.remoteSnapshot) + }, pending.remoteSnapshot); + return { + config: mergedConfig, + conflict: true, + mergeApplied: true, + mergeConflicts: pending.descriptor.paths, + message: conflictResults + ? "Cloud conflict was resolved using the reviewed values." + : "Cloud conflict was resolved using cloud values.", + remoteRevision: pending.descriptor.remoteRevision, + snapshotApplied: true, + snapshotPushed: false, + status: getCloudSyncStatus(mergedConfig) + }; + } + + const pushed = await pushCloudSyncConfigInternal(mergedConfig, {}, { + keyMaterial: pending.keyMaterial + }); + if (pushed.conflictResolution || !pushed.snapshotPushed) { + return pushed; + } + return { + ...pushed, + conflict: true, + mergeApplied: true, + mergeConflicts: pending.descriptor.paths, + message: conflictResults + ? "Cloud conflict was resolved using the reviewed values and the merged snapshot was pushed." + : preference === "local" + ? "Cloud conflict was resolved using local values and the merged snapshot was pushed." + : "Cloud conflict was resolved using cloud values and independent local changes were pushed.", + snapshotApplied: true + }; +} + +function cloudSyncConflictResultsFromRequest( + request: CloudSyncResolveConflictRequest, + descriptor: CloudSyncConflictResolution +): CloudSyncConflictResultMap | undefined { + if (request.resolutions === undefined) { + return undefined; + } + if (!Array.isArray(request.resolutions)) { + throw new Error("Cloud sync conflict resolutions must be an array."); + } + + const expectedPaths = new Set(descriptor.fields.map((field) => field.path)); + const results: CloudSyncConflictResultMap = new Map(); + for (const item of request.resolutions as CloudSyncConflictFieldResolution[]) { + if (!isObject(item)) { + throw new Error("Each cloud sync conflict resolution must be an object."); + } + const path = requiredString(item.path, "Cloud sync conflict path"); + if (!expectedPaths.has(path)) { + throw new Error(`Cloud sync conflict path is no longer available: ${path}`); + } + if (results.has(path)) { + throw new Error(`Cloud sync conflict path was resolved more than once: ${path}`); + } + if (!isObject(item.result) || typeof item.result.exists !== "boolean") { + throw new Error(`Cloud sync conflict result is invalid for ${path}.`); + } + if (!item.result.exists) { + results.set(path, missingCloudSyncMergeValue); + continue; + } + if (!Object.hasOwn(item.result, "value")) { + throw new Error(`Cloud sync conflict result value is required for ${path}.`); + } + const serialized = JSON.stringify(item.result.value); + if (serialized === undefined) { + throw new Error(`Cloud sync conflict result must be valid JSON for ${path}.`); + } + results.set(path, JSON.parse(serialized)); + } + + const unresolvedPaths = [...expectedPaths].filter((path) => !results.has(path)); + if (unresolvedPaths.length > 0 || results.size !== expectedPaths.size) { + throw new Error( + `Resolve every cloud sync conflict field before applying: ${unresolvedPaths.join(", ")}` + ); + } + return results; +} + +function resolveCloudSyncConflictWithoutBase( + pending: PendingCloudSyncConflict, + preference: CloudSyncConflictPreference, + conflictResults?: CloudSyncConflictResultMap +): CloudSyncMergeResult { + const preferredSnapshot = preference === "local" + ? pending.localSnapshot + : pending.remoteSnapshot; + const resolvedConfig = conflictResults?.has("config") + ? conflictResults.get("config") + : preferredSnapshot.config; + if (isMissingCloudSyncMergeValue(resolvedConfig) || !isObject(resolvedConfig)) { + throw new Error("The reviewed cloud sync result must contain a configuration object."); + } + return { + conflictFields: pending.descriptor.fields, + conflicts: pending.descriptor.paths, + snapshot: { + ...cloneJson(preferredSnapshot), + config: cloneJson(resolvedConfig) as CloudSyncSnapshotConfig, + exportedAt: new Date().toISOString() + } + }; +} + 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); + const baseSnapshot = cloudSyncSnapshotFromUnknown(readyConfig.cloudSync.lastSyncedSnapshot); + let remote = baseSnapshot + ? await pullAllCloudDocuments(readyConfig, readyConfig.cloudSync.lastRevision) + : await pullCloudDocument(readyConfig, readyConfig.cloudSync.lastRevision); + if ( + remote.data.document.encryptedSnapshot && + remote.data.document.snapshotRevision === null + ) { + remote = await pullAllCloudDocuments(remote.config, 0); + } let nextConfig = remote.config; const document = remote.data.document; @@ -456,20 +782,82 @@ async function pullCloudSyncConfigInternal( }; } - const snapshot = decryptSnapshot(document.encryptedSnapshot, keyMaterial); + const remoteMerge = resolveCloudDocumentSnapshot( + document, + remote.data.operations ?? [], + keyMaterial + ); + const remoteSnapshot = remoteMerge.snapshot; + let appliedSnapshot = remoteSnapshot; + let localMerge: CloudSyncMergeResult | undefined; + let localSnapshot: CloudSyncSnapshot | undefined; if (request.apply !== false) { - nextConfig = applyCloudSyncSnapshot(nextConfig, snapshot); + localSnapshot = createCloudSyncSnapshot(nextConfig); + if (!baseSnapshot && !sameCloudSyncValue(localSnapshot.config, remoteSnapshot.config)) { + return cloudSyncResolutionRequiredResult({ + config: nextConfig, + keyMaterial, + localSnapshot, + paths: ["config"], + remoteRevision: document.revision, + remoteSnapshot, + remoteSnapshotHash: document.snapshotHash || snapshotHash(remoteSnapshot) + }); + } + if ( + baseSnapshot && + !sameCloudSyncValue(localSnapshot.config, baseSnapshot.config) && + !sameCloudSyncValue(remoteSnapshot.config, baseSnapshot.config) + ) { + localMerge = mergeCloudSyncSnapshots(baseSnapshot, localSnapshot, remoteSnapshot); + appliedSnapshot = localMerge.snapshot; + } + } + const mergeConflicts = uniqueStrings([ + ...remoteMerge.conflicts, + ...(localMerge?.conflicts ?? []) + ]); + if ( + request.apply !== false && + baseSnapshot && + localSnapshot && + mergeConflicts.length > 0 + ) { + return cloudSyncResolutionRequiredResult({ + baseSnapshot, + config: nextConfig, + keyMaterial, + localSnapshot, + paths: mergeConflicts, + remoteRevision: document.revision, + remoteSnapshot, + remoteSnapshotHash: document.snapshotHash || snapshotHash(remoteSnapshot) + }); + } + if (request.apply !== false) { + nextConfig = applyCloudSyncSnapshot(nextConfig, appliedSnapshot); } nextConfig = withCloudSyncSuccess(nextConfig, { lastRevision: document.revision, - snapshotHash: document.snapshotHash || snapshotHash(snapshot) - }, snapshot); + snapshotHash: remoteMerge.appliedOperationCount > 0 + ? snapshotHash(remoteSnapshot) + : document.snapshotHash || snapshotHash(remoteSnapshot) + }, remoteSnapshot); + + const mergeApplied = request.apply !== false && + Boolean(localMerge || remoteMerge.appliedOperationCount > 0); return { config: nextConfig, message: request.apply === false ? "Cloud encrypted snapshot was decrypted successfully." - : "Cloud encrypted snapshot was pulled and applied.", + : mergeApplied + ? mergeConflicts.length > 0 + ? `Cloud changes were pulled and merged. Local values were kept for ${mergeConflicts.length} conflicting path(s).` + : "Cloud changes were pulled and merged." + : "Cloud encrypted snapshot was pulled and applied.", + mergeApplied, + mergeConflicts, remoteRevision: document.revision, snapshotApplied: request.apply !== false, status: getCloudSyncStatus(nextConfig) @@ -481,6 +869,9 @@ export async function autoPushCloudSyncConfig(config: AppConfig): Promise (left.revision ?? 0) - (right.revision ?? 0)) + .map((operation) => ({ + operation, + snapshot: decryptCloudOperationSnapshot(operation, keyMaterial) + })); + const inferredSnapshotRevision = orderedOperations.reduce( + (latest, item) => { + if ( + item.snapshot && + typeof item.operation.revision === "number" && + sameCloudSyncValue(item.snapshot.config, documentSnapshot.config) + ) { + return Math.max(latest ?? 0, item.operation.revision); + } + return latest; + }, + undefined + ); + const representedRevision = typeof snapshotRevision === "number" + ? snapshotRevision + : inferredSnapshotRevision; + + for (const item of orderedOperations) { + if ( + representedRevision !== undefined && + typeof item.operation.revision === "number" && + item.operation.revision <= representedRevision + ) { + continue; + } + const operationSnapshot = item.snapshot; + if ( + !operationSnapshot || + typeof item.operation.revision !== "number" || + item.operation.baseRevision !== item.operation.revision - 1 + ) { + continue; + } + if (!sameCloudSyncValue(snapshot.config, operationSnapshot.config)) { + appliedOperationCount += 1; + } + snapshot = operationSnapshot; + } + + return { + appliedOperationCount, + conflictFields: [], + conflicts: [], + snapshot + }; +} + +function resolveCloudDocumentSnapshot( + document: CloudDocument, + operations: CloudOperation[], + keyMaterial: CloudSyncKeyMaterial +): CloudSyncMergeResult & { appliedOperationCount: number } { + if (document.snapshotRevision === null) { + return { + appliedOperationCount: 0, + conflictFields: [], + conflicts: [], + snapshot: replayCloudOperationLog(operations, keyMaterial) + }; + } + if (!document.encryptedSnapshot) { + throw new Error("Cloud sync document does not contain an encrypted snapshot."); + } + return mergeCloudOperationSnapshots( + decryptSnapshot(document.encryptedSnapshot, keyMaterial), + document.snapshotRevision, + operations, + keyMaterial + ); +} + +function replayCloudOperationLog( + operations: CloudOperation[], + keyMaterial: CloudSyncKeyMaterial +): CloudSyncSnapshot { + let snapshot: CloudSyncSnapshot | undefined; + let unsupportedAcceptedOperation = false; + + for (const operation of [...operations].sort( + (left, right) => (left.revision ?? 0) - (right.revision ?? 0) + )) { + if ( + typeof operation.revision !== "number" || + operation.baseRevision !== operation.revision - 1 + ) { + continue; + } + const operationSnapshot = decryptCloudOperationSnapshot(operation, keyMaterial); + if (operationSnapshot) { + snapshot = operationSnapshot; + unsupportedAcceptedOperation = false; + } else if (snapshot) { + unsupportedAcceptedOperation = true; + } + } + + if (!snapshot || unsupportedAcceptedOperation) { + throw new Error( + "Cloud sync legacy snapshot cannot be restored because its operation log does not contain a complete supported snapshot." + ); + } + return snapshot; +} + +function decryptCloudOperationSnapshot( + operation: CloudOperation, + keyMaterial: CloudSyncKeyMaterial +): CloudSyncSnapshot | undefined { + if (!operation.encryptedPayload) { + return undefined; + } + const payload = decryptJson(operation.encryptedPayload, keyMaterial); + if ( + !isObject(payload) || + payload.kind !== "replace-cloud-sync-snapshot" || + payload.version !== 2 || + !isCloudSyncSnapshot(payload.snapshot) + ) { + return undefined; + } + return payload.snapshot; +} + function mergeCloudSyncValue( base: unknown, local: unknown, remote: unknown, path: string, - conflicts: string[] + conflicts: string[], + conflictPreference: CloudSyncConflictPreference, + conflictFields: CloudSyncConflictField[], + conflictResults?: CloudSyncConflictResultMap ): unknown | typeof missingCloudSyncMergeValue { if (sameCloudSyncValue(local, remote)) { return cloneCloudSyncMergeValue(local); @@ -599,14 +1143,24 @@ function mergeCloudSyncValue( local, remote, path, - conflicts + conflicts, + conflictPreference, + conflictFields, + conflictResults ); if (identifiedMerge) { return identifiedMerge; } - conflicts.push(path); - return cloneJson(local); + return resolveCloudSyncMergeConflict({ + conflictFields, + conflictPreference, + conflictResults, + conflicts, + local, + path, + remote + }); } if (isObject(local) && isObject(remote) && (isObject(base) || isMissingCloudSyncMergeValue(base))) { @@ -615,12 +1169,22 @@ function mergeCloudSyncValue( local, remote, path, - conflicts + conflicts, + conflictPreference, + conflictFields, + conflictResults ); } - conflicts.push(path); - return cloneCloudSyncMergeValue(local); + return resolveCloudSyncMergeConflict({ + conflictFields, + conflictPreference, + conflictResults, + conflicts, + local, + path, + remote + }); } function mergeCloudSyncObject( @@ -628,7 +1192,10 @@ function mergeCloudSyncObject( local: Record, remote: Record, path: string, - conflicts: string[] + conflicts: string[], + conflictPreference: CloudSyncConflictPreference, + conflictFields: CloudSyncConflictField[], + conflictResults?: CloudSyncConflictResultMap ): Record { const result: Record = {}; const keys = new Set([ @@ -643,7 +1210,10 @@ function mergeCloudSyncObject( Object.hasOwn(local, key) ? local[key] : missingCloudSyncMergeValue, Object.hasOwn(remote, key) ? remote[key] : missingCloudSyncMergeValue, `${path}.${key}`, - conflicts + conflicts, + conflictPreference, + conflictFields, + conflictResults ); if (!isMissingCloudSyncMergeValue(merged) && merged !== undefined) { result[key] = merged; @@ -653,12 +1223,46 @@ function mergeCloudSyncObject( return result; } +function resolveCloudSyncMergeConflict({ + conflictFields, + conflictPreference, + conflictResults, + conflicts, + local, + path, + remote +}: { + conflictFields: CloudSyncConflictField[]; + conflictPreference: CloudSyncConflictPreference; + conflictResults?: CloudSyncConflictResultMap; + conflicts: string[]; + local: unknown; + path: string; + remote: unknown; +}): unknown | typeof missingCloudSyncMergeValue { + conflicts.push(path); + if (!conflictFields.some((field) => field.path === path)) { + conflictFields.push({ + local: cloudSyncConflictValue(local), + path, + remote: cloudSyncConflictValue(remote) + }); + } + if (conflictResults?.has(path)) { + return cloneCloudSyncMergeValue(conflictResults.get(path)); + } + return cloneCloudSyncMergeValue(conflictPreference === "local" ? local : remote); +} + function mergeIdentifiedCloudSyncArray( base: unknown[], local: unknown[], remote: unknown[], path: string, - conflicts: string[] + conflicts: string[], + conflictPreference: CloudSyncConflictPreference, + conflictFields: CloudSyncConflictField[], + conflictResults?: CloudSyncConflictResultMap ): unknown[] | undefined { const baseMap = toIdentifiedCloudSyncMap(base, path); const localMap = toIdentifiedCloudSyncMap(local, path); @@ -679,7 +1283,10 @@ function mergeIdentifiedCloudSyncArray( localMap.has(key) ? localMap.get(key) : missingCloudSyncMergeValue, remoteMap.has(key) ? remoteMap.get(key) : missingCloudSyncMergeValue, `${path}[${key}]`, - conflicts + conflicts, + conflictPreference, + conflictFields, + conflictResults ); if (!isMissingCloudSyncMergeValue(merged) && merged !== undefined) { result.push(merged); @@ -696,6 +1303,13 @@ function mergePrimitiveCloudSyncArray(base: unknown[], local: unknown[], remote: if (!baseMap || !localMap || !remoteMap) { return undefined; } + const baseKeys = [...baseMap.keys()]; + if ( + primitiveCloudSyncArrayReordered(baseKeys, [...localMap.keys()]) || + primitiveCloudSyncArrayReordered(baseKeys, [...remoteMap.keys()]) + ) { + return undefined; + } const keys = uniqueStrings([ ...localMap.keys(), @@ -718,6 +1332,14 @@ function mergePrimitiveCloudSyncArray(base: unknown[], local: unknown[], remote: return result; } +function primitiveCloudSyncArrayReordered(baseKeys: string[], nextKeys: string[]): boolean { + const baseSet = new Set(baseKeys); + const nextSet = new Set(nextKeys); + const sharedBaseOrder = baseKeys.filter((key) => nextSet.has(key)); + const sharedNextOrder = nextKeys.filter((key) => baseSet.has(key)); + return !sameCloudSyncValue(sharedBaseOrder, sharedNextOrder); +} + function toIdentifiedCloudSyncMap(items: unknown[], path: string): Map | undefined { const map = new Map(); for (const item of items) { @@ -792,6 +1414,12 @@ function cloneCloudSyncMergeValue(value: unknown): unknown | typeof missingCloud return isMissingCloudSyncMergeValue(value) ? missingCloudSyncMergeValue : cloneJson(value); } +function cloudSyncConflictValue(value: unknown): CloudSyncConflictField["local"] { + return isMissingCloudSyncMergeValue(value) + ? { exists: false } + : { exists: true, value: cloneJson(value) }; +} + function uniqueStrings(values: string[]): string[] { return [...new Set(values)]; } @@ -834,6 +1462,30 @@ export function decryptCloudSyncSnapshotForTest( return decryptSnapshot(encrypted, keyMaterial); } +export function encryptCloudSyncOperationForTest( + snapshot: CloudSyncSnapshot, + password: string, + keySalt: string +): EncryptedPayload { + const cloudSync = normalizedCloudSyncConfig({ + baseUrl: "http://localhost:3000", + deviceName: "test", + enabled: true, + keySalt, + lastRevision: 0, + namespace: "ccr" + }); + const keyMaterial = resolveCloudSyncKey(cloudSync, { password }, { allowCreateSalt: false }); + return encryptJson({ + kind: "replace-cloud-sync-snapshot", + snapshot, + snapshotHash: snapshotHash(snapshot), + snapshotVersion: snapshot.version, + updatedAt: snapshot.exportedAt, + version: 2 + } satisfies CloudSyncSnapshotOperation, keyMaterial); +} + export function mergeCloudSyncSnapshotsForTest( base: CloudSyncSnapshot, local: CloudSyncSnapshot, @@ -877,10 +1529,12 @@ async function pushCloudSyncConfigInternal( const encryptedSnapshot = encryptJson(snapshot, keyMaterial); const encryptedOperation = encryptJson({ kind: "replace-cloud-sync-snapshot", + snapshot, snapshotHash: snapshotHash(snapshot), snapshotVersion: snapshot.version, - updatedAt: snapshot.exportedAt - }, keyMaterial); + updatedAt: snapshot.exportedAt, + version: 2 + } satisfies CloudSyncSnapshotOperation, keyMaterial); const response = await cloudRequest(readyConfig, "/sync/push", { body: { baseRevision, @@ -896,11 +1550,20 @@ async function pushCloudSyncConfigInternal( const document = response.data.document; const remoteRevision = document?.revision ?? baseRevision; - if (response.data.conflict || response.data.mergeRequired || response.data.snapshotRejectedReason) { + if ( + response.data.conflict || + response.data.mergeRequired || + response.data.snapshotAccepted === false || + response.data.snapshotRejectedReason + ) { if (!request.force && options.mergeOnConflict !== false) { return mergeAndPushCloudSyncConflict({ keyMaterial, localSnapshot: snapshot, + missingOperations: response.data.missingOperations ?? [], + missingOperationsExcludeOperationId: response.data.missingOperationsExcludeOperationId, + missingOperationsHasMore: response.data.missingOperationsHasMore === true, + nextMissingRevision: response.data.nextMissingRevision, remoteDocument: document, remoteRevision, responseConfig: response.config @@ -925,6 +1588,9 @@ async function pushCloudSyncConfigInternal( status: getCloudSyncStatus(nextConfig) }; } + if (response.data.accepted === false) { + throw new Error("Cloud sync server did not accept the encrypted operation."); + } const nextConfig = withCloudSyncSuccess(response.config, { lastRevision: remoteRevision, @@ -945,16 +1611,46 @@ async function pushCloudSyncConfigInternal( async function mergeAndPushCloudSyncConflict({ keyMaterial, localSnapshot, + missingOperations, + missingOperationsExcludeOperationId, + missingOperationsHasMore, + nextMissingRevision, remoteDocument, remoteRevision, responseConfig }: { keyMaterial: CloudSyncKeyMaterial; localSnapshot: CloudSyncSnapshot; + missingOperations: CloudOperation[]; + missingOperationsExcludeOperationId?: string; + missingOperationsHasMore: boolean; + nextMissingRevision?: number | null; remoteDocument?: CloudDocument; remoteRevision: number; responseConfig: AppConfig; }): Promise { + let operationCollection: CloudOperationCollection; + if (remoteDocument?.encryptedSnapshot && remoteDocument.snapshotRevision === null) { + const fullHistory = await pullAllCloudDocuments(responseConfig, 0); + operationCollection = { + config: fullHistory.config, + document: fullHistory.data.document, + operations: fullHistory.data.operations ?? [] + }; + } else { + operationCollection = await continueCloudOperationPages({ + config: responseConfig, + document: remoteDocument, + excludeOperationId: missingOperationsExcludeOperationId, + hasMore: missingOperationsHasMore, + nextRevision: nextMissingRevision, + operations: missingOperations + }); + } + responseConfig = operationCollection.config; + remoteDocument = operationCollection.document; + remoteRevision = remoteDocument?.revision ?? remoteRevision; + if (!remoteDocument?.encryptedSnapshot) { return cloudSyncConflictResult( responseConfig, @@ -964,17 +1660,16 @@ async function mergeAndPushCloudSyncConflict({ } 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; + let remoteMergeConflicts: string[] = []; try { - remoteSnapshot = decryptSnapshot(remoteDocument.encryptedSnapshot, keyMaterial); + const remoteMerge = resolveCloudDocumentSnapshot( + remoteDocument, + operationCollection.operations, + keyMaterial + ); + remoteSnapshot = remoteMerge.snapshot; + remoteMergeConflicts = remoteMerge.conflicts; } catch (error) { return cloudSyncConflictResult( responseConfig, @@ -982,8 +1677,35 @@ async function mergeAndPushCloudSyncConflict({ remoteRevision ); } + if (!baseSnapshot) { + return cloudSyncResolutionRequiredResult({ + config: responseConfig, + keyMaterial, + localSnapshot, + paths: ["config"], + remoteRevision, + remoteSnapshot, + remoteSnapshotHash: remoteDocument.snapshotHash || snapshotHash(remoteSnapshot) + }); + } const merge = mergeCloudSyncSnapshots(baseSnapshot, localSnapshot, remoteSnapshot); + const mergeConflicts = uniqueStrings([ + ...remoteMergeConflicts, + ...merge.conflicts + ]); + if (mergeConflicts.length > 0) { + return cloudSyncResolutionRequiredResult({ + baseSnapshot, + config: responseConfig, + keyMaterial, + localSnapshot, + paths: mergeConflicts, + remoteRevision, + remoteSnapshot, + remoteSnapshotHash: remoteDocument.snapshotHash || snapshotHash(remoteSnapshot) + }); + } let mergedConfig = applyCloudSyncSnapshot(responseConfig, merge.snapshot); mergedConfig = { ...mergedConfig, @@ -1005,15 +1727,166 @@ async function mergeAndPushCloudSyncConflict({ ...pushed, conflict: true, mergeApplied: pushed.snapshotPushed === true, - mergeConflicts: merge.conflicts, + mergeConflicts, 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." + ? "Cloud sync conflict was automatically merged and pushed." : pushed.message }; } +function cloudSyncResolutionRequiredResult({ + baseSnapshot, + config, + keyMaterial, + localSnapshot, + paths, + remoteRevision, + remoteSnapshot, + remoteSnapshotHash +}: { + baseSnapshot?: CloudSyncSnapshot; + config: AppConfig; + keyMaterial: CloudSyncKeyMaterial; + localSnapshot: CloudSyncSnapshot; + paths: string[]; + remoteRevision: number; + remoteSnapshot: CloudSyncSnapshot; + remoteSnapshotHash?: string; +}): CloudSyncOperationResult { + const identity = cloudSyncConflictIdentity(config); + const previousId = pendingCloudSyncConflictIds.get(identity); + if (previousId) { + removePendingCloudSyncConflict(previousId); + } + + const id = randomUUID(); + const expiresAt = Date.now() + cloudSyncConflictResolutionMs; + const normalizedPaths = uniqueStrings(paths); + const descriptor: CloudSyncConflictResolution = { + expiresAt: new Date(expiresAt).toISOString(), + fields: describeCloudSyncConflictFields( + baseSnapshot, + localSnapshot, + remoteSnapshot, + normalizedPaths + ), + id, + paths: normalizedPaths, + remoteRevision + }; + const pending: PendingCloudSyncConflict = { + baseSnapshot: baseSnapshot ? cloneJson(baseSnapshot) : undefined, + descriptor, + identity, + keyMaterial, + localSnapshot: cloneJson(localSnapshot), + remoteSnapshot: cloneJson(remoteSnapshot), + remoteSnapshotHash + }; + pendingCloudSyncConflicts.set(id, pending); + pendingCloudSyncConflictIds.set(identity, id); + setTimeout(() => { + if (pendingCloudSyncConflicts.get(id) === pending) { + removePendingCloudSyncConflict(id); + } + }, cloudSyncConflictResolutionMs).unref(); + + const message = "Cloud sync found conflicting changes that require your confirmation."; + const nextConfig = { + ...config, + cloudSync: { + ...normalizedCloudSyncConfig(config.cloudSync), + lastSyncError: message + } + }; + return { + config: nextConfig, + conflict: true, + conflictResolution: descriptor, + mergeApplied: false, + mergeConflicts: descriptor.paths, + message, + remoteRevision, + snapshotApplied: false, + snapshotPushed: false, + status: getCloudSyncStatus(nextConfig) + }; +} + +function describeCloudSyncConflictFields( + baseSnapshot: CloudSyncSnapshot | undefined, + localSnapshot: CloudSyncSnapshot, + remoteSnapshot: CloudSyncSnapshot, + paths: string[] +): CloudSyncConflictField[] { + if (!baseSnapshot) { + if (paths.length !== 1 || paths[0] !== "config") { + throw new Error("Cloud sync cannot describe a conflict without a shared base snapshot."); + } + return [{ + local: cloudSyncConflictValue(localSnapshot.config), + path: "config", + remote: cloudSyncConflictValue(remoteSnapshot.config) + }]; + } + + const byPath = new Map( + mergeCloudSyncSnapshots(baseSnapshot, localSnapshot, remoteSnapshot) + .conflictFields + .map((field) => [field.path, field]) + ); + return paths.map((path) => { + const field = byPath.get(path); + if (!field) { + throw new Error(`Cloud sync could not describe conflicting path: ${path}`); + } + return field; + }); +} + +function pendingCloudSyncConflictForConfig(config: AppConfig): PendingCloudSyncConflict | undefined { + const id = pendingCloudSyncConflictIds.get(cloudSyncConflictIdentity(config)); + return id ? pendingCloudSyncConflictById(id) : undefined; +} + +function pendingCloudSyncConflictById(id: string): PendingCloudSyncConflict | undefined { + const pending = pendingCloudSyncConflicts.get(id); + if (!pending) { + return undefined; + } + if (Date.parse(pending.descriptor.expiresAt) <= Date.now()) { + removePendingCloudSyncConflict(id); + return undefined; + } + return pending; +} + +function clearPendingCloudSyncConflict(config: AppConfig): void { + const id = pendingCloudSyncConflictIds.get(cloudSyncConflictIdentity(config)); + if (id) { + removePendingCloudSyncConflict(id); + } +} + +function removePendingCloudSyncConflict(id: string): void { + const pending = pendingCloudSyncConflicts.get(id); + pendingCloudSyncConflicts.delete(id); + if (pending && pendingCloudSyncConflictIds.get(pending.identity) === id) { + pendingCloudSyncConflictIds.delete(pending.identity); + } +} + +function cloudSyncConflictIdentity(config: AppConfig): string { + const cloudSync = normalizedCloudSyncConfig(config.cloudSync); + return [ + cloudSync.baseUrl, + cloudSync.namespace, + cloudSync.userId || "", + cloudSync.deviceId || "", + cloudSync.keyId || "" + ].join("\u0000"); +} + function cloudSyncConflictResult( config: AppConfig, message: string, @@ -1075,12 +1948,100 @@ function cloudSyncLoggedOutConfig(config: AppConfig): AppConfig { }; } -async function pullCloudDocument(config: AppConfig, sinceRevision: number): Promise<{ config: AppConfig; data: CloudPullResponse }> { +async function pullAllCloudDocuments( + config: AppConfig, + sinceRevision: number, + excludeOperationId?: string +): Promise<{ config: AppConfig; data: CloudPullResponse }> { + const firstPage = await pullCloudDocument(config, sinceRevision, { + excludeOperationId + }); + const collection = await continueCloudOperationPages({ + config: firstPage.config, + document: firstPage.data.document, + excludeOperationId, + hasMore: firstPage.data.pagination?.hasMore === true, + nextRevision: firstPage.data.pagination?.nextRevision, + operations: firstPage.data.operations ?? [] + }); + return { + config: collection.config, + data: { + document: collection.document ?? firstPage.data.document, + operations: collection.operations, + pagination: { + excludeOperationId: excludeOperationId ?? null, + hasMore: false, + limit: firstPage.data.pagination?.limit ?? 100, + nextRevision: null + } + } + }; +} + +async function continueCloudOperationPages({ + config, + document, + excludeOperationId, + hasMore, + nextRevision, + operations +}: { + config: AppConfig; + document?: CloudDocument; + excludeOperationId?: string; + hasMore: boolean; + nextRevision?: number | null; + operations: CloudOperation[]; +}): Promise { + let nextConfig = config; + let nextDocument = document; + let cursor = nextRevision; + let more = hasMore; + const collected = [...operations]; + const seenCursors = new Set(); + + while (more) { + if ( + typeof cursor !== "number" || + !Number.isInteger(cursor) || + cursor < 0 || + seenCursors.has(cursor) + ) { + throw new Error("Cloud sync operation pagination did not provide a valid next revision."); + } + seenCursors.add(cursor); + const page = await pullCloudDocument(nextConfig, cursor, { + excludeOperationId + }); + nextConfig = page.config; + nextDocument = page.data.document; + collected.push(...(page.data.operations ?? [])); + more = page.data.pagination?.hasMore === true; + cursor = page.data.pagination?.nextRevision; + } + + return { + config: nextConfig, + document: nextDocument, + operations: collected + }; +} + +async function pullCloudDocument( + config: AppConfig, + sinceRevision: number, + options: { excludeOperationId?: string; limit?: number } = {} +): Promise<{ config: AppConfig; data: CloudPullResponse }> { const cloudSync = normalizedCloudSyncConfig(config.cloudSync); const search = new URLSearchParams({ + limit: String(options.limit ?? 100), namespace: cloudSync.namespace, sinceRevision: String(Math.max(0, Math.floor(sinceRevision))) }); + if (options.excludeOperationId) { + search.set("excludeOperationId", options.excludeOperationId); + } return cloudRequest(config, `/sync/pull?${search.toString()}`); } @@ -1375,6 +2336,14 @@ function encryptJson(value: unknown, keyMaterial: CloudSyncKeyMaterial): Encrypt } function decryptSnapshot(encrypted: EncryptedPayload, keyMaterial: CloudSyncKeyMaterial): CloudSyncSnapshot { + const parsed = decryptJson(encrypted, keyMaterial); + if (!isCloudSyncSnapshot(parsed)) { + throw new Error("Remote cloud sync snapshot has an unsupported format."); + } + return parsed; +} + +function decryptJson(encrypted: EncryptedPayload, keyMaterial: CloudSyncKeyMaterial): unknown { if (encrypted.algorithm !== "aes-256-gcm") { throw new Error(`Unsupported cloud sync encryption algorithm: ${encrypted.algorithm}`); } @@ -1390,11 +2359,7 @@ function decryptSnapshot(encrypted: EncryptedPayload, keyMaterial: CloudSyncKeyM 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; + return JSON.parse(plaintext) as unknown; } function keySaltFromEncryptedPayload(encrypted: EncryptedPayload): string { @@ -1433,6 +2398,7 @@ function withCloudSyncSuccess( patch: Pick & Partial>, syncedSnapshot?: CloudSyncSnapshot ): AppConfig { + clearPendingCloudSyncConflict(config); return { ...config, cloudSync: { @@ -1518,11 +2484,37 @@ 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) { + const verifier = randomBytes(64).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const callbackKey = cloudSyncAuthCallbackKey(new URL(normalizedCallbackUrl)); + const pending = { + expiresAt: Date.now() + cloudSyncAuthAttemptMs, + verifier + }; + pendingCloudSyncAuth.set(callbackKey, pending); + setTimeout(() => { + if (pendingCloudSyncAuth.get(callbackKey) === pending) { + pendingCloudSyncAuth.delete(callbackKey); + } + }, cloudSyncAuthAttemptMs).unref(); url.searchParams.set("redirect_uri", normalizedCallbackUrl); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("code_challenge_method", "S256"); } return url.toString(); } +function cloudSyncAuthCallbackKey(callbackUrl: URL): string { + const session = optionalString(callbackUrl.searchParams.get("session")); + if (session) { + return `session:${session}`; + } + const normalized = new URL(callbackUrl); + normalized.searchParams.delete("code"); + normalized.searchParams.delete("expires_at"); + return normalized.toString(); +} + function deviceOperationPrefix(config: AppConfig): string { return (config.cloudSync.deviceId || "local").replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 48) || "local"; } diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index a79a9dc6..6c32e399 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -69,6 +69,36 @@ export type CloudSyncPullRequest = CloudSyncKeyInput & { apply?: boolean; }; +export type CloudSyncConflictValue = { + exists: boolean; + value?: unknown; +}; + +export type CloudSyncConflictField = { + local: CloudSyncConflictValue; + path: string; + remote: CloudSyncConflictValue; +}; + +export type CloudSyncConflictResolution = { + expiresAt: string; + fields: CloudSyncConflictField[]; + id: string; + paths: string[]; + remoteRevision: number; +}; + +export type CloudSyncConflictFieldResolution = { + path: string; + result: CloudSyncConflictValue; +}; + +export type CloudSyncResolveConflictRequest = { + conflictId: string; + preference?: "local" | "remote"; + resolutions?: CloudSyncConflictFieldResolution[]; +}; + export type CloudSyncStatus = { authenticated: boolean; baseUrl: string; @@ -82,6 +112,7 @@ export type CloudSyncStatus = { lastSyncAt?: string; lastSyncError?: string; namespace: string; + pendingConflict?: CloudSyncConflictResolution; snapshotHash?: string; unlocked: boolean; userAvatarUrl?: string; @@ -100,6 +131,7 @@ export type CloudSyncOperationResult = { authExpired?: boolean; config?: AppConfig; conflict?: boolean; + conflictResolution?: CloudSyncConflictResolution; message: string; mergeApplied?: boolean; mergeConflicts?: string[]; diff --git a/packages/core/src/contracts/ipc-channels.ts b/packages/core/src/contracts/ipc-channels.ts index 36a01c55..be83a484 100644 --- a/packages/core/src/contracts/ipc-channels.ts +++ b/packages/core/src/contracts/ipc-channels.ts @@ -8,6 +8,7 @@ export const IPC_CHANNELS = { appCloudSyncLogin: "ccr:app:cloud-sync-login", appCloudSyncPull: "ccr:app:cloud-sync-pull", appCloudSyncPush: "ccr:app:cloud-sync-push", + appCloudSyncResolveConflict: "ccr:app:cloud-sync-resolve-conflict", appCloudSyncSetup: "ccr:app:cloud-sync-setup", appCloseTray: "ccr:app:close-tray", appDetectProviderIcon: "ccr:app:detect-provider-icon", diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index b3311102..54231c80 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -12,7 +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 { autoPushCloudSyncConfig, completeCloudSyncLogin, createCloudSyncKeyFile, disableCloudSyncConfig, ensureCloudSyncUserProfile, getCloudSyncStatus, pullCloudSyncConfig, pushCloudSyncConfig, resolveCloudSyncConflict, setupCloudSyncConfig, startCloudSyncLogin } from "@ccr/core/cloud-sync/service"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config"; import { APP_CONFIG_DB_FILE, @@ -63,6 +63,7 @@ import type { CloudSyncLoginResult, CloudSyncPullRequest, CloudSyncPushRequest, + CloudSyncResolveConflictRequest, CloudSyncSetupRequest, GatewayPluginAppConfig, GatewayPluginPermission, @@ -297,7 +298,7 @@ async function handleCloudSyncAuthCallbackRequest( } try { - await saveAppConfig(await refreshCloudSyncUserProfile(applyCloudSyncAuthTokens(await loadAppConfig(), cloudSyncAuthTokensFromUrl(url)))); + await saveAppConfig(await completeCloudSyncLogin(await loadAppConfig(), url.toString())); sendHtml(response, 200, cloudSyncAuthSuccessHtml(), request.method === "HEAD"); } catch (error) { sendHtml(response, 500, cloudSyncAuthFailureHtml(formatError(error)), request.method === "HEAD"); @@ -362,6 +363,7 @@ const rpcHandlers: Record = { }, cloudSyncPull: async (request) => persistCloudSyncOperation(await pullCloudSyncConfig(await loadAppConfig(), request as CloudSyncPullRequest | undefined)), cloudSyncPush: async (request) => persistCloudSyncOperation(await pushCloudSyncConfig(await loadAppConfig(), request as CloudSyncPushRequest | undefined)), + cloudSyncResolveConflict: async (request) => persistCloudSyncOperation(await resolveCloudSyncConflict(await loadAppConfig(), request as CloudSyncResolveConflictRequest)), cloudSyncSetup: async (request) => persistCloudSyncOperation(await setupCloudSyncConfig(await loadAppConfig(), request as CloudSyncSetupRequest)), clearProxyNetworkCaptures: () => proxyService.clearNetworkCaptures(), closeBotGatewayQrWindow: (_request) => ({ closed: false }), @@ -643,33 +645,6 @@ function cloudSyncCallbackUrl(request: IncomingMessage, session: string): string 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 [ "", diff --git a/packages/core/test/unit/config/cloud-sync-service.test.mjs b/packages/core/test/unit/config/cloud-sync-service.test.mjs index d4d0d4a8..78db4fa4 100644 --- a/packages/core/test/unit/config/cloud-sync-service.test.mjs +++ b/packages/core/test/unit/config/cloud-sync-service.test.mjs @@ -1,11 +1,18 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import test from "node:test"; import { + completeCloudSyncLogin, applyCloudSyncSnapshot, createCloudSyncSnapshot, decryptCloudSyncSnapshotForTest, + encryptCloudSyncOperationForTest, encryptCloudSyncSnapshotForTest, - mergeCloudSyncSnapshotsForTest + mergeCloudSyncSnapshotsForTest, + pullCloudSyncConfig, + pushCloudSyncConfig, + resolveCloudSyncConflict, + startCloudSyncLogin } from "@ccr/core/cloud-sync/service.ts"; import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; @@ -153,6 +160,30 @@ test("cloud sync conflict merge combines primitive list changes", () => { assert.deepEqual(merged.snapshot.config.Providers[0].models, ["base-model", "local-model", "remote-model"]); }); +test("cloud sync conflict merge marks concurrent order changes as risky", () => { + const baseConfig = createDefaultAppConfig(); + const localConfig = createDefaultAppConfig(); + const remoteConfig = createDefaultAppConfig(); + const provider = { + api_key: "secret", + api_base_url: "https://example.test/v1", + models: ["first", "second"], + name: "Example" + }; + baseConfig.Providers = [provider]; + localConfig.Providers = [{ ...provider, models: ["second", "first"] }]; + remoteConfig.Providers = [{ ...provider, models: ["first", "second", "remote"] }]; + + const merged = mergeCloudSyncSnapshotsForTest( + createCloudSyncSnapshot(baseConfig), + createCloudSyncSnapshot(localConfig), + createCloudSyncSnapshot(remoteConfig) + ); + + assert.deepEqual(merged.conflicts, ["config.Providers[Example].models"]); + assert.deepEqual(merged.snapshot.config.Providers[0].models, ["second", "first"]); +}); + test("cloud sync conflict merge keeps local values for same-field conflicts", () => { const baseConfig = createDefaultAppConfig(); const localConfig = createDefaultAppConfig(); @@ -182,4 +213,654 @@ test("cloud sync conflict merge keeps local values for same-field conflicts", () "config.Providers[Example].api_key", "config.preferredProvider" ]); + assert.deepEqual(merged.conflictFields, [ + { + local: { exists: true, value: "local-secret" }, + path: "config.Providers[Example].api_key", + remote: { exists: true, value: "remote-secret" } + }, + { + local: { exists: true, value: "Local" }, + path: "config.preferredProvider", + remote: { exists: true, value: "Remote" } + } + ]); +}); + +test("cloud sync login exchanges a browser-safe PKCE handoff code", async () => { + const config = createDefaultAppConfig(); + const callbackUrl = "http://127.0.0.1:45678/cloud-sync/auth/callback?session=test-session"; + const login = startCloudSyncLogin(config, { callbackUrl }); + const loginUrl = new URL(login.loginUrl); + const challenge = loginUrl.searchParams.get("code_challenge"); + + assert.equal(loginUrl.searchParams.get("redirect_uri"), callbackUrl); + assert.equal(loginUrl.searchParams.get("code_challenge_method"), "S256"); + assert.match(challenge ?? "", /^[a-zA-Z0-9_-]{43}$/); + assert.equal(login.loginUrl.includes("access_token"), false); + assert.equal(login.loginUrl.includes("refresh_token"), false); + + const originalFetch = globalThis.fetch; + let handoffRequests = 0; + globalThis.fetch = async (input, init) => { + handoffRequests += 1; + const url = new URL(String(input)); + assert.equal(url.pathname, "/auth/handoff"); + assert.equal(init?.method, "POST"); + const body = JSON.parse(String(init?.body)); + assert.equal(body.code, "one-time-code"); + assert.equal( + createHash("sha256").update(body.codeVerifier).digest("base64url"), + challenge + ); + return new Response(JSON.stringify({ + accessToken: "access-token", + refreshToken: "refresh-token", + refreshTokenExpiresAt: "2026-10-30T00:00:00.000Z", + tokenType: "Bearer", + user: { + avatarUrl: "https://avatars.example.test/user.png", + email: "user@example.test", + githubLogin: "cloud-user", + githubName: "Cloud User", + id: "user-id" + } + }), { + headers: { "content-type": "application/json" }, + status: 200 + }); + }; + + try { + const completedUrl = new URL(callbackUrl); + completedUrl.searchParams.set("code", "one-time-code"); + completedUrl.searchParams.set("expires_at", new Date(Date.now() + 60_000).toISOString()); + const authenticated = await completeCloudSyncLogin(config, completedUrl.toString()); + + assert.equal(authenticated.cloudSync.accessToken, "access-token"); + assert.equal(authenticated.cloudSync.refreshToken, "refresh-token"); + assert.equal(authenticated.cloudSync.userLogin, "cloud-user"); + assert.equal(authenticated.cloudSync.userName, "Cloud User"); + assert.equal(handoffRequests, 1); + await assert.rejects( + completeCloudSyncLogin(config, completedUrl.toString()), + /invalid or expired/i + ); + assert.equal(handoffRequests, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("cloud sync pull does not replay operations already represented by the document snapshot", async () => { + const password = "correct horse battery staple"; + const keySalt = Buffer.alloc(16, 9).toString("base64"); + const baseConfig = createDefaultAppConfig(); + baseConfig.Providers = [{ + api_key: "provider-a-secret", + api_base_url: "https://a.example.test/v1", + models: ["model-a"], + name: "Provider A" + }]; + const staleOperationConfig = createDefaultAppConfig(); + staleOperationConfig.Providers = [{ + api_key: "provider-b-secret", + api_base_url: "https://b.example.test/v1", + models: ["model-b"], + name: "Provider B" + }]; + const documentConfig = createDefaultAppConfig(); + documentConfig.Providers = [ + ...baseConfig.Providers, + ...staleOperationConfig.Providers + ]; + + const baseSnapshot = createCloudSyncSnapshot(baseConfig, "2026-07-30T00:00:00.000Z"); + const staleOperationSnapshot = createCloudSyncSnapshot( + staleOperationConfig, + "2026-07-30T00:01:00.000Z" + ); + const documentSnapshot = createCloudSyncSnapshot(documentConfig, "2026-07-30T00:02:00.000Z"); + const key = encryptCloudSyncSnapshotForTest(baseSnapshot, password, keySalt); + const encryptedDocument = encryptCloudSyncSnapshotForTest( + documentSnapshot, + password, + keySalt + ).encrypted; + baseConfig.cloudSync = { + accessToken: "access-token", + baseUrl: "http://127.0.0.1:3000", + deviceId: "00000000-0000-4000-8000-000000000001", + deviceName: "test-device", + enabled: true, + keyId: key.keyId, + keyMode: "password", + keySalt, + lastRevision: 101, + lastSyncedSnapshot: baseSnapshot, + namespace: "ccr", + refreshToken: "refresh-token" + }; + + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + assert.equal(url.pathname, "/sync/pull"); + return new Response(JSON.stringify({ + document: { + encryptedSnapshot: encryptedDocument, + namespace: "ccr", + revision: 104, + snapshotHash: "document-hash", + snapshotRevision: 104, + updatedAt: "2026-07-30T00:02:00.000Z" + }, + operations: [ + { + baseRevision: 101, + clientOperationId: "stale-operation", + encryptedPayload: encryptCloudSyncOperationForTest( + staleOperationSnapshot, + password, + keySalt + ), + id: "00000000-0000-4000-8000-000000000102", + revision: 102 + }, + { + baseRevision: 103, + clientOperationId: "resolved-operation", + encryptedPayload: encryptCloudSyncOperationForTest( + documentSnapshot, + password, + keySalt + ), + id: "00000000-0000-4000-8000-000000000104", + revision: 104 + } + ], + pagination: { + hasMore: false, + limit: 100, + nextRevision: null + } + }), { status: 200 }); + }; + + try { + const result = await pullCloudSyncConfig(baseConfig, { password }); + + assert.equal(result.remoteRevision, 104); + assert.equal(result.snapshotApplied, true); + assert.deepEqual( + result.config.Providers.map((provider) => provider.name).sort(), + ["Provider A", "Provider B"] + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("cloud sync pull ignores a legacy snapshot with no snapshot revision and replays the full log", async () => { + const password = "correct horse battery staple"; + const keySalt = Buffer.alloc(16, 11).toString("base64"); + const baseConfig = createDefaultAppConfig(); + const otherConfig = createDefaultAppConfig(); + baseConfig.Providers = [{ + api_key: "provider-a-secret", + api_base_url: "https://a.example.test/v1", + models: ["model-a"], + name: "Provider A" + }]; + otherConfig.Providers = [{ + api_key: "provider-b-secret", + api_base_url: "https://b.example.test/v1", + models: ["model-b"], + name: "Provider B" + }]; + const mergedConfig = createDefaultAppConfig(); + mergedConfig.Providers = [...baseConfig.Providers, ...otherConfig.Providers]; + + const baseSnapshot = createCloudSyncSnapshot(baseConfig, "2026-07-30T00:00:00.000Z"); + const otherSnapshot = createCloudSyncSnapshot(otherConfig, "2026-07-30T00:01:00.000Z"); + const mergedSnapshot = createCloudSyncSnapshot(mergedConfig, "2026-07-30T00:02:00.000Z"); + const key = encryptCloudSyncSnapshotForTest(baseSnapshot, password, keySalt); + const encryptedLegacySnapshot = encryptCloudSyncSnapshotForTest( + otherSnapshot, + password, + keySalt + ).encrypted; + baseConfig.cloudSync = { + accessToken: "access-token", + baseUrl: "http://127.0.0.1:3000", + deviceId: "00000000-0000-4000-8000-000000000001", + deviceName: "test-device", + enabled: true, + keyId: key.keyId, + keyMode: "password", + keySalt, + lastRevision: 1, + lastSyncedSnapshot: baseSnapshot, + namespace: "ccr", + refreshToken: "refresh-token" + }; + + const operations = [ + { + baseRevision: 0, + clientOperationId: "initial-operation", + encryptedPayload: encryptCloudSyncOperationForTest(baseSnapshot, password, keySalt), + id: "00000000-0000-4000-8000-000000000001", + revision: 1 + }, + { + baseRevision: 0, + clientOperationId: "stale-operation", + encryptedPayload: encryptCloudSyncOperationForTest(otherSnapshot, password, keySalt), + id: "00000000-0000-4000-8000-000000000002", + revision: 2 + }, + { + baseRevision: 2, + clientOperationId: "resolved-operation", + encryptedPayload: encryptCloudSyncOperationForTest(mergedSnapshot, password, keySalt), + id: "00000000-0000-4000-8000-000000000003", + revision: 3 + } + ]; + const requestedRevisions = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + requestedRevisions.push(url.searchParams.get("sinceRevision")); + const sinceRevision = Number(url.searchParams.get("sinceRevision")); + return new Response(JSON.stringify({ + document: { + encryptedSnapshot: encryptedLegacySnapshot, + namespace: "ccr", + revision: 3, + snapshotHash: "legacy-hash", + snapshotRevision: null, + updatedAt: "2026-07-30T00:02:00.000Z" + }, + operations: operations.filter((operation) => operation.revision > sinceRevision), + pagination: { + hasMore: false, + limit: 100, + nextRevision: null + } + }), { status: 200 }); + }; + + try { + const result = await pullCloudSyncConfig(baseConfig, { password }); + + assert.deepEqual(requestedRevisions, ["1", "0"]); + assert.deepEqual( + result.config.Providers.map((provider) => provider.name).sort(), + ["Provider A", "Provider B"] + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("risky cloud sync pull waits for confirmation and can keep cloud values", async () => { + const password = "correct horse battery staple"; + const keySalt = Buffer.alloc(16, 13).toString("base64"); + const baseConfig = createDefaultAppConfig(); + baseConfig.preferredProvider = "Base"; + const localConfig = createDefaultAppConfig(); + localConfig.preferredProvider = "Local"; + const remoteConfig = createDefaultAppConfig(); + remoteConfig.preferredProvider = "Remote"; + const baseSnapshot = createCloudSyncSnapshot(baseConfig, "2026-07-30T00:00:00.000Z"); + const remoteSnapshot = createCloudSyncSnapshot(remoteConfig, "2026-07-30T00:01:00.000Z"); + const key = encryptCloudSyncSnapshotForTest(baseSnapshot, password, keySalt); + const encryptedRemote = encryptCloudSyncSnapshotForTest( + remoteSnapshot, + password, + keySalt + ).encrypted; + localConfig.cloudSync = { + accessToken: "access-token", + baseUrl: "http://127.0.0.1:3000", + deviceId: "00000000-0000-4000-8000-000000000013", + deviceName: "test-device", + enabled: true, + keyId: key.keyId, + keyMode: "password", + keySalt, + lastRevision: 1, + lastSyncedSnapshot: baseSnapshot, + namespace: "ccr", + refreshToken: "refresh-token" + }; + + const originalFetch = globalThis.fetch; + let requests = 0; + globalThis.fetch = async (input) => { + requests += 1; + const url = new URL(String(input)); + assert.equal(url.pathname, "/sync/pull"); + return new Response(JSON.stringify({ + document: { + encryptedSnapshot: encryptedRemote, + namespace: "ccr", + revision: 2, + snapshotHash: "remote-hash", + snapshotRevision: 2, + updatedAt: "2026-07-30T00:01:00.000Z" + }, + operations: [], + pagination: { + hasMore: false, + limit: 100, + nextRevision: null + } + }), { status: 200 }); + }; + + try { + const conflicted = await pullCloudSyncConfig(localConfig, { password }); + + assert.equal(conflicted.conflict, true); + assert.equal(conflicted.mergeApplied, false); + assert.equal(conflicted.snapshotApplied, false); + assert.equal(conflicted.config.preferredProvider, "Local"); + assert.deepEqual(conflicted.mergeConflicts, ["config.preferredProvider"]); + assert.equal(conflicted.status.pendingConflict?.id, conflicted.conflictResolution?.id); + assert.deepEqual(conflicted.conflictResolution.fields, [{ + local: { exists: true, value: "Local" }, + path: "config.preferredProvider", + remote: { exists: true, value: "Remote" } + }]); + await assert.rejects( + () => resolveCloudSyncConflict(conflicted.config, { + conflictId: conflicted.conflictResolution.id, + resolutions: [] + }), + /Resolve every cloud sync conflict field/i + ); + + const changedAfterConflict = { + ...conflicted.config, + preferredProvider: "Changed again" + }; + const staleResolution = await resolveCloudSyncConflict(changedAfterConflict, { + conflictId: conflicted.conflictResolution.id, + preference: "remote" + }); + assert.match(staleResolution.message, /changed after the conflict/i); + assert.equal(staleResolution.config.preferredProvider, "Changed again"); + + const refreshed = await pullCloudSyncConfig(staleResolution.config, { password }); + const resolved = await resolveCloudSyncConflict(refreshed.config, { + conflictId: refreshed.conflictResolution.id, + preference: "remote" + }); + + assert.equal(requests, 3); + assert.equal(resolved.config.preferredProvider, "Remote"); + assert.equal(resolved.mergeApplied, true); + assert.equal(resolved.snapshotApplied, true); + assert.equal(resolved.snapshotPushed, false); + assert.equal(resolved.status.pendingConflict, undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reviewed cloud sync values support per-field side choices and custom JSON", async () => { + const password = "correct horse battery staple"; + const keySalt = Buffer.alloc(16, 15).toString("base64"); + const baseConfig = createDefaultAppConfig(); + baseConfig.preferredProvider = "Base"; + const localConfig = createDefaultAppConfig(); + localConfig.preferredProvider = "Local"; + localConfig.Router.fallback.retryCount = 2; + const remoteConfig = createDefaultAppConfig(); + remoteConfig.preferredProvider = "Remote"; + remoteConfig.Router.fallback.retryCount = 3; + const baseSnapshot = createCloudSyncSnapshot(baseConfig, "2026-07-30T00:00:00.000Z"); + const remoteSnapshot = createCloudSyncSnapshot(remoteConfig, "2026-07-30T00:01:00.000Z"); + const key = encryptCloudSyncSnapshotForTest(baseSnapshot, password, keySalt); + const encryptedRemote = encryptCloudSyncSnapshotForTest( + remoteSnapshot, + password, + keySalt + ).encrypted; + localConfig.cloudSync = { + accessToken: "access-token", + baseUrl: "http://127.0.0.1:3000", + deviceId: "00000000-0000-4000-8000-000000000015", + deviceName: "test-device", + enabled: true, + keyId: key.keyId, + keyMode: "password", + keySalt, + lastRevision: 1, + lastSyncedSnapshot: baseSnapshot, + namespace: "ccr", + refreshToken: "refresh-token" + }; + + const originalFetch = globalThis.fetch; + let pushedBody; + globalThis.fetch = async (input, init) => { + const url = new URL(String(input)); + if (url.pathname === "/sync/pull") { + return new Response(JSON.stringify({ + document: { + encryptedSnapshot: encryptedRemote, + namespace: "ccr", + revision: 2, + snapshotHash: "remote-hash", + snapshotRevision: 2, + updatedAt: "2026-07-30T00:01:00.000Z" + }, + operations: [], + pagination: { + hasMore: false, + limit: 100, + nextRevision: null + } + }), { status: 200 }); + } + + assert.equal(url.pathname, "/sync/push"); + pushedBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ + accepted: true, + conflict: false, + document: { + encryptedSnapshot: pushedBody.encryptedSnapshot, + namespace: "ccr", + revision: 3, + snapshotHash: pushedBody.snapshotHash, + snapshotRevision: 3, + updatedAt: "2026-07-30T00:02:00.000Z" + }, + mergeRequired: false, + snapshotAccepted: true + }), { status: 200 }); + }; + + try { + const conflicted = await pullCloudSyncConfig(localConfig, { password }); + assert.deepEqual( + conflicted.conflictResolution.fields.map((field) => field.path).sort(), + ["config.Router.fallback.retryCount", "config.preferredProvider"] + ); + const resolved = await resolveCloudSyncConflict(conflicted.config, { + conflictId: conflicted.conflictResolution.id, + resolutions: conflicted.conflictResolution.fields.map((field) => ({ + path: field.path, + result: field.path === "config.preferredProvider" + ? field.local + : { exists: true, value: 9 } + })) + }); + + assert.equal(pushedBody.baseRevision, 2); + assert.equal(resolved.config.preferredProvider, "Local"); + assert.equal(resolved.config.Router.fallback.retryCount, 9); + assert.equal(resolved.snapshotPushed, true); + assert.equal(resolved.status.pendingConflict, undefined); + const pushedSnapshot = decryptCloudSyncSnapshotForTest( + pushedBody.encryptedSnapshot, + password, + keySalt + ); + assert.equal(pushedSnapshot.config.preferredProvider, "Local"); + assert.equal(pushedSnapshot.config.Router.fallback.retryCount, 9); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("cloud sync conflict paging ignores rejected stale snapshots before merge and retry", async () => { + const password = "correct horse battery staple"; + const keySalt = Buffer.alloc(16, 7).toString("base64"); + const baseConfig = createDefaultAppConfig(); + const localConfig = createDefaultAppConfig(); + const remoteConfig = createDefaultAppConfig(); + const pendingConfig = 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" + }]; + pendingConfig.Providers = [{ + api_key: "pending-secret", + api_base_url: "https://pending.example.test/v1", + models: ["pending-model"], + name: "Pending" + }]; + + const baseSnapshot = createCloudSyncSnapshot(baseConfig, "2026-07-30T00:00:00.000Z"); + const remoteSnapshot = createCloudSyncSnapshot(remoteConfig, "2026-07-30T00:01:00.000Z"); + const pendingSnapshot = createCloudSyncSnapshot(pendingConfig, "2026-07-30T00:02:00.000Z"); + const key = encryptCloudSyncSnapshotForTest(baseSnapshot, password, keySalt); + const encryptedRemote = encryptCloudSyncSnapshotForTest(remoteSnapshot, password, keySalt).encrypted; + const encryptedPendingOperation = encryptCloudSyncOperationForTest(pendingSnapshot, password, keySalt); + localConfig.cloudSync = { + accessToken: "access-token", + baseUrl: "http://127.0.0.1:3000", + deviceId: "00000000-0000-4000-8000-000000000001", + deviceName: "test-device", + enabled: true, + keyId: key.keyId, + keyMode: "password", + keySalt, + lastRevision: 1, + lastSyncedSnapshot: baseSnapshot, + namespace: "ccr", + refreshToken: "refresh-token" + }; + + const originalFetch = globalThis.fetch; + const requestBodies = []; + const requestUrls = []; + globalThis.fetch = async (input, init) => { + const url = new URL(String(input)); + requestUrls.push(url); + if (url.pathname === "/sync/pull") { + return new Response(JSON.stringify({ + document: { + encryptedSnapshot: encryptedRemote, + namespace: "ccr", + revision: 102, + snapshotHash: "remote-hash", + updatedAt: "2026-07-30T00:02:30.000Z" + }, + operations: [{ + baseRevision: 1, + clientOperationId: "pending-operation", + encryptedPayload: encryptedPendingOperation, + id: "00000000-0000-4000-8000-000000000100", + revision: 101 + }], + pagination: { + excludeOperationId: "00000000-0000-4000-8000-000000000099", + hasMore: false, + limit: 100, + nextRevision: null + } + }), { status: 200 }); + } + + assert.equal(url.pathname, "/sync/push"); + const body = JSON.parse(String(init?.body)); + requestBodies.push(body); + if (requestBodies.length === 1) { + return new Response(JSON.stringify({ + accepted: true, + conflict: true, + document: { + encryptedSnapshot: encryptedRemote, + namespace: "ccr", + revision: 102, + snapshotHash: "remote-hash", + updatedAt: "2026-07-30T00:02:30.000Z" + }, + mergeRequired: true, + missingOperations: [], + missingOperationsExcludeOperationId: "00000000-0000-4000-8000-000000000099", + missingOperationsHasMore: true, + nextMissingRevision: 100, + snapshotAccepted: false, + snapshotRejectedReason: "merge_required" + }), { status: 200 }); + } + + return new Response(JSON.stringify({ + accepted: true, + conflict: false, + document: { + encryptedSnapshot: body.encryptedSnapshot, + namespace: "ccr", + revision: 103, + snapshotHash: body.snapshotHash, + updatedAt: "2026-07-30T00:03:00.000Z" + }, + mergeRequired: false, + snapshotAccepted: true + }), { status: 200 }); + }; + + try { + const result = await pushCloudSyncConfig(localConfig, { password }); + + assert.equal(result.conflict, true); + assert.equal(result.mergeApplied, true); + assert.equal(result.snapshotPushed, true); + assert.equal(requestBodies.length, 2); + assert.equal(requestBodies[1].baseRevision, 102); + const continuation = requestUrls.find((url) => url.pathname === "/sync/pull"); + assert.equal(continuation?.searchParams.get("sinceRevision"), "100"); + assert.equal( + continuation?.searchParams.get("excludeOperationId"), + "00000000-0000-4000-8000-000000000099" + ); + + const mergedSnapshot = decryptCloudSyncSnapshotForTest( + requestBodies[1].encryptedSnapshot, + password, + keySalt + ); + assert.deepEqual( + mergedSnapshot.config.Providers.map((provider) => provider.name).sort(), + ["Local", "Remote"] + ); + } finally { + globalThis.fetch = originalFetch; + } }); diff --git a/packages/electron/src/main/deep-link.ts b/packages/electron/src/main/deep-link.ts index c7f99000..df1b850d 100644 --- a/packages/electron/src/main/deep-link.ts +++ b/packages/electron/src/main/deep-link.ts @@ -1,7 +1,7 @@ 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 { completeCloudSyncLogin } 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, saveAppConfig } from "@ccr/core/config/config"; @@ -19,10 +19,7 @@ type PluginDeepLinkRequest = { }; type CloudSyncAuthDeepLink = { - accessToken: string; - refreshToken: string; - refreshTokenExpiresAt?: string; - userId?: string; + rawUrl: string; }; const pluginAppExistingGatewayProbeTimeoutMs = 2_000; @@ -133,7 +130,7 @@ class DeepLinkService { private async handleCloudSyncAuth(input: CloudSyncAuthDeepLink): Promise { try { - const savedConfig = await saveAppConfig(await refreshCloudSyncUserProfile(applyCloudSyncAuthTokens(await loadAppConfig(), input))); + const savedConfig = await saveAppConfig(await completeCloudSyncLogin(await loadAppConfig(), input.rawUrl)); logDeepLinkDiagnostic(`[deep-link] Cloud sync login completed for user ${savedConfig.cloudSync.userId || ""}`); if (app.isReady()) { windowsManager.showMainWindow(); @@ -377,25 +374,14 @@ function parseCloudSyncAuthDeepLink(rawUrl: string): CloudSyncAuthDeepLink | und 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" || + host === "auth" && (pathSegments[0] === "callback" || 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") - }; + requiredDeepLinkParam(url.searchParams, "code"); + return { rawUrl: url.toString() }; } function requiredDeepLinkParam(params: URLSearchParams, key: string): string { diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 737204c6..b821df9b 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -12,7 +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 { autoPushCloudSyncConfig, completeCloudSyncLogin, createCloudSyncKeyFile, disableCloudSyncConfig, ensureCloudSyncUserProfile, getCloudSyncStatus, pullCloudSyncConfig, pushCloudSyncConfig, resolveCloudSyncConflict, setupCloudSyncConfig, startCloudSyncLogin } from "@ccr/core/cloud-sync/service"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig, saveAppThemePreference, withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config"; import { APP_CONFIG_DB_FILE, @@ -53,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 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"; +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 CloudSyncResolveConflictRequest, 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); @@ -134,6 +134,9 @@ ipcMain.handle(IPC_CHANNELS.appCloudSyncPush, async (_event, request?: CloudSync ipcMain.handle(IPC_CHANNELS.appCloudSyncPull, async (_event, request?: CloudSyncPullRequest) => { return persistCloudSyncOperation(await pullCloudSyncConfig(await loadAppConfig(), request)); }); +ipcMain.handle(IPC_CHANNELS.appCloudSyncResolveConflict, async (_event, request: CloudSyncResolveConflictRequest) => { + return persistCloudSyncOperation(await resolveCloudSyncConflict(await loadAppConfig(), request)); +}); ipcMain.handle(IPC_CHANNELS.appCloudSyncDisable, async () => { const savedConfig = await saveAppConfig(disableCloudSyncConfig(await loadAppConfig())); return { @@ -494,13 +497,8 @@ async function handleCloudSyncDesktopAuthCallback( 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)); + requiredCloudSyncCallbackParam(url, "code"); + const savedConfig = await saveAppConfig(await completeCloudSyncLogin(await loadAppConfig(), url.toString())); windowsManager.showMainWindow(); windowsManager.broadcast(IPC_CHANNELS.appCloudSyncAuthChanged); await trayController.refreshIconFromConfig(savedConfig); diff --git a/packages/electron/src/main/preload.ts b/packages/electron/src/main/preload.ts index 6973da9d..1ce34693 100644 --- a/packages/electron/src/main/preload.ts +++ b/packages/electron/src/main/preload.ts @@ -35,6 +35,7 @@ import type { CloudSyncLoginResult, CloudSyncPullRequest, CloudSyncPushRequest, + CloudSyncResolveConflictRequest, CloudSyncSetupRequest, CloudSyncStatus, GatewayMcpToolInfo, @@ -115,6 +116,7 @@ contextBridge.exposeInMainWorld("ccr", { 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, + cloudSyncResolveConflict: (request: CloudSyncResolveConflictRequest) => invoke(IPC_CHANNELS.appCloudSyncResolveConflict, 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, diff --git a/packages/ui/src/pages/home/components/settings.tsx b/packages/ui/src/pages/home/components/settings.tsx index 624812b1..67cb9302 100644 --- a/packages/ui/src/pages/home/components/settings.tsx +++ b/packages/ui/src/pages/home/components/settings.tsx @@ -361,6 +361,36 @@ function AppearanceSettingsPage({ ); } +type CloudSyncConflictDescriptor = NonNullable; +type CloudSyncConflictField = CloudSyncConflictDescriptor["fields"][number]; +type CloudSyncConflictValue = CloudSyncConflictField["local"]; +type CloudSyncConflictDraft = { + exists: boolean; + path: string; + source: "local" | "manual" | "remote"; + text: string; +}; + +function formatCloudSyncConflictJson(value: CloudSyncConflictValue): string { + if (!value.exists) { + return ""; + } + return JSON.stringify(value.value, null, 2) ?? "null"; +} + +function cloudSyncConflictDraft( + field: CloudSyncConflictField, + source: "local" | "remote" +): CloudSyncConflictDraft { + const value = field[source]; + return { + exists: value.exists, + path: field.path, + source, + text: formatCloudSyncConflictJson(value) + }; +} + function CloudSyncSettingsPage({ config, copy, @@ -377,10 +407,14 @@ function CloudSyncSettingsPage({ 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 [busy, setBusy] = useState<"" | "setup" | "push" | "pull" | "disable" | "logout" | "status" | "login" | "key-file" | "resolve">(""); const [syncPanelOpen, setSyncPanelOpen] = useState(Boolean(config.cloudSync.enabled)); const [encryptionSettingsOpen, setEncryptionSettingsOpen] = useState(!config.cloudSync.keyId); const [mergeConflicts, setMergeConflicts] = useState([]); + const [pendingConflict, setPendingConflict] = useState(); + const [conflictDialogOpen, setConflictDialogOpen] = useState(false); + const [conflictDraftError, setConflictDraftError] = useState(""); + const [conflictDrafts, setConflictDrafts] = useState([]); const [error, setError] = useState(""); const [failedAvatarUrl, setFailedAvatarUrl] = useState(""); const keyFileInputRef = useRef(null); @@ -423,11 +457,22 @@ function CloudSyncSettingsPage({ } setBusy((current) => current || "status"); void window.ccr.cloudSyncGetStatus() - .then(setStatus) + .then((nextStatus) => { + setStatus(nextStatus); + setPendingConflict(nextStatus.pendingConflict); + setConflictDialogOpen(Boolean(nextStatus.pendingConflict)); + }) .catch(() => undefined) .finally(() => setBusy((current) => current === "status" ? "" : current)); }, []); + useEffect(() => { + setConflictDraftError(""); + setConflictDrafts( + pendingConflict?.fields.map((field) => cloudSyncConflictDraft(field, "local")) ?? [] + ); + }, [pendingConflict?.id]); + useEffect(() => { if (!window.ccr?.onCloudSyncAuthChanged) { return undefined; @@ -448,11 +493,16 @@ function CloudSyncSettingsPage({ onConfigChange(result.config); } setStatus(result.status); + const nextConflict = "conflictResolution" in result + ? result.conflictResolution ?? result.status.pendingConflict + : result.status.pendingConflict; + setPendingConflict(nextConflict); + setConflictDialogOpen(Boolean(nextConflict)); const authExpired = "authExpired" in result && result.authExpired; if (!authExpired && result.message) { onToast(t(result.message)); } - setMergeConflicts("mergeApplied" in result && result.mergeApplied ? result.mergeConflicts ?? [] : []); + setMergeConflicts("mergeConflicts" in result ? result.mergeConflicts ?? [] : []); setError(authExpired ? t(result.message) : ""); setPassword(""); } @@ -468,6 +518,8 @@ function CloudSyncSettingsPage({ ]); onConfigChange(nextConfig); setStatus(nextStatus); + setPendingConflict(nextStatus.pendingConflict); + setConflictDialogOpen(Boolean(nextStatus.pendingConflict)); setSyncPanelOpen(true); setEncryptionSettingsOpen(!nextStatus.configured); onToast(t("Cloud login completed. Choose an encryption method to enable sync.")); @@ -489,6 +541,8 @@ function CloudSyncSettingsPage({ if (nextStatus.authenticated) { onConfigChange(nextConfig); setStatus(nextStatus); + setPendingConflict(nextStatus.pendingConflict); + setConflictDialogOpen(Boolean(nextStatus.pendingConflict)); setSyncPanelOpen(true); setEncryptionSettingsOpen(!nextStatus.configured); onToast(t("Cloud login completed. Choose an encryption method to enable sync.")); @@ -574,6 +628,94 @@ function CloudSyncSettingsPage({ } } + function chooseCloudConflictSide(path: string, source: "local" | "remote") { + const field = pendingConflict?.fields.find((item) => item.path === path); + if (!field) { + return; + } + setConflictDrafts((current) => current.map((draft) => + draft.path === path ? cloudSyncConflictDraft(field, source) : draft + )); + setConflictDraftError(""); + } + + function chooseAllCloudConflictSides(source: "local" | "remote") { + if (!pendingConflict) { + return; + } + setConflictDrafts( + pendingConflict.fields.map((field) => cloudSyncConflictDraft(field, source)) + ); + setConflictDraftError(""); + } + + function updateCloudConflictDraft( + path: string, + patch: Partial> + ) { + setConflictDrafts((current) => current.map((draft) => + draft.path === path ? { ...draft, ...patch } : draft + )); + setConflictDraftError(""); + } + + async function resolveCloudConflict() { + if (!window.ccr?.cloudSyncResolveConflict || !pendingConflict) { + return; + } + const resolutions: Array<{ path: string; result: CloudSyncConflictValue }> = []; + for (const field of pendingConflict.fields) { + const draft = conflictDrafts.find((item) => item.path === field.path); + if (!draft) { + setConflictDraftError(t("Resolve every conflicting field before applying.")); + return; + } + if (!draft.exists) { + resolutions.push({ + path: field.path, + result: { exists: false } + }); + continue; + } + try { + resolutions.push({ + path: field.path, + result: { + exists: true, + value: JSON.parse(draft.text) + } + }); + } catch { + setConflictDraftError( + t("The final result for {path} must be valid JSON.").replace("{path}", field.path) + ); + return; + } + } + + setBusy("resolve"); + setError(""); + setConflictDraftError(""); + try { + applyCloudResult(await window.ccr.cloudSyncResolveConflict({ + conflictId: pendingConflict.id, + resolutions + })); + } catch (resolveError) { + setError(formatAppError(copy, resolveError)); + try { + const nextStatus = await window.ccr.cloudSyncGetStatus(); + setStatus(nextStatus); + setPendingConflict(nextStatus.pendingConflict); + setConflictDialogOpen(Boolean(nextStatus.pendingConflict)); + } catch { + // Keep the original resolution error visible. + } + } finally { + setBusy(""); + } + } + function toggleCloudSyncPanel(checked: boolean) { if (checked) { setSyncPanelOpen(true); @@ -771,14 +913,22 @@ function CloudSyncSettingsPage({ - {effectiveStatus.lastSyncError ? ( + {effectiveStatus.lastSyncError && !pendingConflict ? (
{effectiveStatus.lastSyncError}
) : null} - {mergeConflicts.length > 0 ? ( + {pendingConflict ? (
-
{t("Local values were kept for these conflict paths:")}
+
{t("Cloud sync is paused because conflicting changes require confirmation.")}
+
{pendingConflict.paths.join(", ")}
+ +
+ ) : mergeConflicts.length > 0 ? ( +
+
{t("Resolved conflict paths:")}
{mergeConflicts.join(", ")}
) : null} @@ -790,11 +940,11 @@ function CloudSyncSettingsPage({
- - @@ -803,6 +953,169 @@ function CloudSyncSettingsPage({ ) : null} )} + + + + {t("Resolve cloud sync conflict")} + + + +
+
+ +
+
{t("The same configuration fields changed locally and in the cloud.")}
+
{t("Review the left and right values, then confirm or edit the final JSON result in the middle. Independent changes from both sides will still be preserved.")}
+
+
+
+ +
+
+ {t("Each final result is JSON. Strings must remain quoted.")} +
+
+ + +
+
+ + {pendingConflict?.fields.map((field) => { + const draft = conflictDrafts.find((item) => item.path === field.path) ?? + cloudSyncConflictDraft(field, "local"); + const sourceLabel = draft.source === "local" + ? t("Local") + : draft.source === "remote" + ? t("Cloud") + : t("Manual"); + return ( +
+
{field.path}
+
+
+
+
{t("Local value")}
+ +
+
+                        {field.local.exists ? formatCloudSyncConflictJson(field.local) : t("Field does not exist")}
+                      
+
+ +
+
+
+
{t("Final result")}
+
+ {t("Source")}: {sourceLabel} +
+
+ +
+ {draft.exists ? ( +