diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d49d2cdebb701..3442a54d60c4a 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -245,6 +245,16 @@ const bootstrap = Effect.gen(function* () { } yield* primaryBackend.start; yield* logBootstrapInfo("bootstrap backend start requested"); + // WSL-only fallback can change the primary endpoint before readiness. + const config = yield* primaryBackend.currentConfig; + if ( + !environment.isDevelopment && + !settings.wslOnly && + Option.isSome(config) && + Option.isNone(config.value.preflightFailure) + ) { + yield* desktopWindow.ensureMain; + } yield* appActivation.start.pipe( Effect.tap(() => logBootstrapInfo("desktop app control socket ready")), Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })), diff --git a/apps/desktop/src/app/DesktopStartupRecovery.test.ts b/apps/desktop/src/app/DesktopStartupRecovery.test.ts index d8a0ef1a999cc..12d5289af67c7 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.test.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.test.ts @@ -1,8 +1,23 @@ import type { DesktopUpdateState } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopState from "./DesktopState.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as ElectronTheme from "../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import { createInitialDesktopUpdateState } from "../updates/updateMachine.ts"; -import { resolveStartupRecoveryOffer } from "./DesktopStartupRecovery.ts"; +import { listen, resolveStartupRecoveryOffer } from "./DesktopStartupRecovery.ts"; const updateState = (patch: Partial): DesktopUpdateState => ({ ...createInitialDesktopUpdateState( @@ -68,3 +83,117 @@ describe("resolveStartupRecoveryOffer", () => { }); }); }); + +it.effect.each([ + { visible: false, started: false, closeMain: false }, + { visible: true, started: false, closeMain: false }, + { visible: true, started: true, closeMain: false }, + { visible: true, started: false, closeMain: true }, +])("closes recovery safely with closeMain=$closeMain", ({ visible, started, closeMain }) => + Effect.scoped( + Effect.gen(function* () { + const checked = yield* Deferred.make(); + const remembered = yield* Deferred.make(); + const shouldRecover = !visible || !started; + const closed = Promise.withResolvers(); + const loaded: string[] = []; + const appListeners = new Map void>(); + const mainListeners = new Map void>(); + let closeCount = 0; + let mainVisible = visible; + const main = { + isDestroyed: () => false, + isVisible: () => mainVisible, + on: (event: string, listener: () => void) => mainListeners.set(event, listener), + once: (event: string, listener: () => void) => mainListeners.set(event, listener), + } as unknown as Electron.BrowserWindow; + const mainRef = yield* Ref.make(Option.some(main)); + const recovery = { + isDestroyed: () => false, + close: () => { + closeCount++; + closed.resolve(); + }, + loadURL: (url: string) => { + loaded.push(url); + return Promise.resolve(); + }, + once: () => {}, + webContents: { setWindowOpenHandler: () => {}, on: () => {} }, + } as unknown as Electron.BrowserWindow; + const state = yield* DesktopState.DesktopState; + if (started) yield* SubscriptionRef.set(state.primaryBackendStartup, { _tag: "Ready" }); + yield* listen.pipe( + Effect.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + readFileString: () => Effect.succeed("0.1.2"), + writeFileString: () => Deferred.succeed(remembered, undefined).pipe(Effect.asVoid), + }), + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + appVersion: "0.1.3", + stateDir: "/task/state", + logDir: "/task/logs", + path: { join: (...parts: string[]) => parts.join("/") }, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]), + Layer.succeed(ElectronApp.ElectronApp, { + on: (event: string, listener: (...args: readonly unknown[]) => void) => + Effect.sync(() => { + appListeners.set(event, listener); + }), + } as unknown as ElectronApp.ElectronApp["Service"]), + Layer.succeed( + ElectronShell.ElectronShell, + {} as ElectronShell.ElectronShell["Service"], + ), + Layer.succeed(ElectronTheme.ElectronTheme, { + shouldUseDarkColors: Effect.succeed(false), + } as ElectronTheme.ElectronTheme["Service"]), + Layer.succeed(ElectronWindow.ElectronWindow, { + main: Ref.get(mainRef), + create: () => Effect.succeed(recovery), + } as unknown as ElectronWindow.ElectronWindow["Service"]), + Layer.succeed(DesktopUpdates.DesktopUpdates, { + subscribe: Effect.succeed({ + latest: updateState({ status: "disabled" }), + changes: Stream.empty, + }), + check: () => Deferred.succeed(checked, undefined), + } as unknown as DesktopUpdates.DesktopUpdates["Service"]), + ), + ), + ); + appListeners.get("browser-window-created")?.({}, main); + if (started) yield* Deferred.await(remembered); + yield* SubscriptionRef.set(state.primaryBackendStartup, { + _tag: "Failing", + reason: "Server failed before readiness", + output: "Failure details", + }); + yield* Deferred.await(checked); + expect(loaded.length).toBe(shouldRecover ? 1 : 0); + if (shouldRecover) { + expect(decodeURIComponent(loaded[0]!)).toContain("Failure details"); + if (closeMain) yield* Ref.set(mainRef, Option.none()); + yield* SubscriptionRef.set(state.primaryBackendStartup, { _tag: "Ready" }); + yield* Deferred.await(remembered); + if (closeMain || !visible) { + expect(closeCount).toBe(0); + if (closeMain) { + yield* Ref.set(mainRef, Option.some(main)); + mainVisible = false; + appListeners.get("browser-window-created")?.({}, main); + } + mainListeners.get("ready-to-show")?.(); + expect(closeCount).toBe(0); + mainVisible = true; + const show = mainListeners.get("show"); + expect(show).toBeDefined(); + show!(); + } + yield* Effect.promise(() => closed.promise); + expect(closeCount).toBe(1); + } + }), + ).pipe(Effect.provide(DesktopState.layer)), +); diff --git a/apps/desktop/src/app/DesktopStartupRecovery.ts b/apps/desktop/src/app/DesktopStartupRecovery.ts index 4d2dbcfb14409..ae94fd5b4c135 100644 --- a/apps/desktop/src/app/DesktopStartupRecovery.ts +++ b/apps/desktop/src/app/DesktopStartupRecovery.ts @@ -167,6 +167,7 @@ export const listen: Effect.Effect< const failureRef = yield* Ref.make(Option.none()); const renderedHtmlRef = yield* Ref.make(""); const checkedRef = yield* Ref.make(false); + let backendHasStarted = false; const { latest, changes: updateChanges } = yield* updates.subscribe; const updateStateRef = yield* Ref.make(latest); @@ -230,8 +231,15 @@ export const listen: Effect.Effect< const show = Effect.fn("desktop.startupRecovery.show")( function* () { if (yield* Ref.get(quitting)) return; - // A backend that crashes after the app opened is reported by the app itself. - if (Option.isSome(yield* electronWindow.main)) return; + // A visible boot shell still needs recovery if the backend has never started. + const main = yield* electronWindow.main; + if ( + backendHasStarted && + Option.isSome(main) && + !main.value.isDestroyed() && + main.value.isVisible() + ) + return; const existing = yield* Ref.get(windowRef); if (Option.isSome(existing) && !existing.value.isDestroyed()) { yield* electronWindow.reveal(existing.value); @@ -285,6 +293,19 @@ export const listen: Effect.Effect< if (Option.isSome(window) && !window.value.isDestroyed()) window.value.close(); }); + const closeWhenMainReady = Effect.gen(function* () { + if ((yield* SubscriptionRef.get(primaryBackendStartup))._tag !== "Ready") return; + const main = yield* electronWindow.main; + if (Option.isSome(main) && !main.value.isDestroyed() && main.value.isVisible()) yield* close; + }); + + yield* electronApp.on( + "browser-window-created", + (_event: Electron.Event, window: Electron.BrowserWindow) => { + window.on("show", () => runFork(closeWhenMainReady)); + }, + ); + const rememberStartedVersion = Effect.gen(function* () { if (lastStartedVersion === environment.appVersion) return; yield* fileSystem.writeFileString(lastStartedVersionPath, environment.appVersion); @@ -303,8 +324,9 @@ export const listen: Effect.Effect< Stream.runForEach( Effect.fn("desktop.startupRecovery.onStartupChange")(function* (startup) { if (startup._tag === "Ready") { + backendHasStarted = true; yield* Ref.set(failureRef, Option.none()); - yield* close; + yield* closeWhenMainReady; yield* rememberStartedVersion; return; } diff --git a/apps/desktop/src/app/DesktopWebLinks.test.ts b/apps/desktop/src/app/DesktopWebLinks.test.ts index 9b5bc89148dea..e4259f66fb5e0 100644 --- a/apps/desktop/src/app/DesktopWebLinks.test.ts +++ b/apps/desktop/src/app/DesktopWebLinks.test.ts @@ -4,6 +4,7 @@ import * as NodeEvents from "node:events"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; import * as DesktopWebLinks from "./DesktopWebLinks.ts"; @@ -21,6 +22,11 @@ const makeWindow = () => { const reload = () => webContents.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }); const layer = DesktopWebLinks.layer.pipe( + Layer.provide( + Layer.succeed(DesktopWindow.DesktopWindow, { + activate: Effect.sync(() => void revealed.push(window)), + } as unknown as DesktopWindow.DesktopWindow["Service"]), + ), Layer.provide( Layer.succeed( ElectronWindow.ElectronWindow, diff --git a/apps/desktop/src/app/DesktopWebLinks.ts b/apps/desktop/src/app/DesktopWebLinks.ts index ce63a710f7f7e..c1932c5ce9055 100644 --- a/apps/desktop/src/app/DesktopWebLinks.ts +++ b/apps/desktop/src/app/DesktopWebLinks.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { WEB_LINK_OPEN_CHANNEL } from "../ipc/channels.ts"; @@ -24,6 +25,7 @@ export class DesktopWebLinks extends Context.Service< const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; + const desktopWindow = yield* DesktopWindow.DesktopWindow; const pending: Array = []; let listening: Electron.WebContents | null = null; let detachListening: (() => void) | null = null; @@ -43,7 +45,7 @@ const make = Effect.gen(function* () { return; } for (const url of pending.splice(0)) listening.send(WEB_LINK_OPEN_CHANNEL, url); - yield* electronWindow.reveal(window.value); + yield* desktopWindow.activate.pipe(Effect.ignore); }); const startListening = Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 220143d6cf45c..a5c12043d9aaa 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -192,6 +192,49 @@ function makeTestInstance(input: MakeInstanceInput) { } describe("DesktopBackendManager", () => { + it.effect("notifies readiness waiters on HTTP readiness and stop without clock advancement", () => + Effect.scoped( + Effect.gen(function* () { + const probe = yield* Deferred.make(); + const ready = yield* Deferred.make(); + const exit = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.acquireRelease( + Effect.succeed( + makeProcess({ + exitCode: Deferred.await(exit), + kill: () => + Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)).pipe(Effect.asVoid), + }), + ), + () => Deferred.succeed(exit, ChildProcessSpawner.ExitCode(0)), + ), + ), + ), + httpClientLayer: Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Deferred.succeed(probe, undefined).pipe( + Effect.andThen(Deferred.await(ready)), + Effect.as(HttpClientResponse.fromWeb(request, new Response("{}"))), + ), + ), + ), + }); + yield* instance.start; + yield* Deferred.await(probe); + const waiter = yield* instance.waitForReady(Duration.seconds(15)).pipe(Effect.forkChild); + yield* Deferred.succeed(ready, undefined); + assert.isTrue(yield* Fiber.join(waiter)); + yield* instance.stop(); + assert.isFalse(yield* instance.waitForReady(Duration.seconds(15))); + }), + ), + ); + it.effect("spawns the backend with fd3 bootstrap and fd4 telemetry", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 1930be355513f..aa3b4d59ef1bf 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -8,7 +8,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; -import * as Schedule from "effect/Schedule"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; @@ -629,7 +629,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; - const state = yield* Ref.make(initialState); + const state = yield* SubscriptionRef.make(initialState); const mutex = yield* Semaphore.make(1); const outputTail = yield* Ref.make(""); // One streaming decoder per stream keeps UTF-8 characters split across chunks intact. @@ -644,9 +644,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`); const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => - Ref.update(state, withActiveRun(runId, f)); + SubscriptionRef.update(state, withActiveRun(runId, f)); - const snapshot = Ref.get(state).pipe( + const snapshot = SubscriptionRef.get(state).pipe( Effect.map((current): DesktopBackendSnapshot => ({ desiredRunning: current.desiredRunning, ready: current.ready, @@ -655,10 +655,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( restartScheduled: Option.isSome(current.restartFiber), })), ); - const currentConfig = Ref.get(state).pipe(Effect.map((current) => current.config)); + const currentConfig = SubscriptionRef.get(state).pipe(Effect.map((current) => current.config)); const cancelRestart = Effect.gen(function* () { - const restartFiber = yield* Ref.modify(state, (current) => [ + const restartFiber = yield* SubscriptionRef.modify(state, (current) => [ current.restartFiber, { ...current, @@ -675,10 +675,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const start: Effect.Effect = Effect.suspend(() => mutex.withPermits(1)( Effect.gen(function* () { - const current = yield* Ref.get(state); + const current = yield* SubscriptionRef.get(state); if (Option.isSome(current.active)) { if (!current.desiredRunning) { - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: true, })); @@ -688,7 +688,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (current.ready) { yield* spec.onShutdown?.() ?? Effect.void; - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => latest.ready ? { ...latest, ready: false } : latest, ); } @@ -713,7 +713,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const resetFatalPreflightCounter = !current.desiredRunning && current.preflightFailureAttempt > 0; yield* cancelRestart; - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: true, ready: false, @@ -725,7 +725,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (Option.isSome(preflightFailure)) { const { reason, fatal, retryLimit } = preflightFailure.value; if (!fatal && retryLimit === undefined) { - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, @@ -734,7 +734,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } const attemptLimit = retryLimit ?? MAX_PREFLIGHT_FAILURE_ATTEMPTS; - const attempt = yield* Ref.modify(state, (latest) => { + const attempt = yield* SubscriptionRef.modify(state, (latest) => { const next = latest.preflightFailureAttempt + 1; return [next, { ...latest, preflightFailureAttempt: next }] as const; }); @@ -743,7 +743,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( reason, attempt, }); - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: false, ready: false, @@ -761,7 +761,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (shouldRestart) { yield* scheduleRestart(reason); } else { - yield* Ref.update(state, (latest) => ({ + yield* SubscriptionRef.update(state, (latest) => ({ ...latest, desiredRunning: false, ready: false, @@ -772,7 +772,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* scheduleRestart(reason); return; } - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, ); @@ -782,7 +782,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( } const runScope = yield* Scope.make("sequential"); - const runId = yield* Ref.modify(state, (latest) => [ + const runId = yield* SubscriptionRef.modify(state, (latest) => [ latest.nextRunId, { ...latest, @@ -806,7 +806,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( yield* mutex.withPermits(1)( Effect.gen(function* () { const { isCurrentRun, nextState, pid, exitObserved, stopRequested, wasReady } = - yield* Ref.modify( + yield* SubscriptionRef.modify( state, ( latest, @@ -873,7 +873,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (isCurrentRun && nextState.desiredRunning) { if (stateDirOwned && !stopRequested) { - yield* Ref.update(state, (latest) => ({ ...latest, desiredRunning: false })); + yield* SubscriptionRef.update(state, (latest) => ({ + ...latest, + desiredRunning: false, + })); yield* (spec.onStateDirOwned?.() ?? Effect.void).pipe(Effect.ignore); return; } @@ -907,7 +910,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( exitObserved: true, })), onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { - const isCurrentRun = yield* Ref.modify(state, (latest) => { + const isCurrentRun = yield* SubscriptionRef.modify(state, (latest) => { const activeRun = Option.getOrUndefined(latest.active); if (activeRun?.id !== runId) { return [false, latest] as const; @@ -978,7 +981,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const scheduleRestart = Effect.fn("desktop.backendInstance.scheduleRestart")(function* ( reason: string, ) { - const scheduled = yield* Ref.modify(state, (latest) => { + const scheduled = yield* SubscriptionRef.modify(state, (latest) => { if (!latest.desiredRunning || Option.isSome(latest.restartFiber)) { return [Option.none<{ delay: Duration.Duration; attempt: number }>(), latest] as const; } @@ -1006,7 +1009,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const restartFiber = yield* Effect.forkIn( Effect.sleep(delay).pipe( Effect.andThen( - Ref.modify(state, (latest) => { + SubscriptionRef.modify(state, (latest) => { const shouldRestart = latest.desiredRunning; return [ shouldRestart, @@ -1026,7 +1029,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ), parentScope, ); - yield* Ref.update(state, (latest) => + yield* SubscriptionRef.update(state, (latest) => Option.isNone(latest.restartFiber) ? { ...latest, @@ -1043,7 +1046,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }) { const { active, restartFiber, notifyShutdown } = yield* mutex.withPermits(1)( Effect.gen(function* () { - const result = yield* Ref.modify(state, (latest) => { + const result = yield* SubscriptionRef.modify(state, (latest) => { const active = Option.map(latest.active, (run) => run.exitObserved ? run : { ...run, stopRequested: true }, ); @@ -1082,7 +1085,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } const cleanup = yield* mutex.withPermits(1)( - Ref.modify( + SubscriptionRef.modify( state, ( latest, @@ -1130,23 +1133,16 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); }); - const waitForReady: (timeout: Duration.Duration) => Effect.Effect = Effect.fn( - "DesktopBackendManager.waitForReady", - )( - function* (_timeout: Duration.Duration) { - const current = yield* Ref.get(state); - if (!current.desiredRunning) return { done: true, ready: false }; - return current.ready ? { done: true, ready: true } : { done: false, ready: false }; - }, - (effect, timeout) => - effect.pipe( - Effect.repeat({ - until: (status) => status.done, - schedule: Schedule.spaced(Duration.millis(100)), - }), - Effect.map((status) => status.ready), + const waitForReady = Effect.fn("DesktopBackendManager.waitForReady")( + (timeout: Duration.Duration) => + SubscriptionRef.changes(state).pipe( + Stream.filter((current) => current.ready || !current.desiredRunning), + Stream.map((current) => current.ready), + Stream.runHead, Effect.timeoutOption(timeout), - Effect.map(Option.getOrElse(() => false)), + Effect.map( + (result) => Option.isSome(result) && Option.getOrElse(result.value, () => false), + ), ), ); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index 348d3a7050f70..be16c4ecc43ee 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,4 +1,5 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -40,6 +41,8 @@ describe("DesktopLocalEnvironmentAuth", () => { it.effect("exchanges the desktop bootstrap credential only once", () => Effect.gen(function* () { const requestCount = yield* Ref.make(0); + const waiting = yield* Deferred.make(); + const ready = yield* Deferred.make(); const layerHttpClient = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -68,6 +71,11 @@ describe("DesktopLocalEnvironmentAuth", () => { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), currentConfig: Effect.succeedSome(config), + waitForReady: (timeout: Duration.Duration) => + Deferred.succeed(waiting, undefined).pipe( + Effect.andThen(Deferred.await(ready)), + Effect.timeout(timeout), + ), }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); @@ -77,13 +85,20 @@ describe("DesktopLocalEnvironmentAuth", () => { const [first, second] = yield* Effect.gen(function* () { const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth; - return yield* Effect.all([auth.getBearerToken, auth.getBearerToken]); + const tokens = yield* Effect.all([auth.getBearerToken, auth.getBearerToken]).pipe( + Effect.forkChild, + ); + yield* Deferred.await(waiting); + yield* TestClock.adjust(Duration.seconds(16)); + assert.strictEqual(yield* Ref.get(requestCount), 0); + yield* Deferred.succeed(ready, true); + return yield* Fiber.join(tokens); }).pipe(Effect.provide(layerTest)); assert.strictEqual(first, "desktop-bearer-token"); assert.strictEqual(second, "desktop-bearer-token"); assert.strictEqual(yield* Ref.get(requestCount), 1); - }), + }).pipe(Effect.provide(TestClock.layer())), ); it.effect( @@ -120,6 +135,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), + waitForReady: () => Effect.succeed(true), currentConfig: Effect.succeedSome({ ...config, bootstrap: { ...config.bootstrap, desktopBootstrapSecret: "desktop-secret" }, @@ -167,6 +183,7 @@ describe("DesktopLocalEnvironmentAuth", () => { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), currentConfig: Effect.succeedSome(config), + waitForReady: () => Effect.succeed(true), }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 6481bfd64c897..e916b35053e8f 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -80,7 +80,10 @@ export const make = Effect.gen(function* () { const instances = yield* pool.list; const primary = instances.find((instance) => instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID); - const configOption = primary === undefined ? Option.none() : yield* primary.currentConfig; + if (primary === undefined || !(yield* primary.waitForReady(Duration.infinity))) { + return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); + } + const configOption = yield* primary.currentConfig; if (Option.isNone(configOption)) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 0dcb2b85ac2c5..4f8949823e354 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -549,6 +549,79 @@ describe("DesktopWindow", () => { }), ); + it.effect.each(["before", "after"] as const)( + "keeps a closed startup window closed until activation %s backend readiness", + (activation) => + 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.ensureMain; + fake.windowListeners.get("ready-to-show")?.(); + fake.isDestroyed.mockReturnValue(true); + fake.windowListeners.get("closed")?.(); + assert.isTrue(Option.isNone(yield* Ref.get(mainWindow))); + if (activation === "after") { + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + 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.each(["renderer", "backend"] as const)( + "reveals the painted boot shell when %s becomes ready first", + (first) => + Effect.gen(function* () { + const fake = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const reveal = vi.fn(); + const painted = Promise.withResolvers(); + yield* Effect.gen(function* () { + const windows = yield* DesktopWindow.DesktopWindow; + yield* windows.ensureMain; + assert.equal(fake.loadURL.mock.calls.length, 1); + yield* windows.activate; + yield* windows.revealOrCreateMain; + yield* windows.dispatchMenuAction("open-settings"); + assert.equal(reveal.mock.calls.length, 0); + if (first === "backend") { + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(reveal.mock.calls.length, 0); + } + fake.windowListeners.get("ready-to-show")?.(); + yield* Effect.promise(() => painted.promise); + assert.equal(reveal.mock.calls.length, 1); + yield* windows.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(yield* Ref.get(createCount), 1); + assert.equal(reveal.mock.calls.length, 1); + yield* windows.handleBackendNotReady; + yield* windows.activate; + assert.equal(reveal.mock.calls.length, 2); + }).pipe( + Effect.provide( + layerTest({ + window: fake.window, + createCount, + mainWindow, + onReveal: () => { + reveal(); + painted.resolve(); + }, + }), + ), + ); + }), + ); + it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); @@ -1133,6 +1206,10 @@ describe("DesktopWindow", () => { yield* Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + fakeWindow.windowListeners.get("ready-to-show")?.(); + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + operations.length = 0; + foreground = "Discord"; yield* desktopWindow.dispatchSnapShotEvent({ type: "started", id: captureOne }); assert.equal(foreground, "T3 Code"); foreground = "Explorer"; diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 10801089841d1..5e02f4cfd8bbe 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 Semaphore from "effect/Semaphore"; import * as Electron from "electron"; @@ -306,10 +307,17 @@ 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; const splashWindowRef = yield* Ref.make>(Option.none()); const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); const runPromise = Effect.runPromiseWith(context); + const mainWindowMutex = yield* Semaphore.make(1); + const pendingMainReveals = new WeakMap>(); + const revealMain = Effect.fn("desktop.window.revealMain")((window: Electron.BrowserWindow) => + Effect.suspend(() => pendingMainReveals.get(window) ?? electronWindow.reveal(window)), + ); let flushMainWindowBounds: Effect.Effect = Effect.void; const dismissConnectingSplash = Effect.gen(function* () { @@ -407,6 +415,7 @@ export const make = Effect.gen(function* () { return Option.none(); } + pendingMainReveals.set(window, Effect.void); yield* rendererHistory.register(window.webContents, { surface: "main" }); if (environment.platform === "darwin") { window.setAutoHideCursor(false); @@ -822,14 +831,27 @@ export const make = Effect.gen(function* () { if (environment.platform === "linux") { revealSubscribers.push((fire) => window.webContents.once("did-finish-load", fire)); } + let rendererReady = false; + let firstRevealComplete = false; + const revealWhenReady = Effect.gen(function* () { + if ( + !rendererReady || + firstRevealComplete || + window.isDestroyed() || + (yield* Ref.get(desktopState.quitting)) + ) + return; + firstRevealComplete = true; + pendingMainReveals.delete(window); + window.webContents.setBackgroundThrottling(true); + if (persistedSettings.mainWindowMaximized) window.maximize(); + yield* electronWindow.reveal(window); + yield* dismissConnectingSplash; + }); + pendingMainReveals.set(window, revealWhenReady); bindFirstRevealTrigger(revealSubscribers, () => { - if (!window.isDestroyed()) { - window.webContents.setBackgroundThrottling(true); - } - if (persistedSettings.mainWindowMaximized) { - window.maximize(); - } - void runPromise(Effect.andThen(electronWindow.reveal(window), dismissConnectingSplash)); + rendererReady = true; + void runPromise(revealWhenReady); }); loadApplication(); @@ -838,6 +860,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { + if (!backendHasStarted) mainClosedDuringStartup = true; clearDevelopmentLoadRetry(); clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); @@ -850,6 +873,7 @@ export const make = Effect.gen(function* () { const window = yield* createWindow(); if (Option.isSome(window)) { yield* electronWindow.setMain(window.value); + mainClosedDuringStartup = false; yield* logWindowInfo("main window created"); } return window; @@ -862,7 +886,7 @@ export const make = Effect.gen(function* () { return existingWindow; } return yield* createMain; - }).pipe(Effect.withSpan("desktop.window.ensureMain")); + }).pipe(mainWindowMutex.withPermits(1), Effect.withSpan("desktop.window.ensureMain")); const requireWindow = (window: Option.Option) => Option.match(window, { @@ -872,7 +896,7 @@ export const make = Effect.gen(function* () { const revealOrCreateMain = Effect.gen(function* () { const window = yield* ensureMain.pipe(Effect.flatMap(requireWindow)); - yield* electronWindow.reveal(window); + yield* revealMain(window); return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); @@ -884,9 +908,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; - yield* createMain; + yield* ensureMain; }).pipe(Effect.withSpan("desktop.window.createMainIfBackendReady")); const showConnectingSplash = Effect.gen(function* () { @@ -956,7 +978,7 @@ export const make = Effect.gen(function* () { }); // Send before best-effort reveal; another process may refuse to yield foreground focus. const dispatch = reveal - ? send.pipe(Effect.andThen(electronWindow.reveal(targetWindow).pipe(Effect.ignoreCause))) + ? send.pipe(Effect.andThen(revealMain(targetWindow).pipe(Effect.ignoreCause))) : send; if (targetWindow.webContents.isLoadingMainFrame()) { targetWindow.webContents.once("did-finish-load", () => void runPromise(dispatch)); @@ -971,7 +993,7 @@ export const make = Effect.gen(function* () { revealOrCreateMain, prepareCaptureReveal: Effect.gen(function* () { const existingWindow = yield* currentMainWindow; - if (Option.isSome(existingWindow)) { + if (Option.isSome(existingWindow) && !pendingMainReveals.has(existingWindow.value)) { yield* electronWindow.prepareReveal(existingWindow.value); } }), @@ -979,7 +1001,11 @@ export const make = Effect.gen(function* () { if (yield* Ref.get(desktopState.quitting)) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) { - yield* electronWindow.reveal(existingWindow.value); + yield* revealMain(existingWindow.value); + return; + } + if (mainClosedDuringStartup) { + yield* ensureMain; return; } const backendReady = yield* Ref.get(backendReadyRef); @@ -995,9 +1021,10 @@ 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 }); - yield* createMainIfBackendReady; + if (!mainClosedDuringStartup) yield* createMainIfBackendReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), diff --git a/apps/web/src/environments/primary/httpLayer.test.ts b/apps/web/src/environments/primary/httpLayer.test.ts index 4e75873b4c523..43f72400eb92e 100644 --- a/apps/web/src/environments/primary/httpLayer.test.ts +++ b/apps/web/src/environments/primary/httpLayer.test.ts @@ -1,11 +1,20 @@ import type { DesktopBridge } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import { HttpClient } from "effect/http"; import { __resetDesktopPrimaryAuthForTests } from "./desktopAuth"; import * as PrimaryEnvironmentHttpLayer from "./httpLayer"; +function createSignal() { + let resolve!: (value: A) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + describe("primary environment HTTP layer", { concurrent: false }, () => { afterEach(() => { __resetDesktopPrimaryAuthForTests(); @@ -36,6 +45,8 @@ describe("primary environment HTTP layer", { concurrent: false }, () => { }); it.effect("uses bearer auth without cookies for desktop-managed primaries", () => { + const requested = createSignal(); + const bearerToken = createSignal(); const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); vi.stubGlobal("fetch", fetchMock); Object.defineProperty(globalThis, "window", { @@ -49,13 +60,22 @@ describe("primary environment HTTP layer", { concurrent: false }, () => { wsBaseUrl: "ws://127.0.0.1:3773", bootstrapToken: "desktop-bootstrap-token", }), - getLocalEnvironmentBearerToken: vi.fn().mockResolvedValue("desktop-bearer-token"), + getLocalEnvironmentBearerToken: () => { + requested.resolve(undefined); + return bearerToken.promise; + }, } as unknown as DesktopBridge, }, }); return Effect.gen(function* () { - yield* HttpClient.get("http://127.0.0.1:3773/api/connect/link-state"); + const requestFiber = yield* HttpClient.get( + "http://127.0.0.1:3773/api/connect/link-state", + ).pipe(Effect.forkChild); + yield* Effect.promise(() => requested.promise); + expect(fetchMock).not.toHaveBeenCalled(); + bearerToken.resolve("desktop-bearer-token"); + yield* Fiber.join(requestFiber); const request = new Request(fetchMock.mock.calls[0]?.[0], fetchMock.mock.calls[0]?.[1]); expect(request.credentials).not.toBe("include");