diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 497b315ef5225..8d1cfb05ccd29 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -9,6 +9,7 @@ import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; @@ -583,8 +584,8 @@ describe("DesktopWindow", () => { }), ); - it.effect.each(["before", "after"] as const)( - "keeps a closed startup window closed until activation %s backend readiness", + it.effect.each(["before", "after", "after a restart of"] as const)( + "keeps a closed main window closed until activation %s backend readiness", (activation) => Effect.gen(function* () { const fake = makeFakeBrowserWindow(); @@ -594,10 +595,14 @@ describe("DesktopWindow", () => { const windows = yield* DesktopWindow.DesktopWindow; yield* windows.ensureMain; fake.windowListeners.get("ready-to-show")?.(); + if (activation === "after a restart of") { + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + } fake.isDestroyed.mockReturnValue(true); fake.windowListeners.get("closed")?.(); assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); - if (activation === "after") { + if (activation === "after a restart of") yield* windows.handleBackendNotReady; + if (activation !== "before") { yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); assert.equal(yield* Ref.get(createCount), 1); } @@ -610,6 +615,76 @@ describe("DesktopWindow", () => { }), ); + it.effect.each([ + ["a revealing menu action", { reveal: true }, 2], + ["a non-revealing menu action", { reveal: false }, 1], + ] as const)( + "after %s during a restart, a closed main window follows the backend", + ([, options, expectedCreates]) => + 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; + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + fake.isDestroyed.mockReturnValue(true); + fake.windowListeners.get("closed")?.(); + yield* windows.handleBackendNotReady; + yield* windows.dispatchMenuAction("open-settings", options); + assert.equal(yield* Ref.get(createCount), 1); + fake.isDestroyed.mockReturnValue(false); + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), expectedCreates); + }).pipe(Effect.provide(layerTest({ window: fake.window, createCount, mainWindow }))); + }), + ); + + it.effect("a Dock click after a revealing menu action during a restart opens the window", () => + 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; + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + fake.isDestroyed.mockReturnValue(true); + fake.windowListeners.get("closed")?.(); + yield* windows.handleBackendNotReady; + yield* windows.dispatchMenuAction("open-settings", { reveal: true }); + assert.equal(yield* Ref.get(createCount), 1); + fake.isDestroyed.mockReturnValue(false); + yield* windows.activate; + assert.equal(yield* Ref.get(createCount), 2); + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 2); + }).pipe(Effect.provide(layerTest({ window: fake.window, createCount, mainWindow }))); + }), + ); + + it.effect("reopens a closed main window when a restart recovers from a startup failure", () => + 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* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + fake.windowListeners.get("closed")?.(); + yield* windows.handleBackendNotReady; + yield* SubscriptionRef.set(state.primaryBackendStartup, { + _tag: "Failing", + reason: "readiness timed out", + managedPolicy: false, + output: "", + }); + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 2); + }).pipe(Effect.provide(layerTest({ window: fake.window, createCount, mainWindow }))); + }), + ); + it.effect.each(["renderer", "backend"] as const)( "reveals the painted boot shell when %s becomes ready first", (first) => diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 43404e2d6cfb9..6c5a7f3faaab0 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -393,8 +394,11 @@ export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; const backendReadyRef = yield* Ref.make(false); - let backendHasStarted = false; - let mainClosedDuringStartup = false; + // Backend readiness repeats after every restart, and on macOS the app outlives its last + // window, so a window the user closed stays closed until they activate the app. + let mainClosed = false; + // A revealing request that arrived while the backend was down opens the window once it is back. + let revealWhenReady = false; const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); @@ -998,7 +1002,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { - if (!backendHasStarted) mainClosedDuringStartup = true; + mainClosed = true; clearDevelopmentLoadRetry(); clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); @@ -1010,7 +1014,8 @@ export const make = Effect.gen(function* () { const createMain = Effect.gen(function* () { const window = yield* createWindow(); if (Option.isSome(window)) { - mainClosedDuringStartup = false; + mainClosed = false; + revealWhenReady = false; yield* logWindowInfo("main window created"); } return window; @@ -1106,7 +1111,10 @@ export const make = Effect.gen(function* () { ) { if (yield* Ref.get(desktopState.quitting)) return; const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; - if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return; + if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) { + if (reveal) revealWhenReady = true; + return; + } const window = Option.isSome(existingWindow) ? existingWindow : yield* ensureMain; if (Option.isNone(window) || window.value.isDestroyed()) return; const targetWindow = window.value; @@ -1141,7 +1149,7 @@ export const make = Effect.gen(function* () { yield* revealMain(existingWindow.value); return; } - if (mainClosedDuringStartup) { + if (mainClosed) { yield* ensureMain; return; } @@ -1158,10 +1166,12 @@ export const make = Effect.gen(function* () { createMainIfBackendReady, showConnectingSplash, handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { - backendHasStarted = true; yield* Ref.set(backendReadyRef, true); yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); - if (!mainClosedDuringStartup) yield* createMainIfBackendReady; + // A startup failure surfaced the recovery window, which closes only into a main window. + const recovering = + (yield* SubscriptionRef.get(desktopState.primaryBackendStartup))._tag === "Failing"; + if (!mainClosed || revealWhenReady || recovering) yield* createMainIfBackendReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"),