feat(stage-tamagotchi): promoted to libs/electron

This commit is contained in:
Neko Ayaka
2026-02-09 03:11:21 +08:00
parent 6270487237
commit 9419a77810
5 changed files with 233 additions and 87 deletions
@@ -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<T> {
status: ConfigStatus
path: string
issues?: BaseIssue<unknown>[]
error?: unknown
raw?: string
healed?: boolean
value?: T
}
export interface CreateConfigOptions<T> {
default?: T
autoHeal?: boolean
onValidationFailure?: (diagnostics: ConfigDiagnostics<T>) => void
onReadError?: (diagnostics: ConfigDiagnostics<T>) => void
}
const persistenceMap = new Map<string, unknown>()
const diagnosticsMap = new Map<string, ConfigDiagnostics<unknown>>()
function createConfigPath(namespace: string, filename: string) {
return join(app.getPath('userData'), `${namespace}-${filename}`)
}
type PersistedSchema = BaseSchema<unknown, unknown, BaseIssue<unknown>>
function parseWithSchema<TSchema extends PersistedSchema>(
raw: string,
schema: TSchema,
): { value?: InferOutput<TSchema>, issues?: InferIssue<TSchema>[] } {
const parsed = safeDestr<unknown>(raw)
const result = safeParse(schema, parsed)
if (result.success) {
return { value: result.output }
}
return { issues: result.issues }
}
export function createConfig<TSchema extends PersistedSchema>(
namespace: string,
filename: string,
schema: TSchema,
options?: CreateConfigOptions<InferOutput<TSchema>>,
) {
const key = `${namespace}:${filename}`
const autoHeal = options?.autoHeal ?? Boolean(options?.default)
const configPath = () => createConfigPath(namespace, filename)
const recordDiagnostics = (diagnostics: ConfigDiagnostics<InferOutput<TSchema>>) => {
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<TSchema>) => {
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<TSchema>) => {
persistenceMap.set(key, newData)
save()
}
const get = () => persistenceMap.get(key) as InferOutput<TSchema> | undefined
const getDiagnostics = () => diagnosticsMap.get(key) as ConfigDiagnostics<InferOutput<TSchema>> | undefined
return {
setup,
get,
update,
getDiagnostics,
}
}
@@ -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<string, CaptionMatrixConfig>
}
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<typeof captionConfigSchema>
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<CaptionConfig>('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
@@ -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<Pick<BrowserWindowConstructorOptions, 'title' | 'x' | 'y' | 'width' | 'height'> & { 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<typeof appConfigSchema>
export async function setupMainWindow(params: {
settingsWindow: () => Promise<BrowserWindow>
@@ -40,13 +51,17 @@ export async function setupMainWindow(params: {
}) {
const {
setup: setupConfig,
get: getConfig,
get: getConfigRaw,
update: updateConfig,
} = createConfig<AppConfig>('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 = []
}
@@ -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<T>(config: string, fallback: T | undefined): T | undefined {
try {
const parsed = safeDestr<T>(config)
return parsed ?? fallback
}
catch (error) {
console.warn('Invalid config persisted to disk, resetting to default', error)
return fallback
}
}
const persistenceMap = new Map<string, any>()
export function createConfig<T>(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<T>(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'
@@ -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<typeof widgetsWindowConfigSchema>
function computeDefaultBounds(): Rectangle {
const primary = screen.getPrimaryDisplay().workArea
@@ -85,7 +94,11 @@ interface WidgetWindowContext {
}
export function setupWidgetsWindowManager(): WidgetsWindowManager {
const { setup, get, update } = createConfig<WidgetsWindowConfig>('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<typeof createContext>['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 = {