mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
Merge pull request #523 from webadderallorg/fix/persist-presets-and-theme
fix: persist presets and appearance settings
This commit is contained in:
Vendored
+2
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user