From 69cb613269d0c1c91e766b25f866df8d0cb9737c Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Fri, 14 Nov 2025 19:24:57 +0800 Subject: [PATCH] feat(stage-tamagotchi): basic widget window manager, moved partial code from component-calling --- apps/stage-tamagotchi/src/main/index.ts | 16 +- .../libs/electron/window-manager/reusable.ts | 39 +- .../src/main/services/airi/widgets/index.ts | 67 ++++ .../src/main/windows/main/index.ts | 10 +- .../main/windows/main/rpc/index.electron.ts | 5 + .../src/main/windows/settings/index.ts | 8 +- .../windows/settings/rpc/index.electron.ts | 6 +- .../src/main/windows/widgets/index.ts | 306 ++++++++++++++++ .../windows/widgets/rpc/index.electron.ts | 22 ++ .../src/renderer/layouts/settings.vue | 4 + .../pages/devtools/widgets-calling.vue | 332 ++++++++++++++++++ .../pages/settings/system/developer.vue | 6 + .../src/renderer/pages/widgets.vue | 237 +++++++++++++ .../renderer/widgets/weather/assets/README.md | 10 + .../widgets/weather/assets/clear-day.svg | 13 + .../widgets/weather/components/Skeleton.vue | 75 ++++ .../widgets/weather/components/Weather.vue | 35 ++ .../src/renderer/widgets/weather/index.ts | 1 + apps/stage-tamagotchi/src/shared/eventa.ts | 33 ++ packages/i18n/src/locales/en/settings.yaml | 3 + .../src/locales/en/tamagotchi/settings.yaml | 5 + 21 files changed, 1214 insertions(+), 19 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts create mode 100644 apps/stage-tamagotchi/src/main/windows/widgets/index.ts create mode 100644 apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts create mode 100644 apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue create mode 100644 apps/stage-tamagotchi/src/renderer/pages/widgets.vue create mode 100644 apps/stage-tamagotchi/src/renderer/widgets/weather/assets/README.md create mode 100644 apps/stage-tamagotchi/src/renderer/widgets/weather/assets/clear-day.svg create mode 100644 apps/stage-tamagotchi/src/renderer/widgets/weather/components/Skeleton.vue create mode 100644 apps/stage-tamagotchi/src/renderer/widgets/weather/components/Weather.vue create mode 100644 apps/stage-tamagotchi/src/renderer/widgets/weather/index.ts diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index 27671a8d6..665d41674 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -1,5 +1,7 @@ import type { BrowserWindow } from 'electron' +import type { WidgetsWindowManager } from './windows/widgets' + import { platform } from 'node:process' import { electronApp, optimizer } from '@electron-toolkit/utils' @@ -22,6 +24,7 @@ import { setupInlayWindow } from './windows/inlay' import { setupMainWindow } from './windows/main' import { setupSettingsWindowReusableFunc } from './windows/settings' import { toggleWindowShow } from './windows/shared/window' +import { setupWidgetsWindowManager } from './windows/widgets' // 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 @@ -56,6 +59,7 @@ function setupTray(params: { mainWindow: BrowserWindow settingsWindow: () => Promise captionWindow: ReturnType + widgetsWindow: WidgetsWindowManager }): void { once(() => { const trayImage = nativeImage.createFromPath(isMacOS ? macOSTrayIcon : icon).resize({ width: 16 }) @@ -69,6 +73,7 @@ function setupTray(params: { { label: 'Settings...', click: () => params.settingsWindow().then(window => toggleWindowShow(window)) }, { type: 'separator' }, { label: 'Open Inlay...', click: () => setupInlayWindow() }, + { label: 'Open Widgets...', click: () => params.widgetsWindow.getWindow().then(window => toggleWindowShow(window)) }, { label: 'Open Caption...', click: () => params.captionWindow.getWindow().then(window => toggleWindowShow(window)) }, { type: 'submenu', @@ -96,10 +101,15 @@ app.whenReady().then(async () => { injecta.setLogger(createLoggLogger(useLogg('injecta').useGlobalConfig())) const channelServerModule = injecta.provide('modules:channel-server', async () => setupChannelServer()) - const settingsWindow = injecta.provide('windows:settings', () => setupSettingsWindowReusableFunc()) const chatWindow = injecta.provide('windows:chat', { build: () => setupChatWindowReusableFunc() }) + const widgetsManager = injecta.provide('windows:widgets', { build: () => setupWidgetsWindowManager() }) + + const settingsWindow = injecta.provide('windows:settings', { + dependsOn: { widgetsManager }, + build: ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn), + }) const mainWindow = injecta.provide('windows:main', { - dependsOn: { settingsWindow, chatWindow }, + dependsOn: { settingsWindow, chatWindow, widgetsManager }, build: async ({ dependsOn }) => setupMainWindow(dependsOn), }) const captionWindow = injecta.provide('windows:caption', { @@ -107,7 +117,7 @@ app.whenReady().then(async () => { build: async ({ dependsOn }) => setupCaptionWindowManager(dependsOn), }) const tray = injecta.provide('app:tray', { - dependsOn: { mainWindow, settingsWindow, captionWindow }, + dependsOn: { mainWindow, settingsWindow, captionWindow, widgetsWindow: widgetsManager }, build: async ({ dependsOn }) => setupTray(dependsOn), }) injecta.invoke({ diff --git a/apps/stage-tamagotchi/src/main/libs/electron/window-manager/reusable.ts b/apps/stage-tamagotchi/src/main/libs/electron/window-manager/reusable.ts index ef45038d4..2579d0ab2 100644 --- a/apps/stage-tamagotchi/src/main/libs/electron/window-manager/reusable.ts +++ b/apps/stage-tamagotchi/src/main/libs/electron/window-manager/reusable.ts @@ -2,19 +2,34 @@ import type { BrowserWindow } from 'electron' export function createReusableWindow(setupFn: () => BrowserWindow | Promise): { getWindow: () => Promise } { let window: BrowserWindow | undefined + let windowSetupFnPromise: Promise | undefined + + const ensureWindow = async () => { + if (window && !window.isDestroyed()) + return window + + if (windowSetupFnPromise) + return windowSetupFnPromise + + windowSetupFnPromise = Promise.resolve(setupFn()).then((created) => { + window = created + windowSetupFnPromise = undefined + + created.on?.('closed', () => { + if (window === created) + window = undefined + }) + + return created + }).catch((error) => { + windowSetupFnPromise = undefined + throw error + }) + + return windowSetupFnPromise + } return { - getWindow: async () => { - if (!window) { - window = await setupFn() - return window - } - if (window.isDestroyed()) { - window = await setupFn() - return window - } - - return window - }, + getWindow: async () => ensureWindow(), } } diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts new file mode 100644 index 000000000..c0d5d1447 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts @@ -0,0 +1,67 @@ +import type { createContext } from '@unbird/eventa/adapters/electron/main' +import type { BrowserWindow, IpcMainEvent } from 'electron' + +import type { WidgetsWindowManager } from '../../../windows/widgets' + +import { defineInvokeHandlers } from '@unbird/eventa' + +import { widgetsAdd, widgetsClear, widgetsFetch, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, widgetsUpdate } from '../../../../shared/eventa' + +interface InvokeOptions { + raw?: { ipcMainEvent?: IpcMainEvent } +} + +function isFromWindow(options: InvokeOptions | undefined, window: BrowserWindow) { + const sender = options?.raw?.ipcMainEvent?.sender + if (!sender) + return false + return sender.id === window.webContents.id +} + +export function createWidgetsService(params: { context: ReturnType['context'], widgetsManager: WidgetsWindowManager, window: BrowserWindow }) { + defineInvokeHandlers(params.context, { + widgetsPrepareWindow, + widgetsOpenWindow, + widgetsAdd, + widgetsUpdate, + widgetsRemove, + widgetsClear, + widgetsFetch, + }, { + widgetsPrepareWindow: async (payload, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + return params.widgetsManager!.prepareWidgetWindow(payload ?? undefined) + }, + widgetsOpenWindow: async (payload, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + return params.widgetsManager!.openWindow(payload ?? undefined) + }, + widgetsAdd: async (payload, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + return payload ? params.widgetsManager!.pushWidget(payload) : undefined + }, + widgetsUpdate: async (payload, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + return payload ? params.widgetsManager!.updateWidget(payload) : undefined + }, + widgetsRemove: async (payload, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + return payload?.id ? params.widgetsManager!.removeWidget(payload.id) : undefined + }, + widgetsClear: async (_payload, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + return params.widgetsManager!.clearWidgets() + }, + widgetsFetch: async (payload, options) => { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + return payload?.id ? params.widgetsManager!.getWidgetSnapshot(payload.id) : undefined + }, + }) +} diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index 6615698c2..d39979e03 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -1,5 +1,7 @@ import type { BrowserWindowConstructorOptions, Rectangle } from 'electron' +import type { WidgetsWindowManager } from '../widgets' + import { dirname, join, resolve } from 'node:path' import { env } from 'node:process' import { fileURLToPath } from 'node:url' @@ -28,6 +30,7 @@ interface AppConfig { export async function setupMainWindow(params: { settingsWindow: () => Promise chatWindow: () => Promise + widgetsManager: WidgetsWindowManager }) { const { setup: setupConfig, @@ -123,7 +126,12 @@ export async function setupMainWindow(params: { await load(window, baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))) - setupMainWindowElectronInvokes({ window, settingsWindow: params.settingsWindow, chatWindow: params.chatWindow }) + setupMainWindowElectronInvokes({ + window, + settingsWindow: params.settingsWindow, + chatWindow: params.chatWindow, + widgetsManager: params.widgetsManager, + }) /** * This is a know issue (or expected behavior maybe) to Electron. diff --git a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts index 206949cff..b5e2379f6 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/rpc/index.electron.ts @@ -1,10 +1,13 @@ import type { BrowserWindow } from 'electron' +import type { WidgetsWindowManager } from '../../widgets' + import { defineInvokeHandler } from '@unbird/eventa' import { createContext } from '@unbird/eventa/adapters/electron/main' import { ipcMain } from 'electron' import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings } from '../../../../shared/eventa' +import { createWidgetsService } from '../../../services/airi/widgets' import { createScreenService, createWindowService } from '../../../services/electron' import { toggleWindowShow } from '../../shared' @@ -12,6 +15,7 @@ export function setupMainWindowElectronInvokes(params: { window: BrowserWindow settingsWindow: () => Promise chatWindow: () => Promise + widgetsManager: WidgetsWindowManager }) { // 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 @@ -22,6 +26,7 @@ export function setupMainWindowElectronInvokes(params: { createScreenService({ context, window: params.window }) createWindowService({ context, window: params.window }) + createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' })) defineInvokeHandler(context, electronOpenSettings, async () => toggleWindowShow(await params.settingsWindow())) diff --git a/apps/stage-tamagotchi/src/main/windows/settings/index.ts b/apps/stage-tamagotchi/src/main/windows/settings/index.ts index 65179338c..0e19cf174 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -1,3 +1,5 @@ +import type { WidgetsWindowManager } from '../widgets' + import { join, resolve } from 'node:path' import { BrowserWindow, shell } from 'electron' @@ -8,7 +10,9 @@ import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs import { createReusableWindow } from '../../libs/electron/window-manager' import { setupSettingsWindowInvokes } from './rpc/index.electron' -export function setupSettingsWindowReusableFunc() { +export function setupSettingsWindowReusableFunc(params: { + widgetsManager: WidgetsWindowManager +}) { return createReusableWindow(async () => { const window = new BrowserWindow({ title: 'Settings', @@ -29,7 +33,7 @@ export function setupSettingsWindowReusableFunc() { }) await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/settings')) - await setupSettingsWindowInvokes({ settingsWindow: window }) + await setupSettingsWindowInvokes({ settingsWindow: window, widgetsManager: params.widgetsManager }) return window }).getWindow diff --git a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts index 2ea7a69ab..1cd387204 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/rpc/index.electron.ts @@ -1,13 +1,16 @@ import type { BrowserWindow } from 'electron' +import type { WidgetsWindowManager } from '../../widgets' + import { defineInvokeHandler } from '@unbird/eventa' import { createContext } from '@unbird/eventa/adapters/electron/main' import { ipcMain } from 'electron' import { electronOpenSettingsDevtools } from '../../../../shared/eventa' +import { createWidgetsService } from '../../../services/airi/widgets' import { createScreenService, createWindowService } from '../../../services/electron' -export async function setupSettingsWindowInvokes(params: { settingsWindow: BrowserWindow }) { +export async function setupSettingsWindowInvokes(params: { settingsWindow: BrowserWindow, widgetsManager: WidgetsWindowManager }) { // 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. @@ -17,6 +20,7 @@ export async function setupSettingsWindowInvokes(params: { settingsWindow: Brows createScreenService({ context, window: params.settingsWindow }) createWindowService({ context, window: params.settingsWindow }) + createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.settingsWindow }) defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' })) } diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts new file mode 100644 index 000000000..af1dc005e --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts @@ -0,0 +1,306 @@ +import type { BrowserWindow, Rectangle } from 'electron' + +import type { WidgetsAddPayload, WidgetSnapshot } from '../../../shared/eventa' + +import { join, resolve } from 'node:path' + +import { createContext } from '@unbird/eventa/adapters/electron/main' +import { BrowserWindow as ElectronBrowserWindow, ipcMain, screen, shell } from 'electron' +import { isMacOS } from 'std-env' + +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 { createReusableWindow } from '../../libs/electron/window-manager' +import { createConfig } from '../shared/persistence' +import { transparentWindowConfig } from '../shared/window' +import { setupWidgetsWindowInvokes } from './rpc/index.electron' + +export interface WidgetsWindowManager { + getWindow: () => Promise + openWindow: (params?: { id?: string }) => Promise + pushWidget: (payload: WidgetsAddPayload) => Promise + updateWidget: (payload: { id: string, componentProps?: Record }) => Promise + removeWidget: (id: string) => Promise + clearWidgets: () => Promise + getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined + prepareWidgetWindow: (options?: { id?: string }) => string +} + +interface WidgetsWindowConfig { + bounds?: Rectangle +} + +function computeDefaultBounds(): Rectangle { + const primary = screen.getPrimaryDisplay().workArea + const width = Math.min(500, Math.floor(primary.width * 0.35)) + const height = Math.min(500, Math.floor(primary.height * 0.6)) + const x = primary.x + primary.width - width - 16 + const y = primary.y + 16 + return { x, y, width, height } +} + +function createWidgetsWindow() { + const window = new ElectronBrowserWindow({ + title: 'Widgets', + width: 620, + height: 760, + show: false, + icon, + webPreferences: { + preload: join(__dirname, '../preload/index.mjs'), + sandbox: false, + }, + // Top-level overlay style like other overlay windows + type: 'panel', + ...transparentWindowConfig(), + }) + + // Keep on top like caption/main overlays + window.setAlwaysOnTop(true, 'screen-saver', 1) + window.setFullScreenable(false) + window.setVisibleOnAllWorkspaces(true) + if (isMacOS) + window.setWindowButtonVisibility(false) + + window.on('ready-to-show', () => window.show()) + window.webContents.setWindowOpenHandler((details) => { + shell.openExternal(details.url) + return { action: 'deny' } + }) + + return window +} + +interface WidgetRecord extends WidgetSnapshot { + timer?: ReturnType +} + +interface WidgetWindowContext { + widgetId: string + windowBuilder: () => Promise + window?: BrowserWindow +} + +export function setupWidgetsWindowManager(): WidgetsWindowManager { + const { setup, get, update } = createConfig('windows-widgets', 'config.json', { default: {} }) + setup() + + let eventaContext: ReturnType['context'] | undefined + const widgetRecords = new Map() + const windowContexts = new Map() + + const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) + const defaultRoute = '/widgets' + + let pendingRoute: string | undefined + let currentRoute: string | undefined + + let widgetsManager: WidgetsWindowManager | undefined + + const reusable = createReusableWindow(async () => { + // 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 window = createWidgetsWindow() + const { context } = createContext(ipcMain, window) + eventaContext = context + + const saved = get()?.bounds + if (saved) { + const work = screen.getDisplayMatching(saved).workArea + const clamped: Rectangle = { + x: Math.min(Math.max(saved.x, work.x), work.x + work.width - saved.width), + y: Math.min(Math.max(saved.y, work.y), work.y + work.height - saved.height), + width: Math.min(saved.width, work.width), + height: Math.min(saved.height, work.height), + } + window.setBounds(clamped) + } + else { + window.setBounds(computeDefaultBounds()) + } + + const persist = () => update({ bounds: window.getBounds() }) + window.on('resize', persist) + window.on('move', persist) + + const initialRoute = pendingRoute ?? defaultRoute + await loadWithRoute(window, initialRoute) + await setupWidgetsWindowInvokes({ widgetWindow: window, widgetsManager: widgetsManager! }) + pendingRoute = undefined + + window.on('closed', () => { + eventaContext = undefined + currentRoute = undefined + windowContexts.forEach((context) => { + if (context.window === window) + context.window = undefined + }) + }) + return window + }) + + function prepareWidgetWindow(options?: { id?: string }): string { + const id = options?.id ?? Math.random().toString(36).slice(2, 10) + if (!windowContexts.has(id)) { + windowContexts.set(id, { + widgetId: id, + windowBuilder: () => getWindow(), + window: undefined, + }) + } + return id + } + + function toSnapshot(record: WidgetRecord): WidgetSnapshot { + const { timer, ...snapshot } = record + return snapshot + } + + function upsertRecord(snapshot: WidgetSnapshot) { + const existing = widgetRecords.get(snapshot.id) + if (existing?.timer) + clearTimeout(existing.timer) + + const record: WidgetRecord = { ...snapshot } + + if (snapshot.ttlMs > 0) { + record.timer = setTimeout(() => removeWidgetInternal(snapshot.id), snapshot.ttlMs) + } + + widgetRecords.set(snapshot.id, record) + } + + function removeWidgetInternal(id: string, emitEvent = true) { + const existing = widgetRecords.get(id) + if (!existing) + return + + if (existing.timer) + clearTimeout(existing.timer) + + widgetRecords.delete(id) + windowContexts.delete(id) + + if (emitEvent) { + try { eventaContext?.emit(widgetsRemoveEvent, { id }) } + catch {} + } + } + + async function loadWithRoute(window: BrowserWindow, route: string) { + await load(window, withHashRoute(rendererBase, route)) + currentRoute = route + } + + async function getWindowFromContext(context?: WidgetWindowContext): Promise { + if (!context) + return getWindow() + if (context.window && !context.window.isDestroyed()) + return context.window + const resolved = await context.windowBuilder() + context.window = resolved + return resolved + } + + async function showWindowWithRoute(route: string, context?: WidgetWindowContext) { + pendingRoute = route + const window = await getWindowFromContext(context) + pendingRoute = undefined + if (currentRoute !== route) + await loadWithRoute(window, route) + window.show() + if (context) + context.window = window + return window + } + + async function getWindow(): Promise { + return reusable.getWindow() + } + + async function openWindow(params?: { id?: string }) { + const id = params?.id ? prepareWidgetWindow({ id: params.id }) : undefined + const route = id ? `${defaultRoute}?id=${id}` : defaultRoute + const context = id ? windowContexts.get(id) : undefined + await showWindowWithRoute(route, context) + } + + async function pushWidget(payload: WidgetsAddPayload): Promise { + const id = prepareWidgetWindow({ id: payload.id }) + const snapshot: WidgetSnapshot = { + id, + componentName: payload.componentName, + componentProps: payload.componentProps ?? {}, + size: payload.size ?? 'm', + ttlMs: payload.ttlMs ?? 0, + } + upsertRecord(snapshot) + const context = windowContexts.get(id) + await showWindowWithRoute(`${defaultRoute}?id=${id}`, context) + try { eventaContext?.emit(widgetsRenderEvent, snapshot) } + catch {} + return id + } + + async function updateWidget(payload: { id: string, componentProps?: Record }) { + if (!payload?.id) + return + + const existing = widgetRecords.get(payload.id) + if (!existing) + return + + const nextSnapshot: WidgetSnapshot = { + ...toSnapshot(existing), + componentProps: payload.componentProps ?? existing.componentProps, + } + + upsertRecord(nextSnapshot) + + try { eventaContext?.emit(widgetsUpdateEvent, { id: nextSnapshot.id, componentProps: nextSnapshot.componentProps }) } + catch {} + } + + async function removeWidget(id: string) { + if (!id) + return + removeWidgetInternal(id, false) + try { eventaContext?.emit(widgetsRemoveEvent, { id }) } + catch {} + } + + async function clearWidgets() { + const ids = [...widgetRecords.keys()] + for (const id of ids) + removeWidgetInternal(id, false) + + try { eventaContext?.emit(widgetsClearEvent, undefined) } + catch {} + windowContexts.clear() + } + + function getWidgetSnapshot(id: string) { + const record = widgetRecords.get(id) + if (!record) + return undefined + return toSnapshot(record) + } + + widgetsManager = { + getWindow, + openWindow, + pushWidget, + updateWidget, + removeWidget, + clearWidgets, + getWidgetSnapshot, + prepareWidgetWindow, + } + + return widgetsManager! +} diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts new file mode 100644 index 000000000..49a35924c --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/widgets/rpc/index.electron.ts @@ -0,0 +1,22 @@ +import type { BrowserWindow } from 'electron' + +import type { WidgetsWindowManager } from '../../widgets' + +import { createContext } from '@unbird/eventa/adapters/electron/main' +import { ipcMain } from 'electron' + +import { createWidgetsService } from '../../../services/airi/widgets' +import { createScreenService, createWindowService } from '../../../services/electron' + +export async function setupWidgetsWindowInvokes(params: { widgetWindow: BrowserWindow, widgetsManager: WidgetsWindowManager }) { + // 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.widgetWindow) + + createScreenService({ context, window: params.widgetWindow }) + createWindowService({ context, window: params.widgetWindow }) + createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.widgetWindow }) +} diff --git a/apps/stage-tamagotchi/src/renderer/layouts/settings.vue b/apps/stage-tamagotchi/src/renderer/layouts/settings.vue index f6f01ebea..ff23c7b48 100644 --- a/apps/stage-tamagotchi/src/renderer/layouts/settings.vue +++ b/apps/stage-tamagotchi/src/renderer/layouts/settings.vue @@ -108,6 +108,10 @@ const routeHeaderMetadataMap = computed(() => { subtitle: t('settings.title'), title: t('settings.pages.modules.mcp-server.title'), }, + '/devtools/widgets-calling': { + subtitle: t('tamagotchi.settings.devtools.title'), + title: t('tamagotchi.settings.devtools.pages.widgets-calling.title'), + }, } for (const metadata of allProvidersMetadata.value) { diff --git a/apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue b/apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue new file mode 100644 index 000000000..2938fa4d8 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/pages/devtools/widgets-calling.vue @@ -0,0 +1,332 @@ + + + + + +meta: + layout: settings + diff --git a/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue b/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue index 7b485620f..4322a581b 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/settings/system/developer.vue @@ -29,6 +29,12 @@ const menu = computed(() => [ icon: 'i-solar:sledgehammer-bold-duotone', to: '/devtools/use-electron-all-displays', }, + { + title: 'Widgets Calling', + description: 'Spawn overlay widgets and test component props', + icon: 'i-solar:sledgehammer-bold-duotone', + to: '/devtools/widgets-calling', + }, { title: 'Relative Mouse', description: 'Get mouse position relative to the window', diff --git a/apps/stage-tamagotchi/src/renderer/pages/widgets.vue b/apps/stage-tamagotchi/src/renderer/pages/widgets.vue new file mode 100644 index 000000000..4a53f9db2 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/pages/widgets.vue @@ -0,0 +1,237 @@ + + + + + + + +meta: + layout: stage + diff --git a/apps/stage-tamagotchi/src/renderer/widgets/weather/assets/README.md b/apps/stage-tamagotchi/src/renderer/widgets/weather/assets/README.md new file mode 100644 index 000000000..de6b5727a --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/widgets/weather/assets/README.md @@ -0,0 +1,10 @@ +## Acknowledgements + +- [Meteocons | Bas Milius — Full-Stack Developer](https://bas.dev/work/meteocons) + +## Many other alternatives + +- [Weather Icons by Bas](https://basmilius.github.io/weather-icons/index-fill.html) +- [erikflowers/weather-icons: 215 Weather Themed Icons and CSS](https://github.com/erikflowers/weather-icons) +- [basmilius/weather-icons: Free to use animated weather icons.](https://github.com/basmilius/weather-icons) +- [Makin-Things/weather-icons: A set of updated weather icons based of the AmCharts style of icon.](https://github.com/Makin-Things/weather-icons) diff --git a/apps/stage-tamagotchi/src/renderer/widgets/weather/assets/clear-day.svg b/apps/stage-tamagotchi/src/renderer/widgets/weather/assets/clear-day.svg new file mode 100644 index 000000000..5a5c52c5b --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/widgets/weather/assets/clear-day.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/apps/stage-tamagotchi/src/renderer/widgets/weather/components/Skeleton.vue b/apps/stage-tamagotchi/src/renderer/widgets/weather/components/Skeleton.vue new file mode 100644 index 000000000..098fcd452 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/widgets/weather/components/Skeleton.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/apps/stage-tamagotchi/src/renderer/widgets/weather/components/Weather.vue b/apps/stage-tamagotchi/src/renderer/widgets/weather/components/Weather.vue new file mode 100644 index 000000000..426b537d2 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/widgets/weather/components/Weather.vue @@ -0,0 +1,35 @@ + + + diff --git a/apps/stage-tamagotchi/src/renderer/widgets/weather/index.ts b/apps/stage-tamagotchi/src/renderer/widgets/weather/index.ts new file mode 100644 index 000000000..908d0b2b1 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/widgets/weather/index.ts @@ -0,0 +1 @@ +export { default as Weather } from './components/Weather.vue' diff --git a/apps/stage-tamagotchi/src/shared/eventa.ts b/apps/stage-tamagotchi/src/shared/eventa.ts index aefdae17c..dfb697cfc 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -9,4 +9,37 @@ export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:el export const captionIsFollowingWindowChanged = defineEventa('eventa:event:electron:windows:caption-overlay:is-following-window-changed') export const captionGetIsFollowingWindow = defineInvokeEventa('eventa:invoke:electron:windows:caption-overlay:get-is-following-window') +// Widgets / Adhoc window events +export interface WidgetsAddPayload { + id?: string + componentName: string + componentProps?: Record + // size presets or explicit spans; renderer decides mapping + size?: 's' | 'm' | 'l' | { cols?: number, rows?: number } + // auto-dismiss in ms; if omitted, persistent until closed by user + ttlMs?: number +} + +export interface WidgetSnapshot { + id: string + componentName: string + componentProps: Record + size: 's' | 'm' | 'l' | { cols?: number, rows?: number } + ttlMs: number +} + +export const widgetsOpenWindow = defineInvokeEventa('eventa:invoke:electron:windows:widgets:open') +export const widgetsAdd = defineInvokeEventa('eventa:invoke:electron:windows:widgets:add') +export const widgetsRemove = defineInvokeEventa('eventa:invoke:electron:windows:widgets:remove') +export const widgetsClear = defineInvokeEventa('eventa:invoke:electron:windows:widgets:clear') +export const widgetsUpdate = defineInvokeEventa }>('eventa:invoke:electron:windows:widgets:update') +export const widgetsFetch = defineInvokeEventa('eventa:invoke:electron:windows:widgets:fetch') +export const widgetsPrepareWindow = defineInvokeEventa('eventa:invoke:electron:windows:widgets:prepare') + +// Internal event from main -> widgets renderer when a widget should render +export const widgetsRenderEvent = defineEventa('eventa:event:electron:windows:widgets:render') +export const widgetsRemoveEvent = defineEventa<{ id: string }>('eventa:event:electron:windows:widgets:remove') +export const widgetsClearEvent = defineEventa('eventa:event:electron:windows:widgets:clear') +export const widgetsUpdateEvent = defineEventa<{ id: string, componentProps?: Record }>('eventa:event:electron:windows:widgets:update') + export { electron } from './electron' diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 2a3f10eee..08fffd2ce 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -581,6 +581,9 @@ pages: cerebras: description: cerebras.ai title: Cerebras + aliyun-nls: + description: Aliyun NLS + title: Aliyun NLS transcriptions: playground: title: Transcription Playground diff --git a/packages/i18n/src/locales/en/tamagotchi/settings.yaml b/packages/i18n/src/locales/en/tamagotchi/settings.yaml index d2a7b9792..951086a33 100644 --- a/packages/i18n/src/locales/en/tamagotchi/settings.yaml +++ b/packages/i18n/src/locales/en/tamagotchi/settings.yaml @@ -27,3 +27,8 @@ pages: use-window-mouse: title: useWindowMouse description: Test the Electron window cursor position +devtools: + title: Developer + pages: + widgets-calling: + title: Widget Calling