From babdb0e6e7dee2557c76a850bf538345021e22f9 Mon Sep 17 00:00:00 2001 From: jensenhuangfan Date: Sun, 5 Apr 2026 13:48:02 -0400 Subject: [PATCH] fix(stage-tamagotchi): harden cross-platform auto-updater flow, diagnostics, logs, and cache cleanup (#1566) --- .../electron-builder.config.ts | 156 +++++++++-------- apps/stage-tamagotchi/package.json | 4 +- .../scripts/update-test/README.md | 85 ++++++++++ .../AIRI-9.9.9-update-test.1-darwin-arm64.dmg | 1 + ...-9.9.9-update-test.1-windows-x64-setup.exe | 1 + .../server/stable/latest-arm64-mac.yml | 9 + .../fixtures/server/stable/latest-x64.yml | 9 + .../update-test/generate-manifest.test.ts | 63 +++++++ .../scripts/update-test/generate-manifest.ts | 136 +++++++++++++++ .../scripts/update-test/run-test.sh | 32 ++++ .../scripts/update-test/setup.sh | 13 ++ .../scripts/update-test/start-server.ts | 83 +++++++++ .../services/electron/auto-updater.test.ts | 160 ++++++++++++++++++ .../main/services/electron/auto-updater.ts | 107 +++++++++--- .../src/renderer/pages/about.vue | 28 ++- .../src/renderer/pages/devtools/updater.vue | 115 +++++++++++++ .../pages/settings/system/developer.vue | 15 ++ .../src/electron-updater/index.ts | 11 ++ .../stage-ui/src/stores/settings/developer.ts | 15 ++ .../stage-ui/src/stores/settings/index.ts | 6 + 20 files changed, 949 insertions(+), 100 deletions(-) create mode 100644 apps/stage-tamagotchi/scripts/update-test/README.md create mode 100644 apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-darwin-arm64.dmg create mode 100644 apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-windows-x64-setup.exe create mode 100644 apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-arm64-mac.yml create mode 100644 apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-x64.yml create mode 100644 apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts create mode 100644 apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts create mode 100755 apps/stage-tamagotchi/scripts/update-test/run-test.sh create mode 100755 apps/stage-tamagotchi/scripts/update-test/setup.sh create mode 100644 apps/stage-tamagotchi/scripts/update-test/start-server.ts create mode 100644 apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts create mode 100644 apps/stage-tamagotchi/src/renderer/pages/devtools/updater.vue create mode 100644 packages/stage-ui/src/stores/settings/developer.ts diff --git a/apps/stage-tamagotchi/electron-builder.config.ts b/apps/stage-tamagotchi/electron-builder.config.ts index b90ddad42..557c34629 100644 --- a/apps/stage-tamagotchi/electron-builder.config.ts +++ b/apps/stage-tamagotchi/electron-builder.config.ts @@ -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 diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index 305cdbc9f..62d42a2cd 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -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", diff --git a/apps/stage-tamagotchi/scripts/update-test/README.md b/apps/stage-tamagotchi/scripts/update-test/README.md new file mode 100644 index 000000000..27a3b54b0 --- /dev/null +++ b/apps/stage-tamagotchi/scripts/update-test/README.md @@ -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. diff --git a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-darwin-arm64.dmg b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-darwin-arm64.dmg new file mode 100644 index 000000000..62b4b683b --- /dev/null +++ b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-darwin-arm64.dmg @@ -0,0 +1 @@ +mock-update-stable-9.9.9-update-test.1 \ No newline at end of file diff --git a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-windows-x64-setup.exe b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-windows-x64-setup.exe new file mode 100644 index 000000000..62b4b683b --- /dev/null +++ b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/AIRI-9.9.9-update-test.1-windows-x64-setup.exe @@ -0,0 +1 @@ +mock-update-stable-9.9.9-update-test.1 \ No newline at end of file diff --git a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-arm64-mac.yml b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-arm64-mac.yml new file mode 100644 index 000000000..d6827ca69 --- /dev/null +++ b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-arm64-mac.yml @@ -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. diff --git a/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-x64.yml b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-x64.yml new file mode 100644 index 000000000..7ffa1f7db --- /dev/null +++ b/apps/stage-tamagotchi/scripts/update-test/fixtures/server/stable/latest-x64.yml @@ -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. diff --git a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts new file mode 100644 index 000000000..cdf80ce8e --- /dev/null +++ b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.test.ts @@ -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') + }) +}) diff --git a/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts new file mode 100644 index 000000000..613436f35 --- /dev/null +++ b/apps/stage-tamagotchi/scripts/update-test/generate-manifest.ts @@ -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 { + 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 ', 'Root directory for generated server fixtures', { default: 'scripts/update-test/fixtures/server' }) + .option('--channel ', 'Channel to generate', { default: 'stable' }) + .option('--target ', 'Target triple to generate fixtures for', { default: 'x86_64-pc-windows-msvc' }) + .option('--version ', 'Version to publish in the generated manifest', { default: '9.9.9-update-test.1' }) + .option('--release-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) + }) +} diff --git a/apps/stage-tamagotchi/scripts/update-test/run-test.sh b/apps/stage-tamagotchi/scripts/update-test/run-test.sh new file mode 100755 index 000000000..559daf864 --- /dev/null +++ b/apps/stage-tamagotchi/scripts/update-test/run-test.sh @@ -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." diff --git a/apps/stage-tamagotchi/scripts/update-test/setup.sh b/apps/stage-tamagotchi/scripts/update-test/setup.sh new file mode 100755 index 000000000..3397dd20a --- /dev/null +++ b/apps/stage-tamagotchi/scripts/update-test/setup.sh @@ -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" diff --git a/apps/stage-tamagotchi/scripts/update-test/start-server.ts b/apps/stage-tamagotchi/scripts/update-test/start-server.ts new file mode 100644 index 000000000..62e989816 --- /dev/null +++ b/apps/stage-tamagotchi/scripts/update-test/start-server.ts @@ -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 = { + '.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((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 to listen on', { default: '8787' }) + .option('--root ', '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((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) + }) +} diff --git a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts new file mode 100644 index 000000000..6bf6f0f54 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.test.ts @@ -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 }) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts index 182788c98..5d983e4c5 100644 --- a/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts +++ b/apps/stage-tamagotchi/src/main/services/electron/auto-updater.ts @@ -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 downloadUpdate: () => Promise - quitAndInstall: () => Promise + quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => Promise | 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 downloadUpdate: () => Promise - quitAndInstall: () => void + quitAndInstall: () => Promise 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() }), ) diff --git a/apps/stage-tamagotchi/src/renderer/pages/about.vue b/apps/stage-tamagotchi/src/renderer/pages/about.vue index 9db95a1db..13a16d7f3 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/about.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/about.vue @@ -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(() => {
- Update ready to install (v{{ updateState.info?.version }}). + {{ downloadedStatusText }}
- Restart to update + {{ restartButtonLabel }} @@ -145,12 +162,15 @@ const releaseNotesContent = computed(() => {
Error: {{ updateState.error?.message }}
+
+ Up to date (v{{ buildInfo.version }}). +