feat(stage-tamagotchi): promoted to libs/electron
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user