feat(stage-tamagotchi): dashboard window

This commit is contained in:
Neko Ayaka
2026-02-26 01:06:36 +08:00
parent c48a3719ae
commit 1d18be6150
3 changed files with 210 additions and 0 deletions
@@ -0,0 +1,173 @@
import type { Rectangle } from 'electron'
import type { InferOutput } from 'valibot'
import type { NoticeWindowManager } from '../notice'
import { dirname, join, resolve } from 'node:path'
import { env } from 'node:process'
import { fileURLToPath } from 'node:url'
import clickDragPlugin from 'electron-click-drag-plugin'
import { is } from '@electron-toolkit/utils'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { initScreenCaptureForWindow } from '@proj-airi/electron-screen-capture/main'
import { defu } from 'defu'
import { BrowserWindow, ipcMain, shell } from 'electron'
import { isLinux } 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, withHashRoute } from '../../libs/electron/location'
import { createConfig } from '../../libs/electron/persistence'
import { setupDashboardWindowElectronInvokes } from './rpc/index.electron'
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 setupDashboardWindow(params: {
settingsWindow: () => Promise<BrowserWindow>
chatWindow: () => Promise<BrowserWindow>
noticeWindow: NoticeWindowManager
onWindowCreated?: (window: BrowserWindow) => void
}) {
const {
setup: setupConfig,
get: getConfigRaw,
update: updateConfig,
} = createConfig('app', 'config.json', appConfigSchema, {
default: { windows: [] },
autoHeal: true,
})
const getConfig = (): AppConfig => getConfigRaw() ?? { windows: [] }
setupConfig()
const windowConfig = getConfig().windows?.find(w => w.title === 'AIRI Dashboard' && w.tag === 'dashboard')
const window = new BrowserWindow({
title: 'AIRI Dashboard',
width: windowConfig?.width ?? 1200.0,
height: windowConfig?.height ?? 600.0,
x: windowConfig?.x,
y: windowConfig?.y,
show: false,
icon,
webPreferences: {
preload: join(dirname(fileURLToPath(import.meta.url)), '../preload/index.mjs'),
sandbox: false,
},
})
if (params.onWindowCreated) {
params.onWindowCreated(window)
}
// NOTICE: in development mode, open devtools by default
if (is.dev || env.MAIN_APP_DEBUG || env.APP_DEBUG) {
try {
window.webContents.openDevTools({ mode: 'detach' })
}
catch (err) {
console.error('failed to open devtools:', err)
}
}
function handleNewBounds(newBounds: Rectangle) {
const config = getConfig()
if (!config.windows || !Array.isArray(config.windows)) {
config.windows = []
}
const existingConfigIndex = config.windows.findIndex(w => w.title === 'AIRI Dashboard' && w.tag === 'dashboard')
if (existingConfigIndex === -1) {
config.windows.push({
title: 'AIRI Dashboard',
tag: 'dashboard',
x: newBounds.x,
y: newBounds.y,
width: newBounds.width,
height: newBounds.height,
})
}
else {
const windowConfig = defu(config.windows[existingConfigIndex], { title: 'AIRI Dashboard', tag: 'dashboard' })
windowConfig.x = newBounds.x
windowConfig.y = newBounds.y
windowConfig.width = newBounds.width
windowConfig.height = newBounds.height
config.windows[existingConfigIndex] = windowConfig
}
updateConfig(config)
}
window.on('resize', () => handleNewBounds(window.getBounds()))
window.on('move', () => handleNewBounds(window.getBounds()))
window.on('ready-to-show', () => window!.show())
window.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/dashboard'))
setupDashboardWindowElectronInvokes({
window,
settingsWindow: params.settingsWindow,
chatWindow: params.chatWindow,
noticeWindow: params.noticeWindow,
})
/**
* This is a know issue (or expected behavior maybe) to Electron.
* We don't use this approach on Linux because it's not working.
*
* Discussion: https://github.com/electron/electron/issues/37789
* Workaround: https://github.com/noobfromph/electron-click-drag-plugin
*/
if (!isLinux) {
function handleStartDraggingWindow() {
try {
const windowId = window.getNativeWindowHandle()
clickDragPlugin.startDrag(windowId)
}
catch (error) {
console.error(error)
}
}
// TODO: once we refactored eventa to support window-namespaced contexts,
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
// manage events within eventa's context system.
ipcMain.setMaxListeners(0)
const { context } = createContext(ipcMain, window)
const cleanUpWindowDraggingInvokeHandler = defineInvokeHandler(context, electronStartDraggingWindow, handleStartDraggingWindow)
window.on('closed', () => {
cleanUpWindowDraggingInvokeHandler()
})
}
initScreenCaptureForWindow(window)
return window
}
@@ -0,0 +1,32 @@
import type { BrowserWindow } from 'electron'
import type { NoticeWindowManager } from '../../notice'
import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa'
import { toggleWindowShow } from '../../shared'
import { setupBaseWindowElectronInvokes } from '../../shared/window'
export function setupDashboardWindowElectronInvokes(params: {
window: BrowserWindow
settingsWindow: () => Promise<BrowserWindow>
chatWindow: () => Promise<BrowserWindow>
noticeWindow: NoticeWindowManager
}) {
// TODO: once we refactored eventa to support window-namespaced contexts,
// we can remove the setMaxListeners call below since eventa will be able to dispatch and
// manage events within eventa's context system.
ipcMain.setMaxListeners(0)
const { context } = createContext(ipcMain, params.window)
setupBaseWindowElectronInvokes({ context, window: params.window })
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenSettings, async () => toggleWindowShow(await params.settingsWindow()))
defineInvokeHandler(context, electronOpenChat, async () => toggleWindowShow(await params.chatWindow()))
defineInvokeHandler(context, noticeWindowEventa.openWindow, payload => params.noticeWindow.open(payload))
}
@@ -0,0 +1,5 @@
<template>
<div>
Dashboard
</div>
</template>