diff --git a/apps/desktop/index.html b/apps/desktop/index.html index cc5fe4906f..7a3e0a59c0 100644 --- a/apps/desktop/index.html +++ b/apps/desktop/index.html @@ -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); + } } diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index f1f2a4701d..f5f198cae3 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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 | 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(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")); diff --git a/apps/desktop/src/StartupGate.vue b/apps/desktop/src/StartupGate.vue index 16812ff950..c5b4e9d75a 100644 --- a/apps/desktop/src/StartupGate.vue +++ b/apps/desktop/src/StartupGate.vue @@ -1,15 +1,31 @@ - - diff --git a/apps/desktop/src/__tests__/StartupGate.spec.ts b/apps/desktop/src/__tests__/StartupGate.spec.ts new file mode 100644 index 0000000000..40f13312be --- /dev/null +++ b/apps/desktop/src/__tests__/StartupGate.spec.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const readyStatus = { state: "not_required", needsMigration: false }; +let businessReady: ReturnType>; +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(); + 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) { + 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(); + 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(); + 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(); + }); +}); diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 031c065fcc..d85f8e28af 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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, diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 909825db43..d9687b792e 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -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")); diff --git a/apps/desktop/src/components/layout/StartupLoading.vue b/apps/desktop/src/components/layout/StartupLoading.vue new file mode 100644 index 0000000000..93d8f782f6 --- /dev/null +++ b/apps/desktop/src/components/layout/StartupLoading.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index fa3b927eba..4c4c34c455 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d9e7f96a9c..06b0ecf1b2 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2844,6 +2844,10 @@ export default withEnglishFallback({ legacy: "后端返回了错误。", unknown: "发生了未预期的后端错误。", }, + startup: { + loading: "正在启动 DBX…", + loadFailed: "无法加载 DBX,请重试。", + }, common: { fileNotFound: "文件不存在:{path}", language: "语言", diff --git a/apps/desktop/src/lib/__tests__/startup/startupAuthentication.spec.ts b/apps/desktop/src/lib/__tests__/startup/startupAuthentication.spec.ts new file mode 100644 index 0000000000..7789bb8b6c --- /dev/null +++ b/apps/desktop/src/lib/__tests__/startup/startupAuthentication.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/startup/startupTiming.spec.ts b/apps/desktop/src/lib/__tests__/startup/startupTiming.spec.ts new file mode 100644 index 0000000000..0a88f3c4f8 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/startup/startupTiming.spec.ts @@ -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(); + 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(); + }); +}); diff --git a/apps/desktop/src/lib/app/openTabsStartup.ts b/apps/desktop/src/lib/app/openTabsStartup.ts index e41b28b0ed..e43ccc4579 100644 --- a/apps/desktop/src/lib/app/openTabsStartup.ts +++ b/apps/desktop/src/lib/app/openTabsStartup.ts @@ -27,11 +27,7 @@ interface InitializeOpenTabsOptions { } export async function initializeOpenTabs({ initializeOptionalState, restoreOpenTabs, onOptionalStateError }: InitializeOpenTabsOptions): Promise { - try { - await initializeOptionalState(); - } catch (error) { - onOptionalStateError(error); - } + void Promise.resolve().then(initializeOptionalState).catch(onOptionalStateError); await restoreOpenTabs(); } diff --git a/apps/desktop/src/lib/backend/__tests__/apiStartupBoundary.spec.ts b/apps/desktop/src/lib/backend/__tests__/apiStartupBoundary.spec.ts new file mode 100644 index 0000000000..cf50be8654 --- /dev/null +++ b/apps/desktop/src/lib/backend/__tests__/apiStartupBoundary.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 02fe176d03..b0bde108e9 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -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"); diff --git a/apps/desktop/src/lib/startup/startupAuthentication.ts b/apps/desktop/src/lib/startup/startupAuthentication.ts new file mode 100644 index 0000000000..06308e63a0 --- /dev/null +++ b/apps/desktop/src/lib/startup/startupAuthentication.ts @@ -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 { + 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, + }; +} diff --git a/apps/desktop/src/lib/startup/startupTiming.ts b/apps/desktop/src/lib/startup/startupTiming.ts new file mode 100644 index 0000000000..ccff071627 --- /dev/null +++ b/apps/desktop/src/lib/startup/startupTiming.ts @@ -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); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b7859b82b1..4e8d733763 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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(); diff --git a/apps/desktop/src/stores/__tests__/settingsStore.startup.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.startup.spec.ts new file mode 100644 index 0000000000..56adf79171 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/settingsStore.startup.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 82637b02cf..d761f9ba9d 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -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 | null = null; + let initAiConfigsPromise: Promise | null = null; let pendingEditorSettingsPatches: Partial[] = []; let editorSettingsOperationQueue: Promise | null = null; let editorSettingsPatchRevision = 0; @@ -2168,7 +2169,15 @@ export const useSettingsStore = defineStore("settings", () => { async function initAiConfigs(): Promise { if (isAiConfigLoaded.value) return; + if (!initAiConfigsPromise) { + initAiConfigsPromise = loadAiConfigurationState().finally(() => { + initAiConfigsPromise = null; + }); + } + await initAiConfigsPromise; + } + async function loadAiConfigurationState(): Promise { // 尝试加载新格式 const newConfigs = await api.loadAiConfigs(); diff --git a/package.json b/package.json index 6a17adcd8a..8d11ccfa00 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "bench:redis-key-search": "node scripts/bench/redis-key-search.mjs", "bench:mongodb-count": "node scripts/bench/mongodb-count.mjs", "bench:sidebar-large-schema": "vitest bench apps/desktop/src/lib/sidebar/sidebarLargeSchema.bench.ts", + "bench:startup": "node --import tsx scripts/bench/startup-bundle.mjs", "bench:table-import": "cargo run -p dbx-core --no-default-features --example table_import_bench --release --", "lint": "oxlint --vue-plugin apps/desktop/src", "fmt": "oxfmt \"apps/desktop/src/**/*.{ts,vue}\"", diff --git a/packages/app-tests/externalSqlFileStartup.test.ts b/packages/app-tests/externalSqlFileStartup.test.ts index 6a3b44dd6f..675d1e5322 100644 --- a/packages/app-tests/externalSqlFileStartup.test.ts +++ b/packages/app-tests/externalSqlFileStartup.test.ts @@ -7,6 +7,10 @@ const appSource = readFileSync("apps/desktop/src/App.vue", "utf8"); test("web startup reports optional initialization failure and continues restoring persisted state", async () => { const events: string[] = []; + let reportError!: () => void; + const errorReported = new Promise((resolve) => { + reportError = resolve; + }); await initializeOpenTabs({ initializeOptionalState: async () => { @@ -18,10 +22,18 @@ test("web startup reports optional initialization failure and continues restorin events.push("initialize-connections"); events.push("restore-tabs"); }, - onOptionalStateError: () => events.push("report-ai-config-error"), + onOptionalStateError: () => { + events.push("report-ai-config-error"); + reportError(); + }, }); - assert.deepEqual(events, ["initialize-ai-configs", "report-ai-config-error", "initialize-editor-settings", "initialize-connections", "restore-tabs"]); + await errorReported; + assert.deepEqual( + events.filter((event) => !event.includes("ai-config")), + ["initialize-editor-settings", "initialize-connections", "restore-tabs"], + ); + assert.ok(events.includes("report-ai-config-error")); }); test("shared startup initializes optional state once and propagates required restoration failure", async () => { @@ -64,10 +76,11 @@ test("web startup uses the guarded initializer behind the existing authenticatio assert.ok(mountedStart >= 0 && mountedEnd > mountedStart); const mountedSource = appSource.slice(mountedStart, mountedEnd); - const authCheck = mountedSource.indexOf('fetch(apiUrl("/api/auth/check"))'); + const cachedAuth = mountedSource.indexOf("if (!startupProps.startupAuthentication)"); + const authCheck = mountedSource.indexOf("await checkStartupAuthentication()", cachedAuth); const loginRedirect = mountedSource.indexOf('history.replaceState(null, "", webPath("/login"))', authCheck); const authenticatedInitialization = mountedSource.indexOf("if (!setupRequired.value && (!needsAuth.value || authenticated.value)) void initApp()", loginRedirect); - assert.ok(authCheck >= 0); + assert.ok(cachedAuth >= 0 && authCheck > cachedAuth); assert.ok(loginRedirect > authCheck); assert.ok(authenticatedInitialization > loginRedirect); }); @@ -96,7 +109,55 @@ test("desktop SQL file opening survives unrelated initialization failure and fol }); await fileOpen; - assert.deepEqual(events, ["initialize-ai-configs", "ignore-ai-config-error", "initialize-editor-settings", "initialize-connections", "restore-tabs", "read-file", "open-tab"]); + await Promise.resolve(); + assert.deepEqual( + events.filter((event) => !event.includes("ai-config")), + ["initialize-editor-settings", "initialize-connections", "restore-tabs", "read-file", "open-tab"], + ); + assert.ok(events.includes("ignore-ai-config-error")); +}); + +test("pending optional AI initialization does not hold tab restoration or external file opening", async () => { + let rejectOptional!: (error: Error) => void; + const optional = new Promise((_resolve, reject) => { + rejectOptional = reject; + }); + const errors: unknown[] = []; + const barrier = createOpenTabsRestorationBarrier(); + const events: string[] = []; + const fileOpen = barrier.settled.then(() => events.push("open-file")); + await initializeDesktopOpenTabs({ + barrier, + initializeOptionalState: () => optional, + restoreOpenTabs: async () => { + events.push("restore-tabs"); + }, + onOptionalStateError: (error) => errors.push(error), + }); + await fileOpen; + assert.deepEqual(events, ["restore-tabs", "open-file"]); + const error = new Error("AI config unavailable"); + rejectOptional(error); + await optional.catch(() => {}); + await Promise.resolve(); + assert.deepEqual(errors, [error]); +}); + +test("a synchronous optional initialization failure does not block required restoration", async () => { + const events: string[] = []; + await initializeOpenTabs({ + initializeOptionalState: () => { + throw new Error("optional failure"); + }, + restoreOpenTabs: async () => { + events.push("restored"); + }, + onOptionalStateError: () => { + events.push("reported"); + }, + }); + await Promise.resolve(); + assert.deepEqual(events, ["restored", "reported"]); }); test("desktop SQL file opening is released when persisted tab restoration rejects", async () => { diff --git a/scripts/bench/README.md b/scripts/bench/README.md new file mode 100644 index 0000000000..3058053ad1 --- /dev/null +++ b/scripts/bench/README.md @@ -0,0 +1,28 @@ +# Startup dependency benchmark + +Run `pnpm bench:startup` from the repository root. The benchmark uses the current +production Vite configuration, checks generated connection descriptors, and builds +in memory without replacing `dist` or writing generated source files. + +The JSON report lists cumulative static JavaScript dependencies for the entry, +startup gate, and main application, with English and Simplified Chinese variants. +Later phases include the earlier phases; do not add their byte counts together. +Gzip sizes are calculated per chunk, not measured HTTP transfer sizes. CSS, images, +runtime-selected backend transports, and other dynamic imports are not included. + +The startup gate must not statically depend on the settings/connection/query stores +or the SQL editor. Check the `includes` fields to detect a broken lazy boundary. +Loading a previously saved SQL tab will still load its editor on demand. + +Runtime phase markers are available through +`performance.getEntriesByType("mark").filter(entry => entry.name.startsWith("dbx:startup:"))`. +They distinguish gate mounting, locale/auth/migration completion, app mounting, +settings, connections, and restored tabs. App mounting is not editor interactivity; +the legacy `dbx:startup-ready` input-guard event is not a main-screen readiness metric. + +Validate desktop and Web, returning users and first-run migration, login/setup, +slow or failed locale/module loading, optional AI failures, and restored/external +SQL tabs. Keep authentication before migration inspection, and editor settings +before connections before tab restoration. Bundle reductions are not cold-start +timing claims: native setup, storage, WebView parsing and rendering still need +separate measurement. diff --git a/scripts/bench/startup-bundle.mjs b/scripts/bench/startup-bundle.mjs new file mode 100644 index 0000000000..be38902901 --- /dev/null +++ b/scripts/bench/startup-bundle.mjs @@ -0,0 +1,57 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; +import { build } from "vite"; +import configFactory from "../../apps/desktop/vite.config.ts"; + +const repository = fileURLToPath(new URL("../../", import.meta.url)); +execFileSync(process.execPath, ["scripts/sync-connection-types.mjs", "--check"], { cwd: repository, stdio: ["ignore", "pipe", "inherit"] }); +const config = await configFactory({ command: "build", mode: "production" }); +const result = await build({ + ...config, + configFile: false, + logLevel: "error", + plugins: config.plugins.filter((plugin) => plugin?.name !== "dbx-connection-types"), + build: { ...config.build, write: false, emptyOutDir: false, reportCompressedSize: false }, +}); +const chunks = (Array.isArray(result) ? result : [result]).flatMap((bundle) => bundle.output).filter((output) => output.type === "chunk"); +const byName = new Map(chunks.map((chunk) => [chunk.fileName, chunk])); +const sourceChunk = (suffix) => chunks.find((chunk) => Object.keys(chunk.modules).some((id) => id.endsWith(suffix))); +const entry = chunks.find((chunk) => chunk.isEntry && chunk.facadeModuleId?.endsWith("/index.html")); +const gate = sourceChunk("/src/StartupGate.vue"); +const locale = sourceChunk("/src/i18n/index.ts"); +const app = sourceChunk("/src/App.vue"); +const chinese = sourceChunk("/src/i18n/locales/zh-CN.ts"); +if (!entry || !gate || !locale || !app || !chinese) throw new Error("Startup bundle entrypoints were not found"); + +function summarize(phase, seeds) { + const visited = new Set(); + function visit(name) { + if (visited.has(name) || !byName.has(name)) return; + visited.add(name); + byName.get(name).imports.forEach(visit); + } + seeds.forEach((chunk) => visit(chunk.fileName)); + const dependencies = [...visited].map((name) => byName.get(name)); + const files = dependencies.map((chunk) => ({ file: chunk.fileName, bytes: Buffer.byteLength(chunk.code), gzipBytes: gzipSync(chunk.code).length })); + const modules = dependencies.flatMap((chunk) => Object.keys(chunk.modules)); + return { + phase, + jsChunks: files.length, + bytes: files.reduce((total, file) => total + file.bytes, 0), + gzipBytes: files.reduce((total, file) => total + file.gzipBytes, 0), + largestFiles: files.sort((left, right) => right.bytes - left.bytes).slice(0, 8), + includes: Object.fromEntries(["settingsStore.ts", "connectionStore.ts", "queryStore.ts", "QueryEditor.vue", "/node_modules/@codemirror/"].map((pattern) => [pattern, modules.some((id) => id.includes(pattern))])), + }; +} + +console.log( + JSON.stringify( + { + kind: "static-js-dependencies-not-startup-timing", + phases: [summarize("entry", [entry]), summarize("startup-gate-en", [entry, gate, locale]), summarize("startup-gate-zh-CN", [entry, gate, locale, chinese]), summarize("app-en", [entry, gate, locale, app]), summarize("app-zh-CN", [entry, gate, locale, app, chinese])], + }, + null, + 2, + ), +);