From eef387b0ed045b70c2467e4e4ad0936284353f4e Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 17 May 2026 18:32:59 +1000 Subject: [PATCH] fix: persist presets and theme preferences --- electron/electron-env.d.ts | 2 + electron/ipc/constants.ts | 1 + electron/ipc/register/settings.ts | 55 ++++++++++++++ electron/preload.ts | 13 ++++ .../video-editor/editorPreferences.test.ts | 66 ++++++++++++++++- .../video-editor/editorPreferences.ts | 34 ++++----- src/components/video-editor/types.ts | 2 +- src/contexts/ThemeContext.test.ts | 74 +++++++++++++++++++ src/contexts/ThemeContext.tsx | 26 +++++-- src/lib/appSettings.ts | 42 +++++++++++ 10 files changed, 287 insertions(+), 28 deletions(-) create mode 100644 src/contexts/ThemeContext.test.ts create mode 100644 src/lib/appSettings.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index a2f81889..f4c391e0 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -805,6 +805,8 @@ interface Window { }>; getShortcuts: () => Promise | null>; saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; + getAppSetting: (key: string) => unknown; + setAppSetting: (key: string, value: unknown) => boolean; setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise) => () => void; isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>; diff --git a/electron/ipc/constants.ts b/electron/ipc/constants.ts index e3c34590..2c5cf8f1 100644 --- a/electron/ipc/constants.ts +++ b/electron/ipc/constants.ts @@ -10,6 +10,7 @@ export const MAX_RECENT_PROJECTS = 16; export const SHORTCUTS_FILE = path.join(USER_DATA_PATH, "shortcuts.json"); export const RECORDINGS_SETTINGS_FILE = path.join(USER_DATA_PATH, "recordings-settings.json"); export const COUNTDOWN_SETTINGS_FILE = path.join(USER_DATA_PATH, "countdown-settings.json"); +export const APP_SETTINGS_FILE = path.join(USER_DATA_PATH, "app-settings.json"); export const AUTO_RECORDING_PREFIX = "recording-"; export const AUTO_RECORDING_RETENTION_COUNT = 20; export const AUTO_RECORDING_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index 4622bb2d..cecabb5f 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -1,8 +1,10 @@ +import { readFileSync, writeFileSync } from "node:fs"; import fs from "node:fs/promises"; import { app, ipcMain } from "electron"; import { hideCursor } from "../../cursorHider"; import { closeCountdownWindow, createCountdownWindow, getCountdownWindow } from "../../windows"; import { + APP_SETTINGS_FILE, COUNTDOWN_SETTINGS_FILE, RECORDINGS_SETTINGS_FILE, SHORTCUTS_FILE, @@ -40,6 +42,24 @@ function getBrowserMicrophoneProfileFromEnv() { }; } +function readAppSettingsStore(): Record { + try { + const content = readFileSync(APP_SETTINGS_FILE, "utf-8"); + const parsed = parseJsonWithByteOrderMark(content); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + + return parsed as Record; + } catch { + return {}; + } +} + +function writeAppSettingsStore(store: Record) { + writeFileSync(APP_SETTINGS_FILE, JSON.stringify(store, null, 2), "utf-8"); +} + export function registerSettingsHandlers() { ipcMain.handle('app:getVersion', () => { return app.getVersion() @@ -49,6 +69,41 @@ export function registerSettingsHandlers() { return process.platform; }); + ipcMain.on("app-settings:get", (event, key: unknown) => { + try { + if (typeof key !== "string" || key.length === 0) { + event.returnValue = { success: false, value: null }; + return; + } + + const store = readAppSettingsStore(); + event.returnValue = { + success: true, + value: Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null, + }; + } catch (error) { + console.error("Failed to read app setting:", error); + event.returnValue = { success: false, value: null }; + } + }); + + ipcMain.on("app-settings:set", (event, key: unknown, value: unknown) => { + try { + if (typeof key !== "string" || key.length === 0) { + event.returnValue = { success: false }; + return; + } + + const store = readAppSettingsStore(); + store[key] = value; + writeAppSettingsStore(store); + event.returnValue = { success: true }; + } catch (error) { + console.error("Failed to save app setting:", error); + event.returnValue = { success: false }; + } + }); + // --------------------------------------------------------------------------- // Cursor hiding for the browser-capture fallback. // The IPC promise resolves only after the cursor hide attempt completes. diff --git a/electron/preload.ts b/electron/preload.ts index c823de71..8ef19765 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -866,6 +866,19 @@ contextBridge.exposeInMainWorld("electronAPI", { saveShortcuts: (shortcuts: unknown) => { return ipcRenderer.invoke("save-shortcuts", shortcuts); }, + getAppSetting: (key: string) => { + const result = ipcRenderer.sendSync("app-settings:get", key) as { + success?: boolean; + value?: unknown; + }; + return result?.success ? result.value ?? null : null; + }, + setAppSetting: (key: string, value: unknown) => { + const result = ipcRenderer.sendSync("app-settings:set", key, value) as { + success?: boolean; + }; + return result?.success === true; + }, setHasUnsavedChanges: (hasChanges: boolean) => { ipcRenderer.send("set-has-unsaved-changes", hasChanges); }, diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index b26d3c56..4f80b660 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -37,9 +37,27 @@ function createStorageMock(initialValues: Record = {}): Storage }; } +function stubElectronSettings(initialValues: Record = {}) { + const store = new Map(Object.entries(initialValues)); + + Object.defineProperty(globalThis, "electronAPI", { + configurable: true, + value: { + getAppSetting: (key: string) => (store.has(key) ? store.get(key) : null), + setAppSetting: (key: string, value: unknown) => { + store.set(key, value); + return true; + }, + } as Pick, + }); + + return store; +} + describe("editorPreferences", () => { afterEach(() => { vi.unstubAllGlobals(); + Reflect.deleteProperty(globalThis, "electronAPI"); }); it("normalizes invalid values back to safe defaults", () => { @@ -69,10 +87,10 @@ describe("editorPreferences", () => { expect(DEFAULT_EDITOR_PREFERENCES.exportQuality).toBe("source"); }); - it("defaults cursor preferences to macOS at 2.5x with lighter sway", () => { + it("defaults cursor preferences to macOS at 2.5x with gentler sway", () => { expect(DEFAULT_EDITOR_PREFERENCES.cursorStyle).toBe("macos"); expect(DEFAULT_EDITOR_PREFERENCES.cursorSize).toBe(2.5); - expect(DEFAULT_EDITOR_PREFERENCES.cursorSway).toBe(0.25); + expect(DEFAULT_EDITOR_PREFERENCES.cursorSway).toBe(0.4); }); it("defaults MP4 exports to the Lightning pipeline", () => { @@ -309,6 +327,24 @@ describe("editorPreferences", () => { }); }); + it("loads editor preferences from Electron app settings when available", () => { + stubElectronSettings({ + [EDITOR_PREFERENCES_STORAGE_KEY]: { + wallpaper: "#0f172a", + showCursor: false, + customAspectWidth: "3", + customAspectHeight: "2", + }, + }); + + expect(loadEditorPreferences()).toMatchObject({ + wallpaper: "#0f172a", + showCursor: false, + customAspectWidth: "3", + customAspectHeight: "2", + }); + }); + it("saves editor presets and reports success", () => { const localStorage = createStorageMock(); vi.stubGlobal("localStorage", localStorage); @@ -337,6 +373,32 @@ describe("editorPreferences", () => { ]); }); + it("saves editor presets to Electron app settings when available", () => { + const settingsStore = stubElectronSettings(); + + expect( + saveEditorPresets([ + { + id: "preset-1", + name: "Demo Preset", + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-02T00:00:00.000Z", + snapshot: { + ...DEFAULT_EDITOR_PREFERENCES, + autoCaptionSettings: DEFAULT_AUTO_CAPTION_SETTINGS, + }, + }, + ]), + ).toBe(true); + + expect(settingsStore.get(EDITOR_PRESETS_STORAGE_KEY)).toMatchObject([ + { + id: "preset-1", + name: "Demo Preset", + }, + ]); + }); + it("returns false when preset persistence fails", () => { const localStorage = createStorageMock(); localStorage.setItem = () => { diff --git a/src/components/video-editor/editorPreferences.ts b/src/components/video-editor/editorPreferences.ts index f152b17d..cbd41756 100644 --- a/src/components/video-editor/editorPreferences.ts +++ b/src/components/video-editor/editorPreferences.ts @@ -6,6 +6,7 @@ import { stripPersistedDevMotionBlurSettings, type ProjectEditorState, } from "./projectPersistence"; +import { loadAppSetting, saveAppSetting } from "../../lib/appSettings"; type PersistedEditorControls = Pick< ProjectEditorState, @@ -422,12 +423,13 @@ export function normalizeEditorPreferences( } export function loadEditorPreferences(): EditorPreferences { - if (typeof globalThis.localStorage === "undefined") { - return DEFAULT_EDITOR_PREFERENCES; + const persisted = loadAppSetting(EDITOR_PREFERENCES_STORAGE_KEY); + if (persisted !== null) { + return normalizeEditorPreferences(persisted); } try { - const stored = globalThis.localStorage.getItem(EDITOR_PREFERENCES_STORAGE_KEY); + const stored = globalThis.localStorage?.getItem(EDITOR_PREFERENCES_STORAGE_KEY); if (!stored) { return DEFAULT_EDITOR_PREFERENCES; } @@ -439,16 +441,14 @@ export function loadEditorPreferences(): EditorPreferences { } export function saveEditorPreferences(preferences: Partial): void { - if (typeof globalThis.localStorage === "undefined") { - return; - } - try { const current = loadEditorPreferences(); const merged = normalizeEditorPreferences({ ...current, ...preferences }, current); - globalThis.localStorage.setItem( + const persisted = stripPersistedDevMotionBlurSettings(merged); + saveAppSetting(EDITOR_PREFERENCES_STORAGE_KEY, persisted); + globalThis.localStorage?.setItem( EDITOR_PREFERENCES_STORAGE_KEY, - JSON.stringify(stripPersistedDevMotionBlurSettings(merged)), + JSON.stringify(persisted), ); } catch { // Ignore storage failures so editor controls still work. @@ -456,12 +456,13 @@ export function saveEditorPreferences(preferences: Partial): } export function loadEditorPresets(): EditorPreset[] { - if (typeof globalThis.localStorage === "undefined") { - return []; + const persisted = loadAppSetting(EDITOR_PRESETS_STORAGE_KEY); + if (persisted !== null) { + return normalizeEditorPresets(persisted); } try { - const stored = globalThis.localStorage.getItem(EDITOR_PRESETS_STORAGE_KEY); + const stored = globalThis.localStorage?.getItem(EDITOR_PRESETS_STORAGE_KEY); if (!stored) { return []; } @@ -473,14 +474,11 @@ export function loadEditorPresets(): EditorPreset[] { } export function saveEditorPresets(presets: EditorPreset[]): boolean { - if (typeof globalThis.localStorage === "undefined") { - return false; - } - try { const normalized = normalizeEditorPresets(presets); - globalThis.localStorage.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(normalized)); - return true; + const persisted = saveAppSetting(EDITOR_PRESETS_STORAGE_KEY, normalized); + globalThis.localStorage?.setItem(EDITOR_PRESETS_STORAGE_KEY, JSON.stringify(normalized)); + return persisted || typeof globalThis.localStorage !== "undefined"; } catch { // Ignore storage failures so editor controls still work. return false; diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index d27448ec..30b3adaa 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -101,7 +101,7 @@ export const DEFAULT_CURSOR_SMOOTHING = 0.67; export const DEFAULT_CURSOR_MOTION_BLUR = 0.4; export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5; export const DEFAULT_CURSOR_CLICK_BOUNCE_DURATION = 350; -export const DEFAULT_CURSOR_SWAY = 0.25; +export const DEFAULT_CURSOR_SWAY = 0.4; export const DEFAULT_ZOOM_SMOOTHNESS = 0.5; export const DEFAULT_ZOOM_MOTION_BLUR = 0.35; export interface ZoomMotionBlurTuning { diff --git a/src/contexts/ThemeContext.test.ts b/src/contexts/ThemeContext.test.ts new file mode 100644 index 00000000..9b5d97cd --- /dev/null +++ b/src/contexts/ThemeContext.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadThemePreference, persistThemePreference } from "./ThemeContext"; + +function createStorageMock(initialValues: Record = {}): Storage { + const store = new Map(Object.entries(initialValues)); + + return { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key) { + return store.get(key) ?? null; + }, + key(index) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key) { + store.delete(key); + }, + setItem(key, value) { + store.set(key, value); + }, + }; +} + +function stubElectronSettings(initialValues: Record = {}) { + const store = new Map(Object.entries(initialValues)); + + Object.defineProperty(globalThis, "electronAPI", { + configurable: true, + value: { + getAppSetting: (key: string) => (store.has(key) ? store.get(key) : null), + setAppSetting: (key: string, value: unknown) => { + store.set(key, value); + return true; + }, + } as Pick, + }); + + return store; +} + +describe("ThemeContext persistence", () => { + afterEach(() => { + vi.unstubAllGlobals(); + Reflect.deleteProperty(globalThis, "electronAPI"); + }); + + it("loads the persisted theme preference from Electron app settings", () => { + stubElectronSettings({ "recordly.theme": "dark" }); + + expect(loadThemePreference()).toBe("dark"); + }); + + it("saves the theme preference to Electron app settings", () => { + const settingsStore = stubElectronSettings(); + + persistThemePreference("dark"); + + expect(settingsStore.get("recordly.theme")).toBe("dark"); + }); + + it("falls back to localStorage when Electron settings are unavailable", () => { + vi.stubGlobal( + "localStorage", + createStorageMock({ "recordly.theme": "light" }), + ); + + expect(loadThemePreference()).toBe("light"); + }); +}); diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index 4c3566ad..adaf1e37 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useState } from "react"; +import { loadAppSetting, saveAppSetting } from "../lib/appSettings"; export type ThemePreference = "light" | "dark" | "system"; export type ResolvedTheme = "light" | "dark"; @@ -16,7 +17,12 @@ const THEME_STORAGE_KEY = "recordly.theme"; const ThemeContext = createContext(null); -function getStoredPreference(): ThemePreference { +export function loadThemePreference(): ThemePreference { + const persisted = loadAppSetting(THEME_STORAGE_KEY); + if (persisted === "light" || persisted === "dark" || persisted === "system") { + return persisted; + } + try { const stored = globalThis.localStorage?.getItem(THEME_STORAGE_KEY); if (stored === "light" || stored === "dark" || stored === "system") { @@ -28,6 +34,16 @@ function getStoredPreference(): ThemePreference { return "system"; } +export function persistThemePreference(pref: ThemePreference): void { + saveAppSetting(THEME_STORAGE_KEY, pref); + + try { + globalThis.localStorage?.setItem(THEME_STORAGE_KEY, pref); + } catch { + // Ignore storage errors + } +} + function resolveTheme(pref: ThemePreference): ResolvedTheme { if (pref === "system") { return globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches @@ -49,7 +65,7 @@ function applyThemeToDOM(theme: ResolvedTheme) { export function ThemeProvider({ children }: { children: React.ReactNode }) { const [preference, setPreferenceState] = useState(() => { - const stored = getStoredPreference(); + const stored = loadThemePreference(); applyThemeToDOM(resolveTheme(stored)); return stored; }); @@ -63,11 +79,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { const r = resolveTheme(pref); setResolved(r); applyThemeToDOM(r); - try { - globalThis.localStorage?.setItem(THEME_STORAGE_KEY, pref); - } catch { - // Ignore storage errors - } + persistThemePreference(pref); }, []); const toggleTheme = useCallback(() => { diff --git a/src/lib/appSettings.ts b/src/lib/appSettings.ts new file mode 100644 index 00000000..4aa3cd95 --- /dev/null +++ b/src/lib/appSettings.ts @@ -0,0 +1,42 @@ +type ElectronSettingsApi = Pick; + +function getElectronSettingsApi(): ElectronSettingsApi | null { + const api = (globalThis as typeof globalThis & { electronAPI?: ElectronSettingsApi }) + .electronAPI; + if ( + !api || + typeof api.getAppSetting !== "function" || + typeof api.setAppSetting !== "function" + ) { + return null; + } + + return api; +} + +export function loadAppSetting(key: string): T | null { + const api = getElectronSettingsApi(); + if (!api) { + return null; + } + + try { + const value = api.getAppSetting(key); + return value === undefined ? null : (value as T | null); + } catch { + return null; + } +} + +export function saveAppSetting(key: string, value: unknown): boolean { + const api = getElectronSettingsApi(); + if (!api) { + return false; + } + + try { + return api.setAppSetting(key, value); + } catch { + return false; + } +}