fix(startup): avoid repeated migration success screen

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-23 17:58:11 +08:00
1 parent c373f322d2
commit 6eda116e10
3 files changed
+76 -21

No files matched your search

+30 -14
View File
@@ -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); }
}
</style>
<script data-dbx-startup-theme>
(() => {
@@ -69,20 +98,7 @@
</head>
<body>
<div id="root">
<style>
#root:empty {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-family: system-ui, sans-serif;
color: #888;
font-size: 13px;
}
#root:empty::after {
content: "Loading…";
}
</style>
<div id="startup-loading" role="status" aria-live="polite">Loading…</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
+36 -3
View File
@@ -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" };
+10 -4
View File
@@ -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 {