fix desktop preference synchronization (#1106)

This commit is contained in:
ZacharyZcR
2026-07-28 02:21:09 +08:00
committed by GitHub
parent a9c2aec165
commit 5c709d38d6
9 changed files with 118 additions and 38 deletions
+15
View File
@@ -0,0 +1,15 @@
const SYNCED_ENTITY_TYPES = Object.freeze([
// Ordered by reference dependency: hosts and snippets resolve credential,
// vault and folder syncIds, so those have to exist on the other side first.
"sshCredentials",
"vaultProfiles",
"sshFolders",
"snippetFolders",
"hosts",
"snippets",
"dashboardServiceLinks",
"homepageItems",
"userPreferences",
]);
module.exports = { SYNCED_ENTITY_TYPES };
+1 -11
View File
@@ -14,17 +14,7 @@
const { app, safeStorage } = require("electron");
const fs = require("fs");
const path = require("path");
const SYNCED_ENTITY_TYPES = [
"sshCredentials",
"vaultProfiles",
"sshFolders",
"snippetFolders",
"hosts",
"snippets",
"dashboardServiceLinks",
"homepageItems",
];
const { SYNCED_ENTITY_TYPES } = require("./remote-sync-entities.cjs");
const SYNC_INTERVAL_MS = 90 * 1000;
const EMBEDDED_BASE_URL = "http://127.0.0.1:30001";
@@ -12,7 +12,8 @@ export type SyncEntityType =
| "snippetFolders"
| "vaultProfiles"
| "dashboardServiceLinks"
| "homepageItems";
| "homepageItems"
| "userPreferences";
export class SyncTombstoneRepository {
constructor(
+40 -21
View File
@@ -10,6 +10,7 @@ import {
vaultProfiles,
dashboardServiceLinks,
homepageItems,
userPreferences,
} from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -48,11 +49,13 @@ interface EntityConfig {
| typeof snippetFolders
| typeof vaultProfiles
| typeof dashboardServiceLinks
| typeof homepageItems;
| typeof homepageItems
| typeof userPreferences;
// Fields that only make sense on the device that created the row, or
// that are managed elsewhere and must never be overwritten by a sync
// payload from the other side.
readOnlyFields: string[];
singleton?: boolean;
}
const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
@@ -67,6 +70,11 @@ const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
homepageItems: { table: homepageItems, readOnlyFields: [] },
userPreferences: {
table: userPreferences,
readOnlyFields: ["storageMode"],
singleton: true,
},
};
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
@@ -220,7 +228,7 @@ router.get(
: null;
try {
const { table } = ENTITY_CONFIG[entityType];
const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
const conditions = [eq(table.userId, userId)];
if (since && "updatedAt" in table) {
@@ -233,14 +241,17 @@ router.get(
.where(and(...conditions));
const decrypted = await Promise.all(
rows.map((row) =>
serializeSyncReferences(
rows.map(async (row) => {
const result = await serializeSyncReferences(
entityType,
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
(referenceType, id) =>
findReferenceSyncId(context, referenceType, id, userId),
),
),
);
return singleton
? { ...result, syncId: `${entityType}:singleton` }
: result;
}),
);
res.json({ rows: decrypted });
@@ -293,17 +304,19 @@ router.post(
}
try {
const { table } = ENTITY_CONFIG[entityType];
const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
const existingRows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(
and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
singleton
? eq(table.userId, userId)
: and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
)
.limit(1);
const existing = existingRows[0] as Record<string, unknown> | undefined;
@@ -337,11 +350,15 @@ router.post(
} else {
const insertedRows = await context.drizzle
.insert(table as typeof hosts)
.values({
...encryptedPayload,
userId,
syncId,
} as typeof hosts.$inferInsert)
.values(
(singleton
? { ...encryptedPayload, userId }
: {
...encryptedPayload,
userId,
syncId,
}) as typeof hosts.$inferInsert,
)
.returning();
resultRow = insertedRows[0] as Record<string, unknown>;
}
@@ -453,16 +470,18 @@ router.post(
}
try {
const { table } = ENTITY_CONFIG[entityType];
const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
await context.drizzle
.delete(table as typeof hosts)
.where(
and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
singleton
? eq(table.userId, userId)
: and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
);
await createCurrentSyncTombstoneRepository().record(
@@ -15,6 +15,7 @@ describe("isValidEntityType", () => {
"vaultProfiles",
"dashboardServiceLinks",
"homepageItems",
"userPreferences",
]) {
expect(isValidEntityType(type)).toBe(true);
}
@@ -52,6 +53,16 @@ describe("stripWritePayload", () => {
expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" });
});
it("keeps preference storage mode local to each device", () => {
expect(
stripWritePayload("userPreferences", {
syncId: "userPreferences:singleton",
theme: "dark",
storageMode: "cloud",
}),
).toEqual({ theme: "dark" });
});
it("does not mutate the original payload object", () => {
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
stripWritePayload("snippets", payload);
+7
View File
@@ -0,0 +1,7 @@
export function shouldForceLocalPreferenceStorage(
isDesktop: boolean,
remoteSyncConnected: boolean | null,
storageMode: "local" | "cloud",
): boolean {
return isDesktop && remoteSyncConnected === false && storageMode === "cloud";
}
+12 -5
View File
@@ -30,6 +30,7 @@ import type { UserRole } from "@/main-axios";
import type React from "react";
import { isElectron } from "@/lib/electron";
import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx";
import { shouldForceLocalPreferenceStorage } from "@/settings/remote-sync-state";
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
@@ -555,9 +556,9 @@ export function UserProfilePanel({
// opt-in feature configured from this same panel). "cloud" storage mode
// and Termix ID both assume a real multi-device server account, so they
// stay hidden/forced-off until the user actually connects one.
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState(
() => !isElectron(),
);
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState<
boolean | null
>(() => (isElectron() ? null : true));
useEffect(() => {
if (!isElectron()) return;
@@ -585,7 +586,13 @@ export function UserProfilePanel({
}, []);
useEffect(() => {
if (isElectron() && !isRemoteSyncConnected && storageMode === "cloud") {
if (
shouldForceLocalPreferenceStorage(
isElectron(),
isRemoteSyncConnected,
storageMode,
)
) {
setStorageMode("local");
onPrefsChange?.({ storageMode: "local" });
}
@@ -1342,7 +1349,7 @@ export function UserProfilePanel({
{/* Storage mode toggle — only meaningful once a remote server is
connected; with no sync there's nowhere for "cloud" to sync to,
so this stays forced to local storage and hidden. */}
{(!isElectron() || isRemoteSyncConnected) && (
{(!isElectron() || isRemoteSyncConnected === true) && (
<div className="border border-border bg-card px-3 py-2.5 flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.storageModeSwitch")}
+14
View File
@@ -0,0 +1,14 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { SYNCED_ENTITY_TYPES } =
require("../../../../electron/remote-sync-entities.cjs") as {
SYNCED_ENTITY_TYPES: readonly string[];
};
describe("desktop remote sync entities", () => {
it("includes user preferences", () => {
expect(SYNCED_ENTITY_TYPES).toContain("userPreferences");
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { shouldForceLocalPreferenceStorage } from "../../settings/remote-sync-state";
describe("remote sync preference storage state", () => {
it("does not overwrite cloud mode while desktop sync config is loading", () => {
expect(shouldForceLocalPreferenceStorage(true, null, "cloud")).toBe(false);
});
it("forces local mode only after desktop sync is confirmed unconfigured", () => {
expect(shouldForceLocalPreferenceStorage(true, false, "cloud")).toBe(true);
expect(shouldForceLocalPreferenceStorage(true, true, "cloud")).toBe(false);
expect(shouldForceLocalPreferenceStorage(false, false, "cloud")).toBe(
false,
);
});
});