From 56f8929b83447354bc72a1615e3344bf83a3bdd3 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Sat, 20 Dec 2025 17:57:34 +0800 Subject: [PATCH] feat(stage-tamagotchi): auto updater --- .github/workflows/release-tamagotchi.yml | 6 +- apps/stage-tamagotchi/electron-builder.yml | 5 + apps/stage-tamagotchi/electron.vite.config.ts | 6 +- apps/stage-tamagotchi/package.json | 1 + apps/stage-tamagotchi/src/main/index.ts | 11 +- .../main/services/electron/auto-updater.ts | 186 ++++++++++++++ .../src/main/services/electron/index.ts | 1 + .../services/electron/mock-auto-updater.ts | 71 ++++++ .../src/main/windows/about/index.ts | 11 +- .../main/windows/about/rpc/index.electron.ts | 19 ++ .../src/main/windows/main/index.ts | 3 + .../main/windows/main/rpc/index.electron.ts | 5 +- .../src/main/windows/settings/index.ts | 4 +- .../windows/settings/rpc/index.electron.ts | 6 +- .../composables/electron-vueuse/index.ts | 1 + .../use-electron-auto-updater/index.ts | 52 ++++ .../src/renderer/pages/about.vue | 238 +++++++++++++++++- .../src/shared/electron-updater/index.ts | 40 +++ apps/stage-tamagotchi/src/shared/eventa.ts | 1 + apps/stage-tamagotchi/tsconfig.node.json | 3 +- docs/package.json | 1 + pnpm-lock.yaml | 49 +++- pnpm-workspace.yaml | 3 + 23 files changed, 699 insertions(+), 24 deletions(-) create mode 100644 apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts create mode 100644 apps/stage-tamagotchi/src/main/services/electron/mock-auto-updater.ts create mode 100644 apps/stage-tamagotchi/src/main/windows/about/rpc/index.electron.ts create mode 100644 apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-auto-updater/index.ts create mode 100644 apps/stage-tamagotchi/src/shared/electron-updater/index.ts diff --git a/.github/workflows/release-tamagotchi.yml b/.github/workflows/release-tamagotchi.yml index 8afc04d6c..b563435e4 100644 --- a/.github/workflows/release-tamagotchi.yml +++ b/.github/workflows/release-tamagotchi.yml @@ -125,15 +125,15 @@ jobs: - name: Build (Windows Only) # Windows if: matrix.os == 'windows-latest' - run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=never + run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=onTagOrDraft - name: Build (macOS Only) # macOS if: matrix.os == 'macos-15-intel' || matrix.os == 'macos-latest' - run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=never + run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=onTagOrDraft - name: Build (Linux Only) # Linux if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' - run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=never + run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=onTagOrDraft - name: Setup Flatpak (Linux Only) if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' }} diff --git a/apps/stage-tamagotchi/electron-builder.yml b/apps/stage-tamagotchi/electron-builder.yml index 7d3993246..ad9f897f6 100644 --- a/apps/stage-tamagotchi/electron-builder.yml +++ b/apps/stage-tamagotchi/electron-builder.yml @@ -69,3 +69,8 @@ appImage: artifactName: ${productName}-${version}-linux-${arch}.${ext} npmRebuild: false + +publish: + provider: github + owner: moeru-ai + repo: airi diff --git a/apps/stage-tamagotchi/electron.vite.config.ts b/apps/stage-tamagotchi/electron.vite.config.ts index 60c993116..5a2e85b5c 100644 --- a/apps/stage-tamagotchi/electron.vite.config.ts +++ b/apps/stage-tamagotchi/electron.vite.config.ts @@ -14,14 +14,14 @@ import VueMacros from 'vue-macros/vite' import { Download } from '@proj-airi/unplugin-fetch' import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk' import { templateCompilerOptions } from '@tresjs/core' -import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import { defineConfig } from 'electron-vite' const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets')) const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache')) export default defineConfig({ main: { - plugins: [externalizeDepsPlugin()], + plugins: [Info()], }, preload: { build: { @@ -32,7 +32,7 @@ export default defineConfig({ }, }, }, - plugins: [externalizeDepsPlugin()], + plugins: [], }, renderer: { // Thanks to [@Maqsyo](https://github.com/Maqsyo) diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index 154a0bfca..7b91933f2 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -153,6 +153,7 @@ "@vue-macros/volar": "^3.1.1", "@vueuse/motion": "^3.0.3", "@xsai-transformers/embed": "^0.0.11", + "builder-util-runtime": "catalog:", "cac": "^6.7.14", "csstype": "^3.2.3", "electron": "^39.2.7", diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index b27f479d8..33f503cd5 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -14,6 +14,7 @@ import { openDebugger, setupDebugger } from './app/debugger' import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle' import { setElectronMainDirname } from './libs/electron/location' import { setupServerChannel } from './services/airi/channel-server' +import { setupAutoUpdater } from './services/electron/auto-updater' import { setupTray } from './tray' import { setupAboutWindowReusable } from './windows/about' import { setupBeatSync } from './windows/beat-sync' @@ -71,9 +72,13 @@ app.whenReady().then(async () => { injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig())) const serverChannel = injeca.provide('modules:channel-server', () => setupServerChannel()) + const autoUpdater = injeca.provide('services:auto-updater', () => setupAutoUpdater()) const widgetsManager = injeca.provide('windows:widgets', () => setupWidgetsWindowManager()) const noticeWindow = injeca.provide('windows:notice', () => setupNoticeWindowManager()) - const aboutWindow = injeca.provide('windows:about', () => setupAboutWindowReusable()) + const aboutWindow = injeca.provide('windows:about', { + dependsOn: { autoUpdater }, + build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn), + }) // BeatSync will create a background window to capture and process audio. const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync()) @@ -84,12 +89,12 @@ app.whenReady().then(async () => { }) const settingsWindow = injeca.provide('windows:settings', { - dependsOn: { widgetsManager, beatSync }, + dependsOn: { widgetsManager, beatSync, autoUpdater }, build: async ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn), }) const mainWindow = injeca.provide('windows:main', { - dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync }, + dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater }, build: async ({ dependsOn }) => setupMainWindow(dependsOn), }) diff --git a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts new file mode 100644 index 000000000..a60f9a663 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts @@ -0,0 +1,186 @@ +import type { createContext } from '@moeru/eventa/adapters/electron/main' +import type { BrowserWindow } from 'electron' +import type { UpdateInfo } from 'electron-updater' + +import type { AutoUpdaterState } from '../../../shared/eventa' + +import electronUpdater from 'electron-updater' + +import { is } from '@electron-toolkit/utils' +import { useLogg } from '@guiiai/logg' +import { defineInvokeHandler } from '@moeru/eventa' +import { errorMessageFrom } from '@moeru/std' +import { committerDate } from '~build/git' +import { app } from 'electron' +import { Semaphore } from 'es-toolkit' + +import { + autoUpdater as autoUpdaterEventa, + electronAutoUpdaterStateChanged, +} from '../../../shared/eventa' +import { MockAutoUpdater } from './mock-auto-updater' + +export interface AppUpdaterLike { + on: (event: string, listener: (...args: any[]) => void) => any + checkForUpdates: () => Promise + downloadUpdate: () => Promise + quitAndInstall: () => Promise +} + +// NOTICE: this part of code is copied from https://www.electron.build/auto-update +// Or https://github.com/electron-userland/electron-builder/blob/b866e99ccd3ea9f85bc1e840f0f6a6a162fca388/pages/auto-update.md?plain=1#L57-L66 +export function fromImported(): AppUpdaterLike { + if (is.dev) { + return new MockAutoUpdater() + } + + // Using destructuring to access autoUpdater due to the CommonJS module of 'electron-updater'. + // It is a workaround for ESM compatibility issues, see https://github.com/electron-userland/electron-builder/issues/7976. + const { autoUpdater } = electronUpdater + return autoUpdater as unknown as AppUpdaterLike +} + +type MainContext = ReturnType['context'] + +export interface AutoUpdater { + state: AutoUpdaterState + checkForUpdates: () => Promise + downloadUpdate: () => Promise + quitAndInstall: () => void + subscribe: (callback: (state: AutoUpdaterState) => void) => () => void +} + +export function setupAutoUpdater(): AutoUpdater { + const semaphore = new Semaphore(1) + + const log = useLogg('auto-updater').useGlobalConfig() + const autoUpdater = fromImported() + + let state: AutoUpdaterState = { status: 'idle' } + const hooks = new Set<(state: AutoUpdaterState) => void>() + + function broadcast(next: AutoUpdaterState) { + state = next + + for (const listener of hooks) { + try { + listener(next) + } + catch (error) { + log.withError(error).error('Failed to notify listener') + } + } + } + + autoUpdater.on('error', error => broadcast({ status: 'error', error: { message: errorMessageFrom(error) || String(error) } })) + autoUpdater.on('checking-for-update', () => broadcast({ status: 'checking' })) + autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ status: 'available', info })) + autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ status: 'downloaded', info })) + autoUpdater.on('update-not-available', () => broadcast({ status: 'not-available', info: { version: app.getVersion(), files: [], releaseDate: committerDate } })) + autoUpdater.on('download-progress', progress => broadcast({ + ...state, + status: 'downloading', + progress: { + percent: progress.percent, + bytesPerSecond: progress.bytesPerSecond, + transferred: progress.transferred, + total: progress.total, + }, + })) + + autoUpdater.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed')) + + return { + get state() { + return state + }, + async checkForUpdates() { + broadcast({ status: 'checking' }) + await autoUpdater.checkForUpdates() + }, + async downloadUpdate() { + if (state.status === 'downloading' || state.status === 'downloaded') + return + + await semaphore.acquire() + + try { + await autoUpdater.downloadUpdate() + } + finally { + semaphore.release() + } + }, + async quitAndInstall() { + await semaphore.acquire() + + try { + autoUpdater.quitAndInstall() + } + finally { + semaphore.release() + } + }, + subscribe(callback) { + hooks.add(callback) + // Send current state immediately + try { + callback(state) + } + catch {} + + return () => { + hooks.delete(callback) + } + }, + } +} + +export function createAutoUpdaterService(params: { context: MainContext, window: BrowserWindow, service: AutoUpdater }) { + const { context, window, service } = params + + // Subscribe to state changes and forward to the context + const unsubscribe = service.subscribe((state) => { + if (window.isDestroyed()) + return + + try { + context.emit(electronAutoUpdaterStateChanged, state) + } + catch {} + }) + + const cleanups: Array<() => void> = [unsubscribe] + + cleanups.push( + defineInvokeHandler(context, autoUpdaterEventa.getState, () => service.state), + ) + + cleanups.push( + defineInvokeHandler(context, autoUpdaterEventa.checkForUpdates, async () => { + await service.checkForUpdates() + return service.state + }), + ) + + cleanups.push( + defineInvokeHandler(context, autoUpdaterEventa.downloadUpdate, async () => { + await service.downloadUpdate() + return service.state + }), + ) + + cleanups.push( + defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, () => { + service.quitAndInstall() + }), + ) + + const cleanup = () => { + for (const fn of cleanups) + fn() + } + + window.on('closed', cleanup) + return cleanup +} diff --git a/apps/stage-tamagotchi/src/main/services/electron/index.ts b/apps/stage-tamagotchi/src/main/services/electron/index.ts index 7a466d4ef..ba34ad9fd 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/index.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/index.ts @@ -1,2 +1,3 @@ +export * from './auto-updater' export * from './screen' export * from './window' diff --git a/apps/stage-tamagotchi/src/main/services/electron/mock-auto-updater.ts b/apps/stage-tamagotchi/src/main/services/electron/mock-auto-updater.ts new file mode 100644 index 000000000..b47ea92da --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/electron/mock-auto-updater.ts @@ -0,0 +1,71 @@ +import { EventEmitter } from 'node:events' + +import { app } from 'electron' + +export class MockAutoUpdater extends EventEmitter { + autoDownload = false + + async checkForUpdates() { + this.emit('checking-for-update') + + // Simulate network delay + await new Promise(resolve => setTimeout(resolve, 1500)) + + // Simulate update available + // We can toggle this based on some logic if needed, but for now let's assume update is always available in mock + const updateInfo = { + version: '9.9.9-mock', + files: [], + path: 'mock-path', + sha512: 'mock-sha', + releaseDate: new Date().toISOString(), + releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B', + } + + this.emit('update-available', updateInfo) + + // In real updater, if autoDownload is true, it starts downloading. + // We'll respect that if we were fully mocking, but typically we trigger download manually in this app. + return { updateInfo } + } + + async downloadUpdate() { + // Simulate download progress + const total = 100 * 1024 * 1024 // 100MB + let transferred = 0 + const speed = 5 * 1024 * 1024 // 5MB/s simulation + + const interval = setInterval(() => { + transferred += speed / 10 // Update every 100ms + if (transferred > total) + transferred = total + + const progress = { + total, + transferred, + percent: (transferred / total) * 100, + bytesPerSecond: speed, + } + + this.emit('download-progress', progress) + + if (transferred >= total) { + clearInterval(interval) + this.emit('update-downloaded', { + version: '9.9.9-mock', + files: [], + path: 'mock-path', + sha512: 'mock-sha', + releaseDate: new Date().toISOString(), + releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B', + }) + } + }, 100) + } + + async quitAndInstall() { + // eslint-disable-next-line no-console + console.log('[MockAutoUpdater] quitAndInstall called. Quitting app...') + app.quit() + } +} diff --git a/apps/stage-tamagotchi/src/main/windows/about/index.ts b/apps/stage-tamagotchi/src/main/windows/about/index.ts index b15cfa50d..fd0321b6a 100644 --- a/apps/stage-tamagotchi/src/main/windows/about/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/about/index.ts @@ -1,3 +1,5 @@ +import type { AutoUpdater } from '../../services/electron/auto-updater' + import { join, resolve } from 'node:path' import { BrowserWindow, shell } from 'electron' @@ -6,13 +8,14 @@ import icon from '../../../../resources/icon.png?asset' import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location' import { createReusableWindow } from '../../libs/electron/window-manager' +import { setupAboutWindowElectronInvokes } from './rpc/index.electron' -export function setupAboutWindowReusable() { +export function setupAboutWindowReusable(params: { autoUpdater: AutoUpdater }) { return createReusableWindow(async () => { const window = new BrowserWindow({ title: 'About AIRI', - width: 580, - height: 630, + width: 670, + height: 730, show: false, resizable: true, maximizable: false, @@ -32,6 +35,8 @@ export function setupAboutWindowReusable() { await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about')) + setupAboutWindowElectronInvokes({ window, autoUpdater: params.autoUpdater }) + return window }).getWindow } diff --git a/apps/stage-tamagotchi/src/main/windows/about/rpc/index.electron.ts b/apps/stage-tamagotchi/src/main/windows/about/rpc/index.electron.ts new file mode 100644 index 000000000..c48b34fea --- /dev/null +++ b/apps/stage-tamagotchi/src/main/windows/about/rpc/index.electron.ts @@ -0,0 +1,19 @@ +import type { BrowserWindow } from 'electron' + +import type { AutoUpdater } from '../../../services/electron/auto-updater' + +import { createContext } from '@moeru/eventa/adapters/electron/main' +import { ipcMain } from 'electron' + +import { createAutoUpdaterService } from '../../../services/electron' + +export function setupAboutWindowElectronInvokes(params: { window: BrowserWindow, autoUpdater: AutoUpdater }) { + // 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) + + createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater }) +} diff --git a/apps/stage-tamagotchi/src/main/windows/main/index.ts b/apps/stage-tamagotchi/src/main/windows/main/index.ts index a13d3804c..691048b4d 100644 --- a/apps/stage-tamagotchi/src/main/windows/main/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/main/index.ts @@ -1,5 +1,6 @@ import type { BrowserWindowConstructorOptions, Rectangle } from 'electron' +import type { AutoUpdater } from '../../services/electron/auto-updater' import type { NoticeWindowManager } from '../notice' import type { WidgetsWindowManager } from '../widgets' @@ -33,6 +34,7 @@ export async function setupMainWindow(params: { chatWindow: () => Promise widgetsManager: WidgetsWindowManager noticeWindow: NoticeWindowManager + autoUpdater: AutoUpdater onWindowCreated?: (window: BrowserWindow) => void }) { const { @@ -139,6 +141,7 @@ export async function setupMainWindow(params: { chatWindow: params.chatWindow, widgetsManager: params.widgetsManager, noticeWindow: params.noticeWindow, + autoUpdater: params.autoUpdater, }) /** 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 ac2030f36..905627010 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,5 +1,6 @@ import type { BrowserWindow } from 'electron' +import type { AutoUpdater } from '../../../services/electron/auto-updater' import type { NoticeWindowManager } from '../../notice' import type { WidgetsWindowManager } from '../../widgets' @@ -9,7 +10,7 @@ import { ipcMain } from 'electron' import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa' import { createWidgetsService } from '../../../services/airi/widgets' -import { createScreenService, createWindowService } from '../../../services/electron' +import { createAutoUpdaterService, createScreenService, createWindowService } from '../../../services/electron' import { toggleWindowShow } from '../../shared' export function setupMainWindowElectronInvokes(params: { @@ -18,6 +19,7 @@ export function setupMainWindowElectronInvokes(params: { chatWindow: () => Promise widgetsManager: WidgetsWindowManager noticeWindow: NoticeWindowManager + autoUpdater: AutoUpdater }) { // 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 @@ -29,6 +31,7 @@ export function setupMainWindowElectronInvokes(params: { createScreenService({ context, window: params.window }) createWindowService({ context, window: params.window }) createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window }) + createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater }) 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 d61faa928..37da29805 100644 --- a/apps/stage-tamagotchi/src/main/windows/settings/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/settings/index.ts @@ -1,3 +1,4 @@ +import type { AutoUpdater } from '../../services/electron/auto-updater' import type { WidgetsWindowManager } from '../widgets' import { join, resolve } from 'node:path' @@ -12,6 +13,7 @@ import { setupSettingsWindowInvokes } from './rpc/index.electron' export function setupSettingsWindowReusableFunc(params: { widgetsManager: WidgetsWindowManager + autoUpdater: AutoUpdater onWindowCreated?: (window: BrowserWindow) => void }) { return createReusableWindow(async () => { @@ -38,7 +40,7 @@ export function setupSettingsWindowReusableFunc(params: { }) await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/settings')) - await setupSettingsWindowInvokes({ settingsWindow: window, widgetsManager: params.widgetsManager }) + await setupSettingsWindowInvokes({ settingsWindow: window, widgetsManager: params.widgetsManager, autoUpdater: params.autoUpdater }) 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 ff20d513b..5c087d7ee 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,5 +1,6 @@ import type { BrowserWindow } from 'electron' +import type { AutoUpdater } from '../../../services/electron/auto-updater' import type { WidgetsWindowManager } from '../../widgets' import { defineInvokeHandler } from '@moeru/eventa' @@ -8,9 +9,9 @@ import { ipcMain } from 'electron' import { electronOpenSettingsDevtools } from '../../../../shared/eventa' import { createWidgetsService } from '../../../services/airi/widgets' -import { createScreenService, createWindowService } from '../../../services/electron' +import { createAutoUpdaterService, createScreenService, createWindowService } from '../../../services/electron' -export async function setupSettingsWindowInvokes(params: { settingsWindow: BrowserWindow, widgetsManager: WidgetsWindowManager }) { +export async function setupSettingsWindowInvokes(params: { settingsWindow: BrowserWindow, widgetsManager: WidgetsWindowManager, autoUpdater: AutoUpdater }) { // 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. @@ -21,6 +22,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 }) + createAutoUpdaterService({ context, window: params.settingsWindow, service: params.autoUpdater }) defineInvokeHandler(context, electronOpenSettingsDevtools, async () => params.settingsWindow.webContents.openDevTools({ mode: 'detach' })) } diff --git a/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/index.ts b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/index.ts index 1038af1f2..8bc2b29df 100644 --- a/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/index.ts +++ b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/index.ts @@ -1,4 +1,5 @@ export { useElectronAllDisplays } from './use-electorn-all-displays' +export { useElectronAutoUpdater } from './use-electron-auto-updater' export { useElectronEventaContext, useElectronEventaInvoke } from './use-electron-eventa-context' export { useElectronMouse, useElectronMouseEventTarget } from './use-electron-mouse' export { useElectronMouseAroundWindowBorder } from './use-electron-mouse-around-window-border' diff --git a/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-auto-updater/index.ts b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-auto-updater/index.ts new file mode 100644 index 000000000..59439339f --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/composables/electron-vueuse/use-electron-auto-updater/index.ts @@ -0,0 +1,52 @@ +import type { AutoUpdaterState } from '../../../../shared/eventa' + +import { computed, onMounted, ref } from 'vue' + +import { + autoUpdater, + electronAutoUpdaterStateChanged, +} from '../../../../shared/eventa' +import { useElectronEventaContext, useElectronEventaInvoke } from '../use-electron-eventa-context' + +export function useElectronAutoUpdater() { + const context = useElectronEventaContext() + + const state = ref({ status: 'idle' }) + + const getState = useElectronEventaInvoke(autoUpdater.getState, context.value) + const checkForUpdates = useElectronEventaInvoke(autoUpdater.checkForUpdates, context.value) + const downloadUpdate = useElectronEventaInvoke(autoUpdater.downloadUpdate, context.value) + const quitAndInstall = useElectronEventaInvoke(autoUpdater.quitAndInstall, context.value) + + const isBusy = computed(() => ['checking', 'downloading'].includes(state.value.status)) + const canDownload = computed(() => state.value.status === 'available') + const canRestartToUpdate = computed(() => state.value.status === 'downloaded') + + onMounted(async () => { + try { + const current = await getState() + if (current) + state.value = current + } + catch {} + + try { + context.value.on(electronAutoUpdaterStateChanged, (evt) => { + if (evt.body) + state.value = evt.body + }) + } + + catch {} + }) + + return { + state, + isBusy, + canDownload, + canRestartToUpdate, + checkForUpdates, + downloadUpdate, + quitAndInstall, + } +} diff --git a/apps/stage-tamagotchi/src/renderer/pages/about.vue b/apps/stage-tamagotchi/src/renderer/pages/about.vue index 6e99b5dc1..4e915f85c 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/about.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/about.vue @@ -1,18 +1,66 @@ diff --git a/apps/stage-tamagotchi/src/shared/electron-updater/index.ts b/apps/stage-tamagotchi/src/shared/electron-updater/index.ts new file mode 100644 index 000000000..79edae907 --- /dev/null +++ b/apps/stage-tamagotchi/src/shared/electron-updater/index.ts @@ -0,0 +1,40 @@ +import type { UpdateInfo } from 'builder-util-runtime' + +import { defineEventa, defineInvokeEventa } from '@moeru/eventa' + +export type AutoUpdaterStatus + = | 'idle' + | 'disabled' + | 'checking' + | 'available' + | 'not-available' + | 'downloading' + | 'downloaded' + | 'error' + +export interface AutoUpdaterProgress { + percent: number + bytesPerSecond: number + transferred: number + total: number +} + +export interface AutoUpdaterError { + message: string +} + +export interface AutoUpdaterState { + status: AutoUpdaterStatus + info?: Omit + progress?: AutoUpdaterProgress + error?: AutoUpdaterError +} + +export const electronAutoUpdaterStateChanged = defineEventa('eventa:event:electron:auto-updater:state-changed') + +export const autoUpdater = { + getState: defineInvokeEventa('eventa:invoke:electron:auto-updater:get-state'), + checkForUpdates: defineInvokeEventa('eventa:invoke:electron:auto-updater:check-for-updates'), + downloadUpdate: defineInvokeEventa('eventa:invoke:electron:auto-updater:download-update'), + quitAndInstall: defineInvokeEventa('eventa:invoke:electron:auto-updater:quit-and-install'), +} diff --git a/apps/stage-tamagotchi/src/shared/eventa.ts b/apps/stage-tamagotchi/src/shared/eventa.ts index ea5379384..da65407e4 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -72,3 +72,4 @@ export const widgetsClearEvent = defineEventa('eventa:event:electron:windows:wid export const widgetsUpdateEvent = defineEventa<{ id: string, componentProps?: Record }>('eventa:event:electron:windows:widgets:update') export { electron } from './electron' +export * from './electron-updater' diff --git a/apps/stage-tamagotchi/tsconfig.node.json b/apps/stage-tamagotchi/tsconfig.node.json index 66be51eff..5eefa9439 100644 --- a/apps/stage-tamagotchi/tsconfig.node.json +++ b/apps/stage-tamagotchi/tsconfig.node.json @@ -12,7 +12,8 @@ "types": [ "vitest", "vite/client", - "electron-vite/node" + "electron-vite/node", + "unplugin-info/client" ], "allowJs": true, "strict": true, diff --git a/docs/package.json b/docs/package.json index b191834e0..3d496c244 100644 --- a/docs/package.json +++ b/docs/package.json @@ -23,6 +23,7 @@ "@vueuse/core": "^14.1.0", "colorjs.io": "^0.5.2", "date-fns": "^4.1.0", + "embla-carousel-vue": "catalog:", "mark.js": "^8.11.1", "motion-v": "^1.7.4", "pathe": "^2.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2ed41d00..9e70f4b09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,9 +60,15 @@ catalogs: '@xsai/utils-chat': specifier: 0.4.0-beta.5 version: 0.4.0-beta.5 + builder-util-runtime: + specifier: ^9.3.1 + version: 9.3.1 electron-updater: specifier: ^6.6.2 version: 6.6.2 + embla-carousel-vue: + specifier: ^8.6.0 + version: 8.6.0 es-toolkit: specifier: ^1.43.0 version: 1.43.0 @@ -636,6 +642,9 @@ importers: '@xsai-transformers/embed': specifier: ^0.0.11 version: 0.0.11(electron@39.2.7)(h3@2.0.1-rc.5(crossws@0.4.1(srvx@0.9.7)))(web-worker@1.5.0) + builder-util-runtime: + specifier: 'catalog:' + version: 9.3.1 cac: specifier: ^6.7.14 version: 6.7.14 @@ -1069,6 +1078,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + embla-carousel-vue: + specifier: 'catalog:' + version: 8.6.0(vue@3.5.25(typescript@5.9.3)) mark.js: specifier: ^8.11.1 version: 8.11.1 @@ -2054,7 +2066,7 @@ importers: version: 66.5.10 '@wxt-dev/module-vue': specifier: ^1.0.3 - version: 1.0.3(vite@7.2.7(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.11(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 1.0.3(vite@8.0.0-beta.1(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.11(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) vue-tsc: specifier: ^3.1.8 version: 3.1.8(typescript@5.9.3) @@ -9866,6 +9878,19 @@ packages: email-addresses@3.1.0: resolution: {integrity: sha512-k0/r7GrWVL32kZlGwfPNgB2Y/mMXVTq/decgLczm/j34whdaspNrZO8CnXPf1laaHxI6ptUlsnAxN+UAPw+fzg==} + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel-vue@8.6.0: + resolution: {integrity: sha512-v8UO5UsyLocZnu/LbfQA7Dn2QHuZKurJY93VUmZYP//QRWoCWOsionmvLLAlibkET3pGPs7++03VhJKbWD7vhQ==} + peerDependencies: + vue: ^3.2.37 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex@10.3.0: resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} @@ -22459,6 +22484,12 @@ snapshots: vite: 7.2.7(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.25(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.3(vite@8.0.0-beta.1(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.53 + vite: 8.0.0-beta.1(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vue: 3.5.25(typescript@5.9.3) + '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(jiti@2.6.1)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(less@4.4.2)(lightningcss@1.30.2)(msw@2.7.3(@types/node@24.10.4)(typescript@5.9.3))(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -23249,9 +23280,9 @@ snapshots: '@types/filesystem': 0.0.36 '@types/har-format': 1.2.16 - '@wxt-dev/module-vue@1.0.3(vite@7.2.7(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.11(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + '@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.1(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))(wxt@0.20.11(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': dependencies: - '@vitejs/plugin-vue': 6.0.3(vite@7.2.7(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) + '@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.1(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)) wxt: 0.20.11(@types/node@24.10.4)(jiti@2.6.1)(less@4.4.2)(lightningcss@1.30.2)(rollup@4.53.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - vite @@ -25143,6 +25174,18 @@ snapshots: email-addresses@3.1.0: {} + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel-vue@8.6.0(vue@3.5.25(typescript@5.9.3)): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + vue: 3.5.25(typescript@5.9.3) + + embla-carousel@8.6.0: {} + emoji-regex@10.3.0: {} emoji-regex@8.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f99db7fc5..4543e00d4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ catalogMode: prefer shellEmulator: true + packages: - packages/** - plugins/** @@ -38,7 +39,9 @@ catalog: '@xsai/stream-text': ^0.4.0-beta.10 '@xsai/tool': ^0.4.0-beta.10 '@xsai/utils-chat': 0.4.0-beta.5 + builder-util-runtime: ^9.3.1 electron-updater: ^6.6.2 + embla-carousel-vue: ^8.6.0 es-toolkit: ^1.43.0 posthog-js: 1.306.1 xsschema: ^0.4.0-beta.10