fix(stage-tamagotchi): harden cross-platform auto-updater flow, diagnostics, logs, and cache cleanup (#1566)

This commit is contained in:
jensenhuangfan
2026-04-06 01:48:02 +08:00
committed by GitHub
parent 16a2a37777
commit babdb0e6e7
20 changed files with 949 additions and 100 deletions
@@ -101,6 +101,16 @@ export default {
},
win: {
executableName: 'airi',
// NOTICE: Keep `channel: 'latest-${arch}'` for architecture-aware updater metadata.
// electron-builder expands `${arch}` at publish-time (for example: `latest-x64`, `latest-arm64`),
// and electron-updater later consumes that expanded channel to resolve platform-specific *.yml files.
// This prevents cross-arch lookups such as arm64 clients reading x64 metadata.
publish: {
provider: 'github',
owner: 'moeru-ai',
repo: 'airi',
channel: 'latest-${arch}',
},
},
nsis: {
artifactName: '${productName}-${version}-windows-${arch}-setup.${ext}',
@@ -113,6 +123,76 @@ export default {
},
mac: {
entitlementsInherit: 'build/entitlements.mac.plist',
// NOTICE: Same channel rule as Windows. Keep `${arch}` here so generated metadata resolves
// to architecture-specific update feeds on macOS (for example: `latest-x64-mac.yml`, `latest-arm64-mac.yml`).
publish: {
provider: 'github',
owner: 'moeru-ai',
repo: 'airi',
// NOTICE: `channel: 'latest-${arch}'` matters because electron-builder expands
// `${arch}` before it writes any publish metadata, and electron-updater later
// reuses that expanded channel string when deciding which `*.yml` file to fetch.
//
// Without this, the updater would look for `latest-mac.yml` for both x64 and arm64 macOS builds,
// which means the x64 build would be used for arm64 (Apple Silicon) users, causing suboptimal performance and higher resource usage. By embedding `${arch}`
// into the channel name, we ensure that the updater looks for `latest-x64-mac.yml` and `latest-arm64-mac.yml` respectively.
//
// This is how channel name was constructed:
//
// 1. `expandPublishConfig(...)` expands string values in the publish config.
// That is where `latest-${arch}` becomes `latest-x64` or `latest-arm64`.
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/PublishManager.ts#L521-L532
//
// 2. The expanded publish config is written into `app-update.yml`.
// The packaged app therefore carries `channel: latest-x64` or
// `channel: latest-arm64`, not the literal template string.
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/PublishManager.ts#L93-L96
//
// 3. electron-builder also uses that expanded channel when generating update
// metadata files. `getUpdateInfoFileName(channel, packager, arch)` builds the
// filename as:
// `${channel}${osSuffix}${getArchPrefixForUpdateFile(arch, packager)}.yml`
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/updateInfoBuilder.ts#L65-L68
//
// 4. For macOS, `osSuffix` is `-mac` and `getArchPrefixForUpdateFile(...)`
// returns an empty string. So:
// `latest-x64` -> `latest-x64-mac.yml`
// `latest-arm64` -> `latest-arm64-mac.yml`
// This is the publish-time side of the behavior.
//
// 5. At runtime, electron-updater reads the embedded `app-update.yml` and takes
// its `channel` value. It does not reconstruct `latest-${arch}` itself; it
// consumes the already-expanded value from step 2.
//
// 6. `Provider.getChannelFilePrefix()` appends the platform suffix:
// - macOS -> `-mac`
// - Windows -> ``
// - Linux x64 -> `-linux`
// - Linux non-x64 -> `-linux-${arch}`
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/Provider.ts#L44-L52
//
// 7. `getCustomChannelName(channel)` then returns:
// `${channel}${this.getChannelFilePrefix()}`
// So the updater turns:
// `latest-x64` -> `latest-x64-mac`
// `latest-arm64` -> `latest-arm64-mac`
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/Provider.ts#L58-L60
//
// 8. GitHubProvider passes that channel name into `getChannelFilename(channel)`,
// which simply appends `.yml`, so the final lookup becomes:
// `latest-x64-mac.yml`
// `latest-arm64-mac.yml`
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/util.ts#L27-L29
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/GitHubProvider.ts#L132-L145
//
// Resulting filenames with this config:
// - macOS x64 -> `latest-x64-mac.yml`
// - macOS arm64 -> `latest-arm64-mac.yml`
// - Windows x64 -> `latest-x64.yml`
// - Linux x64 -> `latest-x64-linux.yml`
// - Linux arm64 -> `latest-arm64-linux-arm64.yml`
channel: 'latest-${arch}',
},
extendInfo: [
{
NSMicrophoneUsageDescription: 'AIRI requires microphone access for voice interaction',
@@ -140,6 +220,13 @@ export default {
'deb',
'rpm',
],
// NOTICE: Same channel rule as Windows/macOS. Keep `${arch}` to avoid x64/arm64 feed collisions on Linux.
publish: {
provider: 'github',
owner: 'moeru-ai',
repo: 'airi',
channel: 'latest-${arch}',
},
category: 'Utility',
synopsis: 'AI VTuber/Waifu chatbot app inspired by Neuro-sama.',
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 {
artifactName: '${productName}-${version}-linux-${arch}.${ext}',
},
npmRebuild: false,
publish: {
provider: 'github',
owner: 'moeru-ai',
repo: 'airi',
// NOTICE: `channel: 'latest-${arch}'` matters because electron-builder expands
// `${arch}` before it writes any publish metadata, and electron-updater later
// reuses that expanded channel string when deciding which `*.yml` file to fetch.
//
// Without this, the updater would look for `latest-mac.yml` for both x64 and arm64 macOS builds,
// which means the x64 build would be used for arm64 (Apple Silicon) users, causing suboptimal performance and higher resource usage. By embedding `${arch}`
// into the channel name, we ensure that the updater looks for `latest-x64-mac.yml` and `latest-arm64-mac.yml` respectively.
//
// This is how channel name was constructed:
//
// 1. `expandPublishConfig(...)` expands string values in the publish config.
// That is where `latest-${arch}` becomes `latest-x64` or `latest-arm64`.
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/PublishManager.ts#L521-L532
//
// 2. The expanded publish config is written into `app-update.yml`.
// The packaged app therefore carries `channel: latest-x64` or
// `channel: latest-arm64`, not the literal template string.
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/PublishManager.ts#L93-L96
//
// 3. electron-builder also uses that expanded channel when generating update
// metadata files. `getUpdateInfoFileName(channel, packager, arch)` builds the
// filename as:
// `${channel}${osSuffix}${getArchPrefixForUpdateFile(arch, packager)}.yml`
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/app-builder-lib/src/publish/updateInfoBuilder.ts#L65-L68
//
// 4. For macOS, `osSuffix` is `-mac` and `getArchPrefixForUpdateFile(...)`
// returns an empty string. So:
// `latest-x64` -> `latest-x64-mac.yml`
// `latest-arm64` -> `latest-arm64-mac.yml`
// This is the publish-time side of the behavior.
//
// 5. At runtime, electron-updater reads the embedded `app-update.yml` and takes
// its `channel` value. It does not reconstruct `latest-${arch}` itself; it
// consumes the already-expanded value from step 2.
//
// 6. `Provider.getChannelFilePrefix()` appends the platform suffix:
// - macOS -> `-mac`
// - Windows -> ``
// - Linux x64 -> `-linux`
// - Linux non-x64 -> `-linux-${arch}`
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/Provider.ts#L44-L52
//
// 7. `getCustomChannelName(channel)` then returns:
// `${channel}${this.getChannelFilePrefix()}`
// So the updater turns:
// `latest-x64` -> `latest-x64-mac`
// `latest-arm64` -> `latest-arm64-mac`
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/Provider.ts#L58-L60
//
// 8. GitHubProvider passes that channel name into `getChannelFilename(channel)`,
// which simply appends `.yml`, so the final lookup becomes:
// `latest-x64-mac.yml`
// `latest-arm64-mac.yml`
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/util.ts#L27-L29
// https://github.com/electron-userland/electron-builder/blob/ed422f36540a93e9bd2a19bc7a5e729bf2b033ea/packages/electron-updater/src/providers/GitHubProvider.ts#L132-L145
//
// Resulting filenames with this config:
// - macOS x64 -> `latest-x64-mac.yml`
// - macOS arm64 -> `latest-arm64-mac.yml`
// - Windows x64 -> `latest-x64.yml`
// - Linux x64 -> `latest-x64-linux.yml`
// - Linux arm64 -> `latest-arm64-linux-arm64.yml`
channel: 'latest-${arch}',
},
} satisfies Configuration
+3 -1
View File
@@ -29,7 +29,9 @@
"rename-artifacts": "mkdir -p bundle && tsx scripts/rename-artifacts.ts",
"merge-latest-mac": "tsx scripts/merge-latest-mac.ts",
"regenerate-windows-latest": "tsx scripts/regenerate-windows-latest.ts",
"artifacts-metadata": "tsx scripts/artifacts-metadata.ts"
"artifacts-metadata": "tsx scripts/artifacts-metadata.ts",
"update-test:generate": "tsx scripts/update-test/generate-manifest.ts",
"update-test:server": "tsx scripts/update-test/start-server.ts"
},
"dependencies": {
"@date-fns/utc": "^2.1.1",
@@ -0,0 +1,85 @@
# AIRI Electron Updater Local Test Harness
This directory provides a local mocked update-server workflow for Stage Tamagotchi.
It is intended to verify AIRI's refactored updater path:
- explicit `UPDATE_SERVER_URL` override mode
- no GitHub Releases API dependency
- no custom updater cache ownership
- developer-only updater diagnostics inspection
## Files
- `generate-manifest.ts`: generate local `latest-*.yml` metadata and placeholder artifacts
- `start-server.ts`: serve generated fixtures over HTTP
- `setup.sh`: prepare the local fixture directories
- `run-test.sh`: thin orchestration wrapper
- `dev-app-update.local.yml`: optional generic-provider template for development-only experiments
## Quick Start
From the repo root:
```bash
bash apps/stage-tamagotchi/scripts/update-test/setup.sh
pnpm -F @proj-airi/stage-tamagotchi update-test:generate \
--root scripts/update-test/fixtures/server \
--channel stable \
--target aarch64-apple-darwin \
--version 9.9.9-update-test.1
pnpm -F @proj-airi/stage-tamagotchi update-test:server \
--port 8787 \
--root scripts/update-test/fixtures/server
```
Then, in another terminal:
```bash
cd apps/stage-tamagotchi
UPDATE_SERVER_URL=http://127.0.0.1:8787/stable pnpm run dev
```
## Verification Flow
1. Open the About page.
2. Click `Check for updates`.
3. Confirm the update becomes available.
4. Click `Download update`.
5. Confirm the updater reaches the `downloaded` state.
6. Open `Settings > System > Developer`.
7. Enable `Inspect updater diagnostics`.
8. Open `Devtools > Updater`.
9. Confirm:
- `overrideActive=true`
- `feedUrl` points to `http://127.0.0.1:8787/stable`
- `platform`, `arch`, and `channel` match the current runtime
## Helper Wrapper
You can also print the workflow commands with:
```bash
bash apps/stage-tamagotchi/scripts/update-test/run-test.sh
```
Environment variables supported by the wrapper:
- `PORT`
- `CHANNEL`
- `TARGET`
- `VERSION`
Common targets:
- Apple Silicon macOS: `aarch64-apple-darwin`
- Intel macOS: `x86_64-apple-darwin`
- Windows x64: `x86_64-pc-windows-msvc`
- Linux x64: `x86_64-unknown-linux-gnu`
## Notes
- The generated artifact is a placeholder file meant for update discovery and early download flow verification.
- Real signed installer execution remains a separate manual verification step.
- The first pass is manual-first by design. A Playwright `_electron` smoke layer can be added on top later.
- When invoking the package scripts through `pnpm -F @proj-airi/stage-tamagotchi`, treat `--root` as relative to `apps/stage-tamagotchi`, not the workspace root.
@@ -0,0 +1 @@
mock-update-stable-9.9.9-update-test.1
@@ -0,0 +1 @@
mock-update-stable-9.9.9-update-test.1
@@ -0,0 +1,9 @@
version: 9.9.9-update-test.1
files:
- url: AIRI-9.9.9-update-test.1-darwin-arm64.dmg
sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A==
size: 38
path: AIRI-9.9.9-update-test.1-darwin-arm64.dmg
sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A==
releaseDate: 2026-04-05T17:39:00.050Z
releaseNotes: Mock update for AIRI local updater verification.
@@ -0,0 +1,9 @@
version: 9.9.9-update-test.1
files:
- url: AIRI-9.9.9-update-test.1-windows-x64-setup.exe
sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A==
size: 38
path: AIRI-9.9.9-update-test.1-windows-x64-setup.exe
sha512: D6msk0IrWPMUcpjWnMZfEVrY8Qe/yQN4iJO6O6mttkwl5pENCNazyxdvRJX+kOZFujznbCIc+m1z6dokPzHg2A==
releaseDate: 2026-04-05T14:51:10.149Z
releaseNotes: Mock update for AIRI local updater verification.
@@ -0,0 +1,63 @@
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import * as yaml from 'yaml'
import { generateManifestFixtures, resolveLatestFilenameForTarget } from './generate-manifest'
describe('generateManifestFixtures', () => {
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.map(async (root) => {
await import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true }))
}))
roots.length = 0
})
it('maps targets to the expected latest-yml filenames', () => {
expect(resolveLatestFilenameForTarget('x86_64-pc-windows-msvc')).toBe('latest-x64.yml')
expect(resolveLatestFilenameForTarget('aarch64-apple-darwin')).toBe('latest-arm64-mac.yml')
expect(resolveLatestFilenameForTarget('x86_64-apple-darwin')).toBe('latest-x64-mac.yml')
expect(resolveLatestFilenameForTarget('x86_64-unknown-linux-gnu')).toBe('latest-x64-linux.yml')
expect(resolveLatestFilenameForTarget('aarch64-unknown-linux-gnu')).toBe('latest-arm64-linux-arm64.yml')
})
it('generates a channel directory, manifest, and placeholder artifact', async () => {
const root = await mkdtemp(join(tmpdir(), 'airi-update-test-'))
roots.push(root)
const result = await generateManifestFixtures({
rootDir: root,
channel: 'stable',
target: 'x86_64-pc-windows-msvc',
version: '9.9.9-test.1',
releaseNotes: 'Mock update for AIRI local updater verification.',
artifactContent: 'mock-installer-binary',
})
expect(result.channelDir).toBe(join(root, 'stable'))
expect(result.latestFilename).toBe('latest-x64.yml')
expect(result.artifactFilename).toBe('AIRI-9.9.9-test.1-windows-x64-setup.exe')
const manifest = yaml.parse(await readFile(result.manifestPath, 'utf8'))
expect(manifest).toMatchObject({
version: '9.9.9-test.1',
path: 'AIRI-9.9.9-test.1-windows-x64-setup.exe',
releaseNotes: 'Mock update for AIRI local updater verification.',
files: [
{
url: 'AIRI-9.9.9-test.1-windows-x64-setup.exe',
},
],
})
expect(typeof manifest.sha512).toBe('string')
expect(typeof manifest.releaseDate).toBe('string')
expect(manifest.files[0]?.size).toBeGreaterThan(0)
await expect(readFile(result.artifactPath, 'utf8')).resolves.toBe('mock-installer-binary')
})
})
@@ -0,0 +1,136 @@
import { Buffer } from 'node:buffer'
import { createHash } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { exit } from 'node:process'
import { cac } from 'cac'
import * as yaml from 'yaml'
import { getFilenames } from '../utils'
export type UpdateTestChannel = 'stable' | 'nightly' | 'canary'
export interface GenerateManifestFixturesOptions {
rootDir: string
channel: UpdateTestChannel
target: string
version: string
releaseNotes: string
artifactContent?: string
}
export interface GenerateManifestFixturesResult {
channelDir: string
manifestPath: string
artifactPath: string
latestFilename: string
artifactFilename: string
}
export function resolveLatestFilenameForTarget(target: string) {
switch (target) {
case 'x86_64-pc-windows-msvc':
return 'latest-x64.yml'
case 'x86_64-unknown-linux-gnu':
return 'latest-x64-linux.yml'
case 'aarch64-unknown-linux-gnu':
return 'latest-arm64-linux-arm64.yml'
case 'x86_64-apple-darwin':
return 'latest-x64-mac.yml'
case 'aarch64-apple-darwin':
return 'latest-arm64-mac.yml'
default:
throw new Error(`Unsupported update-test target: ${target}`)
}
}
function encodeBase64Sha512(content: string) {
return createHash('sha512').update(content).digest('base64')
}
async function resolveArtifactFilename(target: string, version: string) {
const filenames = await getFilenames(target, {
release: true,
autoTag: false,
tag: [version],
})
const artifact = filenames.find(entry => !entry.optional && entry.extension !== 'blockmap')
if (!artifact)
throw new Error(`Unable to determine artifact filename for target: ${target}`)
return artifact.releaseArtifactFilename
}
export async function generateManifestFixtures(options: GenerateManifestFixturesOptions): Promise<GenerateManifestFixturesResult> {
const channelDir = join(options.rootDir, options.channel)
const latestFilename = resolveLatestFilenameForTarget(options.target)
const artifactFilename = await resolveArtifactFilename(options.target, options.version)
const manifestPath = join(channelDir, latestFilename)
const artifactPath = join(channelDir, artifactFilename)
const artifactContent = options.artifactContent ?? `mock-update-${options.channel}-${options.version}`
await mkdir(dirname(manifestPath), { recursive: true })
await writeFile(artifactPath, artifactContent, 'utf8')
const sha512 = encodeBase64Sha512(artifactContent)
const releaseDate = new Date().toISOString()
const size = Buffer.byteLength(artifactContent)
const manifest = {
version: options.version,
files: [
{
url: artifactFilename,
sha512,
size,
},
],
path: artifactFilename,
sha512,
releaseDate,
releaseNotes: options.releaseNotes,
}
await writeFile(manifestPath, yaml.stringify(manifest), 'utf8')
return {
channelDir,
manifestPath,
artifactPath,
latestFilename,
artifactFilename,
}
}
async function main() {
const cli = cac('generate-update-test-manifest')
.option('--root <path>', 'Root directory for generated server fixtures', { default: 'scripts/update-test/fixtures/server' })
.option('--channel <channel>', 'Channel to generate', { default: 'stable' })
.option('--target <target>', 'Target triple to generate fixtures for', { default: 'x86_64-pc-windows-msvc' })
.option('--version <version>', 'Version to publish in the generated manifest', { default: '9.9.9-update-test.1' })
.option('--release-notes <notes>', 'Release notes content', { default: 'Mock update for AIRI local updater verification.' })
const parsed = cli.parse()
const result = await generateManifestFixtures({
rootDir: String(parsed.options.root),
channel: String(parsed.options.channel) as UpdateTestChannel,
target: String(parsed.options.target),
version: String(parsed.options.version),
releaseNotes: String(parsed.options.releaseNotes),
})
// eslint-disable-next-line no-console
console.log(`Generated ${result.latestFilename} in ${result.channelDir}`)
// eslint-disable-next-line no-console
console.log(`Artifact: ${result.artifactFilename}`)
}
if (import.meta.main) {
main().catch((error) => {
console.error(error)
exit(1)
})
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
PORT="${PORT:-8787}"
CHANNEL="${CHANNEL:-stable}"
TARGET="${TARGET:-x86_64-pc-windows-msvc}"
VERSION="${VERSION:-9.9.9-update-test.1}"
bash "${SCRIPT_DIR}/setup.sh"
pnpm exec tsx "${SCRIPT_DIR}/generate-manifest.ts" \
--root "${SCRIPT_DIR}/fixtures/server" \
--channel "${CHANNEL}" \
--target "${TARGET}" \
--version "${VERSION}"
echo
echo "Start the local update server in another terminal:"
echo "pnpm exec tsx ${SCRIPT_DIR}/start-server.ts --port ${PORT} --root ${SCRIPT_DIR}/fixtures/server"
echo
echo "Launch AIRI against the mocked update server:"
echo "cd ${APP_DIR}"
echo "UPDATE_SERVER_URL=http://127.0.0.1:${PORT}/${CHANNEL} pnpm run dev"
echo
echo "Verify:"
echo "1. About page shows an available update."
echo "2. Download reaches the downloaded state."
echo "3. Settings > System > Developer enables updater diagnostics."
echo "4. Devtools > Updater shows overrideActive=true and the local feed URL."
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
mkdir -p "${SCRIPT_DIR}/fixtures/server/stable"
mkdir -p "${SCRIPT_DIR}/fixtures/server/nightly"
mkdir -p "${SCRIPT_DIR}/fixtures/server/canary"
chmod +x "${SCRIPT_DIR}/setup.sh" "${SCRIPT_DIR}/run-test.sh"
echo "Prepared update-test fixtures in ${SCRIPT_DIR}/fixtures/server"
@@ -0,0 +1,83 @@
/* eslint-disable no-console */
import process, { exit } from 'node:process'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { extname, join, normalize } from 'node:path'
import { cac } from 'cac'
const CONTENT_TYPES: Record<string, string> = {
'.exe': 'application/vnd.microsoft.portable-executable',
'.yml': 'text/yaml; charset=utf-8',
'.yaml': 'text/yaml; charset=utf-8',
'.zip': 'application/zip',
'.dmg': 'application/octet-stream',
'.deb': 'application/vnd.debian.binary-package',
'.rpm': 'application/x-rpm',
'.txt': 'text/plain; charset=utf-8',
}
function getContentType(pathname: string) {
return CONTENT_TYPES[extname(pathname)] ?? 'application/octet-stream'
}
export async function startUpdateTestServer(options: { port: number, rootDir: string }) {
const server = createServer(async (request, response) => {
const pathname = request.url?.split('?')[0] || '/'
// eslint-disable-next-line e18e/prefer-static-regex
const safePath = normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, '')
const filePath = join(options.rootDir, safePath === '/' ? '/index.html' : safePath)
try {
const body = await readFile(filePath)
response.writeHead(200, { 'content-type': getContentType(filePath) })
response.end(body)
}
catch {
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
response.end(`Not found: ${pathname}`)
}
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(options.port, '127.0.0.1', () => resolve())
})
return server
}
async function main() {
const cli = cac('update-test-server')
.option('--port <port>', 'Port to listen on', { default: '8787' })
.option('--root <path>', 'Server root directory', { default: 'scripts/update-test/fixtures/server' })
const parsed = cli.parse()
const port = Number(parsed.options.port)
const rootDir = String(parsed.options.root)
const server = await startUpdateTestServer({ port, rootDir })
console.log(`Update test server listening on http://127.0.0.1:${port}`)
console.log(`stable: http://127.0.0.1:${port}/stable`)
console.log(`nightly: http://127.0.0.1:${port}/nightly`)
console.log(`canary: http://127.0.0.1:${port}/canary`)
const close = async () => {
await new Promise<void>((resolve, reject) => {
server.close(error => error ? reject(error) : resolve())
})
exit(0)
}
process.on('SIGINT', () => { void close() })
process.on('SIGTERM', () => { void close() })
}
if (import.meta.main) {
main().catch((error) => {
console.error(error)
exit(1)
})
}
@@ -0,0 +1,160 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const appMock = vi.hoisted(() => ({
getVersion: vi.fn(() => '0.9.0-beta.4'),
getPath: vi.fn((name: string) => name === 'logs' ? '/tmp/airi/logs' : `/tmp/${name}`),
quit: vi.fn(),
isPackaged: false,
}))
const isDevState = vi.hoisted(() => ({
value: false,
}))
const updaterState = vi.hoisted(() => ({
instance: createUpdaterMock(),
}))
function createUpdaterMock() {
return {
on: vi.fn(),
autoDownload: true,
allowPrerelease: false,
channel: undefined as string | undefined,
logger: undefined as any,
forceDevUpdateConfig: false,
setFeedURL: vi.fn(),
checkForUpdates: vi.fn().mockResolvedValue(undefined),
downloadUpdate: vi.fn().mockResolvedValue(undefined),
quitAndInstall: vi.fn(),
}
}
vi.mock('electron', () => ({
app: appMock,
}))
vi.mock('@electron-toolkit/utils', () => ({
is: {
get dev() {
return isDevState.value
},
},
}))
vi.mock('@guiiai/logg', () => ({
useLogg: () => ({
useGlobalConfig: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
withError: () => ({
error: vi.fn(),
}),
}),
}),
}))
vi.mock('electron-updater', () => ({
default: {
get autoUpdater() {
return updaterState.instance
},
},
}))
vi.mock('~build/git', () => ({
committerDate: '2026-04-01T00:00:00.000Z',
}))
describe('setupAutoUpdater', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
updaterState.instance = createUpdaterMock()
appMock.getVersion.mockReturnValue('0.9.0-beta.4')
appMock.getPath.mockImplementation((name: string) => name === 'logs' ? '/tmp/airi/logs' : `/tmp/${name}`)
isDevState.value = false
delete process.env.UPDATE_SERVER_URL
})
it('does not query the GitHub Releases API during setup or manual checks', async () => {
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => [],
})
vi.stubGlobal('fetch', fetchSpy)
const { setupAutoUpdater } = await import('./auto-updater')
const service = setupAutoUpdater()
await Promise.resolve()
await service.checkForUpdates()
expect(fetchSpy).not.toHaveBeenCalled()
})
it('only uses setFeedURL when an explicit update server override is provided', async () => {
process.env.UPDATE_SERVER_URL = 'http://localhost:8787/stable'
const { setupAutoUpdater } = await import('./auto-updater')
setupAutoUpdater()
expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({
provider: 'generic',
url: 'http://localhost:8787/stable',
})
})
it('uses the real updater in dev mode when an explicit update server override is provided', async () => {
isDevState.value = true
process.env.UPDATE_SERVER_URL = 'http://localhost:8787/stable'
const { setupAutoUpdater } = await import('./auto-updater')
setupAutoUpdater()
expect(updaterState.instance.setFeedURL).toHaveBeenCalledWith({
provider: 'generic',
url: 'http://localhost:8787/stable',
})
expect(updaterState.instance.forceDevUpdateConfig).toBe(true)
})
it('reports only authoritative diagnostics fields', async () => {
const { setupAutoUpdater } = await import('./auto-updater')
const service = setupAutoUpdater()
expect(service.state.diagnostics).toEqual(expect.objectContaining({
platform: process.platform,
arch: process.arch,
channel: 'latest-arm64',
executablePath: expect.any(String),
logFilePath: '/tmp/airi/logs',
isOverrideActive: false,
}))
expect(service.state.diagnostics).not.toHaveProperty('updaterCacheDir')
expect(service.state.diagnostics).not.toHaveProperty('pendingDir')
expect(service.state.diagnostics).not.toHaveProperty('uninstallPath')
expect(service.state.diagnostics).not.toHaveProperty('uninstallExists')
})
it('uses silent relaunch install on Windows only', async () => {
const originalPlatform = process.platform
vi.stubEnv('TEST_PLATFORM', '')
const { setupAutoUpdater } = await import('./auto-updater')
const service = setupAutoUpdater()
Object.defineProperty(process, 'platform', { value: 'win32' })
await service.quitAndInstall()
expect(updaterState.instance.quitAndInstall).toHaveBeenCalledWith(true, true)
updaterState.instance.quitAndInstall.mockClear()
Object.defineProperty(process, 'platform', { value: 'darwin' })
await service.quitAndInstall()
expect(updaterState.instance.quitAndInstall).toHaveBeenCalledWith()
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
})
@@ -1,10 +1,9 @@
import type { createContext } from '@moeru/eventa/adapters/electron/main'
import type { AutoUpdaterState } from '@proj-airi/electron-eventa/electron-updater'
import type { BrowserWindow } from 'electron'
import type { UpdateInfo } from 'electron-updater'
import type { AutoUpdaterState } from '../../../shared/eventa'
import { arch } from 'node:process'
import process from 'node:process'
import electronUpdater from 'electron-updater'
@@ -22,23 +21,34 @@ import {
} from '../../../shared/eventa'
import { MockAutoUpdater } from './mock-auto-updater'
function getReleaseChannelName() {
return process.arch === 'arm64' ? 'latest-arm64' : 'latest-x64'
}
function getUpdateServerOverride() {
const value = process.env.UPDATE_SERVER_URL?.trim()
return value || undefined
}
export interface AppUpdaterLike {
channel?: string
on: (event: string, listener: (...args: any[]) => void) => any
checkForUpdates: () => Promise<any>
downloadUpdate: () => Promise<any>
quitAndInstall: () => Promise<void>
quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => Promise<void> | void
setFeedURL?: (options: { provider: 'generic', url: string }) => void
logger?: any
allowPrerelease?: boolean
autoDownload?: boolean
channel?: string
forceDevUpdateConfig?: boolean
}
// 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) {
if (is.dev && !getUpdateServerOverride())
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
}
@@ -49,29 +59,53 @@ export interface AutoUpdater {
state: AutoUpdaterState
checkForUpdates: () => Promise<void>
downloadUpdate: () => Promise<void>
quitAndInstall: () => void
quitAndInstall: () => Promise<void>
subscribe: (callback: (state: AutoUpdaterState) => void) => () => void
}
export function setupAutoUpdater(): AutoUpdater {
const semaphore = new Semaphore(1)
const isPrereleaseBuild = app.getVersion().includes('-')
const log = useLogg('auto-updater').useGlobalConfig()
const autoUpdater = fromImported()
const feedUrlOverride = getUpdateServerOverride()
let state: AutoUpdaterState = { status: 'idle' }
autoUpdater.allowPrerelease = isPrereleaseBuild
autoUpdater.autoDownload = false
autoUpdater.channel = getReleaseChannelName()
autoUpdater.forceDevUpdateConfig = !!feedUrlOverride && !app.isPackaged
autoUpdater.logger = {
info: (message: string) => log.log(message),
warn: (message: string) => log.warn(message),
error: (message: string) => log.error(message),
debug: (message: string) => log.debug(message),
}
if (feedUrlOverride)
autoUpdater.setFeedURL?.({ provider: 'generic', url: feedUrlOverride })
const withDiagnostics = (next: AutoUpdaterState): AutoUpdaterState => ({
...next,
diagnostics: {
platform: process.platform,
arch: process.arch,
channel: autoUpdater.channel || getReleaseChannelName(),
logFilePath: app.getPath('logs'),
executablePath: process.execPath,
isOverrideActive: !!feedUrlOverride,
...(feedUrlOverride ? { feedUrl: feedUrlOverride } : {}),
},
})
let state: AutoUpdaterState = withDiagnostics({ status: 'idle' })
const hooks = new Set<(state: AutoUpdaterState) => void>()
// Fix: explicitly map base channel to architecture to resolve 404 targets.
// electron-updater natively appends OS suffixes (-mac.yml, -linux.yml) automatically.
autoUpdater.channel = arch === 'arm64' ? 'latest-arm64' : 'latest-x64'
function broadcast(next: AutoUpdaterState) {
state = next
state = withDiagnostics(next)
for (const listener of hooks) {
try {
listener(next)
listener(state)
}
catch (error) {
log.withError(error).error('Failed to notify listener')
@@ -79,11 +113,26 @@ export function setupAutoUpdater(): AutoUpdater {
}
}
autoUpdater.on('error', error => broadcast({ status: 'error', error: { message: errorMessageFrom(error) || String(error) } }))
function broadcastUpdaterError(error: unknown, reason: string) {
broadcast({
status: 'error',
error: { message: errorMessageFrom(error) ?? String(error) },
})
log.withError(error).error(reason)
}
autoUpdater.on('error', error => broadcastUpdaterError(error, 'autoUpdater 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('update-not-available', () => broadcast({
status: 'not-available',
info: {
version: app.getVersion(),
files: [],
releaseDate: committerDate,
},
}))
autoUpdater.on('download-progress', progress => broadcast({
...state,
status: 'downloading',
@@ -95,7 +144,9 @@ export function setupAutoUpdater(): AutoUpdater {
},
}))
autoUpdater.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed'))
void autoUpdater
.checkForUpdates()
.catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
return {
get state() {
@@ -103,7 +154,7 @@ export function setupAutoUpdater(): AutoUpdater {
},
async checkForUpdates() {
broadcast({ status: 'checking' })
await autoUpdater.checkForUpdates().catch(error => log.withError(error).error('checkForUpdates() failed'))
await autoUpdater.checkForUpdates().catch(error => broadcastUpdaterError(error, 'checkForUpdates() failed'))
},
async downloadUpdate() {
if (state.status === 'downloading' || state.status === 'downloaded')
@@ -122,7 +173,10 @@ export function setupAutoUpdater(): AutoUpdater {
await semaphore.acquire()
try {
autoUpdater.quitAndInstall()
if (process.platform === 'win32')
autoUpdater.quitAndInstall(true, true)
else
autoUpdater.quitAndInstall()
}
finally {
semaphore.release()
@@ -130,7 +184,7 @@ export function setupAutoUpdater(): AutoUpdater {
},
subscribe(callback) {
hooks.add(callback)
// Send current state immediately
try {
callback(state)
}
@@ -148,7 +202,6 @@ export function createAutoUpdaterService(params: { context: MainContext, window:
const log = useLogg('auto-updater-service').useGlobalConfig()
// Subscribe to state changes and forward to the context
const unsubscribe = service.subscribe((state) => {
if (window.isDestroyed())
return
@@ -177,8 +230,8 @@ export function createAutoUpdaterService(params: { context: MainContext, window:
)
cleanups.push(
defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, () => {
service.quitAndInstall()
defineInvokeHandler(context, autoUpdaterEventa.quitAndInstall, async () => {
await service.quitAndInstall()
}),
)
@@ -21,7 +21,9 @@ const {
} = useElectronAutoUpdater()
const isDisabled = computed(() => updateState.value.status === 'disabled')
const isLatestVersion = computed(() => updateState.value.status === 'idle' && !updateState.value.info && !isDisabled.value)
const isLatestVersion = computed(() => {
return updateState.value.status === 'not-available' && !isDisabled.value
})
const isError = computed(() => updateState.value.status === 'error')
const links = [
@@ -33,6 +35,21 @@ const links = [
const showChangelog = ref(false)
const { isDesktop } = useBreakpoints()
const isWindowsUpdater = computed(() => {
return updateState.value.diagnostics?.platform === 'win32'
})
const downloadedStatusText = computed(() => {
if (isWindowsUpdater.value)
return `Update ready to install silently (v${updateState.value.info?.version}).`
return `Update ready to install on restart (v${updateState.value.info?.version}).`
})
const restartButtonLabel = computed(() => {
return isWindowsUpdater.value ? 'Restart to update silently' : 'Restart to install update'
})
function handleDownloadClick() {
if (updateState.value.info?.releaseNotes)
showChangelog.value = true
@@ -122,14 +139,14 @@ const releaseNotesContent = computed(() => {
<!-- State: Downloaded -->
<div v-else-if="updateState.status === 'downloaded'" :class="['flex flex-col gap-4']">
<div :class="['text-sm text-emerald-600 dark:text-emerald-400']">
Update ready to install (v{{ updateState.info?.version }}).
{{ downloadedStatusText }}
</div>
<div>
<DoubleCheckButton
variant="primary"
@confirm="quitAndInstall()"
>
Restart to update
{{ restartButtonLabel }}
<template #confirm>
Confirm Restart
</template>
@@ -145,12 +162,15 @@ const releaseNotesContent = computed(() => {
<div v-if="isError" :class="['text-sm text-red-600 dark:text-red-400']">
Error: {{ updateState.error?.message }}
</div>
<div v-else-if="isLatestVersion" :class="['text-sm text-emerald-600 dark:text-emerald-400']">
Up to date (v{{ buildInfo.version }}).
</div>
<div :class="['flex flex-wrap gap-2']">
<Button
:variant="isError ? 'caution' : 'secondary'"
:loading="isBusy"
:disabled="isDisabled || (isLatestVersion && !isError)"
:disabled="isDisabled"
:icon="isLatestVersion ? 'i-solar:check-circle-outline' : isDisabled ? 'i-solar:forbidden-circle-outline' : 'i-solar:refresh-outline'"
:label="isBusy ? 'Checking...' : isLatestVersion ? 'Latest version' : isDisabled ? 'Updates disabled in Dev' : isError ? 'Retry Check' : 'Check for updates'"
@click="checkForUpdates()"
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { useElectronAutoUpdater } from '@proj-airi/electron-vueuse'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { Button, Progress } from '@proj-airi/ui'
import { computed } from 'vue'
const settings = useSettings()
const {
state: updateState,
isBusy,
checkForUpdates,
downloadUpdate,
quitAndInstall,
} = useElectronAutoUpdater()
const diagnosticsEntries = computed(() => {
const diagnostics = updateState.value.diagnostics
if (!diagnostics)
return []
return [
['status', updateState.value.status],
['currentVersion', updateState.value.info?.version ?? 'n/a'],
['platform', diagnostics.platform],
['arch', diagnostics.arch],
['channel', diagnostics.channel],
['feedUrl', diagnostics.feedUrl ?? 'n/a'],
['logFilePath', diagnostics.logFilePath],
['executablePath', diagnostics.executablePath],
['overrideActive', String(diagnostics.isOverrideActive)],
]
})
</script>
<template>
<div :class="['flex flex-col gap-4', 'pb-8']">
<div
v-if="!settings.inspectUpdaterDiagnostics"
:class="['rounded-2xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-100']"
>
Enable "Inspect updater diagnostics" from Settings > System > Developer to inspect updater internals here.
</div>
<template v-else>
<div :class="['flex flex-wrap gap-2']">
<Button
variant="secondary"
:loading="isBusy"
icon="i-solar:refresh-outline"
label="Check for updates"
@click="checkForUpdates()"
/>
<Button
variant="secondary"
:disabled="updateState.status !== 'available'"
icon="i-solar:download-minimalistic-outline"
label="Download update"
@click="downloadUpdate()"
/>
<Button
variant="secondary"
:disabled="updateState.status !== 'downloaded'"
icon="i-solar:restart-bold-duotone"
label="Restart to install"
@click="quitAndInstall()"
/>
</div>
<div v-if="updateState.status === 'downloading'" :class="['flex flex-col gap-2']">
<div :class="['flex items-center justify-between text-sm text-neutral-300']">
<span>Downloading update</span>
<span>{{ updateState.progress?.percent.toFixed(1) }}%</span>
</div>
<Progress :progress="updateState.progress?.percent ?? 0" />
</div>
<div
v-if="updateState.status === 'error'"
:class="['rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-100 whitespace-pre-wrap']"
>
{{ updateState.error?.message }}
</div>
<section :class="['rounded-2xl border border-neutral-700/60', 'bg-neutral-950/40 p-4']">
<div :class="['mb-3 text-sm text-neutral-400']">
Updater diagnostics
</div>
<div :class="['grid gap-2 text-sm text-neutral-100']">
<div
v-for="[label, value] in diagnosticsEntries"
:key="label"
:class="['grid gap-1 md:grid-cols-[180px_minmax(0,1fr)]']"
>
<div :class="['text-neutral-400']">
{{ label }}
</div>
<div :class="['font-mono break-words']">
{{ value }}
</div>
</div>
</div>
</section>
</template>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
title: Updater
subtitleKey: tamagotchi.settings.devtools.title
</route>
@@ -73,6 +73,12 @@ const menu = computed(() => [
icon: 'i-solar:bug-bold-duotone',
to: '/devtools/plugin-host',
},
{
title: 'Updater',
description: 'Inspect updater state, explicit feed overrides, and install actions',
icon: 'i-solar:restart-bold-duotone',
to: '/devtools/updater',
},
{
title: 'Screen Capture',
description: 'Capture screen or window as video and/or audio streams',
@@ -137,6 +143,15 @@ const openDevtoolsWindow = useElectronEventaInvoke(electronOpenDevtoolsWindow)
description="settings.animations.use-page-specific-transitions.description"
transition="all ease-in-out duration-250"
/>
<CheckBar
v-model="settings.inspectUpdaterDiagnostics"
mb-2
icon-on="i-solar:bug-bold-duotone"
icon-off="i-solar:bug-minimalistic-outline"
text="Inspect updater diagnostics"
description="Show detailed updater diagnostics in the developer updater page."
transition="all ease-in-out duration-250"
/>
<div flex="~ col gap-4" mt-2 pb-12>
<IconItem
@@ -23,11 +23,22 @@ export interface AutoUpdaterError {
message: string
}
export interface AutoUpdaterDiagnostics {
platform: string
arch: string
channel: string
feedUrl?: string
logFilePath: string
executablePath: string
isOverrideActive: boolean
}
export interface AutoUpdaterState {
status: AutoUpdaterStatus
info?: Omit<UpdateInfo, 'path' | 'sha512'>
progress?: AutoUpdaterProgress
error?: AutoUpdaterError
diagnostics?: AutoUpdaterDiagnostics
}
export const electronAutoUpdaterStateChanged = defineEventa<AutoUpdaterState>('eventa:event:electron:auto-updater:state-changed')
@@ -0,0 +1,15 @@
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
import { defineStore } from 'pinia'
export const useSettingsDeveloper = defineStore('settings-developer', () => {
const inspectUpdaterDiagnostics = useLocalStorageManualReset<boolean>('settings/developer/inspect-updater-diagnostics', false)
function resetState() {
inspectUpdaterDiagnostics.reset()
}
return {
inspectUpdaterDiagnostics,
resetState,
}
})
@@ -2,6 +2,7 @@ import { defineStore, storeToRefs } from 'pinia'
import { useSettingsAnalytics } from './analytics'
import { useSettingsControlsIsland } from './controls-island'
import { useSettingsDeveloper } from './developer'
import { useSettingsGeneral } from './general'
import { useSettingsLive2d } from './live2d'
import { useSettingsStageModel } from './stage-model'
@@ -12,6 +13,7 @@ export * from './analytics'
export * from './audio-device'
export * from './beat-sync'
export * from './controls-island'
export * from './developer'
export * from './general'
export * from './live2d'
export * from './stage-model'
@@ -33,6 +35,7 @@ export const useSettings = defineStore('settings', () => {
const live2d = useSettingsLive2d()
const theme = useSettingsTheme()
const controlsIsland = useSettingsControlsIsland()
const developer = useSettingsDeveloper()
async function resetState() {
await stageModel.resetState()
@@ -41,6 +44,7 @@ export const useSettings = defineStore('settings', () => {
live2d.resetState()
theme.resetState()
controlsIsland.resetState()
developer.resetState()
}
// Extract refs from sub-stores to maintain proper reactivity
@@ -50,6 +54,7 @@ export const useSettings = defineStore('settings', () => {
const live2dRefs = storeToRefs(live2d)
const themeRefs = storeToRefs(theme)
const controlsIslandRefs = storeToRefs(controlsIsland)
const developerRefs = storeToRefs(developer)
return {
// Core settings
@@ -84,6 +89,7 @@ export const useSettings = defineStore('settings', () => {
allowVisibleOnAllWorkspaces: controlsIslandRefs.allowVisibleOnAllWorkspaces,
alwaysOnTop: controlsIslandRefs.alwaysOnTop,
controlsIslandIconSize: controlsIslandRefs.controlsIslandIconSize,
inspectUpdaterDiagnostics: developerRefs.inspectUpdaterDiagnostics,
// Methods
setThemeColorsHue: theme.setThemeColorsHue,