mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-10-30 19:31:53 +08:00
Update project implementation
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, RpcHandler> = {
|
||||
},
|
||||
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 [
|
||||
"<!doctype html>",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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 || "<unknown>"}`);
|
||||
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 {
|
||||
|
||||
@@ -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<string, string>();
|
||||
const gatewayPluginPermissionIdSet = new Set<string>(GATEWAY_PLUGIN_PERMISSION_IDS);
|
||||
const gatewayPluginSurfaceIdSet = new Set<string>(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);
|
||||
|
||||
@@ -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<CloudSyncLoginResult>,
|
||||
cloudSyncPull: (request?: CloudSyncPullRequest) => invoke(IPC_CHANNELS.appCloudSyncPull, request) as Promise<CloudSyncOperationResult>,
|
||||
cloudSyncPush: (request?: CloudSyncPushRequest) => invoke(IPC_CHANNELS.appCloudSyncPush, request) as Promise<CloudSyncOperationResult>,
|
||||
cloudSyncResolveConflict: (request: CloudSyncResolveConflictRequest) => invoke(IPC_CHANNELS.appCloudSyncResolveConflict, request) as Promise<CloudSyncOperationResult>,
|
||||
cloudSyncSetup: (request: CloudSyncSetupRequest) => invoke(IPC_CHANNELS.appCloudSyncSetup, request) as Promise<CloudSyncOperationResult>,
|
||||
closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => invoke(IPC_CHANNELS.appBotGatewayQrWindowClose, request) as Promise<BotGatewayQrWindowCloseResult>,
|
||||
clearProxyNetworkCaptures: () => invoke(IPC_CHANNELS.appClearProxyNetworkCaptures) as Promise<ProxyNetworkSnapshot>,
|
||||
|
||||
@@ -361,6 +361,36 @@ function AppearanceSettingsPage({
|
||||
);
|
||||
}
|
||||
|
||||
type CloudSyncConflictDescriptor = NonNullable<CloudSyncStatus["pendingConflict"]>;
|
||||
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<CloudSyncStatus>();
|
||||
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<string[]>([]);
|
||||
const [pendingConflict, setPendingConflict] = useState<CloudSyncStatus["pendingConflict"]>();
|
||||
const [conflictDialogOpen, setConflictDialogOpen] = useState(false);
|
||||
const [conflictDraftError, setConflictDraftError] = useState("");
|
||||
const [conflictDrafts, setConflictDrafts] = useState<CloudSyncConflictDraft[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [failedAvatarUrl, setFailedAvatarUrl] = useState("");
|
||||
const keyFileInputRef = useRef<HTMLInputElement>(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<Pick<CloudSyncConflictDraft, "exists" | "source" | "text">>
|
||||
) {
|
||||
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({
|
||||
<CloudSyncStatusRow label={t("Device ID")} value={effectiveStatus.deviceId || t("Not registered")} />
|
||||
<CloudSyncStatusRow label={t("Last sync")} value={effectiveStatus.lastSyncAt || t("Never")} />
|
||||
</div>
|
||||
{effectiveStatus.lastSyncError ? (
|
||||
{effectiveStatus.lastSyncError && !pendingConflict ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
|
||||
{effectiveStatus.lastSyncError}
|
||||
</div>
|
||||
) : null}
|
||||
{mergeConflicts.length > 0 ? (
|
||||
{pendingConflict ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-[11px] leading-5 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<div className="font-medium">{t("Local values were kept for these conflict paths:")}</div>
|
||||
<div className="font-medium">{t("Cloud sync is paused because conflicting changes require confirmation.")}</div>
|
||||
<div className="mt-1 break-all font-mono">{pendingConflict.paths.join(", ")}</div>
|
||||
<Button className="mt-2" disabled={Boolean(busy)} onClick={() => setConflictDialogOpen(true)} size="sm" type="button" variant="outline">
|
||||
{t("Review conflict")}
|
||||
</Button>
|
||||
</div>
|
||||
) : mergeConflicts.length > 0 ? (
|
||||
<div className="rounded-md border border-border bg-background px-3 py-2 text-[11px] leading-5 text-muted-foreground">
|
||||
<div className="font-medium text-foreground">{t("Resolved conflict paths:")}</div>
|
||||
<div className="mt-1 break-all font-mono">{mergeConflicts.join(", ")}</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -790,11 +940,11 @@ function CloudSyncSettingsPage({
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button disabled={Boolean(busy) || !effectiveStatus.enabled} onClick={() => void runCloudAction("push")} size="sm" type="button" variant="outline">
|
||||
<Button disabled={Boolean(busy) || !effectiveStatus.enabled || Boolean(pendingConflict)} onClick={() => void runCloudAction("push")} size="sm" type="button" variant="outline">
|
||||
{busy === "push" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
|
||||
{t("Push")}
|
||||
</Button>
|
||||
<Button disabled={Boolean(busy) || !effectiveStatus.enabled} onClick={() => void runCloudAction("pull")} size="sm" type="button" variant="outline">
|
||||
<Button disabled={Boolean(busy) || !effectiveStatus.enabled || Boolean(pendingConflict)} onClick={() => void runCloudAction("pull")} size="sm" type="button" variant="outline">
|
||||
{busy === "pull" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : <ArrowDown className="h-4 w-4" />}
|
||||
{t("Pull")}
|
||||
</Button>
|
||||
@@ -803,6 +953,169 @@ function CloudSyncSettingsPage({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<Dialog onOpenChange={setConflictDialogOpen} open={Boolean(pendingConflict) && conflictDialogOpen}>
|
||||
<DialogContent className="h-[min(780px,calc(100dvh-2rem))] max-w-[1180px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("Resolve cloud sync conflict")}</DialogTitle>
|
||||
<Button
|
||||
aria-label={copy.settings.close}
|
||||
disabled={busy === "resolve"}
|
||||
onClick={() => setConflictDialogOpen(false)}
|
||||
size="iconSm"
|
||||
title={copy.settings.close}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
<DialogBody className="grid min-h-0 grid-cols-1 content-start gap-4 overflow-auto">
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2.5 text-[12px] leading-5 text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-semibold">{t("The same configuration fields changed locally and in the cloud.")}</div>
|
||||
<div>{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.")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("Each final result is JSON. Strings must remain quoted.")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={busy === "resolve"}
|
||||
onClick={() => chooseAllCloudConflictSides("local")}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("Use all local values")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy === "resolve"}
|
||||
onClick={() => chooseAllCloudConflictSides("remote")}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("Use all cloud values")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<section className="grid gap-3 rounded-lg border border-border bg-muted/10 p-3" key={field.path}>
|
||||
<div className="break-all font-mono text-[12px] font-semibold text-foreground">{field.path}</div>
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
<div className="grid min-w-0 content-start gap-2 rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-950 dark:bg-red-950/20">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] font-semibold text-red-800 dark:text-red-200">{t("Local value")}</div>
|
||||
<Button
|
||||
aria-label={`${t("Use local value")}: ${field.path}`}
|
||||
disabled={busy === "resolve"}
|
||||
onClick={() => chooseCloudConflictSide(field.path, "local")}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("Use local")}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-64 min-h-32 overflow-auto whitespace-pre-wrap break-all rounded border border-red-200/80 bg-background/80 p-2 font-mono text-[11px] leading-5 text-foreground dark:border-red-950">
|
||||
{field.local.exists ? formatCloudSyncConflictJson(field.local) : t("Field does not exist")}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 content-start gap-2 rounded-md border-2 border-primary/40 bg-primary/[0.04] p-2.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-foreground">{t("Final result")}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t("Source")}: {sourceLabel}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={busy === "resolve"}
|
||||
onClick={() => updateCloudConflictDraft(field.path, draft.exists
|
||||
? { exists: false, source: "manual" }
|
||||
: { exists: true, source: "manual", text: draft.text || "null" })}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{draft.exists ? t("Remove field") : t("Keep field")}
|
||||
</Button>
|
||||
</div>
|
||||
{draft.exists ? (
|
||||
<Textarea
|
||||
aria-label={`${t("Final result")}: ${field.path}`}
|
||||
className="min-h-32 resize-y bg-background font-mono text-[11px] leading-5"
|
||||
disabled={busy === "resolve"}
|
||||
onChange={(event) => updateCloudConflictDraft(field.path, {
|
||||
source: "manual",
|
||||
text: event.target.value
|
||||
})}
|
||||
spellCheck={false}
|
||||
value={draft.text}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-32 items-center justify-center rounded border border-dashed border-border bg-background/70 p-3 text-[11px] text-muted-foreground">
|
||||
{t("This field will be removed from the merged configuration.")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 content-start gap-2 rounded-md border border-emerald-200 bg-emerald-50/60 p-2.5 dark:border-emerald-950 dark:bg-emerald-950/20">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[11px] font-semibold text-emerald-800 dark:text-emerald-200">{t("Cloud value")}</div>
|
||||
<Button
|
||||
aria-label={`${t("Use cloud value")}: ${field.path}`}
|
||||
disabled={busy === "resolve"}
|
||||
onClick={() => chooseCloudConflictSide(field.path, "remote")}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("Use cloud")}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-64 min-h-32 overflow-auto whitespace-pre-wrap break-all rounded border border-emerald-200/80 bg-background/80 p-2 font-mono text-[11px] leading-5 text-foreground dark:border-emerald-950">
|
||||
{field.remote.exists ? formatCloudSyncConflictJson(field.remote) : t("Field does not exist")}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{conflictDraftError || error ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
|
||||
{conflictDraftError || error}
|
||||
</div>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button disabled={busy === "resolve"} onClick={() => setConflictDialogOpen(false)} type="button" variant="outline">
|
||||
{t("Decide later")}
|
||||
</Button>
|
||||
<Button disabled={busy === "resolve"} onClick={() => void resolveCloudConflict()} type="button">
|
||||
{busy === "resolve" ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
|
||||
{t("Apply resolution")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,6 +292,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Cloud encrypted snapshot was decrypted successfully.": "Cloud encrypted snapshot was decrypted successfully.",
|
||||
"Cloud encrypted snapshot was pulled and applied.": "Cloud encrypted snapshot was pulled and applied.",
|
||||
"Cloud sync has no remote snapshot yet.": "Cloud sync has no remote snapshot yet.",
|
||||
"Cloud sync found conflicting changes that require your confirmation.": "Cloud sync found conflicting changes that require your confirmation.",
|
||||
"Cloud sync conflict expired or is no longer available. Sync again to refresh it.": "Cloud sync conflict expired or is no longer available. Sync again to refresh it.",
|
||||
"Cloud sync is disabled on this device.": "Cloud sync is disabled on this device.",
|
||||
"Cloud sync is enabled and the local encrypted snapshot was uploaded.": "Cloud sync is enabled and the local encrypted snapshot was uploaded.",
|
||||
"Cloud sync is enabled and the remote encrypted snapshot was restored on this device.": "Cloud sync is enabled and the remote encrypted snapshot was restored on this device.",
|
||||
@@ -299,6 +301,41 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Cloud sync key file generation is only available in the desktop app.": "Cloud sync key file generation is only available in the desktop app.",
|
||||
"Cloud sync login is only available in the desktop app.": "Cloud sync login is only available in the desktop app.",
|
||||
"Cloud sync login expired. Sign in again.": "Cloud sync login expired. Sign in again.",
|
||||
"Cloud conflict was resolved using cloud values.": "Cloud conflict was resolved using cloud values.",
|
||||
"Cloud conflict was resolved using cloud values and independent local changes were pushed.": "Cloud conflict was resolved using cloud values and independent local changes were pushed.",
|
||||
"Cloud conflict was resolved using local values and the merged snapshot was pushed.": "Cloud conflict was resolved using local values and the merged snapshot was pushed.",
|
||||
"Cloud conflict was resolved using the reviewed values.": "Cloud conflict was resolved using the reviewed values.",
|
||||
"Cloud conflict was resolved using the reviewed values and the merged snapshot was pushed.": "Cloud conflict was resolved using the reviewed values and the merged snapshot was pushed.",
|
||||
"Local configuration changed after the conflict was detected. Sync again before choosing a version.": "Local configuration changed after the conflict was detected. Sync again before choosing a version.",
|
||||
"Cloud sync is paused because conflicting changes require confirmation.": "Cloud sync is paused because conflicting changes require confirmation.",
|
||||
"Choose which side should win for the listed fields. Independent changes from both sides will still be preserved.": "Choose which side should win for the listed fields. Independent changes from both sides will still be preserved.",
|
||||
"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.": "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.",
|
||||
"Each final result is JSON. Strings must remain quoted.": "Each final result is JSON. Strings must remain quoted.",
|
||||
"Conflicting paths": "Conflicting paths",
|
||||
"Decide later": "Decide later",
|
||||
"Resolve cloud sync conflict": "Resolve cloud sync conflict",
|
||||
"Resolved conflict paths:": "Resolved conflict paths:",
|
||||
"Review conflict": "Review conflict",
|
||||
"The same configuration fields changed locally and in the cloud.": "The same configuration fields changed locally and in the cloud.",
|
||||
"Use all cloud values": "Use all cloud values",
|
||||
"Use all local values": "Use all local values",
|
||||
"Use cloud values": "Use cloud values",
|
||||
"Use local values": "Use local values",
|
||||
"Local value": "Local value",
|
||||
"Cloud value": "Cloud value",
|
||||
"Use local value": "Use local value",
|
||||
"Use cloud value": "Use cloud value",
|
||||
"Use local": "Use local",
|
||||
"Use cloud": "Use cloud",
|
||||
"Final result": "Final result",
|
||||
"Cloud": "Cloud",
|
||||
"Remove field": "Remove field",
|
||||
"Keep field": "Keep field",
|
||||
"Field does not exist": "Field does not exist",
|
||||
"This field will be removed from the merged configuration.": "This field will be removed from the merged configuration.",
|
||||
"Resolve every conflicting field before applying.": "Resolve every conflicting field before applying.",
|
||||
"The final result for {path} must be valid JSON.": "The final result for {path} must be valid JSON.",
|
||||
"Apply resolution": "Apply resolution",
|
||||
"Cloud configuration restored. Review it before entering CCR.": "Cloud configuration restored. Review it before entering CCR.",
|
||||
"Cloud login completed. Choose the encryption method used by your cloud snapshot.": "Cloud login completed. Choose the encryption method used by your cloud snapshot.",
|
||||
"Cloud login completed. Choose an encryption method to enable sync.": "Cloud login completed. Choose an encryption method to enable sync.",
|
||||
@@ -1222,6 +1259,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Cloud encrypted snapshot was decrypted successfully.": "云端加密快照已成功解密。",
|
||||
"Cloud encrypted snapshot was pulled and applied.": "云端加密快照已拉取并应用。",
|
||||
"Cloud sync has no remote snapshot yet.": "云端还没有可同步的快照。",
|
||||
"Cloud sync found conflicting changes that require your confirmation.": "检测到风险冲突,需要你确认采用本地值还是云端值。",
|
||||
"Cloud sync conflict expired or is no longer available. Sync again to refresh it.": "云同步冲突已过期或不可用,请重新同步以刷新冲突状态。",
|
||||
"Cloud sync is disabled on this device.": "这台设备上的云同步已禁用。",
|
||||
"Cloud sync is enabled and the local encrypted snapshot was uploaded.": "云同步已启用,本地加密快照已上传。",
|
||||
"Cloud sync is enabled and the remote encrypted snapshot was restored on this device.": "云同步已启用,云端加密快照已恢复到这台设备。",
|
||||
@@ -1229,6 +1268,41 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Cloud sync key file generation is only available in the desktop app.": "云同步 key 文件生成仅在桌面应用中可用。",
|
||||
"Cloud sync login is only available in the desktop app.": "云同步登录仅在桌面应用中可用。",
|
||||
"Cloud sync login expired. Sign in again.": "云同步登录已过期,请重新登录。",
|
||||
"Cloud conflict was resolved using cloud values.": "已使用云端值解决冲突。",
|
||||
"Cloud conflict was resolved using cloud values and independent local changes were pushed.": "已使用云端值解决冲突,并上传了本地的独立修改。",
|
||||
"Cloud conflict was resolved using local values and the merged snapshot was pushed.": "已使用本地值解决冲突并上传合并结果。",
|
||||
"Cloud conflict was resolved using the reviewed values.": "已使用确认后的最终结果解决冲突。",
|
||||
"Cloud conflict was resolved using the reviewed values and the merged snapshot was pushed.": "已使用确认后的最终结果解决冲突并上传合并快照。",
|
||||
"Local configuration changed after the conflict was detected. Sync again before choosing a version.": "检测到冲突后本地配置又发生了变化,请重新同步后再选择版本。",
|
||||
"Cloud sync is paused because conflicting changes require confirmation.": "云同步已暂停,因为冲突变更需要你确认。",
|
||||
"Choose which side should win for the listed fields. Independent changes from both sides will still be preserved.": "请选择冲突字段采用哪一侧的值;双方互不冲突的修改仍会保留。",
|
||||
"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.": "请对比左侧本地值与右侧云端值,然后确认或编辑中间的最终 JSON 结果;双方互不冲突的修改仍会保留。",
|
||||
"Each final result is JSON. Strings must remain quoted.": "每个最终结果都是 JSON,字符串需要保留引号。",
|
||||
"Conflicting paths": "冲突字段",
|
||||
"Decide later": "稍后决定",
|
||||
"Resolve cloud sync conflict": "解决云同步冲突",
|
||||
"Resolved conflict paths:": "已解决的冲突字段:",
|
||||
"Review conflict": "查看冲突",
|
||||
"The same configuration fields changed locally and in the cloud.": "相同的配置字段在本地和云端都发生了修改。",
|
||||
"Use all cloud values": "全部使用云端值",
|
||||
"Use all local values": "全部使用本地值",
|
||||
"Use cloud values": "使用云端值",
|
||||
"Use local values": "使用本地值",
|
||||
"Local value": "本地值",
|
||||
"Cloud value": "云端值",
|
||||
"Use local value": "使用本地值",
|
||||
"Use cloud value": "使用云端值",
|
||||
"Use local": "使用本地",
|
||||
"Use cloud": "使用云端",
|
||||
"Final result": "最终结果",
|
||||
"Cloud": "云端",
|
||||
"Remove field": "删除字段",
|
||||
"Keep field": "保留字段",
|
||||
"Field does not exist": "字段不存在",
|
||||
"This field will be removed from the merged configuration.": "该字段将从合并后的配置中删除。",
|
||||
"Resolve every conflicting field before applying.": "应用前请处理所有冲突字段。",
|
||||
"The final result for {path} must be valid JSON.": "{path} 的最终结果必须是有效 JSON。",
|
||||
"Apply resolution": "应用合并结果",
|
||||
"Cloud configuration restored. Review it before entering CCR.": "云端配置已恢复。进入 CCR 前请确认配置。",
|
||||
"Cloud login completed. Choose the encryption method used by your cloud snapshot.": "云端登录已完成。请选择云端快照使用的加密方式。",
|
||||
"Cloud login completed. Choose an encryption method to enable sync.": "云端登录已完成。请选择加密方式以启用同步。",
|
||||
|
||||
Vendored
+2
@@ -34,6 +34,7 @@ import type {
|
||||
CloudSyncLoginResult,
|
||||
CloudSyncPullRequest,
|
||||
CloudSyncPushRequest,
|
||||
CloudSyncResolveConflictRequest,
|
||||
CloudSyncSetupRequest,
|
||||
CloudSyncStatus,
|
||||
GatewayMcpToolInfo,
|
||||
@@ -102,6 +103,7 @@ declare global {
|
||||
cloudSyncLogin: () => Promise<CloudSyncLoginResult>;
|
||||
cloudSyncPull: (request?: CloudSyncPullRequest) => Promise<CloudSyncOperationResult>;
|
||||
cloudSyncPush: (request?: CloudSyncPushRequest) => Promise<CloudSyncOperationResult>;
|
||||
cloudSyncResolveConflict: (request: CloudSyncResolveConflictRequest) => Promise<CloudSyncOperationResult>;
|
||||
cloudSyncSetup: (request: CloudSyncSetupRequest) => Promise<CloudSyncOperationResult>;
|
||||
closeBotGatewayQrWindow: (request: BotGatewayQrWindowCloseRequest) => Promise<BotGatewayQrWindowCloseResult>;
|
||||
clearProxyNetworkCaptures: () => Promise<ProxyNetworkSnapshot>;
|
||||
|
||||
@@ -119,6 +119,7 @@ const webClientBridge: CcrApi = {
|
||||
},
|
||||
cloudSyncPull: (request) => rpc("cloudSyncPull", [request]) as ReturnType<CcrApi["cloudSyncPull"]>,
|
||||
cloudSyncPush: (request) => rpc("cloudSyncPush", [request]) as ReturnType<CcrApi["cloudSyncPush"]>,
|
||||
cloudSyncResolveConflict: (request) => rpc("cloudSyncResolveConflict", [request]) as ReturnType<CcrApi["cloudSyncResolveConflict"]>,
|
||||
cloudSyncSetup: (request) => rpc("cloudSyncSetup", [request]) as ReturnType<CcrApi["cloudSyncSetup"]>,
|
||||
clearProxyNetworkCaptures: () => rpc("clearProxyNetworkCaptures") as ReturnType<CcrApi["clearProxyNetworkCaptures"]>,
|
||||
closeBotGatewayQrWindow: (request) => rpc("closeBotGatewayQrWindow", [request]) as ReturnType<CcrApi["closeBotGatewayQrWindow"]>,
|
||||
|
||||
Reference in New Issue
Block a user