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 { app, safeStorage } = require("electron");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { SYNCED_ENTITY_TYPES } = require("./remote-sync-entities.cjs");
const SYNCED_ENTITY_TYPES = [
"sshCredentials",
"vaultProfiles",
"sshFolders",
"snippetFolders",
"hosts",
"snippets",
"dashboardServiceLinks",
"homepageItems",
];
const SYNC_INTERVAL_MS = 90 * 1000; const SYNC_INTERVAL_MS = 90 * 1000;
const EMBEDDED_BASE_URL = "http://127.0.0.1:30001"; const EMBEDDED_BASE_URL = "http://127.0.0.1:30001";
@@ -12,7 +12,8 @@ export type SyncEntityType =
| "snippetFolders" | "snippetFolders"
| "vaultProfiles" | "vaultProfiles"
| "dashboardServiceLinks" | "dashboardServiceLinks"
| "homepageItems"; | "homepageItems"
| "userPreferences";
export class SyncTombstoneRepository { export class SyncTombstoneRepository {
constructor( constructor(
+31 -12
View File
@@ -10,6 +10,7 @@ import {
vaultProfiles, vaultProfiles,
dashboardServiceLinks, dashboardServiceLinks,
homepageItems, homepageItems,
userPreferences,
} from "../db/schema.js"; } from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js"; import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js"; import { DataCrypto } from "../../utils/data-crypto.js";
@@ -48,11 +49,13 @@ interface EntityConfig {
| typeof snippetFolders | typeof snippetFolders
| typeof vaultProfiles | typeof vaultProfiles
| typeof dashboardServiceLinks | typeof dashboardServiceLinks
| typeof homepageItems; | typeof homepageItems
| typeof userPreferences;
// Fields that only make sense on the device that created the row, or // 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 // that are managed elsewhere and must never be overwritten by a sync
// payload from the other side. // payload from the other side.
readOnlyFields: string[]; readOnlyFields: string[];
singleton?: boolean;
} }
const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = { const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
@@ -67,6 +70,11 @@ const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
vaultProfiles: { table: vaultProfiles, readOnlyFields: [] }, vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] }, dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
homepageItems: { table: homepageItems, readOnlyFields: [] }, homepageItems: { table: homepageItems, readOnlyFields: [] },
userPreferences: {
table: userPreferences,
readOnlyFields: ["storageMode"],
singleton: true,
},
}; };
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG)); const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
@@ -220,7 +228,7 @@ router.get(
: null; : null;
try { try {
const { table } = ENTITY_CONFIG[entityType]; const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext(); const context = createCurrentRepositoryContext();
const conditions = [eq(table.userId, userId)]; const conditions = [eq(table.userId, userId)];
if (since && "updatedAt" in table) { if (since && "updatedAt" in table) {
@@ -233,14 +241,17 @@ router.get(
.where(and(...conditions)); .where(and(...conditions));
const decrypted = await Promise.all( const decrypted = await Promise.all(
rows.map((row) => rows.map(async (row) => {
serializeSyncReferences( const result = await serializeSyncReferences(
entityType, entityType,
decryptIfNeeded(entityType, row as Record<string, unknown>, userId), decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
(referenceType, id) => (referenceType, id) =>
findReferenceSyncId(context, referenceType, id, userId), findReferenceSyncId(context, referenceType, id, userId),
), );
), return singleton
? { ...result, syncId: `${entityType}:singleton` }
: result;
}),
); );
res.json({ rows: decrypted }); res.json({ rows: decrypted });
@@ -293,14 +304,16 @@ router.post(
} }
try { try {
const { table } = ENTITY_CONFIG[entityType]; const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext(); const context = createCurrentRepositoryContext();
const existingRows = await context.drizzle const existingRows = await context.drizzle
.select() .select()
.from(table as typeof hosts) .from(table as typeof hosts)
.where( .where(
and( singleton
? eq(table.userId, userId)
: and(
eq((table as typeof hosts).syncId, syncId), eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId), eq(table.userId, userId),
), ),
@@ -337,11 +350,15 @@ router.post(
} else { } else {
const insertedRows = await context.drizzle const insertedRows = await context.drizzle
.insert(table as typeof hosts) .insert(table as typeof hosts)
.values({ .values(
(singleton
? { ...encryptedPayload, userId }
: {
...encryptedPayload, ...encryptedPayload,
userId, userId,
syncId, syncId,
} as typeof hosts.$inferInsert) }) as typeof hosts.$inferInsert,
)
.returning(); .returning();
resultRow = insertedRows[0] as Record<string, unknown>; resultRow = insertedRows[0] as Record<string, unknown>;
} }
@@ -453,13 +470,15 @@ router.post(
} }
try { try {
const { table } = ENTITY_CONFIG[entityType]; const { table, singleton } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext(); const context = createCurrentRepositoryContext();
await context.drizzle await context.drizzle
.delete(table as typeof hosts) .delete(table as typeof hosts)
.where( .where(
and( singleton
? eq(table.userId, userId)
: and(
eq((table as typeof hosts).syncId, syncId), eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId), eq(table.userId, userId),
), ),
@@ -15,6 +15,7 @@ describe("isValidEntityType", () => {
"vaultProfiles", "vaultProfiles",
"dashboardServiceLinks", "dashboardServiceLinks",
"homepageItems", "homepageItems",
"userPreferences",
]) { ]) {
expect(isValidEntityType(type)).toBe(true); expect(isValidEntityType(type)).toBe(true);
} }
@@ -52,6 +53,16 @@ describe("stripWritePayload", () => {
expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" }); 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", () => { it("does not mutate the original payload object", () => {
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" }; const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
stripWritePayload("snippets", payload); 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 type React from "react";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx"; import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx";
import { shouldForceLocalPreferenceStorage } from "@/settings/remote-sync-state";
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager"; import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
@@ -555,9 +556,9 @@ export function UserProfilePanel({
// opt-in feature configured from this same panel). "cloud" storage mode // opt-in feature configured from this same panel). "cloud" storage mode
// and Termix ID both assume a real multi-device server account, so they // and Termix ID both assume a real multi-device server account, so they
// stay hidden/forced-off until the user actually connects one. // stay hidden/forced-off until the user actually connects one.
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState( const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState<
() => !isElectron(), boolean | null
); >(() => (isElectron() ? null : true));
useEffect(() => { useEffect(() => {
if (!isElectron()) return; if (!isElectron()) return;
@@ -585,7 +586,13 @@ export function UserProfilePanel({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (isElectron() && !isRemoteSyncConnected && storageMode === "cloud") { if (
shouldForceLocalPreferenceStorage(
isElectron(),
isRemoteSyncConnected,
storageMode,
)
) {
setStorageMode("local"); setStorageMode("local");
onPrefsChange?.({ storageMode: "local" }); onPrefsChange?.({ storageMode: "local" });
} }
@@ -1342,7 +1349,7 @@ export function UserProfilePanel({
{/* Storage mode toggle — only meaningful once a remote server is {/* Storage mode toggle — only meaningful once a remote server is
connected; with no sync there's nowhere for "cloud" to sync to, connected; with no sync there's nowhere for "cloud" to sync to,
so this stays forced to local storage and hidden. */} 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"> <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"> <span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.storageModeSwitch")} {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,
);
});
});