fix(stage-tamagotchi): harden cross-platform auto-updater flow, diagnostics, logs, and cache cleanup (#1566)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+1
@@ -0,0 +1 @@
|
||||
mock-update-stable-9.9.9-update-test.1
|
||||
+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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user