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 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(
|
||||
|
||||
@@ -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,14 +304,16 @@ 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(
|
||||
singleton
|
||||
? eq(table.userId, userId)
|
||||
: and(
|
||||
eq((table as typeof hosts).syncId, syncId),
|
||||
eq(table.userId, userId),
|
||||
),
|
||||
@@ -337,11 +350,15 @@ router.post(
|
||||
} else {
|
||||
const insertedRows = await context.drizzle
|
||||
.insert(table as typeof hosts)
|
||||
.values({
|
||||
.values(
|
||||
(singleton
|
||||
? { ...encryptedPayload, userId }
|
||||
: {
|
||||
...encryptedPayload,
|
||||
userId,
|
||||
syncId,
|
||||
} as typeof hosts.$inferInsert)
|
||||
}) as typeof hosts.$inferInsert,
|
||||
)
|
||||
.returning();
|
||||
resultRow = insertedRows[0] as Record<string, unknown>;
|
||||
}
|
||||
@@ -453,13 +470,15 @@ 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(
|
||||
singleton
|
||||
? eq(table.userId, userId)
|
||||
: and(
|
||||
eq((table as typeof hosts).syncId, syncId),
|
||||
eq(table.userId, userId),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 { 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")}
|
||||
|
||||
@@ -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