fix(startup): reduce initial loading and unblock app restoration

This commit is contained in:
t8y2 committed 2026-09-25 09:03:15 +08:00
1 parent 55c07f5852
commit 48d66d04d0
23 files changed
+745 -123

No files matched your search

+32 -7
View File
@@ -13,6 +13,10 @@
background-color: rgb(255 255 255);
}
body {
margin: 0;
}
html.dark,
html.dark body,
html.dark #root {
@@ -22,30 +26,47 @@
#startup-loading {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100vh;
align-items: center;
justify-content: center;
color: #6b7280;
font: 13px system-ui, sans-serif;
font:
14px system-ui,
sans-serif;
}
#startup-loading::before {
#startup-loading .startup-brand {
color: #111827;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.5px;
}
html.dark #startup-loading .startup-brand {
color: #f3f4f6;
}
#startup-loading .startup-spinner {
width: 22px;
height: 22px;
margin-bottom: 12px;
box-sizing: border-box;
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; }
#startup-loading .startup-spinner {
animation: none;
}
}
@keyframes dbx-startup-spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
</style>
<script data-dbx-startup-theme>
@@ -98,7 +119,11 @@
</head>
<body>
<div id="root">
<div id="startup-loading" role="status" aria-live="polite">Loading…</div>
<div id="startup-loading" role="status" aria-live="polite" aria-busy="true">
<span class="startup-brand" aria-hidden="true">DBX</span>
<span class="startup-spinner" aria-hidden="true"></span>
<span>Loading…</span>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
+23 -12
View File
@@ -2,6 +2,10 @@
import { blockingDesktopAiRunsForUpdate } from "@/lib/ai/desktopAiRunRegistry";
import { setupUpdatePreparation, prepareUpdateWithDraftRecovery, isUpdatePreparationActive } from "@/lib/app/updatePreparation";
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineAsyncComponent, provide } from "vue";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import { checkStartupAuthentication, type StartupAuthentication } from "@/lib/startup/startupAuthentication";
import { markStartupPhase } from "@/lib/startup/startupTiming";
import { clearStartupPreloadRetry } from "@/lib/startup/startupPreloadRecovery";
import { useI18n } from "vue-i18n";
import { FileText } from "@lucide/vue";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -145,7 +149,7 @@ import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis";
import { countAvailableAgentDriverUpdates } from "@/lib/connection/agentDriverUpdateBadge";
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { apiUrl, webPath } from "@/lib/common/webPath";
import { webPath } from "@/lib/common/webPath";
import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts";
import { DATA_GRID_TYPE_COLOR_KEYS, dataGridTypeColorCssVar, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
@@ -215,6 +219,7 @@ type AiAssistantHandle = {
type AuxiliarySearchSurface = "ai" | "history" | "sqlLibrary" | null;
const { t, locale: appLocale } = useI18n();
const startupProps = defineProps<{ startupAuthentication?: StartupAuthentication }>();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
@@ -362,9 +367,9 @@ const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged, appl
const drawDesktopWindowFrame = shouldDrawDesktopWindowFrame(isMacOS(), isDesktop, isWindows());
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
let updateCheckTimer: ReturnType<typeof setInterval> | undefined;
const needsAuth = ref(!isDesktop);
const authenticated = ref(isDesktop);
const setupRequired = ref(false);
const needsAuth = ref(!isDesktop && (startupProps.startupAuthentication?.required ?? true));
const authenticated = ref(isDesktop || (startupProps.startupAuthentication?.authenticated ?? false));
const setupRequired = ref(!isDesktop && (startupProps.startupAuthentication?.setup_required ?? false));
const showConnectionDialog = ref(false);
const connectionDialogPrefill = ref<ConnectionDeepLinkDraft | null>(null);
@@ -3867,10 +3872,13 @@ async function initApp() {
console.log("[STARTUP] initApp begin");
const restoreOpenTabs = async () => {
await settingsStore.initEditorSettings();
markStartupPhase("settings-ready");
console.log(`[STARTUP] settingsStore.initEditorSettings: ${(performance.now() - t0).toFixed(0)}ms`);
await connectionStore.initFromDisk();
markStartupPhase("connections-ready");
console.log(`[STARTUP] connectionStore.initFromDisk: ${(performance.now() - t0).toFixed(0)}ms`);
await queryStore.initOpenTabs({ validConnectionIds: connectionStore.connections.map((connection) => connection.id) });
markStartupPhase("tabs-restored");
console.log(`[STARTUP] queryStore.initOpenTabs: ${(performance.now() - t0).toFixed(0)}ms`);
};
@@ -3975,6 +3983,8 @@ function runUpdateNotificationChecks() {
}
onMounted(async () => {
clearStartupPreloadRetry();
markStartupPhase("app-mounted");
console.log("[STARTUP] onMounted begin");
const mountStart = performance.now();
if (isDetachedWindowContext) {
@@ -4024,14 +4034,15 @@ onMounted(async () => {
true,
);
if (!isDesktop) {
try {
const res = await fetch(apiUrl("/api/auth/check"));
const data = await res.json();
needsAuth.value = data.required;
authenticated.value = data.authenticated;
setupRequired.value = data.setup_required;
} catch {
/* server unreachable */
if (!startupProps.startupAuthentication) {
try {
const data = await checkStartupAuthentication();
needsAuth.value = data.required;
authenticated.value = data.authenticated;
setupRequired.value = data.setup_required;
} catch {
/* server unreachable */
}
}
if (needsAuth.value && !authenticated.value) {
history.replaceState(null, "", webPath("/login"));
+59 -81
View File
@@ -1,15 +1,31 @@
<script setup lang="ts">
import { defineAsyncComponent, onMounted, ref, watch } from "vue";
import { defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from "vue";
import { useI18n } from "vue-i18n";
import LoginPage from "@/components/auth/LoginPage.vue";
import SecurityMigrationWizard from "@/components/migration/SecurityMigrationWizard.vue";
import StartupLoading from "@/components/layout/StartupLoading.vue";
import { useMigrationStore } from "@/stores/migrationStore";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { apiUrl, webPath } from "@/lib/common/webPath";
import { webPath } from "@/lib/common/webPath";
import { loadSavedLocale } from "@/i18n";
import { checkStartupAuthentication, type StartupAuthentication } from "@/lib/startup/startupAuthentication";
import { markStartupPhase } from "@/lib/startup/startupTiming";
import { retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
// App setup installs listeners and instantiates business stores, so even its import
// is deferred until authentication and the security migration have completed.
const App = defineAsyncComponent(() => import("./App.vue"));
const startupAsyncOptions = {
loadingComponent: StartupLoading,
errorComponent: StartupLoading,
delay: 0,
suspensible: false,
onError(error: Error, _retry: () => void, fail: () => void) {
retryStartupAfterPreloadFailure(error);
fail();
},
};
const App = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("./App.vue") });
const LoginPage = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("@/components/auth/LoginPage.vue") });
const SecurityMigrationWizard = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("@/components/migration/SecurityMigrationWizard.vue") });
const props = defineProps<{ localeReady?: Promise<void> }>();
const { t } = useI18n();
const migration = useMigrationStore();
const { blocking } = migration;
@@ -17,33 +33,42 @@ const checkingAuth = ref(true);
const loginRequired = ref(false);
const setupRequired = ref(false);
const authFailed = ref(false);
const enteringApp = ref(false);
let launchTransitionTimer: number | undefined;
const startupAuthentication = shallowRef<StartupAuthentication>();
const checkingLocale = ref(Boolean(props.localeReady));
const localeFailed = ref(false);
let initialLocaleReady = props.localeReady;
let authRequest: AbortController | undefined;
watch(
() => migration.state.entered,
(entered) => {
if (entered) {
enteringApp.value = true;
}
},
);
watch(migration.blocking, (blocked) => {
if (!blocked) markStartupPhase("migration-ready");
});
function appReady() {
window.clearTimeout(launchTransitionTimer);
launchTransitionTimer = window.setTimeout(() => {
enteringApp.value = false;
}, 650);
async function initializeLocale() {
if (!initialLocaleReady && !localeFailed.value) return;
checkingLocale.value = true;
localeFailed.value = false;
try {
await (initialLocaleReady ?? loadSavedLocale());
markStartupPhase("locale-ready");
window.dispatchEvent(new Event("dbx:startup-ready"));
} catch {
localeFailed.value = true;
} finally {
initialLocaleReady = undefined;
checkingLocale.value = false;
}
}
async function initialize() {
authRequest?.abort();
const request = new AbortController();
authRequest = request;
checkingAuth.value = true;
authFailed.value = false;
try {
if (!isTauriRuntime()) {
const response = await fetch(apiUrl("/api/auth/check"), { credentials: "same-origin" });
if (!response.ok) throw new Error("AUTH_CHECK_FAILED");
const result = await response.json();
if (typeof result.required !== "boolean" || typeof result.authenticated !== "boolean") throw new Error("AUTH_CHECK_FAILED");
const result = await checkStartupAuthentication(request.signal);
if (request.signal.aborted) return;
startupAuthentication.value = result;
setupRequired.value = result.setup_required === true;
loginRequired.value = setupRequired.value || (result.required && !result.authenticated);
if (loginRequired.value) {
@@ -51,74 +76,27 @@ async function initialize() {
return;
}
}
markStartupPhase("auth-ready");
await migration.initialize();
} catch {
authFailed.value = true;
if (!request.signal.aborted) authFailed.value = true;
} finally {
checkingAuth.value = false;
if (!request.signal.aborted) checkingAuth.value = false;
}
}
async function authenticated() {
history.replaceState(null, "", webPath("/"));
await initialize();
}
onMounted(initialize);
onMounted(() => {
void initializeLocale();
void initialize();
});
onUnmounted(() => authRequest?.abort());
</script>
<template>
<div v-if="checkingAuth || authFailed" class="fixed inset-0 flex flex-col items-center justify-center gap-4 bg-background text-foreground" role="status">
<p>{{ t(authFailed ? "migration.authFailed" : "migration.checking") }}</p>
<button v-if="authFailed" class="rounded border px-4 py-2" @click="initialize">{{ t("migration.retry") }}</button>
</div>
<StartupLoading v-if="checkingLocale || localeFailed || checkingAuth || authFailed" :label="authFailed ? t('migration.authFailed') : !checkingLocale && !localeFailed ? t('migration.checking') : undefined" :error="localeFailed || authFailed" :retry="localeFailed ? initializeLocale : initialize" />
<LoginPage v-else-if="loginRequired" :setup-mode="setupRequired" @authenticated="authenticated" />
<SecurityMigrationWizard v-else-if="blocking" :store="migration" />
<div v-else class="relative min-h-screen">
<Suspense @resolve="appReady">
<App />
</Suspense>
<Transition name="startup-fade">
<div v-if="enteringApp" class="fixed inset-0 z-[1100] flex items-center justify-center bg-background text-foreground" role="status" aria-live="polite">
<div class="flex flex-col items-center gap-5 text-center">
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-2xl text-primary" aria-hidden="true">✓</span>
<div>
<p class="text-lg font-semibold">{{ t("migration.successTitle") }}</p>
<p class="mt-2 text-sm text-muted-foreground">{{ t("migration.launching") }}</p>
</div>
<span class="h-1.5 w-24 overflow-hidden rounded-full bg-primary/15" aria-hidden="true"><span class="startup-progress block h-full w-1/2 rounded-full bg-primary" /></span>
</div>
</div>
</Transition>
</div>
<App v-else :startup-authentication="startupAuthentication" />
</template>
<style scoped>
.startup-fade-enter-active,
.startup-fade-leave-active {
transition: opacity 280ms ease;
}
.startup-fade-enter-from,
.startup-fade-leave-to {
opacity: 0;
}
.startup-progress {
animation: startup-progress 1.2s ease-in-out infinite;
}
@keyframes startup-progress {
from {
transform: translateX(-100%);
}
to {
transform: translateX(200%);
}
}
@media (prefers-reduced-motion: reduce) {
.startup-progress {
animation: none;
transform: translateX(50%);
}
}
</style>
+212
View File
@@ -0,0 +1,212 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
desktop: vi.fn(),
appImported: vi.fn(),
reloadLocale: vi.fn(),
migrationStatus: vi.fn(),
migrationStart: vi.fn(),
migrationRetry: vi.fn(),
migrationCleanupBackups: vi.fn(),
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: mocks.desktop }));
vi.mock("@/lib/backend/api", () => mocks);
vi.mock("@/i18n", () => ({ loadSavedLocale: mocks.reloadLocale }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
const readyStatus = { state: "not_required", needsMigration: false };
let businessReady: ReturnType<typeof deferred<void>>;
let mountedApp: App | undefined;
let root: HTMLDivElement;
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
sessionStorage.clear();
mocks.desktop.mockReturnValue(true);
mocks.migrationStatus.mockResolvedValue(readyStatus);
mocks.reloadLocale.mockResolvedValue(undefined);
businessReady = deferred<void>();
root = document.createElement("div");
document.body.append(root);
vi.doMock("../App.vue", async () => {
mocks.appImported();
await businessReady.promise;
return {
__esModule: true,
default: defineComponent({
props: ["startupAuthentication"],
setup: (props) => () => h("main", { "data-business-app": "" }, JSON.stringify(props.startupAuthentication ?? null)),
}),
};
});
vi.doMock("@/components/auth/LoginPage.vue", () => ({
__esModule: true,
default: defineComponent({
props: ["setupMode"],
emits: ["authenticated"],
setup:
(props, { emit }) =>
() =>
h("button", { "data-login": "", onClick: () => emit("authenticated") }, props.setupMode ? "setup" : "login"),
}),
}));
vi.doMock("@/components/migration/SecurityMigrationWizard.vue", () => ({
__esModule: true,
default: defineComponent({
props: ["store"],
setup: (props) => () =>
h(
"button",
{
"data-migration": "",
onClick: async () => {
await props.store.start();
props.store.enter();
},
},
"migrate",
),
}),
}));
});
afterEach(() => {
mountedApp?.unmount();
mountedApp = undefined;
root.remove();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
async function mountGate(localeReady?: Promise<void>) {
const { default: Gate } = await import("../StartupGate.vue");
mountedApp = createApp(Gate, { localeReady });
mountedApp.config.errorHandler = vi.fn();
mountedApp.mount(root);
}
describe("startup boundary", () => {
it("checks migration while the locale loads and keeps feedback until the app module resolves", async () => {
const locale = deferred<void>();
await mountGate(locale.promise);
await vi.waitFor(() => expect(mocks.migrationStatus).toHaveBeenCalledTimes(1));
expect(mocks.appImported).not.toHaveBeenCalled();
expect(root.querySelector("[data-startup-loading]")).not.toBeNull();
locale.resolve();
await vi.waitFor(() => expect(mocks.appImported).toHaveBeenCalledTimes(1));
expect(root.querySelector("[data-startup-loading]")).not.toBeNull();
expect(root.querySelector("main")).toBeNull();
businessReady.resolve();
await vi.waitFor(() => expect(root.querySelector("[data-business-app]")).not.toBeNull());
expect(root.querySelector("[data-startup-loading]")).toBeNull();
});
it("passes the checked Web authentication state without an extra request", async () => {
mocks.desktop.mockReturnValue(false);
const authentication = { required: true, authenticated: true, setup_required: false };
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => authentication });
vi.stubGlobal("fetch", fetchMock);
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(JSON.parse(root.querySelector("main")!.textContent!)).toEqual(authentication);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it.each([false, true])("does not inspect protected data until login/setup completes (setup=%s)", async (setup) => {
mocks.desktop.mockReturnValue(false);
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: true, authenticated: false, setup_required: setup }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: true, authenticated: true }) });
vi.stubGlobal("fetch", fetchMock);
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector("[data-login]")).not.toBeNull());
expect(root.querySelector("[data-login]")!.textContent).toBe(setup ? "setup" : "login");
expect(mocks.migrationStatus).not.toHaveBeenCalled();
expect(mocks.appImported).not.toHaveBeenCalled();
(root.querySelector("[data-login]") as HTMLButtonElement).click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(mocks.migrationStatus).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not load the business app until a required migration is completed and entered", async () => {
mocks.migrationStatus.mockResolvedValueOnce({ state: "pending", needsMigration: true }).mockResolvedValue(readyStatus);
mocks.migrationStart.mockResolvedValue({ state: "succeeded" });
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector("[data-migration]")).not.toBeNull());
expect(mocks.appImported).not.toHaveBeenCalled();
(root.querySelector("[data-migration]") as HTMLButtonElement).click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(mocks.migrationStart).toHaveBeenCalledTimes(1);
});
it("fails closed on malformed auth and allows retry", async () => {
mocks.desktop.mockReturnValue(false);
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => null })
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: false, authenticated: false }) });
vi.stubGlobal("fetch", fetchMock);
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
expect(mocks.migrationStatus).not.toHaveBeenCalled();
expect(mocks.appImported).not.toHaveBeenCalled();
root.querySelector("button")!.click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
});
it("retries a failed locale load without rerunning successful security checks", async () => {
const locale = deferred<void>();
businessReady.resolve();
await mountGate(locale.promise);
locale.reject(new Error("locale download failed"));
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
expect(mocks.appImported).not.toHaveBeenCalled();
root.querySelector("button")!.click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(mocks.reloadLocale).toHaveBeenCalledTimes(1);
expect(mocks.migrationStatus).toHaveBeenCalledTimes(1);
});
it("shows a safe retry state when the app module fails to load", async () => {
await mountGate();
await vi.waitFor(() => expect(mocks.appImported).toHaveBeenCalledTimes(1));
businessReady.reject(new Error("private module URL"));
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
expect(root.textContent).toContain("startup.loadFailed");
expect(root.textContent).not.toContain("private module URL");
expect(root.querySelector("button")).not.toBeNull();
});
it("cancels pending authentication when the gate unmounts", async () => {
mocks.desktop.mockReturnValue(false);
const fetchMock = vi.fn().mockReturnValue(new Promise(() => {}));
vi.stubGlobal("fetch", fetchMock);
await mountGate();
await nextTick();
const signal = fetchMock.mock.calls[0]?.[1].signal as AbortSignal;
mountedApp!.unmount();
mountedApp = undefined;
expect(signal.aborted).toBe(true);
expect(mocks.migrationStatus).not.toHaveBeenCalled();
});
});
@@ -8,6 +8,7 @@ import { useUpdateBlocker } from "@/lib/app/updatePreparation";
import { useResultViewUpdateTiming } from "@/composables/useResultViewUpdateTiming";
import { computed, nextTick, onMounted, onUnmounted, onActivated, onDeactivated, ref, shallowRef, toRaw, useSlots, watch, defineAsyncComponent, type Component, type CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { RecycleScroller } from "vue-virtual-scroller";
import {
ArrowUp,
ArrowDown,
@@ -61,7 +61,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Switch } from "@/components/ui/switch";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
@@ -93,6 +92,7 @@ function preloadDataGridComponent() {
void loadDataGridComponent();
}
const QueryEditor = defineAsyncComponent({ loader: () => import("@/components/editor/QueryEditor.vue"), loadingComponent: QueryLoadingState, delay: 0 });
const DataGrid = defineAsyncComponent(loadDataGridComponent);
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
const props = defineProps<{ label?: string; error?: unknown; retry?: () => void }>();
const { t } = useI18n();
function retry() {
if (props.retry) props.retry();
else window.location.reload();
}
</script>
<template>
<div data-startup-loading class="fixed inset-0 z-[1100] flex flex-col items-center justify-center gap-4 bg-background text-foreground" :role="error ? 'alert' : 'status'" aria-live="polite" :aria-busy="!error">
<span class="text-xl font-semibold tracking-tight" aria-hidden="true">DBX</span>
<span v-if="!error" class="startup-spinner" aria-hidden="true" />
<p class="text-sm text-muted-foreground">{{ label || t(error ? "startup.loadFailed" : "startup.loading") }}</p>
<button v-if="error" class="rounded border border-border px-4 py-2 text-sm hover:bg-muted focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring" @click="retry">{{ t("common.retry") }}</button>
</div>
</template>
<style scoped>
.startup-spinner {
width: 22px;
height: 22px;
border: 2px solid var(--muted);
border-top-color: var(--muted-foreground);
border-radius: 50%;
animation: startup-spin 0.8s linear infinite;
}
@keyframes startup-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.startup-spinner {
animation: none;
}
}
</style>
+4
View File
@@ -2932,6 +2932,10 @@ export default {
legacy: "Backend returned an error.",
unknown: "An unexpected backend error occurred.",
},
startup: {
loading: "Starting DBX…",
loadFailed: "Unable to load DBX. Please try again.",
},
common: {
fileNotFound: "File does not exist: {path}",
language: "Language",
+4
View File
@@ -2844,6 +2844,10 @@ export default withEnglishFallback({
legacy: "后端返回了错误。",
unknown: "发生了未预期的后端错误。",
},
startup: {
loading: "正在启动 DBX…",
loadFailed: "无法加载 DBX,请重试。",
},
common: {
fileNotFound: "文件不存在:{path}",
language: "语言",
@@ -0,0 +1,34 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { checkStartupAuthentication } from "@/lib/startup/startupAuthentication";
afterEach(() => vi.unstubAllGlobals());
describe("startup authentication", () => {
it("preserves authenticated, disabled-auth and setup-required states", async () => {
for (const state of [
{ required: true, authenticated: true, setup_required: false },
{ required: false, authenticated: false, setup_required: false },
{ required: true, authenticated: false, setup_required: true },
]) {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => state });
vi.stubGlobal("fetch", fetchMock);
expect(await checkStartupAuthentication()).toEqual(state);
expect(fetchMock).toHaveBeenCalledWith("/api/auth/check", expect.objectContaining({ credentials: "same-origin" }));
}
});
it.each([null, [], "invalid", {}, { required: "false", authenticated: true }, { required: false, authenticated: "true" }])("fails closed for malformed authentication data: %j", async (state) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => state }));
await expect(checkStartupAuthentication()).rejects.toThrow("AUTH_CHECK_FAILED");
});
it("fails closed on unsuccessful responses and forwards cancellation", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
vi.stubGlobal("fetch", fetchMock);
const controller = new AbortController();
await expect(checkStartupAuthentication(controller.signal)).rejects.toThrow("AUTH_CHECK_FAILED");
expect(fetchMock.mock.calls[0]?.[1].signal).toBe(controller.signal);
});
});
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { markStartupPhase } from "@/lib/startup/startupTiming";
afterEach(() => vi.unstubAllGlobals());
describe("startup phase marks", () => {
it("records each phase once without confusing gate mount with app readiness", () => {
const entries = new Set<string>();
const mark = vi.fn((name: string) => entries.add(name));
vi.stubGlobal("performance", { mark, getEntriesByName: (name: string) => (entries.has(name) ? [{}] : []) });
markStartupPhase("gate-mounted");
markStartupPhase("gate-mounted");
expect(mark).toHaveBeenCalledTimes(1);
expect(entries.has("dbx:startup:app-mounted")).toBe(false);
markStartupPhase("app-mounted");
expect(mark).toHaveBeenCalledTimes(2);
});
it("does not block startup when performance marks are unavailable", () => {
vi.stubGlobal("performance", {});
expect(() => markStartupPhase("bootstrap")).not.toThrow();
});
});
+1 -5
View File
@@ -27,11 +27,7 @@ interface InitializeOpenTabsOptions {
}
export async function initializeOpenTabs({ initializeOptionalState, restoreOpenTabs, onOptionalStateError }: InitializeOpenTabsOptions): Promise<void> {
try {
await initializeOptionalState();
} catch (error) {
onOptionalStateError(error);
}
void Promise.resolve().then(initializeOptionalState).catch(onOptionalStateError);
await restoreOpenTabs();
}
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
settingsImported: vi.fn(),
migrationStatus: vi.fn(),
listInstalledAgents: vi.fn(),
installAgent: vi.fn(),
upgradeAllAgents: vi.fn(),
reinstallJre: vi.fn(),
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@/lib/backend/tauri", () => mocks);
vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn() }));
const settings = { editorSettings: { updateDownloadSource: "github" } };
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
settings.editorSettings.updateDownloadSource = "github";
vi.doMock("@/stores/settingsStore", () => {
mocks.settingsImported();
return { useSettingsStore: () => settings };
});
});
describe("startup API dependency boundary", () => {
it("does not import the business settings store for migration preflight", async () => {
mocks.migrationStatus.mockResolvedValue({ state: "not_required" });
const api = await import("../api");
await expect(api.migrationStatus()).resolves.toEqual({ state: "not_required" });
expect(mocks.settingsImported).not.toHaveBeenCalled();
});
it("continues using the current configured source for agent operations", async () => {
const api = await import("../api");
await api.listInstalledAgents();
expect(mocks.listInstalledAgents).toHaveBeenCalledWith("github");
settings.editorSettings.updateDownloadSource = "cloudflare";
await api.installAgent("mysql", "install-1");
await api.upgradeAllAgents("upgrade-1");
await api.reinstallJre("java-21", "jre-1");
expect(mocks.installAgent).toHaveBeenCalledWith("mysql", "cloudflare", "install-1");
expect(mocks.upgradeAllAgents).toHaveBeenCalledWith("cloudflare", "upgrade-1");
expect(mocks.reinstallJre).toHaveBeenCalledWith("java-21", "cloudflare", "jre-1");
expect(mocks.settingsImported).toHaveBeenCalledTimes(1);
});
});
+4 -1
View File
@@ -1,7 +1,6 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import type * as TauriModule from "@/lib/backend/tauri";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { useSettingsStore } from "@/stores/settingsStore";
import type { AiConfigItem } from "@/types/ai";
// ---------------------------------------------------------------------------
@@ -135,6 +134,7 @@ export const uninstallJdbcPlugin = forward("uninstallJdbcPlugin");
export const listInstalledAgentsLocal = forward("listInstalledAgentsLocal");
export async function listInstalledAgents() {
const backend = await getBackend();
const { useSettingsStore } = await import("@/stores/settingsStore");
return backend.listInstalledAgents(useSettingsStore().editorSettings.updateDownloadSource);
}
export const isAgentInstalled = forward("isAgentInstalled");
@@ -145,10 +145,12 @@ export const stopDriverRuntime = forward("stopDriverRuntime");
export const restartDriverRuntime = forward("restartDriverRuntime");
export async function installAgent(dbType: string, operationId?: string) {
const backend = await getBackend();
const { useSettingsStore } = await import("@/stores/settingsStore");
return backend.installAgent(dbType, useSettingsStore().editorSettings.updateDownloadSource, operationId);
}
export async function upgradeAllAgents(operationId?: string) {
const backend = await getBackend();
const { useSettingsStore } = await import("@/stores/settingsStore");
return backend.upgradeAllAgents(useSettingsStore().editorSettings.updateDownloadSource, operationId);
}
export const cancelAgentInstall = forward("cancelAgentInstall");
@@ -168,6 +170,7 @@ export const importAgentDriver = forward("importAgentDriver");
export const importAgentJar = importAgentDriver;
export async function reinstallJre(jreKey?: string, operationId?: string) {
const backend = await getBackend();
const { useSettingsStore } = await import("@/stores/settingsStore");
return backend.reinstallJre(jreKey, useSettingsStore().editorSettings.updateDownloadSource, operationId);
}
export const uninstallJre = forward("uninstallJre");
@@ -0,0 +1,21 @@
import { apiUrl } from "@/lib/common/webPath";
export interface StartupAuthentication {
required: boolean;
authenticated: boolean;
setup_required: boolean;
}
export async function checkStartupAuthentication(signal?: AbortSignal): Promise<StartupAuthentication> {
const response = await fetch(apiUrl("/api/auth/check"), { credentials: "same-origin", signal });
if (!response.ok) throw new Error("AUTH_CHECK_FAILED");
const result: unknown = await response.json();
if (!result || typeof result !== "object" || !("required" in result) || !("authenticated" in result) || typeof result.required !== "boolean" || typeof result.authenticated !== "boolean") {
throw new Error("AUTH_CHECK_FAILED");
}
return {
required: result.required,
authenticated: result.authenticated,
setup_required: "setup_required" in result && result.setup_required === true,
};
}
@@ -0,0 +1,8 @@
export type StartupPhase = "bootstrap" | "gate-mounted" | "locale-ready" | "auth-ready" | "migration-ready" | "app-mounted" | "settings-ready" | "connections-ready" | "tabs-restored";
export function markStartupPhase(phase: StartupPhase): void {
const name = `dbx:startup:${phase}`;
const clock = globalThis.performance;
if (typeof clock?.mark !== "function" || typeof clock.getEntriesByName !== "function") return;
if (!clock.getEntriesByName(name, "mark").length) clock.mark(name);
}
+7 -10
View File
@@ -1,10 +1,9 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import VueVirtualScroller from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import "./styles/globals.css";
import { installDebugLogCapture } from "@/lib/backend/debugLog";
import { clearStartupPreloadRetry, retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
import { retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
import { markStartupPhase } from "@/lib/startup/startupTiming";
import { applyLegacyWebViewClass } from "@/lib/ui/legacyWebView";
function startupErrorMessage(error: unknown): string {
@@ -76,19 +75,17 @@ function installGlobalInputAttrs() {
}
async function bootstrap() {
markStartupPhase("bootstrap");
console.log("[STARTUP] frontend bootstrap begin");
const [{ default: i18n, loadSavedLocale }, { default: App }] = await Promise.all([import("./i18n"), import("./StartupGate.vue")]);
console.log("[STARTUP] frontend modules loaded");
await loadSavedLocale();
console.log("[STARTUP] locale ready");
const app = createApp(App);
const localeReady = loadSavedLocale();
const app = createApp(App, { localeReady });
app.use(createPinia());
app.use(i18n);
app.use(VueVirtualScroller);
app.mount("#root");
clearStartupPreloadRetry();
window.dispatchEvent(new Event("dbx:startup-ready"));
markStartupPhase("gate-mounted");
void localeReady.then(() => window.dispatchEvent(new Event("dbx:startup-ready"))).catch(() => {});
console.log("[STARTUP] vue mounted");
installGlobalInputAttrs();
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSettingsStore } from "../settingsStore";
const backend = vi.hoisted(() => ({
loadAiConfigs: vi.fn(),
loadAiChatSelection: vi.fn(),
loadAiConfig: vi.fn(),
loadAiProviderConfigs: vi.fn(),
saveAiChatSelection: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => backend);
beforeEach(() => {
vi.resetAllMocks();
localStorage.clear();
setActivePinia(createPinia());
backend.loadAiChatSelection.mockResolvedValue(null);
backend.loadAiConfig.mockResolvedValue(null);
backend.loadAiProviderConfigs.mockResolvedValue(null);
});
describe("AI startup initialization", () => {
it("shares pending initialization between startup and the AI panel", async () => {
let resolveConfigs!: (configs: []) => void;
backend.loadAiConfigs.mockImplementation(
() =>
new Promise<[]>((resolve) => {
resolveConfigs = resolve;
}),
);
const store = useSettingsStore();
const first = store.initAiConfigs();
const second = store.initAiConfigs();
expect(backend.loadAiConfigs).toHaveBeenCalledTimes(1);
expect(store.isAiConfigLoaded).toBe(false);
resolveConfigs([]);
await Promise.all([first, second]);
expect(store.isAiConfigLoaded).toBe(true);
expect(backend.loadAiChatSelection).toHaveBeenCalledTimes(1);
await store.initAiConfigs();
expect(backend.loadAiConfigs).toHaveBeenCalledTimes(1);
});
it("permits retry after a shared initialization failure", async () => {
backend.loadAiConfigs.mockRejectedValueOnce(new Error("unavailable")).mockResolvedValue([]);
const store = useSettingsStore();
const pending = [store.initAiConfigs(), store.initAiConfigs()];
const results = await Promise.allSettled(pending);
expect(results.every((result) => result.status === "rejected")).toBe(true);
expect(store.isAiConfigLoaded).toBe(false);
await store.initAiConfigs();
expect(backend.loadAiConfigs).toHaveBeenCalledTimes(2);
expect(store.isAiConfigLoaded).toBe(true);
});
});
+10 -1
View File
@@ -25,7 +25,7 @@ import { REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT, REDIS_DATABASE_DISPLAY_LIMIT_MIN,
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
import { normalizeSidebarCopyTableNameSeparator } from "@/lib/sidebar/sidebarTableNameCopy";
import type { SidebarActivation } from "@/lib/sidebar/treeNodeClick";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlSnippetTemplates";
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
import { canonicalSqlShortcutSql, DEFAULT_SQL_SHORTCUTS, deriveSqlShortcutDatabaseTypes, mergeDefaultSqlShortcuts, normalizeSqlShortcutDatabaseTypes, normalizeSqlShortcutKind, normalizeSqlShortcutLimit, normalizeSqlShortcutSqlByDatabaseType } from "@/lib/sql/sqlShortcutActions";
import { normalizeSqlVariableSyntaxOverrides, type SqlVariableSyntaxOverrides } from "@/lib/sql/sqlVariableSyntax";
@@ -1963,6 +1963,7 @@ export const useSettingsStore = defineStore("settings", () => {
const isMcpGlobalPolicyLoaded = ref(false);
const isEditorSettingsLoaded = ref(false);
let initEditorSettingsPromise: Promise<void> | null = null;
let initAiConfigsPromise: Promise<void> | null = null;
let pendingEditorSettingsPatches: Partial<EditorSettings>[] = [];
let editorSettingsOperationQueue: Promise<void> | null = null;
let editorSettingsPatchRevision = 0;
@@ -2168,7 +2169,15 @@ export const useSettingsStore = defineStore("settings", () => {
async function initAiConfigs(): Promise<void> {
if (isAiConfigLoaded.value) return;
if (!initAiConfigsPromise) {
initAiConfigsPromise = loadAiConfigurationState().finally(() => {
initAiConfigsPromise = null;
});
}
await initAiConfigsPromise;
}
async function loadAiConfigurationState(): Promise<void> {
// 尝试加载新格式
const newConfigs = await api.loadAiConfigs();