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
3 changes: 2 additions & 1 deletion apps/desktop/scripts/dev-electron.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ if (!Number.isInteger(port) || port <= 0) {

const requiredFiles = [
"dist-electron/main.cjs",
"dist-electron/runtime.cjs",
"dist-electron/preload.cjs",
"../server/dist/bin.mjs",
];
const watchedDirectories = [
{ directory: "dist-electron", files: new Set(["main.cjs", "preload.cjs"]) },
{ directory: "dist-electron", files: new Set(["main.cjs", "runtime.cjs", "preload.cjs"]) },
{ directory: "../server/dist", files: new Set(["bin.mjs"]) },
];
const forcedShutdownTimeoutMs = 1_500;
Expand Down
18 changes: 17 additions & 1 deletion apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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 Ref from "effect/Ref";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -178,6 +179,15 @@ const bootstrap = Effect.gen(function* () {
const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort });
const backendConfig = yield* serverExposure.backendConfig;
const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
const fileSystem = yield* FileSystem.FileSystem;
const bundledStaticRoot = environment.path.join(
environment.serverRoot,
"apps/server/dist/client",
);
const loadRendererWhileStarting =
!environment.isDevelopment &&
!(settings.wslOnly === true && settings.wslBackendEnabled === true) &&
(yield* fileSystem.exists(environment.path.join(bundledStaticRoot, "index.html")));
const rendererTarget = environment.isDevelopment
? Option.getOrThrow(environment.devServerUrl)
: backendConfig.httpBaseUrl;
Expand All @@ -186,6 +196,7 @@ const bootstrap = Effect.gen(function* () {
targetOrigin: rendererTarget,
backendOrigin: backendConfig.httpBaseUrl,
clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
...(loadRendererWhileStarting ? { staticRoot: bundledStaticRoot } : {}),
});
yield* logBootstrapInfo("bootstrap resolved backend endpoint", {
baseUrl: backendConfig.httpBaseUrl.href,
Expand Down Expand Up @@ -213,6 +224,11 @@ const bootstrap = Effect.gen(function* () {
}
yield* primaryBackend.start;
yield* logBootstrapInfo("bootstrap backend start requested");
if (loadRendererWhileStarting) {
yield* desktopWindow.ensureMain.pipe(
Effect.catch((error) => logStartupError("early renderer creation failed", { error })),
);
}
yield* appActivation.start.pipe(
Effect.tap(() => logBootstrapInfo("desktop app control socket ready")),
Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })),
Expand Down Expand Up @@ -294,7 +310,7 @@ const startup = Effect.gen(function* () {
yield* applicationMenu.configure;
yield* updates.configure;
yield* DesktopRemoteUpdates.listen;
yield* linuxUrlHandler.register;
yield* Effect.forkScoped(linuxUrlHandler.register);
yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause)));
}).pipe(Effect.withSpan("desktop.startup"));

Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,11 @@ describe("DesktopBackendConfiguration", () => {
assert.equal(first.cwd, environment.backendCwd);
assert.equal(first.captureOutput, true);
assert.equal(first.env.ELECTRON_RUN_AS_NODE, "1");
assert.equal(
first.env.NODE_COMPILE_CACHE,
process.env.NODE_COMPILE_CACHE ??
environment.path.join(environment.baseDir, "cache", "node-compile"),
);
assert.isUndefined(first.env.T3CODE_PORT);
assert.isUndefined(first.env.T3CODE_MODE);
assert.isUndefined(first.env.T3CODE_DESKTOP_LAN_HOST);
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/backend/DesktopBackendConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,9 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
env: {
...backendChildEnvPatch(),
ELECTRON_RUN_AS_NODE: "1",
NODE_COMPILE_CACHE:
process.env.NODE_COMPILE_CACHE ??
environment.path.join(environment.baseDir, "cache", "node-compile"),
},
// Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.).
extendEnv: true,
Expand Down
124 changes: 90 additions & 34 deletions apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
import type * as Duration from "effect/Duration";
import * as Fiber from "effect/Fiber";
import * as TestClock from "effect/testing/TestClock";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
Expand Down Expand Up @@ -30,52 +34,104 @@ const config = {
};

describe("DesktopLocalEnvironmentAuth", () => {
it.effect("exchanges the desktop bootstrap credential only once", () =>
it.effect("does not exchange a credential when the backend stops before readiness", () =>
Effect.gen(function* () {
const requestCount = yield* Ref.make(0);
const httpClientLayer = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Ref.update(requestCount, (count) => count + 1).pipe(
Effect.as(
HttpClientResponse.fromWeb(
request,
new Response(
JSON.stringify({
access_token: "desktop-bearer-token",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "Bearer",
expires_in: 3600,
scope: "orchestration:read",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
),
),
),
),
);
const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
list: Effect.succeed([
{
id: PRIMARY_LOCAL_ENVIRONMENT_ID,
label: Effect.succeed("Windows"),
currentConfig: Effect.succeed(Option.some(config)),
waitForReady: () => Effect.succeed(false),
},
]),
} as unknown as DesktopBackendPool.DesktopBackendPool["Service"]);
const testLayer = DesktopLocalEnvironmentAuth.layer.pipe(
Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)),
const httpClientLayer = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make(() =>
Ref.update(requestCount, (count) => count + 1).pipe(
Effect.andThen(Effect.die("unexpected HTTP request before readiness")),
),
),
);

const [first, second] = yield* Effect.gen(function* () {
const error = yield* Effect.gen(function* () {
const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth;
return yield* Effect.all([auth.getBearerToken, auth.getBearerToken]);
}).pipe(Effect.provide(testLayer));

assert.strictEqual(first, "desktop-bearer-token");
assert.strictEqual(second, "desktop-bearer-token");
assert.strictEqual(yield* Ref.get(requestCount), 1);
return yield* Effect.flip(auth.getBearerToken);
}).pipe(
Effect.provide(
DesktopLocalEnvironmentAuth.layer.pipe(
Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)),
),
),
);
assert.equal(error._tag, "DesktopLocalEnvironmentAuthBackendStoppedError");
assert.equal(yield* Ref.get(requestCount), 0);
}),
);

