fix: persist presets and theme preferences

This commit is contained in:
webadderall
2026-05-17 18:32:59 +10:00
parent 6255890556
commit eef387b0ed
10 changed files with 287 additions and 28 deletions
+2
View File
@@ -805,6 +805,8 @@ interface Window {
}>;
getShortcuts: () => Promise<Record<string, unknown> | 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<boolean>) => () => void;
isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>;
+1
View File
@@ -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;
+55
View File
@@ -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<string, unknown> {
try {
const content = readFileSync(APP_SETTINGS_FILE, "utf-8");
const parsed = parseJsonWithByteOrderMark<unknown>(content);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
return parsed as Record<string, unknown>;
} catch {
return {};
}
}
function writeAppSettingsStore(store: Record<string, unknown>) {
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.
+13
View File
@@ -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);
},
@@ -37,9 +37,27 @@ function createStorageMock(initialValues: Record<string, string> = {}): Storage
};
}
function stubElectronSettings(initialValues: Record<string, unknown> = {}) {
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<Window["electronAPI"], "getAppSetting" | "setAppSetting">,
});
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 = () => {
@@ -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<unknown>(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<EditorPreferences>): 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<EditorPreferences>):
}
export function loadEditorPresets(): EditorPreset[] {
if (typeof globalThis.localStorage === "undefined") {
return [];
const persisted = loadAppSetting<unknown>(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;
+1 -1
View File
@@ -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 {
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadThemePreference, persistThemePreference } from "./ThemeContext";
function createStorageMock(initialValues: Record<string, string> = {}): 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<string, unknown> = {}) {
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<Window["electronAPI"], "getAppSetting" | "setAppSetting">,
});
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");
});
});
+19 -7
View File
@@ -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<ThemeContextValue | null>(null);
function getStoredPreference(): ThemePreference {
export function loadThemePreference(): ThemePreference {
const persisted = loadAppSetting<unknown>(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<ThemePreference>(() => {
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(() => {
+42
View File
@@ -0,0 +1,42 @@
type ElectronSettingsApi = Pick<Window["electronAPI"], "getAppSetting" | "setAppSetting">;
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<T>(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;
}
}