fix(settings): cover theme mode and desktop prefs in transfer

This commit is contained in:
caiyq925-cell authored and GitHub committed 2026-10-23 02:46:36 +08:00
1 parent 433cf5e766
commit 9a4d671a6a
4 files changed
+329 -26

No files matched your search

@@ -359,7 +359,7 @@ import {
type EditorSettingsDraftKey,
} from "@/lib/settings/editorSettingsDraft";
import { applyEditorSettingsDraftToRefs, type EditorSettingsDraftRefMap } from "@/lib/settings/applyEditorSettingsDraft";
import { serializeSettingsTransfer, sortTransferCategories, transferCategoryForKey, type SettingsTransferCategoryId } from "@/lib/settings/settingsTransfer";
import { pickTransferableDesktopSettings, serializeSettingsTransfer, sortTransferCategories, transferCategoryForKey, type SettingsTransferCategoryId, type SettingsTransferExtras, type TransferableDesktopSettings, type TransferableDesktopSettingsKey } from "@/lib/settings/settingsTransfer";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
@@ -3939,9 +3939,17 @@ async function copyAppSupportInfo() {
// --- Settings import / export (About page card) ---
function buildSettingsExportPayload(): string {
return serializeSettingsTransfer(settingsStore.editorSettings, {
appVersion: props.appVersion || appSupportInfo.value?.appVersion || undefined,
});
return serializeSettingsTransfer(
settingsStore.editorSettings,
{ appVersion: props.appVersion || appSupportInfo.value?.appVersion || undefined },
{
themeMode: themeMode.value,
// On web the desktop store only holds defaults (the switches are hidden),
// so exporting them would let a web-made file overwrite a desktop user's
// real tray/close preferences on import.
desktopSettings: isWeb ? undefined : pickTransferableDesktopSettings(settingsStore.desktopSettings),
},
);
}
// "Apply and export": reuse the regular apply flow so validation, persistence
@@ -3972,18 +3980,57 @@ function applyImportedEditorSettings(imported: Partial<EditorSettings>) {
hasImportedSettingsPendingApply.value = true;
}
// Imported desktop preferences load into the same draft refs the settings
// switches use, so the footer Apply (and its unapplied-changes protection)
// covers them exactly like a manual toggle. The desktop section is skipped on
// web: those switches have no meaning there.
function applyImportedDesktopSettings(desktop: Partial<TransferableDesktopSettings>) {
if (isWeb) return;
if (desktop.show_tray_icon !== undefined) editShowTrayIcon.value = desktop.show_tray_icon;
if (desktop.quit_on_close !== undefined) editQuitOnClose.value = desktop.quit_on_close;
if (desktop.icon_theme !== undefined) editIconTheme.value = desktop.icon_theme;
if (desktop.debug_logging_enabled !== undefined) editDebugLoggingEnabled.value = desktop.debug_logging_enabled;
if (desktop.metadata_cache_max_memory_mb !== undefined) editMetadataCacheMaxMemoryMb.value = desktop.metadata_cache_max_memory_mb;
if (desktop.duckdb_worker_process_isolation !== undefined) editDuckDbWorkerProcessIsolation.value = desktop.duckdb_worker_process_isolation;
if (desktop.duckdb_worker_max_processes !== undefined) editDuckDbWorkerMaxProcesses.value = desktop.duckdb_worker_max_processes;
if (desktop.sidebar_table_page_size !== undefined) editSidebarTablePageSize.value = desktop.sidebar_table_page_size;
}
// The app-wide theme mode has no draft state: it applies immediately through
// the same live path the theme buttons in the appearance tab use.
function applyImportedExtras(extras: SettingsTransferExtras) {
if (extras.themeMode) setThemeMode(extras.themeMode);
const desktopKeys = Object.keys(extras.desktopSettings ?? {}) as TransferableDesktopSettingsKey[];
if (desktopKeys.length === 0) return;
applyImportedDesktopSettings(extras.desktopSettings ?? {});
hasImportedSettingsPendingApply.value = true;
}
// Categories whose draft values differ from the saved base AND are covered by
// the imported file — these draft changes will be replaced by the import.
function getDraftConflictCategories(imported: Partial<EditorSettings>): SettingsTransferCategoryId[] {
function getDraftConflictCategories(imported: Partial<EditorSettings>, extras?: SettingsTransferExtras): SettingsTransferCategoryId[] {
const changedKeys = new Set(Object.keys(editorSettingsPatchFromDraft(currentEditorSettingsDraft(), editEditorSettingsBase.value)));
const conflicts = new Set<SettingsTransferCategoryId>();
for (const key of Object.keys(imported)) {
const category = transferCategoryForKey(key);
if (category && changedKeys.has(key)) conflicts.add(category);
}
if (extras?.desktopSettings && !isWeb && importedDesktopKeysTouchDraft(extras.desktopSettings)) conflicts.add("other");
return sortTransferCategories(conflicts);
}
function importedDesktopKeysTouchDraft(desktop: Partial<TransferableDesktopSettings>): boolean {
if (desktop.show_tray_icon !== undefined && editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon) return true;
if (desktop.quit_on_close !== undefined && editQuitOnClose.value !== settingsStore.desktopSettings.quit_on_close) return true;
if (desktop.icon_theme !== undefined && editIconTheme.value !== settingsStore.desktopSettings.icon_theme) return true;
if (desktop.debug_logging_enabled !== undefined && editDebugLoggingEnabled.value !== settingsStore.desktopSettings.debug_logging_enabled) return true;
if (desktop.metadata_cache_max_memory_mb !== undefined && editMetadataCacheMaxMemoryMb.value !== settingsStore.desktopSettings.metadata_cache_max_memory_mb) return true;
if (desktop.duckdb_worker_process_isolation !== undefined && editDuckDbWorkerProcessIsolation.value !== settingsStore.desktopSettings.duckdb_worker_process_isolation) return true;
if (desktop.duckdb_worker_max_processes !== undefined && editDuckDbWorkerMaxProcesses.value !== settingsStore.desktopSettings.duckdb_worker_max_processes) return true;
if (desktop.sidebar_table_page_size !== undefined && editSidebarTablePageSize.value !== (settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE)) return true;
return false;
}
function clearDebugLogs() {
clearStoredDebugLogs();
debugLogCopied.value = false;
@@ -13178,6 +13225,7 @@ LIMIT 100;</pre>
:build-saved-export-payload="buildSettingsExportPayload"
:build-applied-export-payload="buildAppliedExportPayload"
:apply-imported-settings="applyImportedEditorSettings"
:apply-imported-extras="applyImportedExtras"
:get-draft-conflict-categories="getDraftConflictCategories"
/>
@@ -17,6 +17,7 @@ import {
SETTINGS_TRANSFER_FORMAT_VERSION,
type ParsedSettingsTransfer,
type SettingsTransferCategoryId,
type SettingsTransferExtras,
type SettingsTransferParseError,
} from "@/lib/settings/settingsTransfer";
@@ -27,7 +28,8 @@ const props = defineProps<{
buildSavedExportPayload: () => string;
buildAppliedExportPayload: () => Promise<string | null>;
applyImportedSettings: (imported: Partial<EditorSettings>) => void;
getDraftConflictCategories: (imported: Partial<EditorSettings>) => SettingsTransferCategoryId[];
applyImportedExtras: (extras: SettingsTransferExtras) => void;
getDraftConflictCategories: (imported: Partial<EditorSettings>, extras?: SettingsTransferExtras) => SettingsTransferCategoryId[];
}>();
const { t } = useI18n();
@@ -195,14 +197,19 @@ async function handleFileInputChange(event: Event) {
}
function handleImportedText(text: string, fileName: string) {
const result = parseSettingsTransferFile(text);
// The desktop section carries switches that only exist in the Tauri app;
// on web it is skipped so the preview lists only what will be applied.
const result = parseSettingsTransferFile(text, { includeDesktopSettings: isTauriRuntime() });
if (!result.ok) {
toast(translateParseError(result.error), 5000);
return;
}
pendingImport.value = result.value;
pendingImportFileName.value = fileName;
pendingImportConflicts.value = props.getDraftConflictCategories(result.value.editorSettings);
pendingImportConflicts.value = props.getDraftConflictCategories(result.value.editorSettings, {
themeMode: result.value.themeMode,
desktopSettings: result.value.desktopSettings,
});
showImportConfirmDialog.value = true;
}
@@ -233,10 +240,13 @@ function confirmImport() {
const imported = pendingImport.value;
if (!imported) return;
props.applyImportedSettings(imported.editorSettings);
props.applyImportedExtras({ themeMode: imported.themeMode, desktopSettings: imported.desktopSettings });
showImportConfirmDialog.value = false;
pendingImport.value = null;
pendingImportConflicts.value = [];
// The draft is not persisted yet; applying is still the user's job.
// The editor and desktop drafts are not persisted yet; applying is still
// the user's job. The theme mode applies immediately, like the theme
// buttons in the dialog.
toast(t("settings.settingsTransferImportLoaded"));
}
</script>
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_EDITOR_SETTINGS, type EditorSettings } from "@/stores/settingsStore";
import { DEFAULT_DESKTOP_SETTINGS, DEFAULT_EDITOR_SETTINGS, type EditorSettings } from "@/stores/settingsStore";
import { EDITOR_SETTINGS_DRAFT_KEYS } from "../editorSettingsDraft";
import { buildSettingsTransferFilename, collectTransferCategories, parseSettingsTransferFile, serializeSettingsTransfer, SETTINGS_TRANSFER_FORMAT_VERSION, transferCategoryForKey } from "../settingsTransfer";
import { buildSettingsTransferFilename, collectTransferCategories, parseSettingsTransferFile, pickTransferableDesktopSettings, serializeSettingsTransfer, SETTINGS_TRANSFER_FORMAT_VERSION, transferCategoryForKey } from "../settingsTransfer";
function fileWith(editor: Record<string, unknown>, overrides: Record<string, unknown> = {}): string {
return JSON.stringify({
@@ -13,6 +13,15 @@ function fileWith(editor: Record<string, unknown>, overrides: Record<string, unk
});
}
function fileWithSections(sections: Record<string, unknown>): string {
return JSON.stringify({
formatVersion: SETTINGS_TRANSFER_FORMAT_VERSION,
app: { name: "dbx", version: "1.2.3" },
exportedAt: "2026-09-06T00:00:00.000Z",
settings: sections,
});
}
describe("settingsTransfer", () => {
it("round-trips column comment placement as a data preference and rejects non-booleans", () => {
const text = serializeSettingsTransfer({ ...DEFAULT_EDITOR_SETTINGS, dataGridColumnCommentsInInfoArea: true, dataGridTransposeMetadataInInfoColumn: true });
@@ -677,4 +686,125 @@ describe("settingsTransfer", () => {
if (result.ok) expect(result.value.editorSettings.aiSqlApplyTargetMode).toBe("new");
expect(parseSettingsTransferFile(fileWith({ aiSqlApplyTargetMode: "invalid" })).ok).toBe(false);
});
it("round-trips the app theme mode and desktop preferences (#13166)", () => {
const desktopSettings = {
show_tray_icon: false,
icon_theme: "black",
quit_on_close: true,
debug_logging_enabled: true,
metadata_cache_max_memory_mb: 128,
duckdb_worker_process_isolation: true,
duckdb_worker_max_processes: 8,
sidebar_table_page_size: 500,
};
const text = serializeSettingsTransfer(DEFAULT_EDITOR_SETTINGS, {}, { themeMode: "dark", desktopSettings });
const result = parseSettingsTransferFile(text);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.themeMode).toBe("dark");
expect(result.value.desktopSettings).toEqual(desktopSettings);
expect(result.value.categories).toContain("appearance");
expect(result.value.categories).toContain("other");
});
it("imports editor-only exports without theme mode or desktop preferences", () => {
const result = parseSettingsTransferFile(serializeSettingsTransfer(DEFAULT_EDITOR_SETTINGS));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.themeMode).toBeUndefined();
expect(result.value.desktopSettings).toEqual({});
});
it("imports a file that only carries the appearance and desktop sections", () => {
const result = parseSettingsTransferFile(fileWithSections({ appearance: { themeMode: "system" }, desktop: { show_tray_icon: false } }));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.editorSettings).toEqual({});
expect(result.value.themeMode).toBe("system");
expect(result.value.desktopSettings).toEqual({ show_tray_icon: false });
expect(result.value.categories).toEqual(["appearance", "other"]);
});
it("rejects invalid theme modes", () => {
const invalid = parseSettingsTransferFile(fileWithSections({ appearance: { themeMode: "blue" } }));
expect(invalid.ok).toBe(false);
if (!invalid.ok) {
expect(invalid.error.code).toBe("invalid-fields");
expect(invalid.error.detail).toContain("appearance.themeMode");
}
expect(parseSettingsTransferFile(fileWithSections({ appearance: { themeMode: "soft-dark" } })).ok).toBe(false);
});
it("rejects wrong-typed desktop values", () => {
const flag = parseSettingsTransferFile(fileWithSections({ desktop: { show_tray_icon: "yes" } }));
expect(flag.ok).toBe(false);
if (!flag.ok) expect(flag.error.detail).toContain("show_tray_icon");
const numeric = parseSettingsTransferFile(fileWithSections({ desktop: { metadata_cache_max_memory_mb: "128" } }));
expect(numeric.ok).toBe(false);
});
it("rejects desktop values the normalizer would clamp or replace", () => {
const clamped = parseSettingsTransferFile(fileWithSections({ desktop: { duckdb_worker_max_processes: 99 } }));
expect(clamped.ok).toBe(false);
if (!clamped.ok) expect(clamped.error.detail).toContain("duckdb_worker_max_processes");
const iconTheme = parseSettingsTransferFile(fileWithSections({ desktop: { icon_theme: "blue" } }));
expect(iconTheme.ok).toBe(false);
const floored = parseSettingsTransferFile(fileWithSections({ desktop: { sidebar_table_page_size: 0 } }));
expect(floored.ok).toBe(false);
const belowMinimum = parseSettingsTransferFile(fileWithSections({ desktop: { metadata_cache_max_memory_mb: 5 } }));
expect(belowMinimum.ok).toBe(false);
});
it("ignores unknown and excluded keys in the desktop section", () => {
const result = parseSettingsTransferFile(fileWithSections({ desktop: { quit_on_close: true, driver_store_dir: "C:\\evil", close_action_prompted: true, injected: "x" } }));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.desktopSettings).toEqual({ quit_on_close: true });
});
it("keeps device-specific desktop keys out of the serialized payload", () => {
const text = serializeSettingsTransfer(
DEFAULT_EDITOR_SETTINGS,
{},
{
desktopSettings: pickTransferableDesktopSettings({ ...DEFAULT_DESKTOP_SETTINGS, driver_store_dir: "C:\\drivers", quit_on_close: true }),
},
);
expect(text).not.toContain("driver_store_dir");
expect(text).not.toContain("saved_sql_sync_dir");
expect(text).not.toContain("close_action_prompted");
expect(text).toContain("quit_on_close");
});
it("rejects non-object appearance and desktop sections", () => {
expect(parseSettingsTransferFile(fileWithSections({ desktop: [1] })).ok).toBe(false);
expect(parseSettingsTransferFile(fileWithSections({ appearance: "dark" })).ok).toBe(false);
});
it("skips the desktop section on web imports but keeps the editor payload", () => {
const result = parseSettingsTransferFile(fileWithSections({ appearance: { themeMode: "dark" }, desktop: { show_tray_icon: false }, editor: { wordWrap: true } }), { includeDesktopSettings: false });
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.themeMode).toBe("dark");
expect(result.value.desktopSettings).toEqual({});
expect(result.value.editorSettings.wordWrap).toBe(true);
expect(result.value.categories).toContain("appearance");
expect(result.value.categories).not.toContain("other");
});
it("ignores a malformed desktop section on web imports", () => {
expect(parseSettingsTransferFile(fileWithSections({ desktop: [1], editor: { wordWrap: true } }), { includeDesktopSettings: false }).ok).toBe(true);
expect(parseSettingsTransferFile(fileWithSections({ desktop: { show_tray_icon: "yes" }, editor: { wordWrap: true } }), { includeDesktopSettings: false }).ok).toBe(true);
});
it("reports an empty import when a desktop-only file is read on web", () => {
const result = parseSettingsTransferFile(fileWithSections({ desktop: { show_tray_icon: false } }), { includeDesktopSettings: false });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error.code).toBe("empty-settings");
});
});
+130 -15
View File
@@ -1,5 +1,6 @@
import { EDITOR_SETTINGS_DRAFT_KEYS, editorSettingsDraftFromSettings, type EditorSettingsDraftKey } from "./editorSettingsDraft";
import { DEFAULT_CUSTOM_THEME_COLORS, DEFAULT_CUSTOM_THEME_DDL_COLORS, DEFAULT_EDITOR_SETTINGS, DEFAULT_TOOLBAR_ITEMS, normalizeEditorSettings, type EditorSettings } from "@/stores/settingsStore";
import { DEFAULT_CUSTOM_THEME_COLORS, DEFAULT_CUSTOM_THEME_DDL_COLORS, DEFAULT_DESKTOP_SETTINGS, DEFAULT_EDITOR_SETTINGS, DEFAULT_TOOLBAR_ITEMS, normalizeDesktopSettings, normalizeEditorSettings, type DesktopSettings, type EditorSettings } from "@/stores/settingsStore";
import type { AppThemeMode } from "@/lib/app/appTheme";
import { EDITOR_MAX_FONT_SIZE, EDITOR_MIN_FONT_SIZE } from "@/lib/editor/editorZoom";
import { DATA_GRID_TYPE_COLOR_KEYS, DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID } from "@/lib/dataGrid/dataGridTypeColorScheme";
import { SHORTCUT_DEFINITIONS } from "@/lib/editor/shortcutRegistry";
@@ -10,10 +11,12 @@ import { SQL_VARIABLE_SYNTAX_KEYS } from "@/lib/sql/sqlVariableSyntax";
* Local backup / restore of application settings ("配置导入与导出").
*
* The transfer file is a versioned JSON document with a strict field
* whitelist: only dialog-managed editor settings are serialized. Connection
* data, saved SQL libraries, AI configuration and device-specific paths are
* never part of the payload, and unknown fields in an imported file are
* ignored instead of being written into the draft.
* whitelist. The `settings.editor` section carries the dialog-managed editor
* settings; the optional `settings.appearance` and `settings.desktop`
* sections carry the app-wide theme mode and the preference-like desktop
* settings. Connection data, saved SQL libraries, AI configuration and
* device-specific paths are never part of the payload, and unknown fields in
* an imported file are ignored instead of being written anywhere.
*/
export const SETTINGS_TRANSFER_FORMAT_VERSION = 1;
@@ -21,6 +24,48 @@ export const SETTINGS_TRANSFER_FORMAT_VERSION = 1;
/** Upper bound for an import file; real settings files are a few dozen KB. */
export const MAX_SETTINGS_TRANSFER_FILE_BYTES = 5 * 1024 * 1024;
/**
* Preference-like `DesktopSettings` keys eligible for transfer. Device-
* specific paths (driver/plugin/agent/skill directories, saved-SQL sync dir)
* and the one-shot `close_action_prompted` flag are deliberately excluded:
* they are either meaningless on another machine or not a user preference.
*/
export const TRANSFERABLE_DESKTOP_SETTINGS_KEYS = [
"show_tray_icon",
"icon_theme",
"quit_on_close",
"debug_logging_enabled",
"metadata_cache_max_memory_mb",
"duckdb_worker_process_isolation",
"duckdb_worker_max_processes",
"sidebar_table_page_size",
] as const satisfies readonly (keyof DesktopSettings)[];
export type TransferableDesktopSettingsKey = (typeof TRANSFERABLE_DESKTOP_SETTINGS_KEYS)[number];
/** The transferable keys without their `null` "unset" variants. */
export type TransferableDesktopSettings = {
[K in TransferableDesktopSettingsKey]: Exclude<DesktopSettings[K], null>;
};
/** Non-editor sections carried alongside the editor draft in the transfer file. */
export interface SettingsTransferExtras {
/** App-wide light/dark/system mode from `settings.appearance`. */
themeMode?: AppThemeMode;
/** Preference-like desktop settings from `settings.desktop`. */
desktopSettings?: Partial<TransferableDesktopSettings>;
}
/** Keeps only the whitelisted desktop keys, ignoring everything else. */
export function pickTransferableDesktopSettings(settings: Partial<DesktopSettings>): Partial<TransferableDesktopSettings> {
const picked: Partial<TransferableDesktopSettings> = {};
for (const key of TRANSFERABLE_DESKTOP_SETTINGS_KEYS) {
const value = settings[key];
if (value !== undefined && value !== null) picked[key] = value as never;
}
return picked;
}
export type SettingsTransferCategoryId = "appearance" | "editor" | "formatter" | "navigation" | "data" | "shortcuts" | "snippets" | "other";
export type SettingsTransferParseErrorCode = "too-large" | "invalid-json" | "unsupported-version" | "invalid-structure" | "empty-settings" | "invalid-fields";
@@ -37,9 +82,23 @@ export interface ParsedSettingsTransfer {
exportedAt?: string;
/** Validated, normalized values for exactly the keys present in the file. */
editorSettings: Partial<EditorSettings>;
/** Validated theme mode from the optional appearance section. */
themeMode?: AppThemeMode;
/** Validated desktop preferences for exactly the keys present in the file. */
desktopSettings: Partial<TransferableDesktopSettings>;
categories: SettingsTransferCategoryId[];
}
export interface SettingsTransferParseOptions {
/**
* Whether the platform-specific `settings.desktop` section applies. Web
* callers pass false: those switches are desktop-only, so the section is
* skipped the same way an older reader skips an unknown section — its
* values are neither validated nor listed among the importable categories.
*/
includeDesktopSettings?: boolean;
}
export interface SettingsTransferExportMeta {
appVersion?: string;
}
@@ -55,14 +114,19 @@ export function buildSettingsTransferFilename(date = new Date()): string {
* Serializes the saved editor settings into the transfer format. The draft
* helper provides the whitelist, deep cloning and per-key normalization, so
* the payload never contains fields outside `EDITOR_SETTINGS_DRAFT_KEYS`.
* The optional extras add the `appearance`/`desktop` sections; files written
* by older versions simply lack them.
*/
export function serializeSettingsTransfer(settings: EditorSettings, meta: SettingsTransferExportMeta = {}): string {
export function serializeSettingsTransfer(settings: EditorSettings, meta: SettingsTransferExportMeta = {}, extras: SettingsTransferExtras = {}): string {
const desktopSettings = extras.desktopSettings ? pickTransferableDesktopSettings(extras.desktopSettings) : {};
const payload = {
formatVersion: SETTINGS_TRANSFER_FORMAT_VERSION,
app: { name: "dbx", ...(meta.appVersion ? { version: meta.appVersion } : {}) },
exportedAt: new Date().toISOString(),
settings: {
editor: editorSettingsDraftFromSettings(settings),
...(extras.themeMode ? { appearance: { themeMode: extras.themeMode } } : {}),
...(Object.keys(desktopSettings).length > 0 ? { desktop: desktopSettings } : {}),
},
};
return JSON.stringify(payload, null, 2);
@@ -571,7 +635,7 @@ const RAW_STRUCTURED_FIELD_VALIDATORS: Partial<Record<EditorSettingsDraftKey, (v
* nested schema (see NESTED_FIELD_VALIDATORS): malformed nested payloads are
* rejected instead of being silently repaired or persisted.
*/
export function parseSettingsTransferFile(text: string): { ok: true; value: ParsedSettingsTransfer } | { ok: false; error: SettingsTransferParseError } {
export function parseSettingsTransferFile(text: string, options: SettingsTransferParseOptions = {}): { ok: true; value: ParsedSettingsTransfer } | { ok: false; error: SettingsTransferParseError } {
if (exceedsSettingsTransferSizeLimit(text)) {
return { ok: false, error: { code: "too-large" } };
}
@@ -592,17 +656,62 @@ export function parseSettingsTransferFile(text: string): { ok: true; value: Pars
const settings = parsed.settings;
if (!isPlainObject(settings)) return { ok: false, error: { code: "invalid-structure" } };
const editor = settings.editor;
if (!isPlainObject(editor)) return { ok: false, error: { code: "empty-settings" } };
if (editor !== undefined && !isPlainObject(editor)) return { ok: false, error: { code: "invalid-structure" } };
const appearance = settings.appearance;
if (appearance !== undefined && !isPlainObject(appearance)) return { ok: false, error: { code: "invalid-structure" } };
const includeDesktop = options.includeDesktopSettings !== false;
const desktop = settings.desktop;
if (includeDesktop && desktop !== undefined && !isPlainObject(desktop)) return { ok: false, error: { code: "invalid-structure" } };
// App-wide theme mode from the optional appearance section. Only the three
// canonical modes are accepted; legacy soft-* values normalize before they
// ever reach a transfer file.
let themeMode: AppThemeMode | undefined;
if (isPlainObject(appearance) && appearance.themeMode !== undefined) {
const value = appearance.themeMode;
if (value === "light" || value === "dark" || value === "system") {
themeMode = value;
} else {
return { ok: false, error: { code: "invalid-fields", detail: "appearance.themeMode" } };
}
}
// Desktop preferences from the optional desktop section, skipped whole on
// platforms where the switches do not exist. Unknown keys are ignored like
// in the editor section; the accepted values must survive the store
// normalizer unchanged, so a clamped number (duckdb worker processes) or an
// out-of-domain enum (icon theme) rejects the whole file instead of being
// silently repaired.
const desktopKeys = includeDesktop && isPlainObject(desktop) ? TRANSFERABLE_DESKTOP_SETTINGS_KEYS.filter((key) => key in desktop) : [];
const importedDesktop: Partial<TransferableDesktopSettings> = {};
if (desktopKeys.length > 0 && isPlainObject(desktop)) {
const desktopProbe: Record<string, unknown> = { ...DEFAULT_DESKTOP_SETTINGS };
for (const key of desktopKeys) {
const raw = desktop[key];
if (typeof raw !== typeof DEFAULT_DESKTOP_SETTINGS[key]) {
return { ok: false, error: { code: "invalid-fields", detail: `desktop.${key}` } };
}
desktopProbe[key] = raw;
}
const normalizedDesktop = normalizeDesktopSettings(desktopProbe as Partial<DesktopSettings>);
for (const key of desktopKeys) {
if (normalizedDesktop[key] !== desktop[key]) {
return { ok: false, error: { code: "invalid-fields", detail: `desktop.${key}` } };
}
importedDesktop[key] = normalizedDesktop[key] as never;
}
}
// Old exports used the inverse `dataGridAutoHideFilterBuilder` flag. Keep
// accepting it, but normalize it into the single current preference before
// applying the strict whitelist below.
if (!("dataGridKeepFilterEditorExpanded" in editor) && typeof editor.dataGridAutoHideFilterBuilder === "boolean") {
if (isPlainObject(editor) && !("dataGridKeepFilterEditorExpanded" in editor) && typeof editor.dataGridAutoHideFilterBuilder === "boolean") {
editor.dataGridKeepFilterEditorExpanded = !editor.dataGridAutoHideFilterBuilder;
}
const presentKeys = EDITOR_SETTINGS_DRAFT_KEYS.filter((key) => key in editor);
if (presentKeys.length === 0) return { ok: false, error: { code: "empty-settings" } };
const editorObject = isPlainObject(editor) ? editor : {};
const presentKeys = EDITOR_SETTINGS_DRAFT_KEYS.filter((key) => key in editorObject);
if (presentKeys.length === 0 && desktopKeys.length === 0 && themeMode === undefined) return { ok: false, error: { code: "empty-settings" } };
// Validate the raw file values BEFORE the normalizer runs. Some store
// normalizers assume well-formed containers (e.g. customThemes items must
@@ -611,7 +720,7 @@ export function parseSettingsTransferFile(text: string): { ok: true; value: Pars
// rather than crashing the parse.
const invalidKeys: string[] = [];
for (const key of presentKeys) {
const raw = editor[key];
const raw = editorObject[key];
if (jsonKindOf(raw) !== EXPECTED_JSON_KINDS.get(key)) {
invalidKeys.push(key);
continue;
@@ -630,13 +739,13 @@ export function parseSettingsTransferFile(text: string): { ok: true; value: Pars
// mutated by a malformed file.
const probe = deepClone(DEFAULT_EDITOR_SETTINGS) as EditorSettings;
for (const key of presentKeys) {
(probe as unknown as Record<string, unknown>)[key] = editor[key];
(probe as unknown as Record<string, unknown>)[key] = editorObject[key];
}
const normalized = normalizeEditorSettings(probe);
const imported: Partial<EditorSettings> = {};
for (const key of presentKeys) {
const raw = editor[key];
const raw = editorObject[key];
const value = (normalized as unknown as Record<string, unknown>)[key];
const validator = PASS_THROUGH_FIELD_VALIDATORS[key] ?? NESTED_FIELD_VALIDATORS[key];
if (validator && !validator(value)) {
@@ -673,6 +782,10 @@ export function parseSettingsTransferFile(text: string): { ok: true; value: Pars
const rawAppVersion = typeof app.version === "string" ? app.version.trim() : "";
const rawExportedAt = typeof parsed.exportedAt === "string" ? parsed.exportedAt : "";
const categories = new Set(collectTransferCategories(Object.keys(imported)));
if (themeMode !== undefined) categories.add("appearance");
if (desktopKeys.length > 0) categories.add("other");
return {
ok: true,
value: {
@@ -680,7 +793,9 @@ export function parseSettingsTransferFile(text: string): { ok: true; value: Pars
...(rawAppVersion ? { appVersion: rawAppVersion } : {}),
...(rawExportedAt ? { exportedAt: rawExportedAt } : {}),
editorSettings: imported,
categories: collectTransferCategories(Object.keys(imported)),
...(themeMode !== undefined ? { themeMode } : {}),
desktopSettings: importedDesktop,
categories: sortTransferCategories(categories),
},
};
}