From 4eae069991841303dc7bf7b4b78305a6ddbca2df Mon Sep 17 00:00:00 2001 From: jamesx0416 Date: Wed, 18 Mar 2026 23:03:09 +1100 Subject: [PATCH] Persist desktop settings to settings.json --- apps/desktop/src/main.ts | 56 +++++++++++ apps/desktop/src/preload.ts | 7 ++ apps/web/src/appSettings.ts | 57 +++++------ apps/web/src/desktopSettings.ts | 139 ++++++++++++++++++++++++++ apps/web/src/hooks/useLocalStorage.ts | 4 +- apps/web/src/hooks/useTheme.ts | 17 +++- apps/web/src/main.tsx | 25 +++-- packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/settings.ts | 47 +++++++++ 10 files changed, 310 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/desktopSettings.ts create mode 100644 packages/contracts/src/settings.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c3dba6016e..6ca2b22048 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -17,11 +17,18 @@ import { } from "electron"; import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; +import { Schema } from "effect"; import type { + DesktopSettings, DesktopTheme, DesktopUpdateActionResult, DesktopUpdateState, } from "@t3tools/contracts"; +import { + DesktopSettings as DesktopSettingsSchema, + DesktopSettingsInput as DesktopSettingsInputSchema, + DesktopSettingsSchemaVersion, +} from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; @@ -49,6 +56,8 @@ syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; +const GET_INITIAL_SETTINGS_CHANNEL = "desktop:get-initial-settings"; +const SET_SETTINGS_CHANNEL = "desktop:set-settings"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -70,6 +79,7 @@ const COMMIT_HASH_DISPLAY_LENGTH = 12; const LOG_DIR = Path.join(STATE_DIR, "logs"); const LOG_FILE_MAX_BYTES = 10 * 1024 * 1024; const LOG_FILE_MAX_FILES = 10; +const SETTINGS_FILE_NAME = "settings.json"; const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; @@ -109,6 +119,42 @@ function logScope(scope: string): string { return `${scope} run=${APP_RUN_ID}`; } +function getSettingsPath(): string { + return Path.join(STATE_DIR, SETTINGS_FILE_NAME); +} + +function decodeSettingsFile(raw: string): DesktopSettings { + return Schema.decodeSync(Schema.fromJsonString(DesktopSettingsSchema))(raw); +} + +function readSettingsFileSync(): DesktopSettings | null { + try { + const raw = FS.readFileSync(getSettingsPath(), "utf8"); + return decodeSettingsFile(raw); + } catch (error) { + if ((error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { + console.warn("[desktop-settings] Failed to read settings.json", error); + } + return null; + } +} + +async function writeSettingsFile(rawInput: unknown): Promise { + const input = Schema.decodeUnknownSync(DesktopSettingsInputSchema)(rawInput); + const settings: DesktopSettings = { + version: app.getVersion(), + schemaVersion: DesktopSettingsSchemaVersion, + theme: input.theme, + appSettings: input.appSettings, + }; + const settingsPath = getSettingsPath(); + const tempPath = `${settingsPath}.tmp`; + await FS.promises.mkdir(Path.dirname(settingsPath), { recursive: true }); + await FS.promises.writeFile(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + await FS.promises.rename(tempPath, settingsPath); + return settings; +} + function sanitizeLogValue(value: string): string { return value.replace(/\s+/g, " ").trim(); } @@ -1106,6 +1152,16 @@ function registerIpcHandlers(): void { nativeTheme.themeSource = theme; }); + ipcMain.removeAllListeners(GET_INITIAL_SETTINGS_CHANNEL); + ipcMain.on(GET_INITIAL_SETTINGS_CHANNEL, (event) => { + event.returnValue = readSettingsFileSync(); + }); + + ipcMain.removeHandler(SET_SETTINGS_CHANNEL); + ipcMain.handle(SET_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => + writeSettingsFile(rawSettings), + ); + ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); ipcMain.handle( CONTEXT_MENU_CHANNEL, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8e..9a6dce475e 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,6 +4,8 @@ import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; +const GET_INITIAL_SETTINGS_CHANNEL = "desktop:get-initial-settings"; +const SET_SETTINGS_CHANNEL = "desktop:set-settings"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -12,12 +14,17 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; +const initialSettings = ipcRenderer.sendSync(GET_INITIAL_SETTINGS_CHANNEL) as + | DesktopBridge["initialSettings"] + | undefined; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => wsUrl, + initialSettings: initialSettings ?? null, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), + setSettings: (settings) => ipcRenderer.invoke(SET_SETTINGS_CHANNEL, settings), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f92..43162fa030 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,40 +1,23 @@ -import { useCallback } from "react"; -import { Option, Schema } from "effect"; -import { type ProviderKind } from "@t3tools/contracts"; +import { useCallback, useEffect } from "react"; +import { + AppSettings as AppSettingsSchema, + DEFAULT_TIMESTAMP_FORMAT, + type ProviderKind, + type TimestampFormat, +} from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { getBootDesktopAppSettings, persistDesktopSettings } from "./desktopSettings"; import { useLocalStorage } from "./hooks/useLocalStorage"; -const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; +export const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; -export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; -export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; -export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export { DEFAULT_TIMESTAMP_FORMAT }; +export type { TimestampFormat }; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), }; -const AppSettingsSchema = Schema.Struct({ - codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( - Schema.withConstructorDefault(() => Option.some("")), - ), - codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( - Schema.withConstructorDefault(() => Option.some("")), - ), - defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( - Schema.withConstructorDefault(() => Option.some("local")), - ), - confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), - enableAssistantStreaming: Schema.Boolean.pipe( - Schema.withConstructorDefault(() => Option.some(false)), - ), - timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( - Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), - ), - customCodexModels: Schema.Array(Schema.String).pipe( - Schema.withConstructorDefault(() => Option.some([])), - ), -}); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { slug: string; @@ -42,7 +25,7 @@ export interface AppModelOption { isCustom: boolean; } -const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); +export const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); export function normalizeCustomModelSlugs( models: Iterable, @@ -73,6 +56,14 @@ export function normalizeCustomModelSlugs( return normalizedModels; } +export function normalizeAppSettings(settings: AppSettings | null | undefined): AppSettings { + return { + ...DEFAULT_APP_SETTINGS, + ...settings, + customCodexModels: normalizeCustomModelSlugs(settings?.customCodexModels ?? [], "codex"), + }; +} + export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], @@ -145,7 +136,7 @@ export function resolveAppModelSelection( export function useAppSettings() { const [settings, setSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, - DEFAULT_APP_SETTINGS, + getBootDesktopAppSettings() ?? DEFAULT_APP_SETTINGS, AppSettingsSchema, ); @@ -163,8 +154,12 @@ export function useAppSettings() { setSettings(DEFAULT_APP_SETTINGS); }, [setSettings]); + useEffect(() => { + void persistDesktopSettings({ appSettings: normalizeAppSettings(settings) }).catch(() => {}); + }, [settings]); + return { - settings, + settings: normalizeAppSettings(settings), updateSettings, resetSettings, defaults: DEFAULT_APP_SETTINGS, diff --git a/apps/web/src/desktopSettings.ts b/apps/web/src/desktopSettings.ts new file mode 100644 index 0000000000..1e92477e1e --- /dev/null +++ b/apps/web/src/desktopSettings.ts @@ -0,0 +1,139 @@ +import type { DesktopSettings, DesktopSettingsInput, ThemePreference } from "@t3tools/contracts"; +import { + AppSettings as AppSettingsSchema, + DesktopSettingsSchemaVersion, + ThemePreference as ThemePreferenceSchema, +} from "@t3tools/contracts"; +import { Schema } from "effect"; +import { APP_VERSION } from "./branding"; +import { isElectron } from "./env"; +import { + dispatchLocalStorageChange, + getLocalStorageItem, + setLocalStorageItem, +} from "./hooks/useLocalStorage"; +import type { AppSettings } from "./appSettings"; + +const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; +const THEME_STORAGE_KEY = "t3code:theme"; +const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); +const DEFAULT_THEME: ThemePreference = "system"; +const INITIAL_DESKTOP_SETTINGS = isElectron + ? (window.desktopBridge?.initialSettings ?? null) + : null; + +let desktopSettingsHydrated = false; +let lastPersistedSerialized: string | null = null; +let latestTheme: ThemePreference = DEFAULT_THEME; +let latestAppSettings: AppSettings = DEFAULT_APP_SETTINGS; + +function readThemeFromLocalStorage(): ThemePreference { + const raw = localStorage.getItem(THEME_STORAGE_KEY); + if (raw === "light" || raw === "dark" || raw === "system") { + return raw; + } + return "system"; +} + +function buildDesktopSettingsInput(): DesktopSettingsInput { + return { + theme: latestTheme, + appSettings: latestAppSettings, + }; +} + +function serializeSettingsInput(input: DesktopSettingsInput): string { + return JSON.stringify(input); +} + +function hasLegacySettingsInLocalStorage(): boolean { + return ( + localStorage.getItem(THEME_STORAGE_KEY) !== null || + localStorage.getItem(APP_SETTINGS_STORAGE_KEY) !== null + ); +} + +function applyDesktopSettings(settings: DesktopSettings): void { + localStorage.setItem(THEME_STORAGE_KEY, settings.theme); + setLocalStorageItem(APP_SETTINGS_STORAGE_KEY, settings.appSettings, AppSettingsSchema); + dispatchLocalStorageChange(APP_SETTINGS_STORAGE_KEY); + window.dispatchEvent(new StorageEvent("storage", { key: THEME_STORAGE_KEY })); + latestTheme = settings.theme; + latestAppSettings = settings.appSettings; + lastPersistedSerialized = serializeSettingsInput({ + theme: settings.theme, + appSettings: settings.appSettings, + }); +} + +if (INITIAL_DESKTOP_SETTINGS) { + applyDesktopSettings(INITIAL_DESKTOP_SETTINGS); + desktopSettingsHydrated = true; +} + +export async function hydrateDesktopSettings(): Promise { + if (!isElectron || !window.desktopBridge) { + return; + } + + if (INITIAL_DESKTOP_SETTINGS) { + if ( + INITIAL_DESKTOP_SETTINGS.version !== APP_VERSION || + INITIAL_DESKTOP_SETTINGS.schemaVersion !== DesktopSettingsSchemaVersion + ) { + await persistDesktopSettings({ force: true }).catch(() => {}); + } + return; + } + + desktopSettingsHydrated = true; + latestTheme = Schema.decodeSync(ThemePreferenceSchema)(readThemeFromLocalStorage()); + latestAppSettings = + getLocalStorageItem(APP_SETTINGS_STORAGE_KEY, AppSettingsSchema) ?? DEFAULT_APP_SETTINGS; + if (!hasLegacySettingsInLocalStorage()) { + lastPersistedSerialized = serializeSettingsInput({ + theme: DEFAULT_THEME, + appSettings: DEFAULT_APP_SETTINGS, + }); + return; + } + + await persistDesktopSettings({ force: true }).catch(() => {}); +} + +export async function persistDesktopSettings(options?: { + force?: boolean; + theme?: ThemePreference; + appSettings?: AppSettings; +}): Promise { + if (!isElectron || !window.desktopBridge || !desktopSettingsHydrated) { + return; + } + + if (options?.theme) { + latestTheme = Schema.decodeSync(ThemePreferenceSchema)(options.theme); + } + if (options?.appSettings) { + latestAppSettings = options.appSettings; + } + + const next = buildDesktopSettingsInput(); + const serialized = serializeSettingsInput(next); + if (!options?.force && serialized === lastPersistedSerialized) { + return; + } + + const persisted = await window.desktopBridge.setSettings(next); + lastPersistedSerialized = serializeSettingsInput({ + theme: persisted.theme, + appSettings: persisted.appSettings, + }); +} + +export function getBootDesktopTheme(): ThemePreference | null { + return desktopSettingsHydrated ? latestTheme : null; +} + +export function getBootDesktopAppSettings(): AppSettings | null { + return desktopSettingsHydrated ? latestAppSettings : null; +} diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index da9aa4b889..1d890d905a 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -39,13 +39,13 @@ export const removeLocalStorageItem = (key: string) => { isomorphicLocalStorage.removeItem(key); }; -const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change"; +export const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change"; interface LocalStorageChangeDetail { key: string; } -function dispatchLocalStorageChange(key: string) { +export function dispatchLocalStorageChange(key: string) { if (typeof window === "undefined") return; window.dispatchEvent( new CustomEvent(LOCAL_STORAGE_CHANGE_EVENT, { diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 6afe83dfe3..a206089547 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,12 +1,13 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { getBootDesktopTheme, persistDesktopSettings } from "../desktopSettings"; -type Theme = "light" | "dark" | "system"; +export type Theme = "light" | "dark" | "system"; type ThemeSnapshot = { theme: Theme; systemDark: boolean; }; -const STORAGE_KEY = "t3code:theme"; +export const THEME_STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; let listeners: Array<() => void> = []; @@ -21,7 +22,12 @@ function getSystemDark(): boolean { } function getStored(): Theme { - const raw = localStorage.getItem(STORAGE_KEY); + const bootTheme = getBootDesktopTheme(); + if (bootTheme) { + return bootTheme; + } + + const raw = localStorage.getItem(THEME_STORAGE_KEY); if (raw === "light" || raw === "dark" || raw === "system") return raw; return "system"; } @@ -85,7 +91,7 @@ function subscribe(listener: () => void): () => void { // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEY) { + if (e.key === THEME_STORAGE_KEY) { applyTheme(getStored(), true); emitChange(); } @@ -107,8 +113,9 @@ export function useTheme() { theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; const setTheme = useCallback((next: Theme) => { - localStorage.setItem(STORAGE_KEY, next); + localStorage.setItem(THEME_STORAGE_KEY, next); applyTheme(next, true); + void persistDesktopSettings({ theme: next }).catch(() => {}); emitChange(); }, []); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fda5913c97..213036279c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,19 +6,28 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "@xterm/xterm/css/xterm.css"; import "./index.css"; +import { hydrateDesktopSettings } from "./desktopSettings"; import { isElectron } from "./env"; -import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); -const router = getRouter(history); - document.title = APP_DISPLAY_NAME; -ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - , -); +async function bootstrap() { + if (isElectron) { + await hydrateDesktopSettings(); + } + + const { getRouter } = await import("./router"); + const router = getRouter(history); + + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + , + ); +} + +void bootstrap(); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..f70bbf3b64 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,3 +11,4 @@ export * from "./git"; export * from "./orchestration"; export * from "./editor"; export * from "./project"; +export * from "./settings"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb176..da3636b07e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -46,6 +46,7 @@ import type { OrchestrationReadModel, } from "./orchestration"; import { EditorId } from "./editor"; +import type { DesktopSettings, DesktopSettingsInput } from "./settings"; export interface ContextMenuItem { id: T; @@ -96,9 +97,11 @@ export interface DesktopUpdateActionResult { export interface DesktopBridge { getWsUrl: () => string | null; + initialSettings: DesktopSettings | null; pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; + setSettings: (settings: DesktopSettingsInput) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts new file mode 100644 index 0000000000..4fcb299427 --- /dev/null +++ b/packages/contracts/src/settings.ts @@ -0,0 +1,47 @@ +import { Option, Schema } from "effect"; + +export const ThemePreference = Schema.Literals(["light", "dark", "system"]); +export type ThemePreference = typeof ThemePreference.Type; + +export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; +export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; +export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; + +export const AppSettings = Schema.Struct({ + codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( + Schema.withConstructorDefault(() => Option.some("local")), + ), + confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), + enableAssistantStreaming: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), + timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( + Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), + ), + customCodexModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), +}); +export type AppSettings = typeof AppSettings.Type; + +export const DesktopSettingsSchemaVersion = 1 as const; + +export const DesktopSettings = Schema.Struct({ + version: Schema.String, + schemaVersion: Schema.Number, + theme: ThemePreference, + appSettings: AppSettings, +}); +export type DesktopSettings = typeof DesktopSettings.Type; + +export const DesktopSettingsInput = Schema.Struct({ + theme: ThemePreference, + appSettings: AppSettings, +}); +export type DesktopSettingsInput = typeof DesktopSettingsInput.Type;