diff --git a/electron/remote-sync-entities.cjs b/electron/remote-sync-entities.cjs new file mode 100644 index 00000000..a60ce8c4 --- /dev/null +++ b/electron/remote-sync-entities.cjs @@ -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 }; diff --git a/electron/remote-sync.cjs b/electron/remote-sync.cjs index 7c9f9198..57394f65 100644 --- a/electron/remote-sync.cjs +++ b/electron/remote-sync.cjs @@ -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"; diff --git a/src/backend/database/repositories/sync-tombstone-repository.ts b/src/backend/database/repositories/sync-tombstone-repository.ts index 1fe4953f..0e0535e9 100644 --- a/src/backend/database/repositories/sync-tombstone-repository.ts +++ b/src/backend/database/repositories/sync-tombstone-repository.ts @@ -12,7 +12,8 @@ export type SyncEntityType = | "snippetFolders" | "vaultProfiles" | "dashboardServiceLinks" - | "homepageItems"; + | "homepageItems" + | "userPreferences"; export class SyncTombstoneRepository { constructor( diff --git a/src/backend/database/routes/sync.ts b/src/backend/database/routes/sync.ts index cc9030a0..b12a5a79 100644 --- a/src/backend/database/routes/sync.ts +++ b/src/backend/database/routes/sync.ts @@ -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 = { @@ -67,6 +70,11 @@ const ENTITY_CONFIG: Record = { 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, 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 | 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; } @@ -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( diff --git a/src/backend/tests/database/routes/sync.test.ts b/src/backend/tests/database/routes/sync.test.ts index 55d27bb2..377a3b18 100644 --- a/src/backend/tests/database/routes/sync.test.ts +++ b/src/backend/tests/database/routes/sync.test.ts @@ -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); diff --git a/src/ui/settings/remote-sync-state.ts b/src/ui/settings/remote-sync-state.ts new file mode 100644 index 00000000..d73d29b1 --- /dev/null +++ b/src/ui/settings/remote-sync-state.ts @@ -0,0 +1,7 @@ +export function shouldForceLocalPreferenceStorage( + isDesktop: boolean, + remoteSyncConnected: boolean | null, + storageMode: "local" | "cloud", +): boolean { + return isDesktop && remoteSyncConnected === false && storageMode === "cloud"; +} diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx index 1cd8821e..f799c25c 100644 --- a/src/ui/sidebar/UserProfilePanel.tsx +++ b/src/ui/sidebar/UserProfilePanel.tsx @@ -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) && (
{t("newUi.sidebar.userProfile.storageModeSwitch")} diff --git a/src/ui/tests/electron/remote-sync.test.ts b/src/ui/tests/electron/remote-sync.test.ts new file mode 100644 index 00000000..c36ce105 --- /dev/null +++ b/src/ui/tests/electron/remote-sync.test.ts @@ -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"); + }); +}); diff --git a/src/ui/tests/settings/remote-sync-state.test.ts b/src/ui/tests/settings/remote-sync-state.test.ts new file mode 100644 index 00000000..9a80effa --- /dev/null +++ b/src/ui/tests/settings/remote-sync-state.test.ts @@ -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, + ); + }); +});