Skip to content

Commit 56f8929

Browse files
committed
feat(stage-tamagotchi): auto updater
1 parent 7863ce4 commit 56f8929

File tree

23 files changed

+699
-24
lines changed

23 files changed

+699
-24
lines changed

.github/workflows/release-tamagotchi.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,15 @@ jobs:
125125

126126
- name: Build (Windows Only) # Windows
127127
if: matrix.os == 'windows-latest'
128-
run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=never
128+
run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=onTagOrDraft
129129

130130
- name: Build (macOS Only) # macOS
131131
if: matrix.os == 'macos-15-intel' || matrix.os == 'macos-latest'
132-
run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=never
132+
run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=onTagOrDraft
133133

134134
- name: Build (Linux Only) # Linux
135135
if: matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm'
136-
run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=never
136+
run: pnpm run -F @proj-airi/stage-tamagotchi build && pnpm -F @proj-airi/stage-tamagotchi exec electron-builder build ${{ matrix.builder-args }} --publish=onTagOrDraft
137137

138138
- name: Setup Flatpak (Linux Only)
139139
if: ${{ matrix.os == 'ubuntu-latest' || matrix.os == 'ubuntu-24.04-arm' }}

apps/stage-tamagotchi/electron-builder.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,8 @@ appImage:
6969
artifactName: ${productName}-${version}-linux-${arch}.${ext}
7070

7171
npmRebuild: false
72+
73+
publish:
74+
provider: github
75+
owner: moeru-ai
76+
repo: airi

apps/stage-tamagotchi/electron.vite.config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ import VueMacros from 'vue-macros/vite'
1414
import { Download } from '@proj-airi/unplugin-fetch'
1515
import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk'
1616
import { templateCompilerOptions } from '@tresjs/core'
17-
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
17+
import { defineConfig } from 'electron-vite'
1818

1919
const stageUIAssetsRoot = resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'assets'))
2020
const sharedCacheDir = resolve(join(import.meta.dirname, '..', '..', '.cache'))
2121

