mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
fix desktop preference synchronization (#1106)
This commit is contained in:
@@ -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 };
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,17 +304,19 @@ 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 as typeof hosts).syncId, syncId),
|
? eq(table.userId, userId)
|
||||||
eq(table.userId, userId),
|
: and(
|
||||||
),
|
eq((table as typeof hosts).syncId, syncId),
|
||||||
|
eq(table.userId, userId),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const existing = existingRows[0] as Record<string, unknown> | undefined;
|
const existing = existingRows[0] as Record<string, unknown> | undefined;
|
||||||
@@ -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(
|
||||||
...encryptedPayload,
|
(singleton
|
||||||
userId,
|
? { ...encryptedPayload, userId }
|
||||||
syncId,
|
: {
|
||||||
} as typeof hosts.$inferInsert)
|
...encryptedPayload,
|
||||||
|
userId,
|
||||||
|
syncId,
|
||||||
|
}) as typeof hosts.$inferInsert,
|
||||||
|
)
|
||||||
.returning();
|
.returning();
|
||||||
resultRow = insertedRows[0] as Record<string, unknown>;
|
resultRow = insertedRows[0] as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -453,16 +470,18 @@ 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 as typeof hosts).syncId, syncId),
|
? eq(table.userId, userId)
|
||||||
eq(table.userId, userId),
|
: and(
|
||||||
),
|
eq((table as typeof hosts).syncId, syncId),
|
||||||
|
eq(table.userId, userId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await createCurrentSyncTombstoneRepository().record(
|
await createCurrentSyncTombstoneRepository().record(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export function shouldForceLocalPreferenceStorage(
|
||||||
|
isDesktop: boolean,
|
||||||
|
remoteSyncConnected: boolean | null,
|
||||||
|
storageMode: "local" | "cloud",
|
||||||
|
): boolean {
|
||||||
|
return isDesktop && remoteSyncConnected === false && storageMode === "cloud";
|
||||||
|
}
|
||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user