it.effect(
"waits for backend readiness and exchanges the desktop bootstrap credential only once",
() =>
Effect.gen(function* () {
const requestCount = yield* Ref.make(0);
const waiting = yield* Deferred.make<void>();
const ready = yield* Deferred.make<boolean>();
const httpClientLayer = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Ref.update(requestCount, (count) => count + 1).pipe(
Effect.as(
HttpClientResponse.fromWeb(
request,
new Response(
JSON.stringify({
access_token: "desktop-bearer-token",
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
token_type: "Bearer",
expires_in: 3600,
scope: "orchestration:read",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
),
),
),
),
);
const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, {
list: Effect.succeed([
{
id: PRIMARY_LOCAL_ENVIRONMENT_ID,
label: Effect.succeed("Windows"),
currentConfig: Effect.succeed(Option.some(config)),
waitForReady: (timeout: Duration.Duration) =>
Deferred.succeed(waiting, undefined).pipe(
Effect.andThen(Deferred.await(ready)),
Effect.timeoutOption(timeout),
Effect.map(Option.getOrElse(() => false)),
),
},
]),
} as unknown as DesktopBackendPool.DesktopBackendPool["Service"]);
const testLayer = DesktopLocalEnvironmentAuth.layer.pipe(
Layer.provide(Layer.mergeAll(poolLayer, httpClientLayer)),
);

const [first, second] = yield* Effect.gen(function* () {
const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth;
const authentication = yield* Effect.all([auth.getBearerToken, auth.getBearerToken], {
concurrency: 2,
}).pipe(Effect.forkChild);
yield* Deferred.await(waiting);
yield* TestClock.adjust("2 minutes");
assert.strictEqual(yield* Ref.get(requestCount), 0);
yield* Deferred.succeed(ready, true);
return yield* Fiber.join(authentication);
}).pipe(Effect.provide(testLayer));

assert.strictEqual(first, "desktop-bearer-token");
assert.strictEqual(second, "desktop-bearer-token");
assert.strictEqual(yield* Ref.get(requestCount), 1);
}),
);
});
16 changes: 16 additions & 0 deletions apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorizat
import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Duration from "effect/Duration";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
Expand All @@ -20,6 +21,15 @@ export class DesktopLocalEnvironmentAuthBackendNotConfiguredError extends Schema
}
}

export class DesktopLocalEnvironmentAuthBackendStoppedError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthBackendStoppedError>()(
"DesktopLocalEnvironmentAuthBackendStoppedError",
{},
) {
override get message(): string {
return "Local backend stopped before authentication was ready.";
}
}

export class DesktopLocalEnvironmentAuthSessionBootstrapError extends Schema.TaggedErrorClass<DesktopLocalEnvironmentAuthSessionBootstrapError>()(
"DesktopLocalEnvironmentAuthSessionBootstrapError",
{ cause: Schema.Defect() },
Expand All @@ -31,6 +41,7 @@ export class DesktopLocalEnvironmentAuthSessionBootstrapError extends Schema.Tag

export const DesktopLocalEnvironmentAuthError = Schema.Union([
DesktopLocalEnvironmentAuthBackendNotConfiguredError,
DesktopLocalEnvironmentAuthBackendStoppedError,
DesktopLocalEnvironmentAuthSessionBootstrapError,
]);
export type DesktopLocalEnvironmentAuthError = typeof DesktopLocalEnvironmentAuthError.Type;
Expand Down Expand Up @@ -67,6 +78,11 @@ export const make = Effect.gen(function* () {
if (!credential) {
return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError();
}
// Renderer assets can load while the local server starts. Every primary
// HTTP request already awaits this token, so gate the exchange here.
if (primary === undefined || !(yield* primary.waitForReady(Duration.infinity))) {
Comment thread
maria-rcks marked this conversation as resolved.
return yield* new DesktopLocalEnvironmentAuthBackendStoppedError();
}
const session = yield* bootstrapRemoteBearerSession({
httpBaseUrl: config.httpBaseUrl.href,
credential,
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// @effect-diagnostics nodeBuiltinImport:off - compile caching must precede the application runtime.
import * as NodeModule from "node:module";

// Node honors cache overrides and disable flags; an unavailable cache is nonfatal.
NodeModule.enableCompileCache();

// Stay synchronous so Electron's pre-ready configuration cannot miss ready.
NodeModule.createRequire(__filename)("./runtime.cjs");
Loading
Loading