diff --git a/CONTEXT.md b/CONTEXT.md index b8f27b2..f3d7678 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,7 +1,7 @@ --- status: truth topic: context -last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a +last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 --- # BazaarPlusPlus Installer Context @@ -31,6 +31,7 @@ Current behavior truth lives under `docs/truth/` (topic-sliced, code-cited, hash - **Semantic problem** — a command failure contract made of a stable code, string parameters, and an optional troubleshooting diagnostic (`src-tauri/src/problem.rs:3-42`). History publishes unavailable/read/action codes, including cleanup preview/execute operation parameters; Install publishes detection/action/game-running/partial-failure codes; Stream publishes service/window/crop capability codes at the native boundary and adds polling/clipboard/opener codes in its frontend workflow (`src-tauri/src/services/history.rs:74-188`, `src-tauri/src/services/history.rs:258-274`, `src-tauri/src/services/install/mod.rs:200-235`, `src-tauri/src/commands/stream.rs:12-110`, `src/features/stream/streamProblems.ts:7-105`). Presenters localize these codes without using the diagnostic as user copy, while the native adapter preserves the structured payload (`src/api/problems.ts:3-49`, `src/api/nativeCommands.ts:5-15`). - **Confirmed operation** — the shared frontend lifecycle for a target-bearing destructive action: confirming, non-dismissible running, retained failure with retry/safe exit, and success-only closure. It refuses conflicting requests and repeated submission in `src/features/shared/confirmedOperation.ts:3-94`; cleanup, reset, and video deletion supply their actual targets and semantic problems. - **Modal coordinator** — the app-wide frontend owner that renders one registered native dialog at a time, using `critical > confirmation > system > informational` priority and FIFO within each priority. Sources can retain their queue position while changing priority/dismissal policy, and final dismissal restores focus to the connected trigger or the current page heading/main fallback (`src/features/shared/modalCoordinator.ts:1-125`, `src/components/ui/ModalCoordinator.tsx:25-126`). +- **Updater snapshot** — the discriminated frontend contract for checking, available, downloading, installing, ready-to-restart, restarting, and semantic failure states. Only downloading can carry progress; failure carries a stable updater problem while preserving the installed version for restart recovery (`src/features/about/updater.ts:61-120`, `src/features/about/updaterProblems.ts:9-63`). - **Stream runtime / overlay** — the single serialized owner of the local Axum service lifecycle, window selection, and exclusive maintenance; the production service remains on `127.0.0.1:17654` and serves the OBS overlay and settings pages (`src-tauri/src/stream/runtime.rs:43-108`, `src-tauri/src/stream/server.rs:16-69`). - **Stream workflow** — the framework-neutral frontend owner of independent service, polling freshness, window, crop, and one-off action capabilities. It keeps semantic state and derives one snapshot; browser/Tauri concerns enter through injected ports, while React creates the workflow once and only attaches lifecycle and subscription (`src/features/stream/streamWorkflow.ts:53-122`, `src/features/stream/streamWorkflow.ts:185-320`, `src/features/stream/streamWorkflow.ts:640-733`, `src/features/stream/useStreamPage.ts:20-42`). - **Storage cleanup** — preset-driven deletion of old screenshots and run data with upload-safety and referenced-file protections; its IPC is the two scope-tagged, semantic-problem preview/execute operations (`src-tauri/src/commands/history.rs:61-79`, `src-tauri/src/services/history.rs:25-44`, `src-tauri/src/services/history.rs:337-353`). diff --git a/docs/INDEX.md b/docs/INDEX.md index 3b81445..c7d5761 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -22,6 +22,8 @@ Destructive-operation citation refresh: `2026-07-19` on `0f609de844c0cbc48e7fb53 Modal-coordination citation refresh: `2026-07-19` on `838d5d6bf30e16a277e5b367b648333e8923759a` — the context glossary plus architecture, frontend, updater, and verification topics were checked against the global modal priority queue, source lifecycle, dismissal policy, focus restoration, and controlled shell disclosures. +Updater-recovery citation refresh: `2026-07-19` on `2bf15726776492127c3eca2162dd26306c3ab310` — the context glossary plus architecture, frontend, updater, and verification topics were checked against the explicit updater snapshot, semantic problems, shared header/modal presentation, accessible progress, and manual restart recovery. + ## Current Manifest | Path | Topic | Status | Last verified | @@ -31,14 +33,14 @@ Modal-coordination citation refresh: `2026-07-19` on `838d5d6bf30e16a277e5b367b6 | `README.md` | project entrypoint | current-entrypoint | 2026-07-11 | | `.trae/rules/git-commit-message.md` | ignored local rule | ignored-operational | n/a | | `docs/INDEX.md` | documentation manifest | manifest | 2026-07-19 | -| `CONTEXT.md` | entry map + glossary | truth | `838d5d6bf30e16a277e5b367b648333e8923759a` | -| `docs/truth/architecture.md` | architecture | truth | `838d5d6bf30e16a277e5b367b648333e8923759a` | -| `docs/truth/frontend.md` | frontend | truth | `838d5d6bf30e16a277e5b367b648333e8923759a` | +| `CONTEXT.md` | entry map + glossary | truth | `2bf15726776492127c3eca2162dd26306c3ab310` | +| `docs/truth/architecture.md` | architecture | truth | `2bf15726776492127c3eca2162dd26306c3ab310` | +| `docs/truth/frontend.md` | frontend | truth | `2bf15726776492127c3eca2162dd26306c3ab310` | | `docs/truth/install-reset.md` | install-reset | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | | `docs/truth/launch-modes.md` | launch-modes | truth | `f23d786ab3bf1998f556f5fe05b6e47467a7ea48` | | `docs/truth/history-stream.md` | history-stream | truth | `0f609de844c0cbc48e7fb53396a90d5f32776c2b` | -| `docs/truth/updater-release.md` | updater-release | truth | `838d5d6bf30e16a277e5b367b648333e8923759a` | -| `docs/truth/verification.md` | verification | truth | `838d5d6bf30e16a277e5b367b648333e8923759a` | +| `docs/truth/updater-release.md` | updater-release | truth | `2bf15726776492127c3eca2162dd26306c3ab310` | +| `docs/truth/verification.md` | verification | truth | `2bf15726776492127c3eca2162dd26306c3ab310` | | `docs/plans/manual-validation.md` | manual-validation | active-plan | `7500016b1c4adfc7b5d0206c7def0ceabae514d5` | | `docs/agents/issue-tracker.md` | agent skills: issue tracker | operational | 2026-07-11 | | `docs/agents/triage-labels.md` | agent skills: triage labels | operational | 2026-07-11 | diff --git a/docs/truth/architecture.md b/docs/truth/architecture.md index 9363947..db1589b 100644 --- a/docs/truth/architecture.md +++ b/docs/truth/architecture.md @@ -1,7 +1,7 @@ --- status: truth topic: architecture -last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a +last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 --- # Architecture @@ -32,6 +32,7 @@ last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a - The frontend Stream workflow depends inward on command, scheduler, clipboard, and opener ports and exposes capability-oriented semantic snapshots in `src/features/stream/streamWorkflow.ts:25-122`. It owns response ordering and capability gates in `src/features/stream/streamWorkflow.ts:185-320` and `src/features/stream/streamWorkflow.ts:640-733`; the React hook provides outer adapters and only subscribes, starts, and disposes one locale-independent workflow in `src/features/stream/useStreamPage.ts:9-42`. - Frontend destructive confirmation state is centralized in a framework-neutral external-store controller rather than page-local booleans. It owns target retention, single-flight execution, success-only closure, and semantic failure retention in `src/features/shared/confirmedOperation.ts:3-94`; React only memoizes and subscribes to the controller at `src/features/shared/confirmedOperation.ts:82-94`. - Frontend modal scheduling is a separate framework-neutral external store. It owns priority/FIFO ordering, active-source retention, updates, and safe unregistration in `src/features/shared/modalCoordinator.ts:1-113`; the React provider only registers sources, renders the active one, carries dismissal policy into `Dialog`, and restores focus after the queue drains in `src/components/ui/ModalCoordinator.tsx:25-126` and `src/components/ui/Dialog.tsx:13-55`. +- The updater is a framework-neutral state machine over injected Tauri check/relaunch effects and a retained native update handle. Its discriminated snapshot prevents phase/progress/problem contradictions, classifies check/download/install/restart failures semantically, and publishes guarded transitions in `src/features/about/updater.ts:29-130` and `src/features/about/updater.ts:132-335`; one pure presentation contract derives both shell status and coordinated-modal policy/actions in `src/features/about/updaterPresentation.ts:22-129`. ## Build And Generated Artifacts diff --git a/docs/truth/frontend.md b/docs/truth/frontend.md index 4d1bf86..95d9971 100644 --- a/docs/truth/frontend.md +++ b/docs/truth/frontend.md @@ -1,7 +1,7 @@ --- status: truth topic: frontend -last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a +last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 --- # Frontend @@ -10,7 +10,7 @@ last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a - `GlobalShell` wraps the app in bootstrap, updater, and modal-coordinator providers before rendering the shell header, nav rail, and route outlet in `src/layouts/GlobalShell.tsx:18-27` and `src/layouts/GlobalShell.tsx:66-107`. - Bilibili and Support are controlled disclosures: Escape closes the open menu and restores its trigger, while outside pointer-down closes it in `src/layouts/GlobalShell.tsx:39-64`; their triggers expose expanded/control/menu relationships and their entries are menu items in `src/layouts/ShellHeader.tsx:457-565` and `src/layouts/ShellHeader.tsx:590-679`. -- Payment and update dialogs register as global modal sources. Payment is informational; idle update phases are system priority; uncancellable download/install adopts critical blocked semantics without losing source identity in `src/layouts/GlobalShell.tsx:109-133`. +- Payment and update dialogs register as global modal sources. Payment is informational; the updater's pure presentation contract supplies its open state, priority, dismissal policy, and modal action from the same snapshot used by the header in `src/layouts/GlobalShell.tsx:30-38`, `src/layouts/GlobalShell.tsx:110-128`, and `src/layouts/ShellHeader.tsx:183-239`. ## Native-Feel Rules @@ -50,6 +50,7 @@ last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a ## Update Modal -- The update modal is phase-driven: `available`, `downloading`, `installing`, `ready`, and install-sourced `error` render in the modal path in `src/features/about/updater.ts:90-103`. -- During download/install the modal source adopts critical blocked policy because `downloadAndInstall` is not cancellable; the shared dialog enforces Escape/backdrop behavior while the modal removes dismiss actions in `src/layouts/GlobalShell.tsx:118-133` and `src/layouts/ShellUpdateModal.tsx:23-41`. -- Download progress reports downloaded MB and percentage when total size is known in `src/layouts/ShellUpdateModal.tsx:162-190`. +- The updater snapshot explicitly separates `checking`, `available`, `downloading`, `installing`, `ready-to-restart`, `restarting`, and `failed`; empty phases cannot retain update data, only downloading can retain progress, and failures retain a structured problem in `src/features/about/updater.ts:61-120`. +- Header label/icon/busy/error tone and modal title/action/priority/dismissal are derived together for every phase. Available, ready, and recovery decisions are system/dismissible; downloading, installing, and restarting are critical/blocked and have no fake cancel action in `src/features/about/updaterPresentation.ts:39-118` and `src/layouts/ShellUpdateModal.tsx:19-149`. +- Progress exposes an accessible label, minimum, known maximum/value, value text, and polite status announcement; unknown totals stay indeterminate rather than inventing a maximum in `src/layouts/ShellUpdateModal.tsx:155-207`. +- Check, download, install, and restart failures use stable semantic codes with localized retry guidance and optional diagnostic disclosure. Restart failure preserves the installed version, explains reopening the app from Applications, and offers another restart attempt in `src/features/about/updaterProblems.ts:9-63`, `src/features/about/updater.ts:305-319`, and `src/layouts/ShellUpdateModal.tsx:112-147`. diff --git a/docs/truth/updater-release.md b/docs/truth/updater-release.md index 16ef8d0..c0e9a47 100644 --- a/docs/truth/updater-release.md +++ b/docs/truth/updater-release.md @@ -1,7 +1,7 @@ --- status: truth topic: updater-release -last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a +last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 --- # Updater And Release @@ -11,11 +11,11 @@ last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a - The Tauri bundle config creates updater artifacts in `src-tauri/tauri.conf.json:27-30`. - The updater endpoint is `https://bppinstaller.bazaarplusplus.com/latest.json`, and the public key is configured in `src-tauri/tauri.conf.json:31-36`. - Runtime capabilities allow updater check, updater download/install, and process restart in `src-tauri/capabilities/default.json:6-11`. -- The updater implementation keeps the `Update` handle alive across user interactions because `downloadAndInstall` must run on the same handle returned by `check()` in `src/features/about/updater.ts:6-14`. -- `runCheck` returns `preview` outside Tauri runtime, `available` with version/notes/handle when a plugin update exists, or `current` when none exists in `src/features/about/updater.ts:42-56`. -- The state machine deduplicates checks, surfaces manual check errors in the header, keeps startup checks silent, tracks download progress, drops consumed handles after `downloadAndInstall`, and transitions to ready/error in `src/features/about/updater.ts:139-240`. -- The update dialog registers with the global modal coordinator at system priority, so it cannot interrupt an active destructive confirmation; downloading/installing upgrades the same source to critical blocked policy because the native updater operation cannot be cancelled in `src/layouts/GlobalShell.tsx:118-133` and `src/layouts/ShellUpdateModal.tsx:23-41`. -- On Windows, `downloadAndInstall` tries `relaunch()` as a fallback while expecting the NSIS installer to own close/restart behavior in `src/features/about/updater.ts:221-230`. +- The updater implementation keeps the `Update` handle alive across user interactions because `downloadAndInstall` must run on the same handle returned by `check()` in `src/features/about/updater.ts:9-17`. +- `runCheck` returns `preview` outside Tauri runtime, `available` with version/notes/handle when a plugin update exists, or `current` when none exists in `src/features/about/updater.ts:45-59`. +- The discriminated snapshot encodes checking, available, downloading, installing, ready-to-restart, restarting, and failed states without contradictory progress/problem fields in `src/features/about/updater.ts:61-120`. The machine guards duplicate work/dismissal, consumes handles once, refreshes a handle on retry, separates download from install failure at the Finished event, and preserves known version/notes across restart failure in `src/features/about/updater.ts:132-335`. +- Known updater failures are stable semantic problems rather than native error copy. Check/download/install/restart codes carry operation and optional version parameters plus diagnostic detail; bilingual presenters supply recovery text in `src/features/about/updaterProblems.ts:9-63`, `src/i18n/messages.ts:175-204`, and `src/i18n/messages.ts:530-564`. +- One phase presentation supplies both the header status and modal contract. Update decisions remain system priority behind confirmations, while download/install/restart work upgrades the same modal source to critical blocked policy in `src/features/about/updaterPresentation.ts:22-118` and `src/layouts/GlobalShell.tsx:119-128`. ## Reproducible Release Inputs diff --git a/docs/truth/verification.md b/docs/truth/verification.md index ef1a79c..40850bc 100644 --- a/docs/truth/verification.md +++ b/docs/truth/verification.md @@ -1,7 +1,7 @@ --- status: truth topic: verification -last-verified: 838d5d6bf30e16a277e5b367b648333e8923759a +last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 --- # Verification @@ -46,6 +46,7 @@ Use the smallest command that verifies the changed behavior; use the authoritati - Cleanup semantic presentation and native-adapter preservation are covered in `src/features/history/storageCleanupProblems.test.ts:8-30` and `src/api/commandClient.dispatch.test.ts:97-118`; neither test treats diagnostics as user-facing copy. - Install tests cover explicit initial detection, preserved refresh failure and retry, each primary-action branch, shared disabled/loading derivation, bilingual semantic warnings/problems, partial-failure recovery parameters, and native-adapter preservation in `src/features/install/installPageState.test.ts:43-228`, `src/features/install/installProblems.test.ts:10-89`, and `src/api/commandClient.dispatch.test.ts:57-76`. Rust tests pin Install semantic serialization and service-boundary classification in `src-tauri/src/problem.rs:46-84` and `src-tauri/src/services/install/mod.rs:282-366`. - Modal-coordinator tests cover strict priority, equal-priority FIFO, queued-source unregistration, non-preempting update/support dialogs, active confirmation upgrades, critical preemption, and connected/fallback focus restoration in `src/features/shared/modalCoordinator.test.ts:17-110`. The shell/route integration case covers a routed confirmation upgrading to blocked critical work, route-source unmount, and ordered handoff to update then payment in `src/features/shared/modalCoordinator.integration.test.ts:4-40`; controlled disclosure semantics are rendered in `src/layouts/ShellHeader.test.tsx:88-100`. +- Updater state tests cover manual/silent checks, mutually consistent phases, determinate/indeterminate progress, download-versus-install classification, fresh-handle retry, blocked dismissal, restart failure retention, and recovery in `src/features/about/updater.test.ts:58-353`. Presentation tests cover shared header/modal derivation and confirmation collision in `src/features/about/updaterPresentation.test.ts:15-111`; bilingual semantic presentation and optional diagnostics are covered in `src/features/about/updaterProblems.test.ts:10-53`, while modal markup tests cover progress ARIA and manual restart recovery in `src/layouts/ShellUpdateModal.test.tsx:33-89`. ## Version And Platform Guards diff --git a/src/features/about/updater.test.ts b/src/features/about/updater.test.ts index 0ef3076..77a276a 100644 --- a/src/features/about/updater.test.ts +++ b/src/features/about/updater.test.ts @@ -1,9 +1,8 @@ -import { describe, expect, it } from 'vitest'; import type { DownloadEvent } from '@tauri-apps/plugin-updater'; +import { describe, expect, it } from 'vitest'; import { createUpdaterMachine, initialUpdaterSnapshot, - isUpdateModalPhase, type UpdateHandle, type UpdaterImpl } from './updater'; @@ -57,7 +56,7 @@ function drivableDownload() { } describe('createUpdaterMachine checkNow', () => { - it('captures version, notes and the update handle when an update is available', async () => { + it('publishes one consistent available snapshot with version and notes', async () => { const update = fakeUpdate({ version: '5.0.0', body: 'fixes' }); const { machine, snapshot } = harness( fakeImpl({ check: async () => update }) @@ -65,18 +64,22 @@ describe('createUpdaterMachine checkNow', () => { await machine.checkNow(); - expect(snapshot().phase).toBe('available'); - expect(snapshot().version).toBe('5.0.0'); - expect(snapshot().notes).toBe('fixes'); + expect(snapshot()).toEqual({ + phase: 'available', + version: '5.0.0', + notes: 'fixes', + progress: null, + problem: null + }); }); - it('reports current when no update is available', async () => { + it('reports checking then current when no update is available', async () => { const { machine, phases, snapshot } = harness(fakeImpl()); await machine.checkNow(); expect(phases).toEqual(['checking', 'current']); - expect(snapshot().error).toBeNull(); + expect(snapshot().problem).toBeNull(); }); it('short-circuits to preview outside the Tauri runtime', async () => { @@ -97,7 +100,7 @@ describe('createUpdaterMachine checkNow', () => { expect(checked).toBe(false); }); - it('surfaces manual check failures as a check-sourced error', async () => { + it('classifies manual check failures without exposing diagnostics as state copy', async () => { const { machine, snapshot } = harness( fakeImpl({ check: async () => { @@ -108,10 +111,12 @@ describe('createUpdaterMachine checkNow', () => { await machine.checkNow(); - expect(snapshot().phase).toBe('error'); - expect(snapshot().errorSource).toBe('check'); - expect(snapshot().error).toBe('endpoint unreachable'); - expect(isUpdateModalPhase(snapshot())).toBe(false); + expect(snapshot().phase).toBe('failed'); + expect(snapshot().problem).toMatchObject({ + code: 'updater_check_failed', + params: { operation: 'check' }, + diagnostic: 'endpoint unreachable' + }); }); it('keeps silent startup checks quiet unless an update is available', async () => { @@ -133,13 +138,13 @@ describe('createUpdaterMachine checkNow', () => { }); describe('createUpdaterMachine install', () => { - it('walks downloading → installing → ready and accumulates progress', async () => { + it('walks downloading → installing → ready-to-restart and accumulates progress', async () => { const download = drivableDownload(); - const update = fakeUpdate({ - downloadAndInstall: download.downloadAndInstall - }); const { machine, phases, snapshot } = harness( - fakeImpl({ check: async () => update }) + fakeImpl({ + check: async () => + fakeUpdate({ downloadAndInstall: download.downloadAndInstall }) + }) ); await machine.checkNow(); @@ -157,17 +162,20 @@ describe('createUpdaterMachine install', () => { download.finish(); await installed; - expect(snapshot().phase).toBe('ready'); - expect(phases).toContain('downloading'); + expect(snapshot().phase).toBe('ready-to-restart'); + expect(snapshot().version).toBe('9.9.9'); + expect(phases).toEqual( + expect.arrayContaining(['downloading', 'installing', 'ready-to-restart']) + ); }); it('keeps an indeterminate total when Started has no contentLength', async () => { const download = drivableDownload(); - const update = fakeUpdate({ - downloadAndInstall: download.downloadAndInstall - }); const { machine, snapshot } = harness( - fakeImpl({ check: async () => update }) + fakeImpl({ + check: async () => + fakeUpdate({ downloadAndInstall: download.downloadAndInstall }) + }) ); await machine.checkNow(); @@ -181,26 +189,7 @@ describe('createUpdaterMachine install', () => { await installed; }); - it('relaunches automatically on Windows instead of waiting in ready', async () => { - let relaunched = 0; - const { machine, snapshot } = harness( - fakeImpl({ - check: async () => fakeUpdate(), - isWindows: () => true, - relaunch: async () => { - relaunched += 1; - } - }) - ); - await machine.checkNow(); - - await machine.install(); - - expect(relaunched).toBe(1); - expect(snapshot().phase).not.toBe('ready'); - }); - - it('moves to an install-sourced error and re-checks for a fresh handle on retry', async () => { + it('classifies a failure before Finished as download failure and retries with a fresh handle', async () => { let checks = 0; const brokenDownload = drivableDownload(); const broken = fakeUpdate({ @@ -226,19 +215,20 @@ describe('createUpdaterMachine install', () => { brokenDownload.fail(new Error('signature mismatch')); await failed; - expect(snapshot().phase).toBe('error'); - expect(snapshot().errorSource).toBe('install'); - expect(snapshot().error).toBe('signature mismatch'); - expect(isUpdateModalPhase(snapshot())).toBe(true); + expect(snapshot().phase).toBe('failed'); + expect(snapshot().problem).toMatchObject({ + code: 'updater_download_failed', + params: { operation: 'download', version: '9.9.9' }, + diagnostic: 'signature mismatch' + }); - // Retry: the consumed handle must not be reused — a fresh check runs. await machine.install(); expect(checks).toBe(2); expect(healthyInstalls).toBe(1); - expect(snapshot().phase).toBe('ready'); + expect(snapshot().phase).toBe('ready-to-restart'); }); - it('falls back to the check outcome when retrying after the update disappeared', async () => { + it('classifies a failure after Finished as install failure', async () => { const download = drivableDownload(); const { machine, snapshot } = harness( fakeImpl({ @@ -247,29 +237,84 @@ describe('createUpdaterMachine install', () => { }) ); await machine.checkNow(); + + const failed = machine.install(); + download.emit({ event: 'Finished' }); + download.fail(new Error('installer rejected package')); + await failed; + + expect(snapshot().phase).toBe('failed'); + expect(snapshot().problem).toMatchObject({ + code: 'updater_install_failed', + params: { operation: 'install', version: '9.9.9' }, + diagnostic: 'installer rejected package' + }); + }); + + it('falls back to current when retrying after the update disappeared', async () => { + let checks = 0; + const download = drivableDownload(); + const { machine, snapshot } = harness( + fakeImpl({ + check: async () => { + checks += 1; + return checks === 1 + ? fakeUpdate({ downloadAndInstall: download.downloadAndInstall }) + : null; + } + }) + ); + await machine.checkNow(); const failed = machine.install(); download.fail(new Error('boom')); await failed; - const noUpdate = harness(fakeImpl({ check: async () => null })); - await noUpdate.machine.install(); - expect(noUpdate.snapshot().phase).toBe('current'); - expect(snapshot().phase).toBe('error'); + await machine.install(); + expect(snapshot().phase).toBe('current'); }); }); -describe('createUpdaterMachine guards and actions', () => { - it('ignores checkNow and a second install while busy', async () => { +describe('createUpdaterMachine restart, dismissal, and guards', () => { + it('publishes restarting before relaunch and retains the installed result on failure', async () => { + let attempts = 0; + const { machine, phases, snapshot } = harness( + fakeImpl({ + check: async () => fakeUpdate({ version: '5.1.0' }), + relaunch: async () => { + attempts += 1; + if (attempts === 1) throw new Error('spawn failed'); + } + }) + ); + await machine.checkNow(); + await machine.install(); + expect(snapshot().phase).toBe('ready-to-restart'); + + await machine.restart(); + expect(phases).toContain('restarting'); + expect(snapshot().phase).toBe('failed'); + expect(snapshot().version).toBe('5.1.0'); + expect(snapshot().problem).toMatchObject({ + code: 'updater_restart_failed', + params: { operation: 'restart', version: '5.1.0' }, + diagnostic: 'spawn failed' + }); + + await machine.restart(); + expect(attempts).toBe(2); + expect(snapshot().phase).toBe('restarting'); + }); + + it('blocks check, duplicate install, and dismissal during native work', async () => { let checks = 0; const download = drivableDownload(); - const update = fakeUpdate({ - downloadAndInstall: download.downloadAndInstall - }); const { machine, snapshot } = harness( fakeImpl({ check: async () => { checks += 1; - return update; + return fakeUpdate({ + downloadAndInstall: download.downloadAndInstall + }); } }) ); @@ -288,38 +333,21 @@ describe('createUpdaterMachine guards and actions', () => { await installed; }); - it('dismiss returns to idle from a dismissable phase', async () => { - const { machine, snapshot } = harness( - fakeImpl({ check: async () => fakeUpdate() }) - ); - await machine.checkNow(); - expect(snapshot().phase).toBe('available'); - - machine.dismiss(); - expect(snapshot().phase).toBe('idle'); - expect(isUpdateModalPhase(snapshot())).toBe(false); - }); - - it('restart relaunches and keeps the phase with an inline error on failure', async () => { - let relaunched = 0; - const ok = harness( - fakeImpl({ - relaunch: async () => { - relaunched += 1; - } - }) - ); - await ok.machine.restart(); - expect(relaunched).toBe(1); + it('dismisses available and failed phases back to a clean idle snapshot', async () => { + const available = harness(fakeImpl({ check: async () => fakeUpdate() })); + await available.machine.checkNow(); + available.machine.dismiss(); + expect(available.snapshot()).toEqual(initialUpdaterSnapshot); - const failing = harness( + const failed = harness( fakeImpl({ - relaunch: async () => { - throw new Error('spawn failed'); + check: async () => { + throw new Error('offline'); } }) ); - await failing.machine.restart(); - expect(failing.snapshot().error).toBe('spawn failed'); + await failed.machine.checkNow(); + failed.machine.dismiss(); + expect(failed.snapshot()).toEqual(initialUpdaterSnapshot); }); }); diff --git a/src/features/about/updater.ts b/src/features/about/updater.ts index b8d6e7b..38dadbf 100644 --- a/src/features/about/updater.ts +++ b/src/features/about/updater.ts @@ -1,7 +1,10 @@ import { check, type Update } from '@tauri-apps/plugin-updater'; import { relaunch } from '@tauri-apps/plugin-process'; import { hasTauriRuntime } from '../../api/runtime'; -import { toErrorMessage } from '../shared/errors'; +import { + updaterProblemFromError, + type UpdaterProblem +} from './updaterProblems'; /** * Structural slice of the plugin-updater `Update` resource the machine needs. @@ -65,42 +68,58 @@ export type UpdaterPhase = | 'available' | 'downloading' | 'installing' - | 'ready' - | 'error'; + | 'ready-to-restart' + | 'restarting' + | 'failed'; -export type UpdaterSnapshot = { - phase: UpdaterPhase; +type EmptyUpdaterSnapshot = { + phase: 'idle' | 'checking' | 'current' | 'preview'; + version: null; + notes: null; + progress: null; + problem: null; +}; + +type KnownUpdateFields = { + version: string; + notes: string; + problem: null; +}; + +type KnownUpdateSnapshot = KnownUpdateFields & + ( + | { + phase: 'available' | 'installing' | 'ready-to-restart' | 'restarting'; + progress: null; + } + | { + phase: 'downloading'; + progress: UpdateProgress | null; + } + ); + +type FailedUpdaterSnapshot = { + phase: 'failed'; version: string | null; notes: string | null; - progress: UpdateProgress | null; - error: string | null; - /** Check errors render inline in the header; install errors in the modal. */ - errorSource: 'check' | 'install' | null; + progress: null; + problem: UpdaterProblem; }; +export type UpdaterSnapshot = + | EmptyUpdaterSnapshot + | KnownUpdateSnapshot + | FailedUpdaterSnapshot; + export const initialUpdaterSnapshot: UpdaterSnapshot = { phase: 'idle', version: null, notes: null, progress: null, - error: null, - errorSource: null + problem: null }; -/** Phases rendered inside the update modal (vs. inline header feedback). */ -export function isUpdateModalPhase(snapshot: UpdaterSnapshot): boolean { - switch (snapshot.phase) { - case 'available': - case 'downloading': - case 'installing': - case 'ready': - return true; - case 'error': - return snapshot.errorSource === 'install'; - default: - return false; - } -} +type KnownUpdate = { version: string; notes: string }; export type UpdaterMachine = { getSnapshot: () => UpdaterSnapshot; @@ -114,19 +133,66 @@ export function createUpdaterMachine( impl: UpdaterImpl, onChange: (snapshot: UpdaterSnapshot) => void ): UpdaterMachine { - let snapshot = initialUpdaterSnapshot; + let snapshot: UpdaterSnapshot = initialUpdaterSnapshot; + let knownUpdate: KnownUpdate | null = null; let handle: UpdateHandle | null = null; let checkInFlight = false; - const set = (patch: Partial) => { - snapshot = { ...snapshot, ...patch }; + const publish = (next: UpdaterSnapshot) => { + snapshot = next; onChange(snapshot); }; + const publishEmpty = (phase: EmptyUpdaterSnapshot['phase']) => { + publish({ + phase, + version: null, + notes: null, + progress: null, + problem: null + }); + }; + + const publishKnown = ( + phase: Exclude + ) => { + if (!knownUpdate) return; + publish({ + phase, + ...knownUpdate, + progress: null, + problem: null + }); + }; + + const publishDownloading = (progress: UpdateProgress | null = null) => { + if (!knownUpdate) return; + publish({ + phase: 'downloading', + ...knownUpdate, + progress, + problem: null + }); + }; + + const publishFailure = ( + problem: UpdaterProblem, + update: KnownUpdate | null = knownUpdate + ) => { + publish({ + phase: 'failed', + version: update?.version ?? null, + notes: update?.notes ?? null, + progress: null, + problem + }); + }; + const busy = () => snapshot.phase === 'checking' || snapshot.phase === 'downloading' || - snapshot.phase === 'installing'; + snapshot.phase === 'installing' || + snapshot.phase === 'restarting'; const replaceHandle = (next: UpdateHandle | null) => { const previous = handle; @@ -136,122 +202,128 @@ export function createUpdaterMachine( } }; + const canCheck = () => + snapshot.phase === 'idle' || + snapshot.phase === 'current' || + snapshot.phase === 'preview' || + (snapshot.phase === 'failed' && + snapshot.problem.code === 'updater_check_failed'); + const checkNow = async (options?: { silent?: boolean }) => { const silent = options?.silent ?? false; - if (checkInFlight || busy()) return; + if (checkInFlight || busy() || !canCheck()) return; checkInFlight = true; - if (!silent) { - set({ phase: 'checking', error: null, errorSource: null }); - } + if (!silent) publishEmpty('checking'); try { const result = await runCheck(impl); if (result.status === 'available') { + knownUpdate = { version: result.version, notes: result.notes }; replaceHandle(result.update); - set({ - phase: 'available', - version: result.version, - notes: result.notes, - error: null, - errorSource: null - }); + publishKnown('available'); return; } - if (!silent) { - set({ phase: result.status }); - } + replaceHandle(null); + knownUpdate = null; + if (!silent) publishEmpty(result.status); } catch (error) { - // Startup checks stay silent; manual checks surface inline in the header. if (!silent) { - set({ - phase: 'error', - error: toErrorMessage(error), - errorSource: 'check' - }); + knownUpdate = null; + publishFailure(updaterProblemFromError(error, 'check'), null); } } finally { checkInFlight = false; } }; + const canInstall = () => + snapshot.phase === 'available' || + (snapshot.phase === 'failed' && + (snapshot.problem.code === 'updater_download_failed' || + snapshot.problem.code === 'updater_install_failed')); + const install = async () => { - if (checkInFlight || busy()) return; - set({ - phase: 'downloading', - progress: null, - error: null, - errorSource: null - }); + if (checkInFlight || busy() || !canInstall() || !knownUpdate) return; + publishDownloading(); + let operation: 'download' | 'install' = 'download'; try { let update = handle; if (!update) { - // Retry path: a failed downloadAndInstall may have consumed the old - // handle, so fetch a fresh one instead of reusing it. const result = await runCheck(impl); if (result.status !== 'available') { - set({ phase: result.status, progress: null }); + knownUpdate = null; + publishEmpty(result.status); return; } + knownUpdate = { version: result.version, notes: result.notes }; replaceHandle(result.update); update = result.update; - set({ version: result.version, notes: result.notes }); + publishDownloading(); } - // The handle is consumed by downloadAndInstall either way; drop it so a - // failure re-checks instead of reusing a dead resource. + // The handle is consumed by downloadAndInstall either way. A retry must + // fetch a new resource instead of reusing the consumed native handle. handle = null; let downloaded = 0; await update.downloadAndInstall((event) => { if (event.event === 'Started') { downloaded = 0; - set({ - progress: { downloaded, total: event.data.contentLength ?? null } + publishDownloading({ + downloaded, + total: event.data.contentLength ?? null }); } else if (event.event === 'Progress') { downloaded += event.data.chunkLength; - set({ - progress: { downloaded, total: snapshot.progress?.total ?? null } + publishDownloading({ + downloaded, + total: snapshot.progress?.total ?? null }); } else { - // 'Finished': download done; the synthetic installing phase covers - // the gap until the downloadAndInstall promise resolves. - set({ phase: 'installing' }); + operation = 'install'; + publishKnown('installing'); } }); if (impl.isWindows()) { - // The NSIS installer owns closing and restarting the app on Windows; - // relaunch() is a best-effort fallback in case the app is still alive. + publishKnown('restarting'); try { await impl.relaunch(); - } catch { - set({ phase: 'ready', progress: null }); + } catch (error) { + publishFailure( + updaterProblemFromError(error, 'restart', knownUpdate?.version) + ); } return; } - set({ phase: 'ready', progress: null }); + publishKnown('ready-to-restart'); } catch (error) { - set({ - phase: 'error', - error: toErrorMessage(error), - errorSource: 'install', - progress: null - }); + publishFailure( + updaterProblemFromError(error, operation, knownUpdate?.version) + ); } }; + const canRestart = () => + snapshot.phase === 'ready-to-restart' || + (snapshot.phase === 'failed' && + snapshot.problem.code === 'updater_restart_failed'); + const restart = async () => { + if (busy() || !canRestart() || !knownUpdate) return; + publishKnown('restarting'); try { await impl.relaunch(); } catch (error) { - // Rare; keep the ready phase and surface the failure so the user can - // restart manually. - set({ error: toErrorMessage(error) }); + publishFailure( + updaterProblemFromError(error, 'restart', knownUpdate.version) + ); } }; const dismiss = () => { if (busy()) return; - set({ phase: 'idle', error: null, errorSource: null, progress: null }); + replaceHandle(null); + knownUpdate = null; + publish(initialUpdaterSnapshot); }; return { diff --git a/src/features/about/updaterPresentation.test.ts b/src/features/about/updaterPresentation.test.ts new file mode 100644 index 0000000..1007569 --- /dev/null +++ b/src/features/about/updaterPresentation.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { createModalCoordinator } from '../shared/modalCoordinator'; +import { createUiProblem } from '../shared/problems'; +import type { UpdaterSnapshot } from './updater'; +import { getUpdaterUiContract } from './updaterPresentation'; + +const available: UpdaterSnapshot = { + phase: 'available', + version: '5.1.0', + notes: 'notes', + progress: null, + problem: null +}; + +describe('updater UI contract', () => { + it('derives non-conflicting header and modal behavior from each explicit phase', () => { + const downloading: UpdaterSnapshot = { + ...available, + phase: 'downloading', + progress: { downloaded: 25, total: 100 } + }; + const installing: UpdaterSnapshot = { + ...available, + phase: 'installing' + }; + const ready: UpdaterSnapshot = { + ...available, + phase: 'ready-to-restart' + }; + const restarting: UpdaterSnapshot = { + ...available, + phase: 'restarting' + }; + + expect(getUpdaterUiContract(available)).toMatchObject({ + header: { labelKey: 'updateHeaderAvailable', disabled: true }, + modal: { + action: 'install', + priority: 'system', + dismissalPolicy: 'dismissible' + } + }); + expect(getUpdaterUiContract(downloading)).toMatchObject({ + header: { labelKey: 'updateDownloading', busy: true }, + modal: { + action: null, + priority: 'critical', + dismissalPolicy: 'blocked' + } + }); + expect(getUpdaterUiContract(installing).modal).toMatchObject({ + action: null, + dismissalPolicy: 'blocked' + }); + expect(getUpdaterUiContract(ready).modal).toMatchObject({ + action: 'restart', + dismissalPolicy: 'dismissible' + }); + expect(getUpdaterUiContract(restarting)).toMatchObject({ + header: { labelKey: 'updateRestarting', busy: true }, + modal: { action: null, dismissalPolicy: 'blocked' } + }); + }); + + it('keeps check failure in the shell and gives modal failures the correct retry', () => { + const checkFailed: UpdaterSnapshot = { + phase: 'failed', + version: null, + notes: null, + progress: null, + problem: createUiProblem('updater_check_failed', { + params: { operation: 'check' } + }) + }; + const restartFailed: UpdaterSnapshot = { + ...available, + phase: 'failed', + progress: null, + problem: createUiProblem('updater_restart_failed', { + params: { operation: 'restart', version: '5.1.0' } + }) + }; + + expect(getUpdaterUiContract(checkFailed)).toMatchObject({ + header: { labelKey: 'headerCheckFailed', disabled: false }, + modal: null + }); + expect(getUpdaterUiContract(restartFailed).modal).toMatchObject({ + action: 'retry-restart', + dismissalPolicy: 'dismissible' + }); + }); + + it('queues system update decisions behind an active confirmation', () => { + const coordinator = createModalCoordinator(); + coordinator.register({ + id: 'route:reset', + priority: 'confirmation', + dismissalPolicy: 'dismissible' + }); + const modal = getUpdaterUiContract(available).modal; + if (!modal) throw new Error('available must have a modal contract'); + coordinator.register({ + id: 'shell:update', + priority: modal.priority, + dismissalPolicy: modal.dismissalPolicy + }); + + expect(coordinator.getSnapshot().active?.id).toBe('route:reset'); + expect(coordinator.getSnapshot().queued[0]?.id).toBe('shell:update'); + }); +}); diff --git a/src/features/about/updaterPresentation.ts b/src/features/about/updaterPresentation.ts new file mode 100644 index 0000000..192b0e3 --- /dev/null +++ b/src/features/about/updaterPresentation.ts @@ -0,0 +1,129 @@ +import type { MessageKey } from '../../i18n/messages'; +import type { + ModalDismissalPolicy, + ModalPriority +} from '../shared/modalCoordinator'; +import type { UpdaterSnapshot } from './updater'; + +export type UpdaterHeaderIcon = + | 'download' + | 'checking' + | 'current' + | 'preview' + | 'restart' + | 'error'; + +export type UpdaterModalAction = + | 'install' + | 'restart' + | 'retry-install' + | 'retry-restart'; + +export type UpdaterUiContract = { + header: { + labelKey: MessageKey; + icon: UpdaterHeaderIcon; + busy: boolean; + disabled: boolean; + tone: 'default' | 'error'; + }; + modal: { + titleKey: MessageKey; + action: UpdaterModalAction | null; + actionLabelKey: MessageKey | null; + priority: ModalPriority; + dismissalPolicy: ModalDismissalPolicy; + } | null; +}; + +export function getUpdaterUiContract( + snapshot: UpdaterSnapshot +): UpdaterUiContract { + const modal = ( + titleKey: MessageKey, + action: UpdaterModalAction | null, + actionLabelKey: MessageKey | null, + blocked = false + ): UpdaterUiContract['modal'] => ({ + titleKey, + action, + actionLabelKey, + priority: blocked ? 'critical' : 'system', + dismissalPolicy: blocked ? 'blocked' : 'dismissible' + }); + + switch (snapshot.phase) { + case 'idle': + return { + header: header('headerCheckUpdate', 'download'), + modal: null + }; + case 'checking': + return { + header: header('headerCheckingUpdate', 'checking', true), + modal: null + }; + case 'current': + return { + header: header('updaterCurrent', 'current'), + modal: null + }; + case 'preview': + return { + header: header('updaterPreview', 'preview'), + modal: null + }; + case 'available': + return { + header: header('updateHeaderAvailable', 'download', false, true), + modal: modal('updateModalTitle', 'install', 'updateInstall') + }; + case 'downloading': + return { + header: header('updateDownloading', 'checking', true), + modal: modal('updateDownloading', null, null, true) + }; + case 'installing': + return { + header: header('updateInstalling', 'checking', true), + modal: modal('updateInstalling', null, null, true) + }; + case 'ready-to-restart': + return { + header: header('updateReady', 'restart', false, true), + modal: modal('updateReady', 'restart', 'updateRestartNow') + }; + case 'restarting': + return { + header: header('updateRestarting', 'checking', true), + modal: modal('updateRestarting', null, null, true) + }; + case 'failed': { + if (snapshot.problem.code === 'updater_check_failed') { + return { + header: header('headerCheckFailed', 'error', false, false, 'error'), + modal: null + }; + } + const restartFailure = snapshot.problem.code === 'updater_restart_failed'; + return { + header: header('updateError', 'error', false, true, 'error'), + modal: modal( + restartFailure ? 'updateRestartFailedTitle' : 'updateError', + restartFailure ? 'retry-restart' : 'retry-install', + restartFailure ? 'updateRetryRestart' : 'updateRetry' + ) + }; + } + } +} + +function header( + labelKey: MessageKey, + icon: UpdaterHeaderIcon, + busy = false, + disabled = false, + tone: 'default' | 'error' = 'default' +): UpdaterUiContract['header'] { + return { labelKey, icon, busy, disabled, tone }; +} diff --git a/src/features/about/updaterProblems.test.ts b/src/features/about/updaterProblems.test.ts new file mode 100644 index 0000000..25d4762 --- /dev/null +++ b/src/features/about/updaterProblems.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { formatMessage, type Locale } from '../../i18n/messages'; +import { formatProblemDiagnostic } from '../shared/problems'; +import { + presentUpdaterProblem, + updaterProblemFromError, + type UpdaterProblemOperation +} from './updaterProblems'; + +const expected: Record> = { + check: { + zh: '无法检查更新。请检查网络连接后重试。', + en: 'Could not check for updates. Check your network connection, then retry.' + }, + download: { + zh: '更新下载失败。请检查网络连接后重试。', + en: 'The update could not be downloaded. Check your network connection, then retry.' + }, + install: { + zh: '更新安装失败。请重试;如果问题持续,请重新打开应用后再次检查更新。', + en: 'The update could not be installed. Retry; if the problem continues, reopen the app and check again.' + }, + restart: { + zh: '自动重启失败,但 BazaarPlusPlus 5.1.0 已安装完成。请退出 BazaarPlusPlus Installer,再从“应用程序”中重新打开。', + en: 'Automatic restart failed, but BazaarPlusPlus 5.1.0 is installed. Quit BazaarPlusPlus Installer, then open it again from Applications.' + } +}; + +describe('updater semantic problems', () => { + for (const operation of [ + 'check', + 'download', + 'install', + 'restart' + ] as const) { + it(`localizes ${operation} recovery without using diagnostics as copy`, () => { + const problem = updaterProblemFromError( + new Error('native secret'), + operation, + operation === 'restart' ? '5.1.0' : null + ); + + for (const locale of ['zh', 'en'] as const) { + const presented = presentUpdaterProblem(problem, (key, params) => + formatMessage(locale, key, params) + ); + expect(presented).toBe(expected[operation][locale]); + expect(presented).not.toContain('native secret'); + } + expect(formatProblemDiagnostic(problem)).toContain('native secret'); + }); + } +}); diff --git a/src/features/about/updaterProblems.ts b/src/features/about/updaterProblems.ts new file mode 100644 index 0000000..80e8a68 --- /dev/null +++ b/src/features/about/updaterProblems.ts @@ -0,0 +1,64 @@ +import type { Translate } from '../../i18n/LocaleProvider'; +import type { MessageKey } from '../../i18n/messages'; +import { + createUiProblem, + problemFromError, + type UiProblem +} from '../shared/problems'; + +export type UpdaterProblemOperation = + | 'check' + | 'download' + | 'install' + | 'restart'; + +export type UpdaterProblemCode = + | 'updater_check_failed' + | 'updater_download_failed' + | 'updater_install_failed' + | 'updater_restart_failed'; + +export type UpdaterProblem = UiProblem; + +const CODE_BY_OPERATION: Record = { + check: 'updater_check_failed', + download: 'updater_download_failed', + install: 'updater_install_failed', + restart: 'updater_restart_failed' +}; + +export function updaterProblemFromError( + error: unknown, + operation: UpdaterProblemOperation, + version: string | null = null +): UpdaterProblem { + const code = CODE_BY_OPERATION[operation]; + const captured = problemFromError(error, code); + return createUiProblem(code, { + params: { + operation, + ...(version ? { version } : {}) + }, + diagnostic: captured.diagnostic + }); +} + +export function presentUpdaterProblem( + problem: UpdaterProblem, + t: Translate +): string { + return t(updaterProblemMessageKey(problem), problem.params); +} + +function updaterProblemMessageKey(problem: UpdaterProblem): MessageKey { + switch (problem.code) { + case 'updater_check_failed': + return 'updaterProblemCheckFailed'; + case 'updater_download_failed': + return 'updaterProblemDownloadFailed'; + case 'updater_install_failed': + return 'updaterProblemInstallFailed'; + case 'updater_restart_failed': + return 'updaterProblemRestartFailed'; + } +} diff --git a/src/features/about/useUpdater.ts b/src/features/about/useUpdater.ts index 139882b..7b04dec 100644 --- a/src/features/about/useUpdater.ts +++ b/src/features/about/useUpdater.ts @@ -28,19 +28,15 @@ export function useUpdaterState(): UpdaterController { void machine.checkNow({ silent: true }); }, [machine]); - // Manual check results (up to date / preview / check failed) are shown inside - // the header button; auto-clear them after a moment so the button reverts to - // its idle label instead of displaying a stale result indefinitely. - const { phase, errorSource } = snapshot; + // Successful manual check results briefly replace the header action. A check + // failure remains until retry so its localized recovery guidance is not lost. + const { phase } = snapshot; useEffect(() => { - const isHeaderResult = - phase === 'current' || - phase === 'preview' || - (phase === 'error' && errorSource === 'check'); + const isHeaderResult = phase === 'current' || phase === 'preview'; if (!isHeaderResult) return; const timer = window.setTimeout(() => machine.dismiss(), 3000); return () => window.clearTimeout(timer); - }, [phase, errorSource, machine]); + }, [phase, machine]); return { ...snapshot, diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 8edefe1..7cfc4e3 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -175,6 +175,7 @@ const zh = { updaterPreview: '浏览器预览', updaterCurrent: '已是最新', headerCheckFailed: '检查失败', + updateHeaderAvailable: '发现更新', updateModalKicker: '应用更新', updateModalTitle: '发现新版本', updateModalBody: 'BazaarPlusPlus {version} 已可用。', @@ -183,10 +184,24 @@ const zh = { updateNotesLabel: '更新内容', updateDownloading: '正在下载…', updateInstalling: '正在安装…', + updateInstallingBody: '下载已完成,正在安装 BazaarPlusPlus {version}。', updateReady: '更新完成,重启后生效', + updateReadyBody: 'BazaarPlusPlus {version} 已安装完成。重启应用后即可使用。', updateRestartNow: '立即重启', + updateRestarting: '正在重启…', updateError: '自动更新失败', + updateRestartFailedTitle: '更新已安装,需要手动重启', updateRetry: '重试', + updateRetryRestart: '再次尝试重启', + updateDownloadProgressLabel: '更新下载进度', + updateDownloadProgressUnknown: '已下载 {downloaded} MB', + updateDownloadProgressKnown: '已下载 {downloaded} / {total} MB({percent}%)', + updaterProblemCheckFailed: '无法检查更新。请检查网络连接后重试。', + updaterProblemDownloadFailed: '更新下载失败。请检查网络连接后重试。', + updaterProblemInstallFailed: + '更新安装失败。请重试;如果问题持续,请重新打开应用后再次检查更新。', + updaterProblemRestartFailed: + '自动重启失败,但 BazaarPlusPlus {version} 已安装完成。请退出 BazaarPlusPlus Installer,再从“应用程序”中重新打开。', // History page historyLoading: '读取战绩中', @@ -515,6 +530,7 @@ const en: Record = { updaterPreview: 'Preview mode', updaterCurrent: 'Up to date', headerCheckFailed: 'Check failed', + updateHeaderAvailable: 'Update available', updateModalKicker: 'App Update', updateModalTitle: 'Update Available', updateModalBody: 'BazaarPlusPlus {version} is available.', @@ -523,10 +539,29 @@ const en: Record = { updateNotesLabel: "What's new", updateDownloading: 'Downloading…', updateInstalling: 'Installing…', + updateInstallingBody: + 'The download is complete. Installing BazaarPlusPlus {version}.', updateReady: 'Update ready — restart to apply', + updateReadyBody: + 'BazaarPlusPlus {version} is installed. Restart the app to use it.', updateRestartNow: 'Restart Now', + updateRestarting: 'Restarting…', updateError: 'Update failed', + updateRestartFailedTitle: 'Update installed — manual restart needed', updateRetry: 'Retry', + updateRetryRestart: 'Try Restart Again', + updateDownloadProgressLabel: 'Update download progress', + updateDownloadProgressUnknown: 'Downloaded {downloaded} MB', + updateDownloadProgressKnown: + 'Downloaded {downloaded} / {total} MB ({percent}%)', + updaterProblemCheckFailed: + 'Could not check for updates. Check your network connection, then retry.', + updaterProblemDownloadFailed: + 'The update could not be downloaded. Check your network connection, then retry.', + updaterProblemInstallFailed: + 'The update could not be installed. Retry; if the problem continues, reopen the app and check again.', + updaterProblemRestartFailed: + 'Automatic restart failed, but BazaarPlusPlus {version} is installed. Quit BazaarPlusPlus Installer, then open it again from Applications.', historyLoading: 'Loading runs', noLocalRuns: 'No local runs yet', diff --git a/src/layouts/GlobalShell.tsx b/src/layouts/GlobalShell.tsx index 40bf5da..6deedb6 100644 --- a/src/layouts/GlobalShell.tsx +++ b/src/layouts/GlobalShell.tsx @@ -5,7 +5,7 @@ import { useAppBootstrap } from '../features/about/AppBootstrapProvider'; import { UpdaterProvider, useUpdater } from '../features/about/UpdaterProvider'; -import { isUpdateModalPhase } from '../features/about/updater'; +import { getUpdaterUiContract } from '../features/about/updaterPresentation'; import { ShellHeader } from './ShellHeader'; import { ShellNavRail } from './ShellNavRail'; import { ShellPaymentModal } from './ShellPaymentModal'; @@ -35,6 +35,7 @@ function GlobalShellContent() { const supportTriggerRef = useRef(null); const app = useAppBootstrap(); const updater = useUpdater(); + const updaterUi = getUpdaterUiContract(updater); // Close the header popovers on Escape or a click outside them — the native // behaviour these controlled dropdowns were missing. @@ -117,19 +118,13 @@ function GlobalShellContent() { - + {updaterUi.modal && ( + + )} ); diff --git a/src/layouts/ShellHeader.tsx b/src/layouts/ShellHeader.tsx index 0af7c60..18bbf7c 100644 --- a/src/layouts/ShellHeader.tsx +++ b/src/layouts/ShellHeader.tsx @@ -8,11 +8,14 @@ import { Heart, MonitorPlay, QrCode, + RefreshCw, Users } from 'lucide-react'; import type { CSSProperties, ReactNode, RefObject } from 'react'; import type { AppBootstrapController } from '../features/about/useAppBootstrap'; import { useUpdater } from '../features/about/UpdaterProvider'; +import { getUpdaterUiContract } from '../features/about/updaterPresentation'; +import { presentUpdaterProblem } from '../features/about/updaterProblems'; import { useI18n } from '../i18n/LocaleProvider'; import douyinPng from '../../static/support/douyin.png'; import xiaohongshuSvg from '../../static/support/xiaohongshu.svg'; @@ -179,30 +182,22 @@ function ShellHeaderActions({ }: ShellHeaderActionsProps) { const { t, toggle } = useI18n(); const updater = useUpdater(); - const checking = updater.phase === 'checking'; - - // The check button folds its own result in: "检查中" → a brief result flash - // ("已是最新" / "浏览器预览" / "检查失败"), then auto-reverts once useUpdater - // clears the result phase on a timer. Install errors render in the modal. - let checkIcon = Download; - let checkLabel = t('headerCheckUpdate'); - let checkTitle: string | undefined; - let checkErrorTone = false; - if (checking) { - checkLabel = t('headerCheckingUpdate'); - } else if (updater.phase === 'current') { - checkIcon = Check; - checkLabel = t('updaterCurrent'); - } else if (updater.phase === 'preview') { - checkIcon = Eye; - checkLabel = t('updaterPreview'); - } else if (updater.phase === 'error' && updater.errorSource === 'check') { - checkIcon = AlertCircle; - checkLabel = t('headerCheckFailed'); - checkTitle = updater.error ?? undefined; - checkErrorTone = true; - } - const CheckIcon = checkIcon; + const updaterUi = getUpdaterUiContract(updater); + const checkLabel = t(updaterUi.header.labelKey); + const checkTitle = + updater.phase === 'failed' + ? presentUpdaterProblem(updater.problem, t) + : undefined; + const CheckIcon = + updaterUi.header.icon === 'current' + ? Check + : updaterUi.header.icon === 'preview' + ? Eye + : updaterUi.header.icon === 'restart' + ? RefreshCw + : updaterUi.header.icon === 'error' + ? AlertCircle + : Download; return (
@@ -217,20 +212,30 @@ function ShellHeaderActions({ undefined, + install: () => undefined, + restart: () => undefined, + dismiss: () => undefined + }; +} + +function renderModal(snapshot: UpdaterSnapshot): string { + const presentation = getUpdaterUiContract(snapshot).modal; + if (!presentation) throw new Error('test snapshot must render a modal'); + return renderToStaticMarkup( + + + + ); +} + +describe('ShellUpdateModal', () => { + it('exposes determinate progress value, minimum, maximum, label, and status', () => { + const mib = 1024 * 1024; + const html = renderModal({ + phase: 'downloading', + version: '5.1.0', + notes: '', + progress: { downloaded: 25 * mib, total: 100 * mib }, + problem: null + }); + + expect(html).toContain('role="progressbar"'); + expect(html).toContain('aria-label="更新下载进度"'); + expect(html).toContain('aria-valuemin="0"'); + expect(html).toContain(`aria-valuemax="${100 * mib}"`); + expect(html).toContain(`aria-valuenow="${25 * mib}"`); + expect(html).toContain('role="status"'); + expect(html).toContain('已下载 25.0 / 100.0 MB(25%)'); + expect(html).not.toContain('稍后'); + }); + + it('announces indeterminate downloaded bytes without inventing a maximum', () => { + const html = renderModal({ + phase: 'downloading', + version: '5.1.0', + notes: '', + progress: { downloaded: 2 * 1024 * 1024, total: null }, + problem: null + }); + + expect(html).not.toContain('aria-valuemax'); + expect(html).not.toContain('aria-valuenow'); + expect(html).toContain('aria-valuetext="已下载 2.0 MB"'); + }); + + it('shows restart recovery, retry, and optional diagnostics after install succeeded', () => { + const html = renderModal({ + phase: 'failed', + version: '5.1.0', + notes: '', + progress: null, + problem: updaterProblemFromError( + new Error('native relaunch detail'), + 'restart', + '5.1.0' + ) + }); + + expect(html).toContain('更新已安装,需要手动重启'); + expect(html).toContain( + '自动重启失败,但 BazaarPlusPlus 5.1.0 已安装完成。请退出 BazaarPlusPlus Installer,再从“应用程序”中重新打开。' + ); + expect(html).toContain('再次尝试重启'); + expect(html).toContain('查看诊断信息'); + expect(html).toContain('native relaunch detail'); + }); +}); diff --git a/src/layouts/ShellUpdateModal.tsx b/src/layouts/ShellUpdateModal.tsx index 32a257a..421624c 100644 --- a/src/layouts/ShellUpdateModal.tsx +++ b/src/layouts/ShellUpdateModal.tsx @@ -1,41 +1,32 @@ import { Download, LoaderCircle, RefreshCw } from 'lucide-react'; import { Dialog } from '../components/ui/Dialog'; +import { ProblemBanner } from '../components/ui/ProblemBanner'; import type { UpdaterController } from '../features/about/useUpdater'; +import type { UpdaterUiContract } from '../features/about/updaterPresentation'; +import { presentUpdaterProblem } from '../features/about/updaterProblems'; +import { formatProblemDiagnostic } from '../features/shared/problems'; import { useI18n } from '../i18n/LocaleProvider'; -import type { MessageKey } from '../i18n/messages'; type ShellUpdateModalProps = { updater: UpdaterController; -}; - -const PHASE_TITLES: Partial> = { - available: 'updateModalTitle', - downloading: 'updateDownloading', - installing: 'updateInstalling', - ready: 'updateReady', - error: 'updateError' + presentation: NonNullable; }; function formatMegabytes(bytes: number): string { return (bytes / (1024 * 1024)).toFixed(1); } -export function ShellUpdateModal({ updater }: ShellUpdateModalProps) { +export function ShellUpdateModal({ + updater, + presentation +}: ShellUpdateModalProps) { const { t } = useI18n(); - // downloadAndInstall cannot be cancelled cleanly, so the modal is not - // dismissable while it runs. - const dismissable = - updater.phase !== 'downloading' && updater.phase !== 'installing'; - - const laterButton = ( - - ); + const dismissible = presentation.dismissalPolicy === 'dismissible'; + const action = presentation.action; + const actionHandler = + action === 'install' || action === 'retry-install' + ? updater.install + : updater.restart; return ( @@ -44,9 +35,12 @@ export function ShellUpdateModal({ updater }: ShellUpdateModalProps) {
{updater.phase === 'downloading' || - updater.phase === 'installing' ? ( + updater.phase === 'installing' || + updater.phase === 'restarting' ? ( - ) : updater.phase === 'ready' ? ( + ) : updater.phase === 'ready-to-restart' || + (updater.phase === 'failed' && + updater.problem.code === 'updater_restart_failed') ? ( ) : ( @@ -60,7 +54,7 @@ export function ShellUpdateModal({ updater }: ShellUpdateModalProps) { id="update-modal-title" className="m-0 mt-2 cinzel text-xl leading-tight text-[#f2e4c8]" > - {t(PHASE_TITLES[updater.phase] ?? 'updateModalTitle')} + {t(presentation.titleKey)}
@@ -70,7 +64,7 @@ export function ShellUpdateModal({ updater }: ShellUpdateModalProps) { {updater.phase === 'available' && ( <>

- {t('updateModalBody', { version: updater.version ?? '' })} + {t('updateModalBody', { version: updater.version })}

{updater.notes && (
@@ -90,67 +84,66 @@ export function ShellUpdateModal({ updater }: ShellUpdateModalProps) { )} {updater.phase === 'installing' && ( -

- {t('updateModalBody', { version: updater.version ?? '' })} +

+ {t('updateInstallingBody', { version: updater.version })}

)} - {updater.phase === 'ready' && updater.error && ( -

- {updater.error} + {updater.phase === 'ready-to-restart' && ( +

+ {t('updateReadyBody', { version: updater.version })}

)} - {updater.phase === 'error' && ( + {updater.phase === 'restarting' && (

- {updater.error} + {t('updateRestarting')}

)} + + {updater.phase === 'failed' && ( + + )}
- {dismissable && ( + {dismissible && (
- {updater.phase === 'available' && ( - <> - {laterButton} - - - )} - {updater.phase === 'ready' && ( + + {action && presentation.actionLabelKey && ( - )} - {updater.phase === 'error' && ( - <> - {laterButton} - - + )} + {t(presentation.actionLabelKey)} + )}
)} @@ -159,21 +152,42 @@ export function ShellUpdateModal({ updater }: ShellUpdateModalProps) { ); } -function UpdateDownloadProgress({ +export function UpdateDownloadProgress({ progress }: { progress: UpdaterController['progress']; }) { + const { t } = useI18n(); const downloaded = progress?.downloaded ?? 0; const total = progress?.total ?? null; const percent = total && total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null; + const accessibleValue = + total === null ? undefined : Math.min(downloaded, total); + const status = + percent === null + ? t('updateDownloadProgressUnknown', { + downloaded: formatMegabytes(downloaded) + }) + : t('updateDownloadProgressKnown', { + downloaded: formatMegabytes(downloaded), + total: formatMegabytes(total ?? 0), + percent + }); return (
-
+
-

- {percent === null - ? `${formatMegabytes(downloaded)} MB` - : `${formatMegabytes(downloaded)} / ${formatMegabytes(total ?? 0)} MB (${percent}%)`} +

+ {status}

);