From 16059ba6b48b143771882ea8e1b1cc574453f921 Mon Sep 17 00:00:00 2001 From: perry gupta Date: Sun, 15 Nov 2026 10:12:02 +0530 Subject: [PATCH 1/2] fix(startup): show database migration progress Refs #35995 --- apps/desktop/src/app/DesktopState.ts | 3 + .../src/backend/DesktopBackendManager.test.ts | 23 ++- .../src/backend/DesktopBackendManager.ts | 32 +++- .../desktop/src/backend/DesktopBackendPool.ts | 4 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.ts | 13 ++ apps/desktop/src/preload.ts | 2 + .../server/src/httpResponseErrorGuard.test.ts | 60 ++++++-- apps/server/src/httpResponseErrorGuard.ts | 29 +++- .../persistence/DatabaseMigrationProgress.ts | 15 ++ .../server/src/persistence/Migrations.test.ts | 57 ++++++- apps/server/src/persistence/Migrations.ts | 143 ++++++++++-------- apps/server/src/server.ts | 5 + apps/web/index.html | 29 ++++ packages/contracts/src/ipc.ts | 2 + packages/contracts/src/server.ts | 12 ++ .../shared/src/databaseMigrationLog.test.ts | 30 ++++ packages/shared/src/databaseMigrationLog.ts | 22 +++ 19 files changed, 400 insertions(+), 84 deletions(-) create mode 100644 apps/server/src/persistence/DatabaseMigrationProgress.ts create mode 100644 packages/shared/src/databaseMigrationLog.test.ts diff --git a/apps/desktop/src/app/DesktopState.ts b/apps/desktop/src/app/DesktopState.ts index f869fd335ab07..39d3dabdf7864 100644 --- a/apps/desktop/src/app/DesktopState.ts +++ b/apps/desktop/src/app/DesktopState.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import type { DatabaseMigrationProgress } from "@t3tools/contracts"; import type { BackendStartupFailure } from "../backend/DesktopBackendManager.ts"; @@ -20,6 +21,7 @@ export class DesktopState extends Context.Service< // Set by DesktopUpdates.configure, which runs before the first backend start. readonly canInstallUpdates: Ref.Ref; readonly primaryBackendStartup: SubscriptionRef.SubscriptionRef; + readonly databaseMigrationProgress: Ref.Ref; } >()("@t3tools/desktop/app/DesktopState") {} @@ -28,6 +30,7 @@ const make = Effect.all({ quitting: Ref.make(false), canInstallUpdates: Ref.make(false), primaryBackendStartup: SubscriptionRef.make({ _tag: "Starting" }), + databaseMigrationProgress: Ref.make(null), }); export const layer = Layer.effect(DesktopState, make); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 06a1f768f4bd4..5406b2357d017 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -129,6 +129,9 @@ interface MakeInstanceInput { readonly onReady?: Effect.Effect; readonly onShutdown?: Effect.Effect; readonly onStateDirOwned?: Effect.Effect; + readonly onMigrationProgress?: ( + progress: { current: number; total: number } | null, + ) => Effect.Effect; readonly onStartupFailure?: ( failure: DesktopBackendManager.BackendStartupFailure, ) => Effect.Effect; @@ -192,6 +195,7 @@ function makeTestInstance(input: MakeInstanceInput) { ...(input.onStateDirOwned ? { onStateDirOwned: () => input.onStateDirOwned! } : {}), ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), ...(input.onStartupFailure ? { onStartupFailure: input.onStartupFailure } : {}), + ...(input.onMigrationProgress ? { onMigrationProgress: input.onMigrationProgress } : {}), }); return instance.pipe(Effect.provide(layerServices)); @@ -657,7 +661,8 @@ describe("DesktopBackendManager", () => { "reports exit $code with the server output after $exits exits", ({ code, exits, managedPolicy }) => Effect.gen(function* () { - const message = "Error: Cannot find module 'zod/v4' in /Users/René"; + const message = `${DATABASE_MIGRATION_PROGRESS_MESSAGE} (1 of 3)\n progress: 1 of 3\nError: Cannot find module 'zod/v4' in /Users/René`; + const progress: Array<{ current: number; total: number } | null> = []; let exitCount = 0; const firstExit = yield* Deferred.make(); const reports = yield* Queue.unbounded<{ @@ -691,6 +696,10 @@ describe("DesktopBackendManager", () => { return Deferred.succeed(firstExit, undefined).pipe(Effect.asVoid); }, }, + onMigrationProgress: (value) => + Effect.sync(() => { + progress.push(value); + }), onStartupFailure: (failure) => Queue.offer(reports, { exitCount, failure }).pipe(Effect.asVoid), }); @@ -703,12 +712,15 @@ describe("DesktopBackendManager", () => { assert.equal(failure.managedPolicy, managedPolicy); assert.isTrue(failure.output.endsWith(message)); assert.isTrue(failure.output.isWellFormed()); + assert.isTrue(progress.some((value) => value?.current === 1 && value.total === 3)); + assert.isNull(progress.at(-1)); }).pipe(Effect.provide(TestClock.layer())), ); it.effect("waits out a slow startup and reports one that never becomes ready", () => Effect.gen(function* () { const reports = yield* Queue.unbounded(); + const progress: Array<{ current: number; total: number } | null> = []; const startInstance = (status: Ref.Ref, onReady?: Effect.Effect, stdout = "") => makeTestInstance({ spawnerLayer: Layer.succeed( @@ -725,6 +737,10 @@ describe("DesktopBackendManager", () => { httpClientLayer: layerHttpClient((request) => Ref.get(status).pipe(Effect.map((code) => responseForRequest(request, code))), ), + onMigrationProgress: (value) => + Effect.sync(() => { + progress.push(value); + }), onStartupFailure: (failure) => Queue.offer(reports, failure).pipe(Effect.asVoid), ...(onReady ? { onReady } : {}), }).pipe(Effect.tap((instance) => instance.start)); @@ -748,14 +764,16 @@ describe("DesktopBackendManager", () => { Array.from( { length: 80 }, (_, index) => - `INFO ${DATABASE_MIGRATION_PROGRESS_MESSAGE} migration=${index}_Migration progress="${index + 1} of 80"\n`, + `INFO ${DATABASE_MIGRATION_PROGRESS_MESSAGE} (${index + 1} of 80)\n migration: ${index}_Migration\n progress: ${index + 1} of 80\n`, ).join(""), ); yield* TestClock.adjust(Duration.minutes(10)); + assert.deepStrictEqual(progress.at(-1), { current: 80, total: 80 }); assert.isTrue(Option.isNone(yield* Queue.poll(reports))); yield* Ref.set(migratingStatus, 200); yield* TestClock.adjust(Duration.millis(100)); yield* Deferred.await(migrated); + assert.isNull(progress.at(-1)); yield* startInstance( yield* Ref.make(503), @@ -766,6 +784,7 @@ describe("DesktopBackendManager", () => { assert.isTrue(Option.isNone(yield* Queue.poll(reports))); yield* TestClock.adjust(Duration.seconds(1)); const failure = yield* Queue.take(reports); + assert.isNull(progress.at(-1)); assert.equal(failure.reason, "The server started but did not become ready in time."); }).pipe(Effect.provide(TestClock.layer())), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 4acaa8a3c18b8..00662d1293269 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -25,8 +25,12 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID, DesktopTelemetryControlMessage, type DesktopTelemetryControlMessage as DesktopTelemetryControlMessageValue, + type DatabaseMigrationProgress, } from "@t3tools/contracts"; -import { isRunningDatabaseMigrations } from "@t3tools/shared/databaseMigrationLog"; +import { + databaseMigrationProgress, + isRunningDatabaseMigrations, +} from "@t3tools/shared/databaseMigrationLog"; import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/httpReadiness"; import * as DesktopObservability from "../app/DesktopObservability.ts"; @@ -260,6 +264,9 @@ export interface BackendInstanceSpec { readonly onStateDirOwned?: () => Effect.Effect; /** Fires on each repeated crash or readiness timeout until the backend becomes ready. */ readonly onStartupFailure?: (failure: BackendStartupFailure) => Effect.Effect; + readonly onMigrationProgress?: ( + progress: DatabaseMigrationProgress | null, + ) => Effect.Effect; } interface ActiveBackendRun { @@ -893,6 +900,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ); if (isCurrentRun) { + yield* spec.onMigrationProgress?.(null) ?? Effect.void; yield* desktopTelemetryPublisher.removeControlSource(spec.id); if (Option.isSome(pid)) { if (exitObserved && !stopRequested) { @@ -936,6 +944,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( pid: Option.some(pid), })); yield* Ref.set(outputTail, ""); + yield* spec.onMigrationProgress?.(null) ?? Effect.void; outputDecoders = makeOutputDecoders(); yield* backendOutputLog.beginSession({ details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, @@ -966,6 +975,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } + yield* spec.onMigrationProgress?.(null) ?? Effect.void; yield* spec.onReady?.(config.value.httpBaseUrl, pid) ?? Effect.void; if ( config.value.runningDistro !== undefined && @@ -995,12 +1005,28 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }, ), onOutput: (streamName, chunk) => - Ref.update(outputTail, (tail) => + Ref.updateAndGet(outputTail, (tail) => // A cut through a surrogate pair would make encodeURIComponent throw when rendering. (tail + outputDecoders[streamName].decode(chunk, { stream: true })) .slice(-STARTUP_FAILURE_OUTPUT_CHARS) .toWellFormed(), - ).pipe(Effect.andThen(backendOutputLog.writeOutputChunk(streamName, chunk))), + ).pipe( + Effect.flatMap((tail) => + Effect.gen(function* () { + const latest = yield* SubscriptionRef.get(state); + if ( + !latest.ready && + Option.isSome(latest.active) && + latest.active.value.id === runId + ) { + yield* ( + spec.onMigrationProgress?.(databaseMigrationProgress(tail)) ?? Effect.void + ); + } + }), + ), + Effect.andThen(backendOutputLog.writeOutputChunk(streamName, chunk)), + ), }).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index d69e1ce1427fe..8e1ba3214e0d7 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -6,6 +6,7 @@ import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; import * as Exit from "effect/Exit"; import * as FiberHandle from "effect/FiberHandle"; import * as Layer from "effect/Layer"; @@ -124,7 +125,7 @@ export const layer = Layer.effect( const electronDialog = yield* ElectronDialog.ElectronDialog; const electronApp = yield* ElectronApp.ElectronApp; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; - const { primaryBackendStartup } = yield* DesktopState.DesktopState; + const { primaryBackendStartup, databaseMigrationProgress } = yield* DesktopState.DesktopState; // Parent instance scopes to the pool so shutdown runs stop finalizers before the OS kills backends. const layerScope = yield* Scope.Scope; const factoryContext = yield* Effect.context(); @@ -226,6 +227,7 @@ export const layer = Layer.effect( ), onStartupFailure: (failure) => SubscriptionRef.set(primaryBackendStartup, { _tag: "Failing", ...failure }), + onMigrationProgress: (progress) => Ref.set(databaseMigrationProgress, progress), onShutdown: () => FiberHandle.clear(updateMarkerWait).pipe( Effect.andThen(desktopWindow.handleBackendNotReady), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 57d7101cf9ec7..e4c1ed852d87f 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -35,6 +35,7 @@ import { } from "./methods/updates.ts"; import { getAppBranding, + getDatabaseMigrationProgress, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, installRelayTelemetryEnabled, @@ -98,6 +99,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(takeLegacyLocalStorage); yield* ipc.handle(completeLegacyLocalStorage); yield* ipc.handleSync(getAppBranding); + yield* ipc.handle(getDatabaseMigrationProgress); yield* installRelayTelemetryEnabled(); yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getPreferredSystemLanguages); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index b7bb5e579572f..973452a009596 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -35,6 +35,7 @@ export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install"; export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const CLI_COMMAND_ADD_TO_PATH_CHANNEL = "desktop:cli-command-add-to-path"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_DATABASE_MIGRATION_PROGRESS_CHANNEL = "desktop:get-database-migration-progress"; export const GET_RELAY_TELEMETRY_ENABLED_CHANNEL = "desktop:get-relay-telemetry-enabled"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_PREFERRED_SYSTEM_LANGUAGES_CHANNEL = "desktop:get-preferred-system-languages"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index cee92e1ead38c..3b858c2844960 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -2,6 +2,7 @@ import { ContextMenuItemSchema, ContextMenuSource, DesktopAppBrandingSchema, + DatabaseMigrationProgress, DesktopEnvironmentBootstrapSchema, DesktopMediaReadSchema, DesktopThemeSchema, @@ -24,6 +25,8 @@ import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Ref from "effect/Ref"; +import * as DesktopState from "../../app/DesktopState.ts"; import * as DesktopBackendConfiguration from "../../backend/DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; @@ -69,6 +72,16 @@ function toWebSocketBaseUrl(httpBaseUrl: URL): string { return url.href; } +export const getDatabaseMigrationProgress = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_DATABASE_MIGRATION_PROGRESS_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(DatabaseMigrationProgress), + handler: Effect.fn("desktop.ipc.window.getDatabaseMigrationProgress")(function* () { + const state = yield* DesktopState.DesktopState; + return yield* Ref.get(state.databaseMigrationProgress); + }), +}); + export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_APP_BRANDING_CHANNEL, result: Schema.NullOr(DesktopAppBrandingSchema), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e0ac5f2161051..9c517169d3637 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -104,6 +104,8 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { } contextBridge.exposeInMainWorld("desktopBridge", { + getDatabaseMigrationProgress: () => + ipcRenderer.invoke(IpcChannels.GET_DATABASE_MIGRATION_PROGRESS_CHANNEL), getAppBranding: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); if (typeof result !== "object" || result === null) { diff --git a/apps/server/src/httpResponseErrorGuard.test.ts b/apps/server/src/httpResponseErrorGuard.test.ts index 7227e03dade0a..6ed044f6dba90 100644 --- a/apps/server/src/httpResponseErrorGuard.test.ts +++ b/apps/server/src/httpResponseErrorGuard.test.ts @@ -27,7 +27,12 @@ function fetchResponse( port: number, path: string, upgrade = false, -): Promise { + accept?: string, +): Promise<{ + statusCode: number | undefined; + headers: NodeHttp.IncomingHttpHeaders; + body: string; +}> { return new Promise((resolve, reject) => { const request = NodeHttp.get( { @@ -35,26 +40,35 @@ function fetchResponse( port, path, agent: false, - headers: upgrade - ? { - connection: "Upgrade", - upgrade: "websocket", - "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", - "sec-websocket-version": "13", - } - : {}, + headers: { + ...(accept ? { accept } : {}), + ...(upgrade + ? { + connection: "Upgrade", + upgrade: "websocket", + "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", + "sec-websocket-version": "13", + } + : {}), + }, }, (response) => { response.on("error", reject); - response.on("end", () => resolve(response)); - response.resume(); + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + body += chunk; + }); + response.on("end", () => + resolve({ statusCode: response.statusCode, headers: response.headers, body }), + ); }, ); requests.push(request); request.on("error", reject); request.on("upgrade", (response, socket) => { socket.destroy(); - resolve(response); + resolve({ statusCode: response.statusCode, headers: response.headers, body: "" }); }); }); } @@ -205,8 +219,10 @@ describe("keepIdleConnections", () => { describe("rejectRequestsUntilServing", () => { it("rejects early HTTP and upgrades, then hands each off to its serving handler", async () => { const writeErrors: unknown[] = []; + let progress: { current: number; total: number } | null = { current: 2, total: 7 }; const server = HttpResponseErrorGuard.rejectRequestsUntilServing( guardHttpResponseWriteErrors(NodeHttp.createServer(), (error) => writeErrors.push(error)), + () => progress, ); const port = await listen(server); @@ -214,6 +230,26 @@ describe("rejectRequestsUntilServing", () => { expect(earlyHttp.statusCode).toBe(503); expect(earlyHttp.headers["retry-after"]).toBe("1"); + const startup = await fetchResponse( + port, + "/?name=", + false, + "text/html", + ); + const startupHtml = startup.body; + expect(startupHtml).toContain("Upgrading database (2 of 7)"); + expect(startupHtml).not.toContain(" diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 8f5258596c5f5..be28cbbb15631 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import type { DatabaseMigrationProgress } from "./server.ts"; import type { PreviewCaptureFailureEncoded } from "./previewCapture.ts"; import { SnapShotSource } from "./chatAttachment.ts"; @@ -1052,6 +1053,7 @@ export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + getDatabaseMigrationProgress?: () => Promise; getRelayTelemetryEnabled: () => boolean; getPathForFile: (file: File) => string; getClientPlatform: () => string; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index d859ecb91ffa4..3031e9937550f 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -983,6 +983,18 @@ export class ServerProviderUpdateError extends Schema.TaggedError { + it.each([ + ["", null], + [START, null], + [`${STEP} (2 of 7)\n progress: 2 of 7\n`, { current: 2, total: 7 }], + [`${STEP} (2 of 7`, null], + [`${STEP} (8 of 7)\n`, null], + [`${STEP} progress="1 of 7"\n${STEP} progress="2 of 7"`, { current: 2, total: 7 }], + [`${STEP} migration= progress="2 of 7"`, { current: 2, total: 7 }], + [`{"message":"${STEP}","progress":"3 of 7"}`, { current: 3, total: 7 }], + [`${STEP} progress="2 of 7`, null], + [`${STEP} progress="0 of 7"`, null], + [`${STEP} progress="8 of 7"`, null], + [`${STEP} progress="1 of 99999999999999999999"`, null], + [`${STEP} progress="2 of 7"\n${FINISH}`, null], + [`${STEP} progress="2 of 7"\n${START}`, null], + [`${FINISH}\n${START}\n${STEP} progress="1 of 5"`, { current: 1, total: 5 }], + ])("reads the current complete progress from %s", (output, expected) => { + expect(databaseMigrationProgress(output)).toEqual(expected); + }); +}); diff --git a/packages/shared/src/databaseMigrationLog.ts b/packages/shared/src/databaseMigrationLog.ts index 7adc4e4a6c84c..12d953b9828b5 100644 --- a/packages/shared/src/databaseMigrationLog.ts +++ b/packages/shared/src/databaseMigrationLog.ts @@ -1,5 +1,7 @@ // The server logs these around pending migrations; the desktop reads them from its output tail, // which keeps only the last few thousand characters, so each progress line also marks a running upgrade. +import type { DatabaseMigrationProgress } from "@t3tools/contracts"; + export const DATABASE_MIGRATIONS_STARTED_MESSAGE = "Running database migrations"; export const DATABASE_MIGRATION_PROGRESS_MESSAGE = "Upgrading database"; export const DATABASE_MIGRATIONS_FINISHED_MESSAGE = "Migrations ran successfully"; @@ -12,3 +14,23 @@ export function isRunningDatabaseMigrations(output: string): boolean { ) > output.lastIndexOf(DATABASE_MIGRATIONS_FINISHED_MESSAGE) ); } + +export function databaseMigrationProgress(output: string): DatabaseMigrationProgress | null { + if (!isRunningDatabaseMigrations(output)) return null; + const start = output.lastIndexOf(DATABASE_MIGRATIONS_STARTED_MESSAGE); + const line = output.slice( + Math.max(start, output.lastIndexOf(DATABASE_MIGRATION_PROGRESS_MESSAGE)), + ); + const match = + /^Upgrading database \((\d+) of (\d+)\)/.exec(line) ?? + /(?:progress=|"progress"\s*:\s*)"(\d+) of (\d+)"/.exec(line); + if (!match) return null; + const current = Number(match[1]); + const total = Number(match[2]); + return Number.isSafeInteger(current) && + Number.isSafeInteger(total) && + current > 0 && + current <= total + ? { current, total } + : null; +} From dff60ec66739f42de5f8add401da1b7ef1af0a77 Mon Sep 17 00:00:00 2001 From: perry gupta Date: Sun, 15 Nov 2026 18:25:23 +0530 Subject: [PATCH 2/2] fix(desktop): show migration progress in WSL splash --- apps/desktop/src/window/DesktopWindow.test.ts | 28 ++++++++++++- apps/desktop/src/window/DesktopWindow.ts | 40 ++++++++++++++++--- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index f66586503e24d..892fcb99c6706 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -114,7 +114,7 @@ function makeFakeBrowserWindow() { isMaximized: vi.fn(() => false), isMinimized: vi.fn(() => false), isVisible: vi.fn(() => true), - loadURL: vi.fn(() => Promise.resolve()), + loadURL: vi.fn<(url: string) => Promise>(() => Promise.resolve()), maximize: vi.fn(), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { windowListeners.set(eventName, listener); @@ -484,6 +484,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n }), ), ), + Layer.merge(DesktopState.layer), ); return { layer, createCalls, mainWindow, revealedWindows } as const; @@ -1559,9 +1560,21 @@ describe("DesktopWindow", () => { yield* Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; + const state = yield* DesktopState.DesktopState; + const splashHtml = () => + decodeURIComponent(String(splash.loadURL.mock.calls.at(-1)?.[0]).split(",")[1] ?? ""); yield* desktopWindow.showConnectingSplash; assert.equal(yield* Ref.get(scenario.createCalls), 1); + yield* TestClock.adjust("1 second"); + assert.include(splashHtml(), "Connecting to WSL…"); + const initialLoads = splash.loadURL.mock.calls.length; + yield* TestClock.adjust("1 second"); + assert.equal(splash.loadURL.mock.calls.length, initialLoads); + + yield* Ref.set(state.databaseMigrationProgress, { current: 2, total: 7 }); + yield* TestClock.adjust("1 second"); + assert.include(splashHtml(), "Upgrading database (2 of 7)"); const readyExit = yield* Effect.exit( desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")), @@ -1570,11 +1583,24 @@ describe("DesktopWindow", () => { assert.equal(yield* Ref.get(scenario.createCalls), 2); assert.isTrue(Option.isNone(yield* Ref.get(scenario.mainWindow))); + yield* Ref.set(state.databaseMigrationProgress, { current: 3, total: 7 }); + yield* TestClock.adjust("1 second"); + assert.include(splashHtml(), "Upgrading database (3 of 7)"); + yield* Ref.set(state.databaseMigrationProgress, null); + yield* TestClock.adjust("1 second"); + assert.include(splashHtml(), "Connecting to WSL…"); + assert.notInclude(splashHtml(), "Upgrading database"); + yield* desktopWindow.activate; assert.equal(yield* Ref.get(scenario.createCalls), 3); const registeredMain = yield* Ref.get(scenario.mainWindow); assert.isTrue(Option.isSome(registeredMain)); assert.equal(Option.getOrThrow(registeredMain), main.window); + splash.windowListeners.get("closed")?.(); + const loadsAtClose = splash.loadURL.mock.calls.length; + yield* Ref.set(state.databaseMigrationProgress, { current: 4, total: 7 }); + yield* TestClock.adjust("2 seconds"); + assert.equal(splash.loadURL.mock.calls.length, loadsAtClose); }).pipe(Effect.provide(scenario.layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 7be14bc720838..bcfc42634202c 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -6,11 +6,16 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Schedule from "effect/Schedule"; import * as Semaphore from "effect/Semaphore"; import * as Electron from "electron"; -import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; +import { + type DatabaseMigrationProgress, + type DesktopSnapShotEvent, + DEFAULT_CLIENT_SETTINGS, +} from "@t3tools/contracts"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -260,12 +265,18 @@ export function resolveInitialMainWindowBounds( return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; } -function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { +function buildConnectingSplashDataUrl( + shouldUseDarkColors: boolean, + progress: DatabaseMigrationProgress | null, +): string { const background = getInitialWindowBackgroundColor(shouldUseDarkColors); const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937"; const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; - const html = `
Connecting to WSL…
`; + const message = progress + ? `Upgrading database (${progress.current} of ${progress.total})` + : "Connecting to WSL…"; + const html = `
${message}
`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } @@ -1082,15 +1093,34 @@ export const make = Effect.gen(function* () { } yield* rendererHistory.register(splash.webContents, { surface: "splash" }); yield* Ref.set(splashWindowRef, Option.some(splash)); + let renderedUrl: string | undefined; + const progressFiber = runFork( + Effect.gen(function* () { + if (splash.isDestroyed() || (yield* Ref.get(desktopState.quitting))) return; + const progress = yield* Ref.get(desktopState.databaseMigrationProgress); + const url = buildConnectingSplashDataUrl(shouldUseDarkColors, progress); + if (url === renderedUrl) return; + const loaded = yield* Effect.promise(() => + splash.loadURL(url).then( + () => true, + () => false, + ), + ); + if (loaded) renderedUrl = url; + }).pipe(Effect.repeat(Schedule.spaced("1 second"))), + ); splash.once("closed", () => { - void runPromise(Ref.set(splashWindowRef, Option.none())); + runFork( + Fiber.interrupt(progressFiber).pipe( + Effect.andThen(Ref.set(splashWindowRef, Option.none())), + ), + ); }); splash.once("ready-to-show", () => { if (!splash.isDestroyed()) { splash.show(); } }); - void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); yield* logWindowInfo("connecting splash shown"); }).pipe( Effect.catch((error) =>