From 6eda116e1079d139538e6a8ecc715767866b9a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Wed, 23 Sep 2026 17:58:11 +0800 Subject: [PATCH] fix(startup): avoid repeated migration success screen --- apps/desktop/index.html | 44 +++++++++++++------ .../desktop/src/stores/migrationStore.spec.ts | 39 ++++++++++++++-- apps/desktop/src/stores/migrationStore.ts | 14 ++++-- 3 files changed, 76 insertions(+), 21 deletions(-) diff --git a/apps/desktop/index.html b/apps/desktop/index.html index be982e97c5..cc5fe4906f 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -18,6 +18,35 @@ html.dark #root { background-color: rgb(19 20 22); } + + #startup-loading { + display: flex; + flex-direction: column; + min-height: 100vh; + align-items: center; + justify-content: center; + color: #6b7280; + font: 13px system-ui, sans-serif; + } + + #startup-loading::before { + width: 22px; + height: 22px; + margin-bottom: 12px; + border: 2px solid rgba(107, 114, 128, 0.22); + border-top-color: currentColor; + border-radius: 999px; + content: ""; + animation: dbx-startup-spin 0.8s linear infinite; + } + + @media (prefers-reduced-motion: reduce) { + #startup-loading::before { animation: none; } + } + + @keyframes dbx-startup-spin { + to { transform: rotate(360deg); } + } diff --git a/apps/desktop/src/stores/migrationStore.spec.ts b/apps/desktop/src/stores/migrationStore.spec.ts index 09148ae9a6..992d639687 100644 --- a/apps/desktop/src/stores/migrationStore.spec.ts +++ b/apps/desktop/src/stores/migrationStore.spec.ts @@ -48,6 +48,20 @@ describe("migration store", () => { await store.initialize(); expect(store.completed.value).toBe(true); expect(store.blocking.value).toBe(false); + expect(store.state.entered).toBe(false); + }); + + it("does not block a restart when migration is not required", async () => { + const store = useMigrationStore({ + migrationStatus: vi.fn().mockResolvedValue({ ...done(), state: "not_required" }), + migrationStart: vi.fn(), + migrationRetry: vi.fn(), + migrationCleanupBackups: vi.fn(), + }); + await store.initialize(); + expect(store.completed.value).toBe(true); + expect(store.blocking.value).toBe(false); + expect(store.state.entered).toBe(false); }); it("keeps status failures on the status retry path", async () => { @@ -127,10 +141,10 @@ describe("migration store", () => { expect(store.state.busy).toBe(true); await store.cleanup(); expect(cleanup).toHaveBeenCalledTimes(2); - store.enter(); - expect(store.state.entered).toBe(true); finishCleanup(); await cleanupAttempt; + store.enter(); + expect(store.state.entered).toBe(true); expect(store.state.busy).toBe(false); expect(store.state.step).toBe(3); expect(store.state.error).toBeNull(); @@ -140,11 +154,30 @@ describe("migration store", () => { }); it("recovers when a lost action response is followed by verified completion", async () => { - const store = useMigrationStore({ migrationStatus: vi.fn().mockResolvedValue(done()), migrationStart: vi.fn().mockRejectedValue(new Error("response lost")), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() }); + const store = useMigrationStore({ migrationStatus: vi.fn().mockResolvedValueOnce(pending()).mockResolvedValue(done()), migrationStart: vi.fn().mockRejectedValue(new Error("response lost")), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() }); + await store.initialize(); await store.start(); expect(store.state.step).toBe(3); expect(store.state.error).toBeNull(); expect(store.state.busy).toBe(false); + expect(store.state.report).toBeNull(); + expect(store.blocking.value).toBe(true); + store.enter(); + expect(store.blocking.value).toBe(false); + }); + + it("keeps the success page after retrying a failed status check in the same session", async () => { + const status = vi.fn().mockResolvedValueOnce(pending()).mockRejectedValueOnce(new Error("status unavailable")).mockResolvedValueOnce(done()); + const store = useMigrationStore({ migrationStatus: status, migrationStart: vi.fn().mockRejectedValue(new Error("response lost")), migrationRetry: vi.fn(), migrationCleanupBackups: vi.fn() }); + await store.initialize(); + await store.start(); + expect(store.state.error).toBe("statusFailed"); + await store.initialize(); + expect(store.completed.value).toBe(true); + expect(store.state.report).toBeNull(); + expect(store.blocking.value).toBe(true); + store.enter(); + expect(store.blocking.value).toBe(false); }); it("retries a failed migration and completes without losing the error report", async () => { const failed = { ...pending(), state: "failed" as const, errorCode: "BACKUP_FAILED", errorMessage: "Cannot create backup" }; diff --git a/apps/desktop/src/stores/migrationStore.ts b/apps/desktop/src/stores/migrationStore.ts index b857b48d00..819ba8ef06 100644 --- a/apps/desktop/src/stores/migrationStore.ts +++ b/apps/desktop/src/stores/migrationStore.ts @@ -1,4 +1,4 @@ -import { computed, reactive } from "vue"; +import { computed, reactive, ref } from "vue"; import * as api from "@/lib/backend/api"; import type { MigrationPreflight, MigrationReport } from "@/lib/backend/migration"; @@ -17,12 +17,16 @@ export function useMigrationStore(backend: MigrationApi = api) { entered: boolean; step: 1 | 2 | 3; }>({ status: null, report: null, loading: true, busy: false, error: null, errorCode: null, errorMessage: null, entered: false, step: 1 }); + const migrationAttempted = ref(false); const completed = computed(() => state.status !== null && !state.status.needsMigration && ["succeeded", "not_required"].includes(state.status.state)); // A managed data-directory key is intentionally created when migration // starts. Its absence during the read-only preflight is actionable, not a // migration failure, as long as the backend explicitly allows creation. const hasBlockingError = () => Boolean(state.errorCode && !(state.errorCode === "MISSING_MANAGED_KEY" && state.status?.keyCreationAllowed === true)); - const blocking = computed(() => state.loading || state.busy || state.error === "statusFailed" || !completed.value || (!state.entered && Boolean(state.report || state.status?.backupPath))); + // A retained backup is a recovery asset and must not make the success page + // reappear on later launches. An attempt in this session keeps the wizard + // visible even when its response is lost, until the user enters the app. + const blocking = computed(() => state.loading || state.busy || state.error === "statusFailed" || !completed.value || (!state.entered && (migrationAttempted.value || Boolean(state.report)))); function clearError() { state.error = null; state.errorCode = null; @@ -43,8 +47,9 @@ export function useMigrationStore(backend: MigrationApi = api) { state.step = completed.value ? 3 : 1; // A completed migration only needs the success page in the session that // performed it. On later launches, retained backups are recovery assets, - // not a reason to block the normal application startup. - state.entered = completed.value; + // not a reason to block the normal application startup. `entered` is + // therefore only changed by the explicit success-page action below. + state.entered = false; if (state.status?.state === "failed" || hasBlockingError()) state.error = "migrationFailed"; } catch { state.error = "statusFailed"; @@ -55,6 +60,7 @@ export function useMigrationStore(backend: MigrationApi = api) { async function run(retry: boolean) { if (state.busy) return; state.busy = true; + migrationAttempted.value = true; state.step = 2; clearError(); try {