2222
export default defineConfig({
2323
main: {
24-
plugins: [externalizeDepsPlugin()],
24+
plugins: [Info()],
2525
},
2626
preload: {
2727
build: {
@@ -32,7 +32,7 @@ export default defineConfig({
3232
},
3333
},
3434
},
35-
plugins: [externalizeDepsPlugin()],
35+
plugins: [],
3636
},
3737
renderer: {
3838
// Thanks to [@Maqsyo](https://github.com/Maqsyo)

apps/stage-tamagotchi/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
"@vue-macros/volar": "^3.1.1",
154154
"@vueuse/motion": "^3.0.3",
155155
"@xsai-transformers/embed": "^0.0.11",
156+
"builder-util-runtime": "catalog:",
156157
"cac": "^6.7.14",
157158
"csstype": "^3.2.3",
158159
"electron": "^39.2.7",

apps/stage-tamagotchi/src/main/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { openDebugger, setupDebugger } from './app/debugger'
1414
import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
1515
import { setElectronMainDirname } from './libs/electron/location'
1616
import { setupServerChannel } from './services/airi/channel-server'
17+
import { setupAutoUpdater } from './services/electron/auto-updater'
1718
import { setupTray } from './tray'
1819
import { setupAboutWindowReusable } from './windows/about'
1920
import { setupBeatSync } from './windows/beat-sync'
@@ -71,9 +72,13 @@ app.whenReady().then(async () => {
7172
injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig()))
7273

7374
const serverChannel = injeca.provide('modules:channel-server', () => setupServerChannel())
75+
const autoUpdater = injeca.provide('services:auto-updater', () => setupAutoUpdater())
7476
const widgetsManager = injeca.provide('windows:widgets', () => setupWidgetsWindowManager())
7577
const noticeWindow = injeca.provide('windows:notice', () => setupNoticeWindowManager())
76-
const aboutWindow = injeca.provide('windows:about', () => setupAboutWindowReusable())
78+
const aboutWindow = injeca.provide('windows:about', {
79+
dependsOn: { autoUpdater },
80+
build: ({ dependsOn }) => setupAboutWindowReusable(dependsOn),
81+
})
7782

7883
// BeatSync will create a background window to capture and process audio.
7984
const beatSync = injeca.provide('windows:beat-sync', () => setupBeatSync())
@@ -84,12 +89,12 @@ app.whenReady().then(async () => {
8489
})
8590

8691
const settingsWindow = injeca.provide('windows:settings', {
87-
dependsOn: { widgetsManager, beatSync },
92+
dependsOn: { widgetsManager, beatSync, autoUpdater },
8893
build: async ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn),
8994
})
9095

9196
const mainWindow = injeca.provide('windows:main', {
92-
dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync },
97+
dependsOn: { settingsWindow, chatWindow, widgetsManager, noticeWindow, beatSync, autoUpdater },
9398
build: async ({ dependsOn }) => setupMainWindow(dependsOn),
9499
})
95100

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import type { createContext } from '@moeru/eventa/adapters/electron/main'
2+
import type { BrowserWindow } from 'electron'
3+
import type { UpdateInfo } from 'electron-updater'
4+
5+
import type { AutoUpdaterState } from '../../../shared/eventa'
6+
7+
import electronUpdater from 'electron-updater'
8+
9+
import { is } from '@electron-toolkit/utils'
10+
import { useLogg } from '@guiiai/logg'
11+
import { defineInvokeHandler } from '@moeru/eventa'
12+
import { errorMessageFrom } from '@moeru/std'
13+
import { committerDate } from '~build/git'
14+
import { app } from 'electron'
15+
import { Semaphore } from 'es-toolkit'
16+
17+
import {
18+
autoUpdater as autoUpdaterEventa,
19+
electronAutoUpdaterStateChanged,
20+
} from '../../../shared/eventa'
21+
import { MockAutoUpdater } from './mock-auto-updater'
22+
23+
export interface AppUpdaterLike {
24+
on: (event: string, listener: (...args: any[]) => void) => any
25+
checkForUpdates: () => Promise<any>
26+
downloadUpdate: () => Promise<any>
27+
quitAndInstall: () => Promise<void>
28+
}
29+
30+
// NOTICE: this part of code is copied from https://www.electron.build/auto-update
31+
// Or https://github.com/electron-userland/electron-builder/blob/b866e99ccd3ea9f85bc1e840f0f6a6a162fca388/pages/auto-update.md?plain=1#L57-L66
32+
export function fromImported(): AppUpdaterLike {
33+
if (is.dev) {
34+
return new MockAutoUpdater()
35+
}
36+
37+
// Using destructuring to access autoUpdater due to the CommonJS module of 'electron-updater'.
38+
// It is a workaround for ESM compatibility issues, see https://github.com/electron-userland/electron-builder/issues/7976.
39+
const { autoUpdater } = electronUpdater
40+
return autoUpdater as unknown as AppUpdaterLike
41+
}
42+
43+
type MainContext = ReturnType<typeof createContext>['context']
44+
45+
export interface AutoUpdater {
46+
state: AutoUpdaterState
47+
checkForUpdates: () => Promise<void>
48+
downloadUpdate: () => Promise<void>
49+
quitAndInstall: () => void
50+
subscribe: (callback: (state: AutoUpdaterState) => void) => () => void
51+
}
52+
53+
export function setupAutoUpdater(): AutoUpdater {
54+
const semaphore = new Semaphore(1)
55+
56+
const log = useLogg('auto-updater').useGlobalConfig()
57+
const autoUpdater = fromImported()
58+
59+
let state: AutoUpdaterState = { status: 'idle' }
60+
const hooks = new Set<(state: AutoUpdaterState) => void>()
61+
62+
function broadcast(next: AutoUpdaterState) {
63+
state = next
64+
65+
for (const listener of hooks) {
66+
try {
67+
listener(next)
68+
}
69+
catch (error) {
70+
log.withError(error).error('Failed to notify listener')
71+
}
72+
}
73+
}
74+
75+
autoUpdater.on('error', error => broadcast({ status: 'error', error: { message: errorMessageFrom(error) || String(error) } }))
76+
autoUpdater.on('checking-for-update', () => broadcast({ status: 'checking' }))
77+
autoUpdater.on('update-available', (info: UpdateInfo) => broadcast({ status: 'available', info }))
78+
autoUpdater.on('update-downloaded', (info: UpdateInfo) => broadcast({ status: 'downloaded', info }))
79+
autoUpdater.on('update-not-available', () => broadcast({ status: 'not-available', info: { version: app.getVersion(), files: [], releaseDate: committerDate } }))
80+
autoUpdater.on('download-progress', progress => broadcast({
81+
...state,
82+
status: 'downloading',
83+
progress: {
84+
percent: progress.percent,
85+
bytesPerSecond: progress.bytesPerSecond,
86+
transferred: progress.transferred,
87+
total: progress.total,
88+
},
89+
}))
90+
91+
autoUpdater.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed'))
92+
93+
return {
94+
get state() {
95+
return state
96+
},
97+
async checkForUpdates() {
98+
broadcast({ status: 'checking' })
99+
await autoUpdater.checkForUpdates()
100+
},
101+
async downloadUpdate() {
102+
if (state.status === 'downloading' || state.status === 'downloaded')
103+
return
104+
105+
await semaphore.acquire()
106+
107+
try {
108+
await autoUpdater.downloadUpdate()
109+
}
110+
finally {
111+
semaphore.release()
112+
}
113+
},
114+
async quitAndInstall() {
115+
await semaphore.acquire()
116+
117+
try {
118+
autoUpdater.quitAndInstall()
119+
}
120+
finally {
121+
semaphore.release()
122+
}
123+
},
124+
subscribe(callback) {
125+
hooks.add(callback)
126+
// Send current state immediately
127+
try {
128+
callback(state)
129+
}
130+
catch {}
131+
132+
return () => {
133+
hooks.delete(callback)
134+
}
135+
},
136+
}
137+
}
138+
139+
export function createAutoUpdaterService(params: { context: MainContext, window: BrowserWindow, service: AutoUpdater }) {
140+
const { context, window, service } = params
141+
142+
// Subscribe to state changes and forward to the context
143+
const unsubscribe = service.subscribe((state) => {
144+
if (window.isDestroyed())
145+
return
146+
147+
try {
148+
context.emit(electronAutoUpdaterStateChanged, state)
149+
}
150+
catch {}
151+
})
152+
153+
const cleanups: Array<() => void> = [unsubscribe]
154+
155+
cleanups.push(
156+
defineInvokeHandler(context, autoUpdaterEventa.getState, () => service.state),
157+
)
158+
159+
cleanups.push(
160+
defineInvokeHandler(context, autoUpdaterEventa.checkForUpdates, async () => {
161+
await service.checkForUpdates()
162+
return service.state
163+
}),
164+
)
165+
166+
cleanups.push(
167+
defineInvokeHandler(context, autoUpdaterEventa.downloadUpdate, async () => {
168+
await service.downloadUpdate()
169+
return service.state
170+
}),
171+
)
172+
173+
cleanups.push(
174+
defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, () => {
175+
service.quitAndInstall()
176+
}),
177+
)
178+
179+
const cleanup = () => {
180+
for (const fn of cleanups)
181+
fn()
182+
}
183+
184+
window.on('closed', cleanup)
185+
return cleanup
186+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export * from './auto-updater'
12
export * from './screen'
23
export * from './window'
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { EventEmitter } from 'node:events'
2+
3+
import { app } from 'electron'
4+
5+
export class MockAutoUpdater extends EventEmitter {
6+
autoDownload = false
7+
8+
async checkForUpdates() {
9+
this.emit('checking-for-update')
10+
11+
// Simulate network delay
12+
await new Promise(resolve => setTimeout(resolve, 1500))
13+
14+
// Simulate update available
15+
// We can toggle this based on some logic if needed, but for now let's assume update is always available in mock
16+
const updateInfo = {
17+
version: '9.9.9-mock',
18+
files: [],
19+
path: 'mock-path',
20+
sha512: 'mock-sha',
21+
releaseDate: new Date().toISOString(),
22+
releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B',
23+
}
24+
25+
this.emit('update-available', updateInfo)
26+
27+
// In real updater, if autoDownload is true, it starts downloading.
28+
// We'll respect that if we were fully mocking, but typically we trigger download manually in this app.
29+
return { updateInfo }
30+
}
31+
32+
async downloadUpdate() {
33+
// Simulate download progress
34+
const total = 100 * 1024 * 1024 // 100MB
35+
let transferred = 0
36+
const speed = 5 * 1024 * 1024 // 5MB/s simulation
37+
38+
const interval = setInterval(() => {
39+
transferred += speed / 10 // Update every 100ms
40+
if (transferred > total)
41+
transferred = total
42+
43+
const progress = {
44+
total,
45+
transferred,
46+
percent: (transferred / total) * 100,
47+
bytesPerSecond: speed,
48+
}
49+
50+
this.emit('download-progress', progress)
51+
52+
if (transferred >= total) {
53+
clearInterval(interval)
54+
this.emit('update-downloaded', {
55+
version: '9.9.9-mock',
56+
files: [],
57+
path: 'mock-path',
58+
sha512: 'mock-sha',
59+
releaseDate: new Date().toISOString(),
60+
releaseNotes: '## Mock Update\n\nThis is a simulated update for testing purposes.\n\n- Feature A\n- Bugfix B',
61+
})
62+
}
63+
}, 100)
64+
}
65+
66+
async quitAndInstall() {
67+
// eslint-disable-next-line no-console
68+
console.log('[MockAutoUpdater] quitAndInstall called. Quitting app...')
69+
app.quit()
70+
}
71+
}

apps/stage-tamagotchi/src/main/windows/about/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { AutoUpdater } from '../../services/electron/auto-updater'
2+
13
import { join, resolve } from 'node:path'
24

35
import { BrowserWindow, shell } from 'electron'
@@ -6,13 +8,14 @@ import icon from '../../../../resources/icon.png?asset'
68

79
import { baseUrl, getElectronMainDirname, load, withHashRoute } from '../../libs/electron/location'
810
import { createReusableWindow } from '../../libs/electron/window-manager'
11+
import { setupAboutWindowElectronInvokes } from './rpc/index.electron'
912

10-
export function setupAboutWindowReusable() {
13+
export function setupAboutWindowReusable(params: { autoUpdater: AutoUpdater }) {
1114
return createReusableWindow(async () => {
1215
const window = new BrowserWindow({
1316
title: 'About AIRI',
14-
width: 580,
15-
height: 630,
17+
width: 670,
18+
height: 730,
1619
show: false,
1720
resizable: true,
1821
maximizable: false,
@@ -32,6 +35,8 @@ export function setupAboutWindowReusable() {
3235

3336
await load(window, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/about'))
3437

38+
setupAboutWindowElectronInvokes({ window, autoUpdater: params.autoUpdater })
39+
3540
return window
3641
}).getWindow
3742
}

0 commit comments

Comments
 (0)