fix(startup): avoid repeated migration success screen
This commit is contained in:
3 files changed
+76
-21
No files matched your search
+30
-14
@@ -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
@@ -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" };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user