Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 98 additions & 7 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";

import type { DesktopBackendMode as DesktopBackendModeValue } from "@t3tools/contracts";
import * as NetService from "@t3tools/shared/Net";
import * as Crypto from "effect/Crypto";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
import * as DesktopBackendMode from "./DesktopBackendMode.ts";
import * as DesktopClerk from "./DesktopClerk.ts";
import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
Expand Down Expand Up @@ -57,6 +61,17 @@ export class DesktopDevelopmentBackendPortRequiredError extends Schema.TaggedErr
}
}

export class DesktopRendererAssetsUnavailableError extends Schema.TaggedErrorClass<DesktopRendererAssetsUnavailableError>()(
"DesktopRendererAssetsUnavailableError",
{
candidates: Schema.Array(Schema.String),
},
) {
override get message(): string {
return `The packaged desktop renderer was not found. Checked: ${this.candidates.join(", ")}.`;
}
}

const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } =
DesktopObservability.makeComponentLogger("desktop-bootstrap");

Expand Down Expand Up @@ -136,21 +151,88 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr
const fatalStartupCause = <E>(stage: string, cause: Cause.Cause<E>) =>
handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause)));

export const latchDesktopBackendModeForStartup = Effect.fn(
"desktop.startup.latchDesktopBackendMode",
)(function* (configuredMode: DesktopBackendModeValue) {
const backendMode = yield* DesktopBackendMode.DesktopBackendMode;
return yield* backendMode
.latch(configuredMode)
.pipe(Effect.catchCause((cause) => fatalStartupCause("backendMode", cause)));
});

export const handleClientOnlyRendererReady = <E extends { readonly message: string }>(
rendererReady: Effect.Effect<void, E>,
): Effect.Effect<void, E> =>
rendererReady.pipe(
Effect.tapError((error) =>
logBootstrapWarning("failed to open main window after renderer readiness", {
error: error.message,
}),
),
);

const resolvePackagedClientRoot = Effect.fn("desktop.bootstrap.resolvePackagedClientRoot")(
function* (candidates: readonly string[]) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
for (const candidate of candidates) {
if (
yield* fileSystem
.exists(path.join(candidate, "index.html"))
.pipe(Effect.orElseSucceed(() => false))
) {
return candidate;
}
}
return yield* new DesktopRendererAssetsUnavailableError({ candidates: [...candidates] });
},
);

