Skip to content

Commit babdb0e

Browse files
fix(stage-tamagotchi): harden cross-platform auto-updater flow, diagnostics, logs, and cache cleanup (#1566)
1 parent 16a2a37 commit babdb0e

20 files changed

Lines changed: 949 additions & 100 deletions

File tree

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

Lines changed: 88 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ export default {
101101
},
102102
win: {
103103
executableName: 'airi',
104+
// NOTICE: Keep `channel: 'latest-${arch}'` for architecture-aware updater metadata.
105+
// electron-builder expands `${arch}` at publish-time (for example: `latest-x64`, `latest-arm64`),
106+
// and electron-updater later consumes that expanded channel to resolve platform-specific *.yml files.
107+
// This prevents cross-arch lookups such as arm64 clients reading x64 metadata.
108+
publish: {
109+
provider: 'github',
110+
owner: 'moeru-ai',
111+
repo: 'airi',
112+
channel: 'latest-${arch}',
113+
},
104114
},
105115
nsis: {
106116
artifactName: '${productName}-${version}-windows-${arch}-setup.${ext}',
@@ -113,6 +123,76 @@ export default {
113123
},
114124
mac: {
115125
entitlementsInherit: 'build/entitlements.mac.plist',
126+
// NOTICE: Same channel rule as Windows. Keep `${arch}` here so generated metadata resolves
127+
// to architecture-specific update feeds on macOS (for example: `latest-x64-mac.yml`, `latest-arm64-mac.yml`).
128+
publish: {
129+
provider: 'github',
130+
owner: 'moeru-ai',
131+
repo: 'airi',
132+
// NOTICE: `channel: 'latest-${arch}'` matters because electron-builder expands
133+
// `${arch}` before it writes any publish metadata, and electron-updater later
134+
// reuses that expanded channel string when deciding which `*.yml` file to fetch.
135+
//
136+
// Without this, the updater would look for `latest-mac.yml` for both x64 and arm64 macOS builds,
137+
// which means the x64 build would be used for arm64 (Apple Silicon) users, causing suboptimal performance and higher resource usage. By embedding `${arch}`
138+
// into the channel name, we ensure that the updater looks for `latest-x64-mac.yml` and `latest-arm64-mac.yml` respectively.
139+
//
140+
// This is how channel name was constructed:
141+
//
142+
// 1. `expandPublishConfig(...)` expands string values in the publish config.
143+
// That is where `latest-${arch}` becomes `latest-x64` or `latest-arm64`.
144+
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/PublishManager.ts#L521-L532
145+
//
146+
// 2. The expanded publish config is written into `app-update.yml`.
147+
// The packaged app therefore carries `channel: latest-x64` or
148+
// `channel: latest-arm64`, not the literal template string.
149+
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/PublishManager.ts#L93-L96
150+
//
151+
// 3. electron-builder also uses that expanded channel when generating update
152+
// metadata files. `getUpdateInfoFileName(channel, packager, arch)` builds the
153+
// filename as:
154+
// `${channel}${osSuffix}${getArchPrefixForUpdateFile(arch, packager)}.yml`
155+
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/updateInfoBuilder.ts#L65-L68
156+
//
157+
// 4. For macOS, `osSuffix` is `-mac` and `getArchPrefixForUpdateFile(...)`
158+
// returns an empty string. So:
159+
// `latest-x64` -> `latest-x64-mac.yml`
160+
// `latest-arm64` -> `latest-arm64-mac.yml`
161+
// This is the publish-time side of the behavior.
162+
//
163+
// 5. At runtime, electron-updater reads the embedded `app-update.yml` and takes
164+
// its `channel` value. It does not reconstruct `latest-${arch}` itself; it
165+
// consumes the already-expanded value from step 2.
166+
//
167+
// 6. `Provider.getChannelFilePrefix()` appends the platform suffix:
168+
// - macOS -> `-mac`
169+
// - Windows -> ``
170+
// - Linux x64 -> `-linux`
171+
// - Linux non-x64 -> `-linux-${arch}`
172+
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/Provider.ts#L44-L52
173+
//
174+
// 7. `getCustomChannelName(channel)` then returns:
175+
// `${channel}${this.getChannelFilePrefix()}`
176+
// So the updater turns:
177+
// `latest-x64` -> `latest-x64-mac`
178+
// `latest-arm64` -> `latest-arm64-mac`
179+
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/Provider.ts#L58-L60
180+
//
181+
// 8. GitHubProvider passes that channel name into `getChannelFilename(channel)`,
182+
// which simply appends `.yml`, so the final lookup becomes:
183+
// `latest-x64-mac.yml`
184+
// `latest-arm64-mac.yml`
185+
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/util.ts#L27-L29
186+
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/GitHubProvider.ts#L132-L145
187+
//
188+
// Resulting filenames with this config:
189+
// - macOS x64 -> `latest-x64-mac.yml`
190+
// - macOS arm64 -> `latest-arm64-mac.yml`
191+
// - Windows x64 -> `latest-x64.yml`
192+
// - Linux x64 -> `latest-x64-linux.yml`
193+
// - Linux arm64 -> `latest-arm64-linux-arm64.yml`
194+
channel: 'latest-${arch}',
195+
},
116196
extendInfo: [
117197
{
118198
NSMicrophoneUsageDescription: 'AIRI requires microphone access for voice interaction',
@@ -140,6 +220,13 @@ export default {
140220
'deb',
141221
'rpm',
142222
],
223+
// NOTICE: Same channel rule as Windows/macOS. Keep `${arch}` to avoid x64/arm64 feed collisions on Linux.
224+
publish: {
225+
provider: 'github',
226+
owner: 'moeru-ai',
227+
repo: 'airi',
228+
channel: 'latest-${arch}',
229+
},
143230
category: 'Utility',
144231
synopsis: 'AI VTuber/Waifu chatbot app inspired by Neuro-sama.',
145232
description: 'AIRI is an AI VTuber/Waifu chatbot supporting Live2D/VRM avatars, featuring human-like interactions and modular stage-based rendering.',
@@ -151,72 +238,5 @@ export default {
151238
artifactName: '${productName}-${version}-linux-${arch}.${ext}',
152239
},
153240
npmRebuild: false,
154-
publish: {
155-
provider: 'github',
156-
owner: 'moeru-ai',
157-
repo: 'airi',
158-
// NOTICE: `channel: 'latest-${arch}'` matters because electron-builder expands
159-
// `${arch}` before it writes any publish metadata, and electron-updater later
160-
// reuses that expanded channel string when deciding which `*.yml` file to fetch.
161-
//
162-
// Without this, the updater would look for `latest-mac.yml` for both x64 and arm64 macOS builds,
163-
// which means the x64 build would be used for arm64 (Apple Silicon) users, causing suboptimal performance and higher resource usage. By embedding `${arch}`
164-
// into the channel name, we ensure that the updater looks for `latest-x64-mac.yml` and `latest-arm64-mac.yml` respectively.
165-
//
166-
// This is how channel name was constructed:
167-
//
168-
// 1. `expandPublishConfig(...)` expands string values in the publish config.
169-
// That is where `latest-${arch}` becomes `latest-x64` or `latest-arm64`.
170-
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/PublishManager.ts#L521-L532
171-
//
172-
// 2. The expanded publish config is written into `app-update.yml`.
173-
// The packaged app therefore carries `channel: latest-x64` or
174-
// `channel: latest-arm64`, not the literal template string.
175-
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/PublishManager.ts#L93-L96
176-
//
177-
// 3. electron-builder also uses that expanded channel when generating update
178-
// metadata files. `getUpdateInfoFileName(channel, packager, arch)` builds the
179-
// filename as:
180-
// `${channel}${osSuffix}${getArchPrefixForUpdateFile(arch, packager)}.yml`
181-
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/updateInfoBuilder.ts#L65-L68
182-
//
183-
// 4. For macOS, `osSuffix` is `-mac` and `getArchPrefixForUpdateFile(...)`
184-
// returns an empty string. So:
185-
// `latest-x64` -> `latest-x64-mac.yml`
186-
// `latest-arm64` -> `latest-arm64-mac.yml`
187-
// This is the publish-time side of the behavior.
188-
//
189-
// 5. At runtime, electron-updater reads the embedded `app-update.yml` and takes
190-
// its `channel` value. It does not reconstruct `latest-${arch}` itself; it
191-
// consumes the already-expanded value from step 2.
192-
//
193-
// 6. `Provider.getChannelFilePrefix()` appends the platform suffix:
194-
// - macOS -> `-mac`
195-
// - Windows -> ``
196-
// - Linux x64 -> `-linux`
197-
// - Linux non-x64 -> `-linux-${arch}`
198-
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/Provider.ts#L44-L52
199-
//
200-
// 7. `getCustomChannelName(channel)` then returns:
201-
// `${channel}${this.getChannelFilePrefix()}`
202-
// So the updater turns:
203-
// `latest-x64` -> `latest-x64-mac`
204-
// `latest-arm64` -> `latest-arm64-mac`
205-
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/Provider.ts#L58-L60
206-
//
207-
// 8. GitHubProvider passes that channel name into `getChannelFilename(channel)`,
208-
// which simply appends `.yml`, so the final lookup becomes:
209-
// `latest-x64-mac.yml`
210-
// `latest-arm64-mac.yml`
211-
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/util.ts#L27-L29
212-
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/GitHubProvider.ts#L132-L145
213-
//
214-
// Resulting filenames with this config:
215-
// - macOS x64 -> `latest-x64-mac.yml`
216-
// - macOS arm64 -> `latest-arm64-mac.yml`
217-
// - Windows x64 -> `latest-x64.yml`
218-
// - Linux x64 -> `latest-x64-linux.yml`
219-
// - Linux arm64 -> `latest-arm64-linux-arm64.yml`
220-
channel: 'latest-${arch}',
221-
},
241+
222242
} satisfies Configuration

apps/stage-tamagotchi/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
"rename-artifacts": "mkdir -p bundle && tsx scripts/rename-artifacts.ts",
3030
"merge-latest-mac": "tsx scripts/merge-latest-mac.ts",
3131
"regenerate-windows-latest": "tsx scripts/regenerate-windows-latest.ts",
32-
"artifacts-metadata": "tsx scripts/artifacts-metadata.ts"
32+
"artifacts-metadata": "tsx scripts/artifacts-metadata.ts",
33+
"update-test:generate": "tsx scripts/update-test/generate-manifest.ts",
34+
"update-test:server": "tsx scripts/update-test/start-server.ts"
3335
},
3436
"dependencies": {
3537
"@date-fns/utc": "^2.1.1",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# AIRI Electron Updater Local Test Harness
2+
3+
This directory provides a local mocked update-server workflow for Stage Tamagotchi.
4+
5+
It is intended to verify AIRI's refactored updater path:
6+
7+
- explicit `UPDATE_SERVER_URL` override mode
8+
- no GitHub Releases API dependency
9+
- no custom updater cache ownership
10+
- developer-only updater diagnostics inspection
11+
12+
## Files
13+
14+
- `generate-manifest.ts`: generate local `latest-*.yml` metadata and placeholder artifacts
15+
- `start-server.ts`: serve generated fixtures over HTTP
16+
- `setup.sh`: prepare the local fixture directories
17+
- `run-test.sh`: thin orchestration wrapper
18+
- `dev-app-update.local.yml`: optional generic-provider template for development-only experiments
19+
20+
## Quick Start
21+
22+
From the repo root:
23+
24+
```bash
25+
bash apps/stage-tamagotchi/scripts/update-test/setup.sh
26+
pnpm -F @proj-airi/stage-tamagotchi update-test:generate \
27+
--root scripts/update-test/fixtures/server \
28+
--channel stable \
29+
--target aarch64-apple-darwin \
30+
--version 9.9.9-update-test.1
31+
pnpm -F @proj-airi/stage-tamagotchi update-test:server \
32+
--port 8787 \
33+
--root scripts/update-test/fixtures/server
34+
```
35+
36+
Then, in another terminal:
37+
38+
```bash
39+
cd apps/stage-tamagotchi
40+
UPDATE_SERVER_URL=http://127.0.0.1:8787/stable pnpm run dev
41+
```
42+
43+
## Verification Flow
44+
45+
1. Open the About page.
46+
2. Click `Check for updates`.
47+
3. Confirm the update becomes available.
48+
4. Click `Download update`.
49+
5. Confirm the updater reaches the `downloaded` state.
50+
6. Open `Settings > System > Developer`.
51+
7. Enable `Inspect updater diagnostics`.
52+
8. Open `Devtools > Updater`.
53+
9. Confirm:
54+
- `overrideActive=true`
55+
- `feedUrl` points to `http://127.0.0.1:8787/stable`
56+
- `platform`, `arch`, and `channel` match the current runtime
57+
58+
## Helper Wrapper
59+
60+
You can also print the workflow commands with:
61+
62+
```bash
63+
bash apps/stage-tamagotchi/scripts/update-test/run-test.sh
64+
```
65+
66+
Environment variables supported by the wrapper:
67+
68+
- `PORT`
69+
- `CHANNEL`
70+
- `TARGET`
71+
- `VERSION`
72+
73+
Common targets:
74+
75+
- Apple Silicon macOS: `aarch64-apple-darwin`
76+
- Intel macOS: `x86_64-apple-darwin`
77+
- Windows x64: `x86_64-pc-windows-msvc`
78+
- Linux x64: `x86_64-unknown-linux-gnu`
79+
80+
## Notes
81+
82+
- The generated artifact is a placeholder file meant for update discovery and early download flow verification.
83+
- Real signed installer execution remains a separate manual verification step.
84+
- The first pass is manual-first by design. A Playwright `_electron` smoke layer can be added on top later.
85+
- When invoking the package scripts through `pnpm -F @proj-airi/stage-tamagotchi`, treat `--root` as relative to `apps/stage-tamagotchi`, not the workspace root.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
mock-update-stable-9.9.9-update-test.1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
mock-update-stable-9.9.9-update-test.1
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
version: 9.9.9-update-test.1
2+
files:
3+
- url: AIRI-9.9.9-update-test.1-darwin-arm64.dmg
4+
sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A==
5+
size: 38
6+
path: AIRI-9.9.9-update-test.1-darwin-arm64.dmg
7+
sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A==
8+
releaseDate: 2026-04-05T17:39:00.050Z
9+
releaseNotes: Mock update for AIRI local updater verification.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
version: 9.9.9-update-test.1
2+
files:
3+
- url: AIRI-9.9.9-update-test.1-windows-x64-setup.exe
4+
sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A==
5+
size: 38
6+
path: AIRI-9.9.9-update-test.1-windows-x64-setup.exe
7+
sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A==
8+
releaseDate: 2026-04-05T14:51:10.149Z
9+
releaseNotes: Mock update for AIRI local updater verification.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { mkdtemp, readFile } from 'node:fs/promises'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
7+
import * as yaml from 'yaml'
8+
9+
import { generateManifestFixtures, resolveLatestFilenameForTarget } from './generate-manifest'
10+
11+
describe('generateManifestFixtures', () => {
12+
const roots: string[] = []
13+
14+
afterEach(async () => {
15+
await Promise.all(roots.map(async (root) => {
16+
await import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true }))
17+
}))
18+
roots.length = 0
19+
})
20+
21+
it('maps targets to the expected latest-yml filenames', () => {
22+
expect(resolveLatestFilenameForTarget('x86_64-pc-windows-msvc')).toBe('latest-x64.yml')
23+
expect(resolveLatestFilenameForTarget('aarch64-apple-darwin')).toBe('latest-arm64-mac.yml')
24+
expect(resolveLatestFilenameForTarget('x86_64-apple-darwin')).toBe('latest-x64-mac.yml')
25+
expect(resolveLatestFilenameForTarget('x86_64-unknown-linux-gnu')).toBe('latest-x64-linux.yml')
26+
expect(resolveLatestFilenameForTarget('aarch64-unknown-linux-gnu')).toBe('latest-arm64-linux-arm64.yml')
27+
})
28+
29+
it('generates a channel directory, manifest, and placeholder artifact', async () => {
30+
const root = await mkdtemp(join(tmpdir(), 'airi-update-test-'))
31+
roots.push(root)
32+
33+
const result = await generateManifestFixtures({
34+
rootDir: root,
35+
channel: 'stable',
36+
target: 'x86_64-pc-windows-msvc',
37+
version: '9.9.9-test.1',
38+
releaseNotes: 'Mock update for AIRI local updater verification.',
39+
artifactContent: 'mock-installer-binary',
40+
})
41+
42+
expect(result.channelDir).toBe(join(root, 'stable'))
43+
expect(result.latestFilename).toBe('latest-x64.yml')
44+
expect(result.artifactFilename).toBe('AIRI-9.9.9-test.1-windows-x64-setup.exe')
45+
46+
const manifest = yaml.parse(await readFile(result.manifestPath, 'utf8'))
47+
expect(manifest).toMatchObject({
48+
version: '9.9.9-test.1',
49+
path: 'AIRI-9.9.9-test.1-windows-x64-setup.exe',
50+
releaseNotes: 'Mock update for AIRI local updater verification.',
51+
files: [
52+
{
53+
url: 'AIRI-9.9.9-test.1-windows-x64-setup.exe',
54+
},
55+
],
56+
})
57+
58+
expect(typeof manifest.sha512).toBe('string')
59+
expect(typeof manifest.releaseDate).toBe('string')
60+
expect(manifest.files[0]?.size).toBeGreaterThan(0)
61+
await expect(readFile(result.artifactPath, 'utf8')).resolves.toBe('mock-installer-binary')
62+
})
63+
})

0 commit comments

Comments
 (0)