diff --git a/CONTEXT.md b/CONTEXT.md index f3d7678..f0a7e58 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,7 +1,7 @@ --- status: truth topic: context -last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 +last-verified: 506e85b363a53511d14a44db22804a53331f2c03 --- # BazaarPlusPlus Installer Context @@ -32,6 +32,7 @@ Current behavior truth lives under `docs/truth/` (topic-sliced, code-cited, hash - **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`). +- **About bootstrap snapshot** — the frontend resource contract that distinguishes initial loading, authoritative native data, packaged fallback data, and no-data blocking failure. Fallback state identifies unavailable fields, retains a semantic problem separately from localized copy, and can retry in place until native data replaces it (`src/features/about/appBootstrap.ts:18-55`, `src/features/about/appBootstrap.ts:90-178`, `src/features/about/aboutProblems.ts:8-25`). - **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 c7d5761..ad83831 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -24,6 +24,8 @@ Modal-coordination citation refresh: `2026-07-19` on `838d5d6bf30e16a277e5b367b6 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. +About-bootstrap citation refresh: `2026-07-19` on `506e85b363a53511d14a44db22804a53331f2c03` — the context glossary plus architecture, frontend, and verification topics were checked against the explicit bootstrap resource states, packaged fallback provenance, semantic recovery, and accessible About feedback. + ## Current Manifest | Path | Topic | Status | Last verified | @@ -33,14 +35,14 @@ Updater-recovery citation refresh: `2026-07-19` on `2bf15726776492127c3eca2162dd | `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 | `2bf15726776492127c3eca2162dd26306c3ab310` | -| `docs/truth/architecture.md` | architecture | truth | `2bf15726776492127c3eca2162dd26306c3ab310` | -| `docs/truth/frontend.md` | frontend | truth | `2bf15726776492127c3eca2162dd26306c3ab310` | +| `CONTEXT.md` | entry map + glossary | truth | `506e85b363a53511d14a44db22804a53331f2c03` | +| `docs/truth/architecture.md` | architecture | truth | `506e85b363a53511d14a44db22804a53331f2c03` | +| `docs/truth/frontend.md` | frontend | truth | `506e85b363a53511d14a44db22804a53331f2c03` | | `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 | `2bf15726776492127c3eca2162dd26306c3ab310` | -| `docs/truth/verification.md` | verification | truth | `2bf15726776492127c3eca2162dd26306c3ab310` | +| `docs/truth/verification.md` | verification | truth | `506e85b363a53511d14a44db22804a53331f2c03` | | `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 db1589b..13372f8 100644 --- a/docs/truth/architecture.md +++ b/docs/truth/architecture.md @@ -1,7 +1,7 @@ --- status: truth topic: architecture -last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 +last-verified: 506e85b363a53511d14a44db22804a53331f2c03 --- # Architecture @@ -33,6 +33,7 @@ last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 - 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`. +- About bootstrap loading is a framework-neutral state machine over an injected loader and optional packaged fallback. Its discriminated snapshot separates initial loading, authoritative native data, fallback data, and blocking failure; retry remains single-flight and preserves usable fallback until native recovery in `src/features/about/appBootstrap.ts:18-66` and `src/features/about/appBootstrap.ts:90-178`. The runtime adapter labels Browser Preview as fallback rather than native authority in `src/features/about/aboutApi.ts:5-10`. ## Build And Generated Artifacts diff --git a/docs/truth/frontend.md b/docs/truth/frontend.md index 95d9971..92cf72f 100644 --- a/docs/truth/frontend.md +++ b/docs/truth/frontend.md @@ -1,7 +1,7 @@ --- status: truth topic: frontend -last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 +last-verified: 506e85b363a53511d14a44db22804a53331f2c03 --- # Frontend @@ -29,6 +29,7 @@ last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 - Shared install, stream, crop, history, cleanup, and bootstrap preview values live in the leaf module `src/api/previewDefaults.ts`; Preview reuses those object references in `src/api/previewCommands.ts:16-50` so polling preserves React state bailouts. - Stream commands pass through one semantic port over the selected native or Preview command adapter in `src/features/stream/streamApi.ts:7-36`. The framework-neutral workflow owns replayable initialization, polling freshness and response epochs, capability-scoped operations/problems, semantic notices, and the derived page snapshot in `src/features/stream/streamWorkflow.ts:185-320` and `src/features/stream/streamWorkflow.ts:509-733`; `useStreamPage` supplies browser ports and creates the workflow once, independently of locale, before binding its lifecycle to React in `src/features/stream/useStreamPage.ts:9-42`. - The shared page-state seam is a discriminated union of initial loading, blocking failure, ready-empty, and ready-content with nested idle/refreshing/failed refresh state; request ids reject stale completions in `src/features/shared/pageState.ts:1-60`. Shared UI problems retain code, parameters, and optional diagnostics separately from localized copy in `src/features/shared/problems.ts:4-40` and `src/components/ui/ProblemBanner.tsx:3-43`. +- About bootstrap uses its own discriminated resource snapshot because packaged data can remain usable while native provenance fails. The machine labels unavailable fallback fields, keeps retry single-flight, and replaces fallback in place after recovery in `src/features/about/appBootstrap.ts:18-55` and `src/features/about/appBootstrap.ts:90-178`; React creates it once and exposes the packaged fallback to existing shell consumers in `src/features/about/useAppBootstrap.ts:12-42`. - Destructive workflows use a framework-neutral confirmed-operation controller that keeps the target across confirming/running/failure, rejects conflicting requests and repeat execution, blocks dismissal while running, closes only after success, and retains a semantic problem for retry or safe exit after failure in `src/features/shared/confirmedOperation.ts:3-94`. - Run Detail specializes that seam with a distinct not-found state, preserved ready content on refresh failure, and a separate action state that globally gates conflicting work while retaining target-scoped failures in `src/features/history/runDetailPageState.ts:5-167`. Its semantic problem presenter maps stable backend codes and operation parameters to localized copy without using diagnostics as user-facing text in `src/features/history/runDetailProblems.ts:9-62`. @@ -47,6 +48,7 @@ last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 - Storage cleanup submits only generated `StorageCleanupScope` plus `StorageCleanupPreset`. Preview and execute are separately single-flight; the selected scope, preset, counts, and consequence remain in the confirmed-operation target through running/failure, and success refreshes History before publishing the outcome in `src/features/history/useStorageCleanup.ts:23-78` and `src/features/history/StorageCleanupCard.tsx:125-185`. Cleanup failures are localized from semantic problem codes/operation parameters while diagnostics remain separate in `src/features/history/storageCleanupProblems.ts:9-52`. - Stream renders capability-localized service, polling, display-window, crop, and one-off action problems beside the controls that can recover them; diagnostics remain in the optional disclosure rather than becoming user copy in `src/pages/Stream.tsx:34-137`, `src/pages/Stream.tsx:182-319`, and `src/pages/Stream.tsx:324-358`. - Stream status, database, window, and notice copy is derived from the current translator at render time. Stale running/stopped values have distinct presentation and are not presented as authoritative in `src/features/stream/streamPresentation.ts:20-80`. +- About renders source and unavailable-field labels beside selectable version values. Initial/fallback/blocking feedback uses live regions, native retry controls, bilingual semantic copy, and optional diagnostics; Browser Preview is explicitly labeled as packaged fallback rather than authoritative native data in `src/pages/About.tsx:38-107` and `src/pages/About.tsx:171-264`. ## Update Modal diff --git a/docs/truth/verification.md b/docs/truth/verification.md index 40850bc..92ee8d0 100644 --- a/docs/truth/verification.md +++ b/docs/truth/verification.md @@ -1,7 +1,7 @@ --- status: truth topic: verification -last-verified: 2bf15726776492127c3eca2162dd26306c3ab310 +last-verified: 506e85b363a53511d14a44db22804a53331f2c03 --- # Verification @@ -47,6 +47,7 @@ Use the smallest command that verifies the changed behavior; use the authoritati - 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`. +- About bootstrap tests cover initial loading, native success, packaged fallback, retry success, repeated failure, no-data blocking failure, and Preview provenance in `src/features/about/appBootstrap.test.ts:34-168`. The same semantic problem is presented in Chinese and English without exposing diagnostics as user copy in `src/features/about/aboutProblems.test.ts:8-35`; rendered markup tests cover live-region feedback, native retry/disclosure controls, selectable versions, and explicit unavailable values in `src/pages/About.test.tsx:34-73`. ## Version And Platform Guards diff --git a/src/features/about/aboutApi.ts b/src/features/about/aboutApi.ts index 546ebbc..ed48506 100644 --- a/src/features/about/aboutApi.ts +++ b/src/features/about/aboutApi.ts @@ -1,5 +1,11 @@ import { commandClient } from '../../api/commandClient'; +import { hasTauriRuntime } from '../../api/runtime'; +import type { AppBootstrapLoadResult } from './appBootstrap'; -export async function loadAppBootstrap() { - return commandClient.getAppBootstrap(); +export async function loadAppBootstrap(): Promise { + const data = await commandClient.getAppBootstrap(); + return { + source: hasTauriRuntime() ? 'native' : 'preview', + data + }; } diff --git a/src/features/about/aboutProblems.test.ts b/src/features/about/aboutProblems.test.ts new file mode 100644 index 0000000..25a4613 --- /dev/null +++ b/src/features/about/aboutProblems.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { formatMessage } from '../../i18n/messages'; +import { + aboutBootstrapProblemFromError, + presentAboutProblem +} from './aboutProblems'; + +describe('About bootstrap problem presentation', () => { + it('keeps diagnostics separate from localized recovery copy', () => { + const problem = aboutBootstrapProblemFromError( + new Error('native stack detail') + ); + + expect(problem).toEqual({ + code: 'about_bootstrap_failed', + params: { operation: 'load_bootstrap' }, + diagnostic: 'native stack detail' + }); + expect( + presentAboutProblem(problem, (key, params) => + formatMessage('zh', key, params) + ) + ).toContain('备用数据'); + expect( + presentAboutProblem(problem, (key, params) => + formatMessage('en', key, params) + ) + ).toContain('fallback'); + expect( + presentAboutProblem(problem, (key, params) => + formatMessage('en', key, params) + ) + ).not.toContain('native stack detail'); + }); +}); diff --git a/src/features/about/aboutProblems.ts b/src/features/about/aboutProblems.ts new file mode 100644 index 0000000..4c4cfb0 --- /dev/null +++ b/src/features/about/aboutProblems.ts @@ -0,0 +1,25 @@ +import type { Translate } from '../../i18n/LocaleProvider'; +import { + createUiProblem, + problemFromError, + type UiProblem +} from '../shared/problems'; + +export type AboutBootstrapProblem = UiProblem<'about_bootstrap_failed'>; + +export function aboutBootstrapProblemFromError( + error: unknown +): AboutBootstrapProblem { + const captured = problemFromError(error, 'about_bootstrap_failed'); + return createUiProblem('about_bootstrap_failed', { + params: { operation: 'load_bootstrap' }, + diagnostic: captured.diagnostic + }); +} + +export function presentAboutProblem( + problem: AboutBootstrapProblem, + t: Translate +): string { + return t('aboutProblemBootstrapFailed', problem.params); +} diff --git a/src/features/about/appBootstrap.test.ts b/src/features/about/appBootstrap.test.ts new file mode 100644 index 0000000..647b981 --- /dev/null +++ b/src/features/about/appBootstrap.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AppBootstrap } from '../../types/backend'; +import { + createAppBootstrapMachine, + type AppBootstrapLoadResult +} from './appBootstrap'; + +function bootstrap( + appVersion: string, + bundledBppVersion: string | null = '4.5.0' +): AppBootstrap { + return { + app_version: appVersion, + bundled_bpp_version: bundledBppVersion, + links: { + github: 'https://example.com/github', + x: 'https://example.com/x', + bilibili_project: 'https://example.com/bilibili-project', + bilibili_author: 'https://example.com/bilibili-author', + bilibili_core_dev: 'https://example.com/bilibili-core-dev', + xiaohongshu: 'https://example.com/xiaohongshu', + kofi: 'https://example.com/kofi', + supporter_list: 'https://example.com/supporters' + }, + credits: [], + licenses: [] + }; +} + +function authoritative(data: AppBootstrap): AppBootstrapLoadResult { + return { source: 'native', data }; +} + +describe('About bootstrap resource state', () => { + it('starts in loading and replaces fallback with authoritative native data', async () => { + const fallback = bootstrap('4.5.0', null); + const native = bootstrap('4.5.1'); + const machine = createAppBootstrapMachine({ + fallback, + load: vi.fn().mockResolvedValue(authoritative(native)) + }); + + expect(machine.getSnapshot()).toMatchObject({ + phase: 'initial-loading', + data: fallback, + source: 'packaged-fallback', + unavailableFields: ['bundled_bpp_version'] + }); + + await machine.start(); + + expect(machine.getSnapshot()).toEqual({ + phase: 'authoritative', + data: native, + source: 'native', + unavailableFields: [], + problem: null, + retrying: false + }); + }); + + it('keeps usable packaged data and exposes a semantic failure', async () => { + const fallback = bootstrap('4.5.0', null); + const machine = createAppBootstrapMachine({ + fallback, + load: vi.fn().mockRejectedValue(new Error('IPC unavailable')) + }); + + await machine.start(); + + expect(machine.getSnapshot()).toMatchObject({ + phase: 'fallback', + data: fallback, + source: 'packaged-fallback', + unavailableFields: ['bundled_bpp_version'], + retrying: false, + problem: { + code: 'about_bootstrap_failed', + params: { operation: 'load_bootstrap' }, + diagnostic: 'IPC unavailable' + } + }); + }); + + it('retries in place and replaces fallback after recovery', async () => { + const fallback = bootstrap('4.5.0', null); + const native = bootstrap('4.5.1'); + const load = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(authoritative(native)); + const machine = createAppBootstrapMachine({ fallback, load }); + + await machine.start(); + const retry = machine.retry(); + expect(machine.getSnapshot()).toMatchObject({ + phase: 'fallback', + data: fallback, + retrying: true + }); + await retry; + + expect(machine.getSnapshot()).toMatchObject({ + phase: 'authoritative', + data: native, + source: 'native' + }); + }); + + it('retains fallback and the latest semantic problem after repeated failure', async () => { + const fallback = bootstrap('4.5.0', null); + const load = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('first failure')) + .mockRejectedValueOnce(new Error('second failure')); + const machine = createAppBootstrapMachine({ fallback, load }); + + await machine.start(); + await machine.retry(); + + expect(machine.getSnapshot()).toMatchObject({ + phase: 'fallback', + data: fallback, + retrying: false, + problem: { + code: 'about_bootstrap_failed', + diagnostic: 'second failure' + } + }); + }); + + it('uses a blocking failure only when no usable fallback exists', async () => { + const machine = createAppBootstrapMachine({ + fallback: null, + load: vi.fn().mockRejectedValue(new Error('no bootstrap data')) + }); + + expect(machine.getSnapshot()).toMatchObject({ + phase: 'initial-loading', + data: null + }); + await machine.start(); + + expect(machine.getSnapshot()).toMatchObject({ + phase: 'blocking-failure', + data: null, + problem: { code: 'about_bootstrap_failed' }, + retrying: false + }); + }); + + it('marks Browser Preview results as fallback without inventing a failure', async () => { + const fallback = bootstrap('4.5.0', null); + const machine = createAppBootstrapMachine({ + fallback, + load: vi.fn().mockResolvedValue({ source: 'preview', data: fallback }) + }); + + await machine.start(); + + expect(machine.getSnapshot()).toMatchObject({ + phase: 'fallback', + data: fallback, + source: 'packaged-fallback', + problem: null + }); + }); +}); diff --git a/src/features/about/appBootstrap.ts b/src/features/about/appBootstrap.ts new file mode 100644 index 0000000..8cf9d03 --- /dev/null +++ b/src/features/about/appBootstrap.ts @@ -0,0 +1,186 @@ +import type { AppBootstrap } from '../../types/backend'; +import { + aboutBootstrapProblemFromError, + type AboutBootstrapProblem +} from './aboutProblems'; + +export type AppBootstrapLoadResult = { + source: 'native' | 'preview'; + data: AppBootstrap; +}; + +export type AppBootstrapUnavailableField = 'bundled_bpp_version'; + +type AppBootstrapAvailability = { + unavailableFields: readonly AppBootstrapUnavailableField[]; +}; + +export type AppBootstrapSnapshot = + | (AppBootstrapAvailability & { + phase: 'initial-loading'; + data: AppBootstrap; + source: 'packaged-fallback'; + problem: null; + retrying: false; + }) + | { + phase: 'initial-loading'; + data: null; + source: null; + unavailableFields: readonly []; + problem: null; + retrying: false; + } + | (AppBootstrapAvailability & { + phase: 'authoritative'; + data: AppBootstrap; + source: 'native'; + problem: null; + retrying: false; + }) + | (AppBootstrapAvailability & { + phase: 'fallback'; + data: AppBootstrap; + source: 'packaged-fallback'; + problem: AboutBootstrapProblem | null; + retrying: boolean; + }) + | { + phase: 'blocking-failure'; + data: null; + source: null; + unavailableFields: readonly []; + problem: AboutBootstrapProblem; + retrying: boolean; + }; + +export type AppBootstrapImpl = { + fallback: AppBootstrap | null; + load: () => Promise; +}; + +export type AppBootstrapMachine = { + getSnapshot: () => AppBootstrapSnapshot; + start: () => Promise; + retry: () => Promise; +}; + +export function createInitialAppBootstrapSnapshot( + fallback: AppBootstrap | null +): AppBootstrapSnapshot { + return fallback + ? { + phase: 'initial-loading', + data: fallback, + source: 'packaged-fallback', + unavailableFields: unavailableFields(fallback), + problem: null, + retrying: false + } + : { + phase: 'initial-loading', + data: null, + source: null, + unavailableFields: [], + problem: null, + retrying: false + }; +} + +export function createAppBootstrapMachine( + impl: AppBootstrapImpl, + onChange: (snapshot: AppBootstrapSnapshot) => void = () => undefined +): AppBootstrapMachine { + let snapshot = createInitialAppBootstrapSnapshot(impl.fallback); + let requestInFlight = false; + + const publish = (next: AppBootstrapSnapshot) => { + snapshot = next; + onChange(snapshot); + }; + + const complete = (result: AppBootstrapLoadResult) => { + if (result.source === 'native') { + publish({ + phase: 'authoritative', + data: result.data, + source: 'native', + unavailableFields: unavailableFields(result.data), + problem: null, + retrying: false + }); + return; + } + + publish({ + phase: 'fallback', + data: result.data, + source: 'packaged-fallback', + unavailableFields: unavailableFields(result.data), + problem: null, + retrying: false + }); + }; + + const fail = (error: unknown, usableData: AppBootstrap | null) => { + const problem = aboutBootstrapProblemFromError(error); + if (usableData) { + publish({ + phase: 'fallback', + data: usableData, + source: 'packaged-fallback', + unavailableFields: unavailableFields(usableData), + problem, + retrying: false + }); + return; + } + + publish({ + phase: 'blocking-failure', + data: null, + source: null, + unavailableFields: [], + problem, + retrying: false + }); + }; + + const request = async (usableData: AppBootstrap | null) => { + if (requestInFlight) return; + requestInFlight = true; + try { + complete(await impl.load()); + } catch (error) { + fail(error, usableData); + } finally { + requestInFlight = false; + } + }; + + const start = () => { + if (snapshot.phase !== 'initial-loading') return Promise.resolve(); + return request(snapshot.data); + }; + + const retry = () => { + if ( + requestInFlight || + (snapshot.phase !== 'fallback' && snapshot.phase !== 'blocking-failure') + ) { + return Promise.resolve(); + } + const usableData = snapshot.data; + publish({ ...snapshot, retrying: true }); + return request(usableData); + }; + + return { getSnapshot: () => snapshot, start, retry }; +} + +function unavailableFields( + data: AppBootstrap | null +): readonly AppBootstrapUnavailableField[] { + if (!data) return []; + return data.bundled_bpp_version == null ? ['bundled_bpp_version'] : []; +} diff --git a/src/features/about/useAppBootstrap.ts b/src/features/about/useAppBootstrap.ts index 7eb2399..1e4a9b4 100644 --- a/src/features/about/useAppBootstrap.ts +++ b/src/features/about/useAppBootstrap.ts @@ -1,30 +1,45 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { AppBootstrap } from '../../types/backend'; import { fallbackBootstrap } from '../../api/previewDefaults'; import { loadAppBootstrap } from './aboutApi'; +import { + createAppBootstrapMachine, + createInitialAppBootstrapSnapshot, + type AppBootstrapMachine, + type AppBootstrapSnapshot +} from './appBootstrap'; export function useAppBootstrapState() { - const [bootstrap, setBootstrap] = useState(fallbackBootstrap); + const [resource, setResource] = useState(() => + createInitialAppBootstrapSnapshot(fallbackBootstrap) + ); + const mountedRef = useRef(true); + const machineRef = useRef(null); + machineRef.current ??= createAppBootstrapMachine( + { fallback: fallbackBootstrap, load: loadAppBootstrap }, + (snapshot) => { + if (mountedRef.current) setResource(snapshot); + } + ); + const machine = machineRef.current; useEffect(() => { - let mounted = true; - loadAppBootstrap() - .then((payload) => { - if (mounted) setBootstrap(payload); - }) - .catch((error) => { - console.error( - 'Failed to load app bootstrap from Tauri runtime.', - error - ); - if (mounted) setBootstrap(fallbackBootstrap); - }); + mountedRef.current = true; + void machine.start(); return () => { - mounted = false; + mountedRef.current = false; }; - }, []); + }, [machine]); - return { bootstrap }; + // The production controller always has the packaged fallback. The resource + // union still models no-data failure for callers with a different bootstrap + // source and keeps that case testable without weakening shell link types. + const bootstrap: AppBootstrap = resource.data ?? fallbackBootstrap; + return { + bootstrap, + resource, + retry: () => void machine.retry() + }; } export type AppBootstrapController = ReturnType; diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 7cfc4e3..6315565 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -362,7 +362,19 @@ const zh = { aboutCredits: '致谢', aboutAcknowledgements: '数据与灵感', aboutLicenses: '开源许可', - aboutVerifiedBadge: 'Fable 5 认证' + aboutVerifiedBadge: 'Fable 5 认证', + aboutLoadingBootstrapOnly: '正在获取本机应用信息。', + aboutLoadingBootstrap: '正在获取本机应用信息,暂时显示随应用打包的备用数据。', + aboutFallbackPreview: '当前为浏览器预览,显示随应用打包的备用数据。', + aboutProblemBootstrapFailed: + '无法获取本机应用信息。已保留可用的备用数据,请重试。', + aboutDataSourceLabel: '数据来源', + aboutDataSourceNative: '本机运行时', + aboutDataSourceFallback: '应用内置备用数据', + aboutUnavailableFieldsLabel: '不可用字段', + aboutUnavailableValue: '不可用', + aboutRetrying: '正在重试', + aboutBlockingFailure: '无法获取应用信息,且没有可用的备用数据。请重试。' } as const; export type MessageKey = keyof typeof zh; @@ -732,7 +744,22 @@ const en: Record = { aboutCredits: 'Credits', aboutAcknowledgements: 'Data & Inspiration', aboutLicenses: 'Licenses', - aboutVerifiedBadge: 'Fable 5 Verified' + aboutVerifiedBadge: 'Fable 5 Verified', + aboutLoadingBootstrapOnly: 'Loading native app information.', + aboutLoadingBootstrap: + 'Loading native app information. Packaged fallback data is shown for now.', + aboutFallbackPreview: + 'Browser Preview is showing fallback data packaged with the app.', + aboutProblemBootstrapFailed: + 'Native app information could not be loaded. Usable fallback data remains available; please retry.', + aboutDataSourceLabel: 'Data source', + aboutDataSourceNative: 'Native runtime', + aboutDataSourceFallback: 'Fallback data packaged with the app', + aboutUnavailableFieldsLabel: 'Unavailable fields', + aboutUnavailableValue: 'Unavailable', + aboutRetrying: 'Retrying', + aboutBlockingFailure: + 'App information could not be loaded and no usable fallback data is available. Please retry.' }; export const messages: Record> = { zh, en }; diff --git a/src/layouts/ShellHeader.test.tsx b/src/layouts/ShellHeader.test.tsx index 3b4da89..8ea18d5 100644 --- a/src/layouts/ShellHeader.test.tsx +++ b/src/layouts/ShellHeader.test.tsx @@ -5,23 +5,34 @@ import { UpdaterProvider } from '../features/about/UpdaterProvider'; import { LocaleProvider } from '../i18n/LocaleProvider'; import { ShellHeader } from './ShellHeader'; +const bootstrap: AppBootstrapController['bootstrap'] = { + app_version: '4.4.0', + bundled_bpp_version: '4.4.0', + links: { + github: 'https://example.com/github', + x: 'https://example.com/x', + bilibili_project: 'https://example.com/bilibili-project', + bilibili_author: 'https://example.com/bilibili-author', + bilibili_core_dev: 'https://example.com/bilibili-core-dev', + xiaohongshu: 'https://example.com/xiaohongshu', + kofi: 'https://example.com/kofi', + supporter_list: 'https://example.com/supporters' + }, + credits: [], + licenses: [] +}; + const app: AppBootstrapController = { - bootstrap: { - app_version: '4.4.0', - bundled_bpp_version: '4.4.0', - links: { - github: 'https://example.com/github', - x: 'https://example.com/x', - bilibili_project: 'https://example.com/bilibili-project', - bilibili_author: 'https://example.com/bilibili-author', - bilibili_core_dev: 'https://example.com/bilibili-core-dev', - xiaohongshu: 'https://example.com/xiaohongshu', - kofi: 'https://example.com/kofi', - supporter_list: 'https://example.com/supporters' - }, - credits: [], - licenses: [] - } + bootstrap, + resource: { + phase: 'authoritative', + data: bootstrap, + source: 'native', + unavailableFields: [], + problem: null, + retrying: false + }, + retry: () => undefined }; function renderHeader({ diff --git a/src/pages/About.test.tsx b/src/pages/About.test.tsx new file mode 100644 index 0000000..1735aaa --- /dev/null +++ b/src/pages/About.test.tsx @@ -0,0 +1,73 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import type { AppBootstrapSnapshot } from '../features/about/appBootstrap'; +import { createUiProblem } from '../features/shared/problems'; +import { LocaleProvider } from '../i18n/LocaleProvider'; +import type { AppBootstrap } from '../types/backend'; +import { AboutView } from './About'; + +const fallback: AppBootstrap = { + app_version: '4.5.0', + bundled_bpp_version: null, + links: { + github: 'https://example.com/github', + x: 'https://example.com/x', + bilibili_project: 'https://example.com/bilibili-project', + bilibili_author: 'https://example.com/bilibili-author', + bilibili_core_dev: 'https://example.com/bilibili-core-dev', + xiaohongshu: 'https://example.com/xiaohongshu', + kofi: 'https://example.com/kofi', + supporter_list: 'https://example.com/supporters' + }, + credits: [], + licenses: [] +}; + +function render(resource: AppBootstrapSnapshot) { + return renderToStaticMarkup( + + undefined} /> + + ); +} + +describe('About bootstrap feedback', () => { + it('announces loading while keeping packaged version data readable', () => { + const html = render({ + phase: 'initial-loading', + data: fallback, + source: 'packaged-fallback', + unavailableFields: ['bundled_bpp_version'], + problem: null, + retrying: false + }); + + expect(html).toContain('role="status"'); + expect(html).toContain('正在获取本机应用信息'); + expect(html).toContain('aria-label="应用 4.5.0"'); + expect(html).toContain('应用内置备用数据'); + expect(html).toContain('不可用字段'); + expect(html).toContain('selectable'); + }); + + it('renders retry and optional diagnostics as accessible native controls', () => { + const html = render({ + phase: 'fallback', + data: fallback, + source: 'packaged-fallback', + unavailableFields: ['bundled_bpp_version'], + problem: createUiProblem('about_bootstrap_failed', { + params: { operation: 'load_bootstrap' }, + diagnostic: 'IPC unavailable' + }), + retrying: false + }); + + expect(html).toContain('role="alert"'); + expect(html).toContain(''); + expect(html).toContain('; +} + +export function AboutView({ + resource, + onRetry +}: { + resource: AppBootstrapSnapshot; + onRetry: () => void; +}) { const { t } = useI18n(); + const bootstrap = resource.data; return (
-
-
-
-