const bootstrap = Effect.gen(function* () {
const pool = yield* DesktopBackendPool.DesktopBackendPool;
const primaryBackend = yield* pool.primary;
const launchMode = yield* DesktopBackendMode.DesktopBackendMode;
const state = yield* DesktopState.DesktopState;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
const desktopWindow = yield* DesktopWindow.DesktopWindow;
const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
const backendMode = (yield* launchMode.get).effectiveMode;
yield* logBootstrapInfo("bootstrap start");

if (backendMode === "client-only") {
if (environment.isDevelopment) {
yield* electronProtocol.registerDesktopProtocol({
scheme: ElectronProtocol.getDesktopScheme(true),
source: "proxy",
targetOrigin: Option.getOrThrow(environment.devServerUrl),
clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
});
} else {
const staticRoot = yield* resolvePackagedClientRoot(environment.packagedClientRootCandidates);
yield* electronProtocol.registerDesktopProtocol({
scheme: ElectronProtocol.getDesktopScheme(false),
source: "static",
staticRoot,
clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
});
yield* logBootstrapInfo("bootstrap resolved packaged renderer", { staticRoot });
}

yield* installDesktopIpcHandlers();
yield* logBootstrapInfo("bootstrap ipc handlers registered");
if (!(yield* Ref.get(state.quitting))) {
yield* handleClientOnlyRendererReady(desktopWindow.handleRendererReady);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client-only window errors quit app

Medium Severity

In client-only bootstrap, handleClientOnlyRendererReady logs then re-fails main-window creation, so bootstrap hits fatal startup and quits. The prior Effect.catch only logged and let startup finish, leaving backendReadyRef set so activate could retry—matching managed mode’s swallowed handleBackendReady path.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9d90bf8. Configure here.

}
return;
}

if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
return yield* new DesktopDevelopmentBackendPortRequiredError();
}

const pool = yield* DesktopBackendPool.DesktopBackendPool;
const primaryBackend = yield* pool.primary;
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort);
const backendPort = backendPortSelection.port;
yield* logBootstrapInfo(
Expand All @@ -171,14 +253,13 @@ const bootstrap = Effect.gen(function* () {
}
const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort });
const backendConfig = yield* serverExposure.backendConfig;
const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
const rendererTarget = environment.isDevelopment
? Option.getOrThrow(environment.devServerUrl)
: backendConfig.httpBaseUrl;
yield* electronProtocol.registerDesktopProtocol({
scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment),
source: "proxy",
targetOrigin: rendererTarget,
backendOrigin: backendConfig.httpBaseUrl,
clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
});
yield* logBootstrapInfo("bootstrap resolved backend endpoint", {
Expand Down Expand Up @@ -230,7 +311,13 @@ const startup = Effect.gen(function* () {
const userDataPath = yield* appIdentity.resolveUserDataPath;
yield* electronApp.setPath("userData", userDataPath);
yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir });
yield* desktopSettings.load;
const settings = yield* desktopSettings.load;
const launchMode = yield* latchDesktopBackendModeForStartup(settings.backendMode);
yield* logStartupInfo("desktop backend mode selected", {
effectiveMode: launchMode.effectiveMode,
configuredMode: launchMode.configuredMode,
...(launchMode.cliOverride === null ? {} : { cliOverride: launchMode.cliOverride }),
});

if (environment.platform === "linux") {
yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass);
Expand Down Expand Up @@ -261,6 +348,10 @@ const scopedProgram = Effect.scoped(

yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const backendMode = yield* DesktopBackendMode.DesktopBackendMode;
if ((yield* backendMode.get).effectiveMode === "client-only") {
return;
}
const pool = yield* DesktopBackendPool.DesktopBackendPool;
// Stop every backend in the pool, not just the primary. The
// electronApp.quit() path can race ahead of the layer-scope
Expand Down
68 changes: 68 additions & 0 deletions apps/desktop/src/app/DesktopAppErrors.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { assert, describe, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";

import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import {
DesktopBackendPortUnavailableError,
DesktopDevelopmentBackendPortRequiredError,
handleClientOnlyRendererReady,
latchDesktopBackendModeForStartup,
} from "./DesktopApp.ts";
import * as DesktopBackendMode from "./DesktopBackendMode.ts";
import * as DesktopShutdown from "./DesktopShutdown.ts";
import * as DesktopState from "./DesktopState.ts";

describe("DesktopApp errors", () => {
it("preserves unavailable backend port context", () => {
Expand All @@ -27,4 +40,59 @@ describe("DesktopApp errors", () => {

assert.equal(error.message, "T3CODE_PORT is required in desktop development.");
});

it.effect("propagates client-only window creation failures", () =>
Effect.gen(function* () {
const error = new Error("window creation failed");

const exit = yield* Effect.exit(handleClientOnlyRendererReady(Effect.fail(error)));
assert(Exit.isFailure(exit));
const failure = Cause.findErrorOption(exit.cause);
assert(Option.isSome(failure));
assert.strictEqual(failure.value, error);
}),
);

it.effect("reports invalid backend-mode launch arguments as fatal startup errors", () =>
Effect.gen(function* () {
const quitCount = yield* Ref.make(0);
const shownErrors = yield* Ref.make<readonly { title: string; content: string }[]>([]);

const layer = Layer.mergeAll(
DesktopBackendMode.layerTest(["electron", "--backend-mode=invalid"]),
DesktopShutdown.layer,
DesktopState.layer,
Layer.mock(ElectronApp.ElectronApp)({
quit: Ref.update(quitCount, (count) => count + 1),
}),
Layer.mock(ElectronDialog.ElectronDialog)({
showErrorBox: (title, content) =>
Ref.update(shownErrors, (errors) => [...errors, { title, content }]),
}),
);

yield* Effect.gen(function* () {
const exit = yield* Effect.exit(latchDesktopBackendModeForStartup("managed"));
assert(Exit.isFailure(exit));
const failure = Cause.findErrorOption(exit.cause);
assert(Option.isSome(failure));
assert(
DesktopBackendMode.isDesktopBackendModeArgumentError(failure.value),
"expected the original backend mode argument error",
);

const errors = yield* Ref.get(shownErrors);
assert.equal(errors.length, 1);
assert.equal(errors[0]?.title, "T3 Code failed to start");
assert.include(errors[0]?.content ?? "", "Stage: backendMode");
assert.include(errors[0]?.content ?? "", 'Invalid --backend-mode value "invalid"');
assert.equal(yield* Ref.get(quitCount), 1);

const state = yield* DesktopState.DesktopState;
assert.isTrue(yield* Ref.get(state.quitting));
const shutdown = yield* DesktopShutdown.DesktopShutdown;
yield* shutdown.awaitRequest;
}).pipe(Effect.provide(layer));
}),
);
});
70 changes: 70 additions & 0 deletions apps/desktop/src/app/DesktopBackendMode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { assert, describe, it } from "@effect/vitest";

import * as DesktopBackendMode from "./DesktopBackendMode.ts";

describe("DesktopBackendMode", () => {
const captureThrown = (run: () => unknown): unknown => {
try {
run();
} catch (error) {
return error;
}
throw new Error("Expected the operation to throw.");
};

it("uses the persisted mode when no CLI override is present", () => {
assert.deepEqual(DesktopBackendMode.resolveDesktopBackendModeState([], "client-only"), {
effectiveMode: "client-only",
configuredMode: "client-only",
cliOverride: null,
});
});

it("gives the CLI override precedence without changing the configured mode", () => {
assert.deepEqual(
DesktopBackendMode.resolveDesktopBackendModeState(
["electron", "main.cjs", "--backend-mode=client-only"],
"managed",
),
{
effectiveMode: "client-only",
configuredMode: "managed",
cliOverride: "client-only",
},
);
});

it("accepts a separate flag value", () => {
assert.equal(
DesktopBackendMode.parseDesktopBackendModeOverride(["electron", "--backend-mode", "managed"]),
"managed",
);
});

it.each([
["--backend-mode=other", "invalid-value"],
["--backend-mode=", "missing-value"],
["--backend-mode", "missing-value"],
])("rejects invalid launch argument %s", (argument, reason) => {
const error = captureThrown(() =>
DesktopBackendMode.parseDesktopBackendModeOverride(["electron", argument]),
);
assert.isTrue(DesktopBackendMode.isDesktopBackendModeArgumentError(error));
if (DesktopBackendMode.isDesktopBackendModeArgumentError(error)) {
assert.equal(error.reason, reason);
}
});

it("rejects repeated overrides", () => {
const error = captureThrown(() =>
DesktopBackendMode.parseDesktopBackendModeOverride([
"--backend-mode=managed",
"--backend-mode=client-only",
]),
);
assert.isTrue(DesktopBackendMode.isDesktopBackendModeArgumentError(error));
if (DesktopBackendMode.isDesktopBackendModeArgumentError(error)) {
assert.equal(error.reason, "repeated");
}
});
});
Loading
Loading