diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index f059d672b1f2..5116e7905f96 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2789,6 +2789,77 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("keeps frame capture closed when a replacement is destroyed during cleanup", () => + withManager((manager) => + Effect.gen(function* () { + const windowEvents = new Map void>(); + const host = makeTestHostWebContents(); + const capturePage = vi.fn(async () => ({ + toJPEG: () => Buffer.from("recording-frame"), + getSize: () => ({ width: 1280, height: 720 }), + })); + const guest = makeTestPreviewWebContents(capturePage, 42, host); + fromId.mockReturnValue(guest); + yield* manager.createTab("tab_shutdown"); + yield* manager.registerWebview("tab_shutdown", 42); + yield* manager.setMainWindow({ + isDestroyed: () => false, + once: (event: string, listener: () => void) => windowEvents.set(event, listener), + webContents: { setBackgroundThrottling: vi.fn() }, + } as never); + yield* manager.startRecording("tab_shutdown"); + const pip = makeTestPictureInPictureWindow(); + browserWindowConstructor.mockImplementation(function () { + return pip.pictureInPictureWindow; + }); + yield* manager.openPictureInPicture("tab_shutdown"); + const capturesBeforeClose = capturePage.mock.calls.length; + const cleanupStarted = yield* Deferred.make(); + const releaseCleanup = yield* Deferred.make(); + yield* manager.subscribeStateChanges((_tabId, state) => + state.pictureInPicture + ? Effect.void + : Deferred.succeed(cleanupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCleanup)), + ), + ); + + windowEvents.get("closed")!(); + yield* Deferred.await(cleanupStarted); + let destroyed = false; + const replacement = yield* manager + .setMainWindow({ + isDestroyed: () => destroyed, + once: vi.fn(), + webContents: { setBackgroundThrottling: vi.fn() }, + } as never) + .pipe(Effect.forkChild({ startImmediately: true })); + try { + expect(replacement.pollUnsafe()).toBeUndefined(); + destroyed = true; + } finally { + yield* Deferred.succeed(releaseCleanup, undefined); + } + yield* Fiber.join(replacement); + + const restart = yield* Effect.exit(manager.startRecording("tab_shutdown")); + expect(Exit.isFailure(restart)).toBe(true); + if (Exit.isFailure(restart)) { + expect(Option.getOrThrow(Cause.findErrorOption(restart.cause))).toMatchObject({ + _tag: "PreviewMainWindowClosedError", + tabId: "tab_shutdown", + }); + } + expect(capturePage).toHaveBeenCalledTimes(capturesBeforeClose); + expect(pip.pictureInPictureWindow.isDestroyed()).toBe(true); + expect(guest.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]); + const grants: Array<{ video?: unknown }> = []; + host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => grants.push(value)); + expect(grants).toEqual([{}]); + }), + ), + ); + effectIt.effect("ignores close events from replaced main windows", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 56b9d960369c..4f76212d84ce 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1648,6 +1648,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => Effect.gen(function* () { + // Cleanup or lock acquisition can outlive the replacement window. + if (window.isDestroyed()) return [undefined, sessions] as const; if (sessions.size > 0) { yield* setWindowBackgroundThrottling(window, false); } diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cebdcbb77972..ab59f63e1ad7 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import { DesktopSnapShotId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -227,6 +228,9 @@ function layerTest(input: { readonly beforeMainWindowBoundsUpdate?: ( bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; + readonly setPreviewMainWindow?: (window: Electron.BrowserWindow) => Effect.Effect; + readonly destroyAll?: Effect.Effect; + readonly onWindowCreate?: Effect.Effect; readonly openedExternalUrls?: unknown[]; readonly copiedTexts?: string[]; readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect; @@ -275,6 +279,7 @@ function layerTest(input: { input.createdWindowOptions?.push(options); }).pipe( Effect.andThen(Ref.update(input.createCount, (count) => count + 1)), + Effect.andThen(input.onWindowCreate ?? Effect.void), Effect.as(input.window), ), main: Ref.get(input.mainWindow), @@ -288,7 +293,7 @@ function layerTest(input: { prepareReveal: () => Effect.succeed(false), reveal: (window) => Effect.sync(() => input.onReveal?.(window)), sendAll: () => Effect.void, - destroyAll: Effect.void, + destroyAll: input.destroyAll ?? Effect.void, syncAllAppearance: (sync) => sync(input.window), } satisfies ElectronWindow.ElectronWindow["Service"]); @@ -333,7 +338,7 @@ function layerTest(input: { }), Layer.mock(PreviewManager.PreviewManager)({ getBrowserSession: () => Effect.succeed({} as Electron.Session), - setMainWindow: () => Effect.void, + setMainWindow: input.setPreviewMainWindow ?? (() => Effect.void), prepareWebview: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"), getBrowserPartition: () => Effect.succeed("persist:t3code-preview-test"), @@ -344,6 +349,7 @@ function layerTest(input: { }), ), ), + Layer.merge(DesktopState.layer), ); } @@ -425,6 +431,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n DesktopAppSettings.layerTest(), layerDesktopClientSettings, layerDesktopServerExposure, + DesktopState.layer, layerElectronApp, layerElectronMenu, Layer.succeed(ElectronShell.ElectronShell, { @@ -924,6 +931,140 @@ describe("DesktopWindow", () => { }), ); + it.effect("does not open main or splash windows once quitting has started", () => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + const state = yield* DesktopState.DesktopState; + yield* Ref.set(state.quitting, true); + + yield* windows.showConnectingSplash; + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + yield* windows.activate; + yield* windows.dispatchMenuAction("open-settings"); + for (const creation of [ + windows.createMain, + windows.ensureMain, + windows.revealOrCreateMain, + ]) { + assert.isTrue(Exit.isFailure(yield* Effect.exit(creation))); + } + + assert.equal(yield* Ref.get(createCount), 0); + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + assert.equal(fake.send.mock.calls.length, 0); + }).pipe(Effect.provide(layerTest({ window: fake.window, createCount, mainWindow }))); + }), + ); + + it.effect.each(["main", "splash"] as const)( + "destroys a %s window when quit races native creation without loading or revealing it", + (kind) => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const creationStarted = yield* Deferred.make(); + const releaseCreation = yield* Deferred.make(); + const reveal = vi.fn(); + const layer = layerTest({ + window: fake.window, + createCount, + mainWindow, + onWindowCreate: Deferred.succeed(creationStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseCreation)), + ), + destroyAll: Effect.sync(() => fake.isDestroyed.mockReturnValue(true)), + onReveal: reveal, + }); + + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + const state = yield* DesktopState.DesktopState; + const fiber = yield* ( + kind === "splash" + ? windows.showConnectingSplash + : windows.handleBackendReady(new URL("http://127.0.0.1:3773")) + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(creationStarted); + yield* Ref.set(state.quitting, true); + yield* Deferred.succeed(releaseCreation, undefined); + assert.isTrue(Exit.isSuccess(yield* Fiber.await(fiber))); + assert.equal(yield* Ref.get(createCount), 1); + assert.isTrue(fake.window.isDestroyed()); + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + assert.equal(fake.loadURL.mock.calls.length, 0); + assert.equal(fake.openDevTools.mock.calls.length, 0); + assert.equal(reveal.mock.calls.length, 0); + }).pipe(Effect.provide(layer)); + }), + ); + + it.effect.each(["backend ready", "activation", "renderer event"] as const)( + "quietly cancels %s when quit races preview cleanup", + (entry) => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const previewStarted = yield* Deferred.make(); + const releasePreview = yield* Deferred.make(); + const reveal = vi.fn(); + const warnings: unknown[] = []; + const logger = Logger.make(({ logLevel, message }) => { + if (logLevel === "Warn" || logLevel === "Error") warnings.push(message); + }); + const layer = layerTest({ + window: fake.window, + createCount, + mainWindow, + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }, + setPreviewMainWindow: () => + Deferred.succeed(previewStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePreview)), + ), + destroyAll: Effect.sync(() => fake.isDestroyed.mockReturnValue(true)), + onReveal: reveal, + }); + + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + const state = yield* DesktopState.DesktopState; + const creation = + entry === "backend ready" + ? windows.handleBackendReady(new URL("http://127.0.0.1:3773")) + : entry === "activation" + ? windows.activate + : windows.dispatchMenuAction("open-settings"); + const fiber = yield* creation.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(previewStarted); + yield* Ref.set(state.quitting, true); + // Cover both an already-destroyed window and one shutdown has yet to destroy. + fake.isDestroyed.mockReturnValue(entry !== "backend ready"); + yield* Deferred.succeed(releasePreview, undefined); + + assert.isTrue(Exit.isSuccess(yield* Fiber.await(fiber))); + assert.isTrue(fake.window.isDestroyed()); + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + assert.equal(fake.loadURL.mock.calls.length, 0); + assert.equal(fake.openDevTools.mock.calls.length, 0); + assert.equal(fake.send.mock.calls.length, 0); + assert.equal(reveal.mock.calls.length, 0); + assert.deepEqual(warnings, []); + }).pipe( + Effect.provide( + Layer.mergeAll(layer, Logger.layer([logger], { mergeWithExisting: false })), + ), + ); + }), + ); + it.effect("restores the persisted maximized state", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 1d76107425ed..3926cda14ece 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -13,6 +13,7 @@ import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/con import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; +import * as DesktopState from "../app/DesktopState.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; @@ -69,6 +70,7 @@ type WindowTitleBarOptions = Pick< type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment + | DesktopState.DesktopState | DesktopAssets.DesktopAssets | DesktopAppSettings.DesktopAppSettings | DesktopClientSettings.DesktopClientSettings @@ -287,6 +289,7 @@ function bindFirstRevealTrigger( /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; + const desktopState = yield* DesktopState.DesktopState; const assets = yield* DesktopAssets.DesktopAssets; const electronMenu = yield* ElectronMenu.ElectronMenu; const electronShell = yield* ElectronShell.ElectronShell; @@ -345,9 +348,10 @@ export const make = Effect.gen(function* () { }); const createWindow = Effect.fn("desktop.window.createWindow")(function* (): Effect.fn.Return< - Electron.BrowserWindow, + Option.Option, DesktopWindowError > { + if (yield* Ref.get(desktopState.quitting)) return Option.none(); yield* previewManager.getBrowserSession(); const applicationUrl = getDesktopUrl(environment.isDevelopment); const iconPaths = yield* assets.iconPaths; @@ -376,6 +380,7 @@ export const make = Effect.gen(function* () { if (persistedBounds !== null && initialBounds === DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE) { yield* logWindowWarning("saved main window bounds could not be restored; using defaults"); } + if (yield* Ref.get(desktopState.quitting)) return Option.none(); const window = yield* electronWindow.create({ ...initialBounds, minWidth: 840, @@ -404,6 +409,10 @@ export const make = Effect.gen(function* () { disableHtmlFullscreenWindowResize: true, }, }); + if (yield* Ref.get(desktopState.quitting)) { + yield* electronWindow.destroyAll; + return Option.none(); + } yield* rendererHistory.register(window.webContents, { surface: "main" }); if (environment.platform === "darwin") { @@ -497,6 +506,10 @@ export const make = Effect.gen(function* () { flushMainWindowBounds = flushBoundsPersist; yield* previewManager.setMainWindow(window); + if (yield* Ref.get(desktopState.quitting)) { + yield* electronWindow.destroyAll; + return Option.none(); + } window.webContents.on("will-attach-webview", (event, webPreferences, params) => { if ( typeof params.partition !== "string" || @@ -835,26 +848,35 @@ export const make = Effect.gen(function* () { void runPromise(electronWindow.clearMain(Option.some(window))); }); - return window; + return Option.some(window); }); const createMain = Effect.gen(function* () { const window = yield* createWindow(); - yield* electronWindow.setMain(window); - yield* logWindowInfo("main window created"); + if (Option.isSome(window)) { + yield* electronWindow.setMain(window.value); + yield* logWindowInfo("main window created"); + } return window; }).pipe(Effect.withSpan("desktop.window.createMain")); const ensureMain = Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) return Option.none(); const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { - return existingWindow.value; + return existingWindow; } return yield* createMain; }).pipe(Effect.withSpan("desktop.window.ensureMain")); + const requireWindow = (window: Option.Option) => + Option.match(window, { + onNone: () => Effect.interrupt, + onSome: Effect.succeed, + }); + const revealOrCreateMain = Effect.gen(function* () { - const window = yield* ensureMain; + const window = yield* ensureMain.pipe(Effect.flatMap(requireWindow)); yield* electronWindow.reveal(window); return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); @@ -865,6 +887,7 @@ export const make = Effect.gen(function* () { }); const createMainIfBackendReady = Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) return; if (yield* waitingForBackend) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) return; @@ -872,12 +895,14 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); const showConnectingSplash = Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) return; const existingSplash = yield* Ref.get(splashWindowRef); if (Option.isSome(existingSplash)) return; const existingWindow = yield* electronWindow.currentMainOrFirst; if (Option.isSome(existingWindow)) return; const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + if (yield* Ref.get(desktopState.quitting)) return; const splash = yield* electronWindow.create({ width: 360, height: 220, @@ -897,6 +922,10 @@ export const make = Effect.gen(function* () { sandbox: true, }, }); + if (yield* Ref.get(desktopState.quitting)) { + yield* electronWindow.destroyAll; + return; + } yield* rendererHistory.register(splash.webContents, { surface: "splash" }); yield* Ref.set(splashWindowRef, Option.some(splash)); splash.once("closed", () => { @@ -921,10 +950,12 @@ export const make = Effect.gen(function* () { payload: unknown, { reveal = true }: { readonly reveal?: boolean } = {}, ) { + if (yield* Ref.get(desktopState.quitting)) return; const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return; - const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; - if (targetWindow.isDestroyed()) return; + const window = Option.isSome(existingWindow) ? existingWindow : yield* ensureMain; + if (Option.isNone(window) || window.value.isDestroyed()) return; + const targetWindow = window.value; const send = Effect.sync(() => { if (!targetWindow.isDestroyed()) targetWindow.webContents.send(channel, payload); }); @@ -941,8 +972,8 @@ export const make = Effect.gen(function* () { }); return DesktopWindow.of({ - createMain, - ensureMain, + createMain: createMain.pipe(Effect.flatMap(requireWindow)), + ensureMain: ensureMain.pipe(Effect.flatMap(requireWindow)), revealOrCreateMain, prepareCaptureReveal: Effect.gen(function* () { const existingWindow = yield* currentMainWindow; @@ -951,6 +982,7 @@ export const make = Effect.gen(function* () { } }), activate: Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { yield* electronWindow.reveal(existingWindow.value);