- BazaarPlusPlus -

-
- - {t('aboutAppLabel')} - - - v{bootstrap.app_version} - -
- - {t('aboutBppLabel')} - - - {bootstrap.bundled_bpp_version ?? '-'} - -
+ + + {bootstrap ? ( + + ) : null} +
+
+ ); +} + +function AboutBootstrapContent({ + bootstrap, + resource +}: { + bootstrap: AppBootstrap; + resource: AppBootstrapSnapshot; +}) { + const { t } = useI18n(); + + return ( + <> +
+
+
+

+ BazaarPlusPlus +

+
+ + {t('aboutAppLabel')} + + + v{bootstrap.app_version} + +
+ + {t('aboutBppLabel')} + + + {bootstrap.bundled_bpp_version ?? t('aboutUnavailableValue')} +
- - GitHub - - +
+ + GitHub + + +
-
- -
- {groupCredits(bootstrap.credits).map((group) => ( -
-

- {t(CREDIT_GROUP_LABELS[group.key] ?? 'aboutCredits')} -

-
    - {group.items.map((credit) => ( - - ))} -
-
- ))} -
-
- -
-

- {t('aboutLicenses')} -

-
    - {bootstrap.licenses.map((license) => ( - - ))} -
-
- -
- {t('aboutVerifiedBadge')} -
-
+
+ +
+ {groupCredits(bootstrap.credits).map((group) => ( +
+

+ {t(CREDIT_GROUP_LABELS[group.key] ?? 'aboutCredits')} +

+
    + {group.items.map((credit) => ( + + ))} +
+
+ ))} +
+
+ +
+

+ {t('aboutLicenses')} +

+
    + {bootstrap.licenses.map((license) => ( + + ))} +
+
+ +
+ {t('aboutVerifiedBadge')} +
+ + ); +} + +function AboutBootstrapFeedback({ + resource, + onRetry +}: { + resource: AppBootstrapSnapshot; + onRetry: () => void; +}) { + const { t } = useI18n(); + if (resource.phase === 'authoritative') return null; + + if (resource.phase === 'initial-loading') { + return ( + + ); + } + + const diagnostic = resource.problem?.diagnostic + ? formatProblemDiagnostic(resource.problem) + : null; + const retryAction = resource.problem ? ( + + ) : null; + + if (resource.phase === 'blocking-failure') { + return ( + + ); + } + + return ( + + ); +} + +function BootstrapProvenance({ resource }: { resource: AppBootstrapSnapshot }) { + const { t } = useI18n(); + const source = + resource.source === 'native' + ? t('aboutDataSourceNative') + : t('aboutDataSourceFallback'); + const unavailable = resource.unavailableFields + .map((field) => t(bootstrapFieldLabel(field))) + .join(', '); + + return ( +
+

+ {t('aboutDataSourceLabel')}:{' '} + {source} +

+ {unavailable && ( +

+ {t('aboutUnavailableFieldsLabel')}:{' '} + {unavailable} +

+ )}
); } +function bootstrapFieldLabel(field: AppBootstrapUnavailableField): MessageKey { + switch (field) { + case 'bundled_bpp_version': + return 'aboutBppLabel'; + } +} + function ListItem({ name, role,