Skip to content
Merged
10 changes: 10 additions & 0 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
Yash-Singh1 marked this conversation as resolved.
Resolved
}
yield* appActivation.start.pipe(
Effect.tap(() => logBootstrapInfo("desktop app control socket ready")),
Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })),
Expand Down
133 changes: 131 additions & 2 deletions apps/desktop/src/app/DesktopStartupRecovery.test.ts
Original file line number Diff line number Diff line change
@@ -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>): DesktopUpdateState => ({
...createInitialDesktopUpdateState(
Expand Down Expand Up @@ -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<void>();
const remembered = yield* Deferred.make<void>();
const shouldRecover = !visible || !started;
const closed = Promise.withResolvers<void>();
const loaded: string[] = [];
const appListeners = new Map<string, (...args: readonly unknown[]) => void>();
const mainListeners = new Map<string, () => 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)),
);
28 changes: 25 additions & 3 deletions apps/desktop/src/app/DesktopStartupRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export const listen: Effect.Effect<
const failureRef = yield* Ref.make(Option.none<BackendStartupFailure>());
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);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/app/DesktopWebLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/app/DesktopWebLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string> = [];
let listening: Electron.WebContents | null = null;
let detachListening: (() => void) | null = null;
Expand All @@ -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* () {
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/src/backend/DesktopBackendManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
const ready = yield* Deferred.make<void>();
const exit = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
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* () {
Expand Down
Loading
Loading