diff --git a/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts b/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts new file mode 100644 index 000000000..95b678dfb --- /dev/null +++ b/apps/stage-tamagotchi/src/main/libs/electron/persistence.ts @@ -0,0 +1,162 @@ +import type { BaseIssue, BaseSchema, InferIssue, InferOutput } from 'valibot' + +import { existsSync, readFileSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { safeDestr } from 'destr' +import { app } from 'electron' +import { throttle } from 'es-toolkit' +import { safeParse } from 'valibot' + +type ConfigStatus = 'ok' | 'missing' | 'invalid' | 'read-error' + +export interface ConfigDiagnostics { + status: ConfigStatus + path: string + issues?: BaseIssue[] + error?: unknown + raw?: string + healed?: boolean + value?: T +} + +export interface CreateConfigOptions { + default?: T + autoHeal?: boolean + onValidationFailure?: (diagnostics: ConfigDiagnostics) => void + onReadError?: (diagnostics: ConfigDiagnostics) => void +} + +const persistenceMap = new Map() +const diagnosticsMap = new Map>() + +function createConfigPath(namespace: string, filename: string) { + return join(app.getPath('userData'), `${namespace}-${filename}`) +} + +type PersistedSchema = BaseSchema> + +function parseWithSchema( + raw: string, + schema: TSchema, +): { value?: InferOutput, issues?: InferIssue[] } { + const parsed = safeDestr(raw) + const result = safeParse(schema, parsed) + if (result.success) { + return { value: result.output } + } + return { issues: result.issues } +} + +export function createConfig( + namespace: string, + filename: string, + schema: TSchema, + options?: CreateConfigOptions>, +) { + const key = `${namespace}:${filename}` + const autoHeal = options?.autoHeal ?? Boolean(options?.default) + + const configPath = () => createConfigPath(namespace, filename) + + const recordDiagnostics = (diagnostics: ConfigDiagnostics>) => { + diagnosticsMap.set(key, diagnostics) + return diagnostics + } + + const save = throttle(async () => { + try { + await writeFile(configPath(), JSON.stringify(persistenceMap.get(key))) + } + catch (error) { + console.error('Failed to save config', error) + } + }, 250) + + const writeHealingConfig = async (value: InferOutput) => { + try { + await writeFile(configPath(), JSON.stringify(value)) + return true + } + catch (error) { + console.error('Failed to heal config', error) + return false + } + } + + const setup = () => { + const path = configPath() + if (!existsSync(path)) { + const diagnostics = recordDiagnostics({ + status: 'missing', + path, + value: options?.default, + }) + persistenceMap.set(key, options?.default) + return diagnostics + } + + try { + const raw = readFileSync(path, { encoding: 'utf-8' }) + const parsed = parseWithSchema(raw, schema) + if (parsed.value !== undefined) { + const diagnostics = recordDiagnostics({ + status: 'ok', + path, + value: parsed.value, + }) + persistenceMap.set(key, parsed.value) + return diagnostics + } + + const fallback = options?.default + const diagnostics = recordDiagnostics({ + status: 'invalid', + path, + issues: parsed.issues, + raw, + value: fallback, + }) + options?.onValidationFailure?.(diagnostics) + persistenceMap.set(key, fallback) + + if (autoHeal && fallback !== undefined) { + void writeHealingConfig(fallback).then((healed) => { + if (healed) { + diagnosticsMap.set(key, { ...diagnostics, healed }) + } + }) + } + return diagnostics + } + catch (error) { + const fallback = options?.default + const diagnostics = recordDiagnostics({ + status: 'read-error', + path, + error, + value: fallback, + }) + options?.onReadError?.(diagnostics) + persistenceMap.set(key, fallback) + return diagnostics + } + } + + const update = (newData: InferOutput) => { + persistenceMap.set(key, newData) + save() + } + + const get = () => persistenceMap.get(key) as InferOutput | undefined + + const getDiagnostics = () => diagnosticsMap.get(key) as ConfigDiagnostics> | undefined + + return { + setup, + get, + update, + getDiagnostics, + } +} diff --git a/apps/stage-tamagotchi/src/main/windows/caption/index.ts b/apps/stage-tamagotchi/src/main/windows/caption/index.ts index c040a87e1..e7f8c8e4a 100644 --- a/apps/stage-tamagotchi/src/main/windows/caption/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/caption/index.ts @@ -1,4 +1,5 @@ import type { BrowserWindow, BrowserWindowConstructorOptions, Rectangle } from 'electron' +import type { InferOutput } from 'valibot' import { createHash } from 'node:crypto' import { join, resolve } from 'node:path' @@ -9,26 +10,34 @@ import { animate, utils } from 'animejs' import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen, shell } from 'electron' import { debounce, throttle } from 'es-toolkit' import { isMacOS } from 'std-env' +import { boolean, number, object, optional, record, string } from 'valibot' import icon from '../../../../resources/icon.png?asset' import { captionGetIsFollowingWindow, captionIsFollowingWindowChanged } from '../../../shared/eventa' import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' +import { createConfig } from '../../libs/electron/persistence' import { createReusableWindow } from '../../libs/electron/window-manager' import { setupBaseWindowElectronInvokes } from '../main/rpc/index.electron' import { mapForBreakpoints, resolutionBreakpoints, widthFrom } from '../shared/display' -import { createConfig } from '../shared/persistence' import { transparentWindowConfig } from '../shared/window' -interface CaptionMatrixConfig { - bounds: Rectangle - relativeToMain?: { dx: number, dy: number } -} - -interface CaptionConfig { - isFollowing: boolean - matrices: Record -} +const captionConfigSchema = object({ + isFollowing: boolean(), + matrices: record(string(), object({ + bounds: object({ + x: number(), + y: number(), + width: number(), + height: number(), + }), + relativeToMain: optional(object({ + dx: number(), + dy: number(), + })), + })), +}) +type CaptionConfig = InferOutput function computeDisplayMatrixHash(): string { const displays = screen.getAllDisplays() @@ -139,13 +148,17 @@ export function setupCaptionWindowManager(params: { mainWindow: BrowserWindow }) const { setup: setupConfig, - get: getConfig, + get: getConfigRaw, update: updateConfig, - } = createConfig('windows-caption', 'config.json', { default: { isFollowing: true, matrices: {} } }) + } = createConfig('windows-caption', 'config.json', captionConfigSchema, { + default: { isFollowing: true, matrices: {} }, + autoHeal: true, + }) + const getConfig = (): CaptionConfig => getConfigRaw() ?? { isFollowing: true, matrices: {} } setupConfig() - let isFollowing = getConfig()?.isFollowing ?? true + let isFollowing = getConfig().isFollowing ?? true let lastProgrammaticMoveAt = 0 // Keep references to listeners so we can detach when toggling diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index 43d48b5d1..479e4e44a 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -1,4 +1,5 @@ -import type { BrowserWindowConstructorOptions, Rectangle } from 'electron' +import type { Rectangle } from 'electron' +import type { InferOutput } from 'valibot' import type { AutoUpdater } from '../../services/electron/auto-updater' import type { NoticeWindowManager } from '../notice' @@ -17,18 +18,28 @@ import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/m import { defu } from 'defu' import { BrowserWindow, ipcMain, shell } from 'electron' import { isLinux, isMacOS } from 'std-env' +import { array, number, object, optional, string } from 'valibot' import icon from '../../../../resources/icon.png?asset' import { electronStartDraggingWindow } from '../../../shared/eventa' import { baseUrl, getElectronMainDirname, load } from '../../libs/electron/location' +import { createConfig } from '../../libs/electron/persistence' import { transparentWindowConfig } from '../shared' -import { createConfig } from '../shared/persistence' import { setupMainWindowElectronInvokes } from './rpc/index.electron' -interface AppConfig { - windows?: Array & { tag: string }> -} +const appConfigSchema = object({ + windows: optional(array(object({ + title: optional(string()), + tag: string(), + x: optional(number()), + y: optional(number()), + width: optional(number()), + height: optional(number()), + }))), +}) + +type AppConfig = InferOutput export async function setupMainWindow(params: { settingsWindow: () => Promise @@ -40,13 +51,17 @@ export async function setupMainWindow(params: { }) { const { setup: setupConfig, - get: getConfig, + get: getConfigRaw, update: updateConfig, - } = createConfig('app', 'config.json', { default: { windows: [] } }) + } = createConfig('app', 'config.json', appConfigSchema, { + default: { windows: [] }, + autoHeal: true, + }) + const getConfig = (): AppConfig => getConfigRaw() ?? { windows: [] } setupConfig() - const mainWindowConfig = getConfig()?.windows?.find(w => w.title === 'AIRI' && w.tag === 'main') + const mainWindowConfig = getConfig().windows?.find(w => w.title === 'AIRI' && w.tag === 'main') const window = new BrowserWindow({ title: 'AIRI', @@ -83,7 +98,7 @@ export async function setupMainWindow(params: { } function handleNewBounds(newBounds: Rectangle) { - const config = getConfig()! + const config = getConfig() if (!config.windows || !Array.isArray(config.windows)) { config.windows = [] } diff --git a/apps/stage-tamagotchi/src/main/windows/shared/persistence.ts b/apps/stage-tamagotchi/src/main/windows/shared/persistence.ts index f83bcb3fc..e60bba1dd 100644 --- a/apps/stage-tamagotchi/src/main/windows/shared/persistence.ts +++ b/apps/stage-tamagotchi/src/main/windows/shared/persistence.ts @@ -1,59 +1,2 @@ -import { existsSync, readFileSync } from 'node:fs' -import { writeFile } from 'node:fs/promises' -import { join } from 'node:path' - -import { safeDestr } from 'destr' -import { app } from 'electron' -import { throttle } from 'es-toolkit' - -function parseOrFallback(config: string, fallback: T | undefined): T | undefined { - try { - const parsed = safeDestr(config) - return parsed ?? fallback - } - catch (error) { - console.warn('Invalid config persisted to disk, resetting to default', error) - return fallback - } -} - -const persistenceMap = new Map() - -export function createConfig(namespace: string, filename: string, options?: { default?: T }) { - function configPath() { - const path = join(app.getPath('userData'), `${namespace}-${filename}`) - return path - } - - function setup() { - const path = configPath() - const data = existsSync(path) - ? parseOrFallback(readFileSync(configPath(), { encoding: 'utf-8' }), options?.default) - : options?.default - persistenceMap.set(`${namespace}-${filename}`, data) - } - - const save = throttle(async () => { - try { - await writeFile(configPath(), JSON.stringify(persistenceMap.get(`${namespace}-${filename}`))) - } - catch (e) { - console.error('Failed to save config', e) - } - }, 250) - - function update(newData: T) { - persistenceMap.set(`${namespace}-${filename}`, newData) - save() - } - - function get(): T | undefined { - return persistenceMap.get(`${namespace}-${filename}`) as T | undefined - } - - return { - setup, - get, - update, - } -} +export { createConfig } from '../../libs/electron/persistence' +export type { ConfigDiagnostics, CreateConfigOptions } from '../../libs/electron/persistence' diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts index b62443215..8cb417fa5 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts @@ -1,4 +1,5 @@ import type { BrowserWindow, Rectangle } from 'electron' +import type { InferOutput } from 'valibot' import type { WidgetsAddPayload, WidgetSnapshot } from '../../../shared/eventa' @@ -7,13 +8,14 @@ import { join, resolve } from 'node:path' import { createContext } from '@moeru/eventa/adapters/electron/main' import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen, shell } from 'electron' import { isMacOS } from 'std-env' +import { number, object, optional } from 'valibot' import icon from '../../../../resources/icon.png?asset' import { widgetsClearEvent, widgetsRemoveEvent, widgetsRenderEvent, widgetsUpdateEvent } from '../../../shared/eventa' import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' +import { createConfig } from '../../libs/electron/persistence' import { createReusableWindow } from '../../libs/electron/window-manager' -import { createConfig } from '../shared/persistence' import { spotlightLikeWindowConfig, transparentWindowConfig } from '../shared/window' import { setupWidgetsWindowInvokes } from './rpc/index.electron' @@ -28,9 +30,16 @@ export interface WidgetsWindowManager { prepareWidgetWindow: (options?: { id?: string }) => string } -interface WidgetsWindowConfig { - bounds?: Rectangle -} +const widgetsWindowConfigSchema = object({ + bounds: optional(object({ + x: number(), + y: number(), + width: number(), + height: number(), + })), +}) + +type WidgetsWindowConfig = InferOutput function computeDefaultBounds(): Rectangle { const primary = screen.getPrimaryDisplay().workArea @@ -85,7 +94,11 @@ interface WidgetWindowContext { } export function setupWidgetsWindowManager(): WidgetsWindowManager { - const { setup, get, update } = createConfig('windows-widgets', 'config.json', { default: {} }) + const { setup, get: getConfigRaw, update } = createConfig('windows-widgets', 'config.json', widgetsWindowConfigSchema, { + default: {}, + autoHeal: true, + }) + const getConfig = (): WidgetsWindowConfig => getConfigRaw() ?? {} setup() let eventaContext: ReturnType['context'] | undefined @@ -110,7 +123,7 @@ export function setupWidgetsWindowManager(): WidgetsWindowManager { const { context } = createContext(ipcMain, window) eventaContext = context - const saved = get()?.bounds + const saved = getConfig().bounds if (saved) { const work = screen.getDisplayMatching(saved).workArea const clamped: Rectangle = {