Skip to content
Merged
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
57 changes: 34 additions & 23 deletions apps/desktop/src/app/DesktopApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,18 +158,43 @@ export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances"
);

const bootstrap = Effect.gen(function* () {
const pool = yield* DesktopBackendPool.DesktopBackendPool;
const primaryBackend = yield* pool.primary;
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 snapShot = yield* DesktopSnapShot.DesktopSnapShot;
const appActivation = yield* DesktopAppActivation.DesktopAppActivation;
yield* logBootstrapInfo("bootstrap start");

const settings = yield* desktopSettings.get;
// The renderer is served from the bundled client (or Vite in development)
// rather than through the local backend, so the window can open without one.
const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
yield* electronProtocol.registerDesktopProtocol({
scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment),
...(environment.isDevelopment
? { targetOrigin: Option.getOrThrow(environment.devServerUrl) }
: { assetDirectory: environment.clientAssetsDir }),
clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
});
yield* installDesktopIpcHandlers();
yield* logBootstrapInfo("bootstrap ipc handlers registered");

yield* snapShot.initialize;

if (!settings.localEnvironmentEnabled) {
yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)");
if (!(yield* Ref.get(state.quitting))) {
yield* desktopWindow.createMainIfBackendReady;
}
return;
}

const pool = yield* DesktopBackendPool.DesktopBackendPool;
const primaryBackend = yield* pool.primary;
const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;

if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
return yield* new DesktopDevelopmentBackendPortRequiredError();
}
Expand All @@ -186,24 +211,13 @@ const bootstrap = Effect.gen(function* () {
},
);

const settings = yield* desktopSettings.get;
if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) {
yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", {
mode: settings.serverExposureMode,
});
}
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),
targetOrigin: rendererTarget,
backendOrigin: backendConfig.httpBaseUrl,
clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
});
yield* logBootstrapInfo("bootstrap resolved backend endpoint", {
baseUrl: backendConfig.httpBaseUrl.href,
});
Expand All @@ -219,16 +233,13 @@ const bootstrap = Effect.gen(function* () {
"bootstrap fell back to local-only because no advertised network host was available",
);
}
yield* snapShot.initialize;

yield* installDesktopIpcHandlers();
yield* logBootstrapInfo("bootstrap ipc handlers registered");

if (!(yield* Ref.get(state.quitting))) {
// In wsl-only mode the renderer is served by the WSL backend, which can be
// slow to cold-boot — show a "Connecting to WSL" splash immediately so the
// app feels responsive instead of presenting no window until WSL is ready.
// (Dual mode opens fast off the Windows primary, so no splash there.)
// The main window waits for the primary backend. In wsl-only mode that is
// the WSL backend, which can be slow to cold-boot — show a "Connecting to
// WSL" splash immediately so the app feels responsive instead of presenting
// no window until WSL is ready. (Dual mode opens fast off the Windows
// primary, so no splash there.)
if (settings.wslOnly === true && settings.wslBackendEnabled === true) {
yield* desktopWindow.showConnectingSplash;
}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ describe("DesktopEnvironment", () => {
environment.backendEntryPath,
"/install/resources/server.asar/apps/server/dist/bin.mjs",
);
assert.equal(
environment.clientAssetsDir,
"/install/resources/server.asar/apps/server/dist/client",
);
}),
);

Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export class DesktopEnvironment extends Context.Service<
// extracts on demand (see DesktopWslServerTree).
readonly serverRoot: string;
readonly backendEntryPath: string;
// Built web client the packaged renderer is served from over t3code://app.
readonly clientAssetsDir: string;
readonly backendCwd: string;
readonly preloadPath: string;
readonly appUpdateYmlPath: string;
Expand Down Expand Up @@ -211,6 +213,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
appRoot,
serverRoot,
backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"),
clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"),
backendCwd: input.isPackaged ? homeDirectory : appRoot,
preloadPath: path.join(input.dirname, "preload.cjs"),
appUpdateYmlPath: input.isPackaged
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/backend/DesktopServerExposure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ describe("DesktopServerExposure", () => {
setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
setWslDistro: () => Effect.die("unexpected WSL distro change"),
setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"),
applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
} satisfies DesktopAppSettings.DesktopAppSettings["Service"]);
Expand Down
61 changes: 50 additions & 11 deletions apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { assert, describe, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import { beforeEach, vi } from "vite-plus/test";

const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({
Expand All @@ -16,13 +19,55 @@ vi.mock("electron", () => ({

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

const protocolLayer = ElectronProtocol.layer.pipe(Layer.provide(NodeServices.layer));

describe("ElectronProtocol", () => {
beforeEach(() => {
handleMock.mockReset();
netFetchMock.mockReset();
unhandleMock.mockReset();
});

it.effect("serves the bundled client from disk without a backend", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const directory = yield* fileSystem.makeTempDirectoryScoped();
yield* fileSystem.writeFileString(`${directory}/index.html`, "<html>app</html>");
yield* fileSystem.writeFileString(`${directory}/app.js`, "export default 1;");
let handler: ((request: Request) => Promise<Response>) | undefined;
handleMock.mockImplementation((_scheme, nextHandler) => {
handler = nextHandler;
});
const protocol = yield* ElectronProtocol.ElectronProtocol;
yield* protocol.registerDesktopProtocol({
scheme: "t3code",
assetDirectory: directory,
clerkFrontendApiHostname: undefined,
});
const request = (pathname: string, init?: RequestInit) =>
Effect.promise(() => handler!(new Request(`t3code://app${pathname}`, init)));

// SPA routes fall back to index.html, including ones containing dots.
const page = yield* request("/settings/connections");
assert.equal(yield* Effect.promise(() => page.text()), "<html>app</html>");
assert.include(page.headers.get("content-security-policy") ?? "", "default-src 'self'");
const dottedRoute = yield* request("/environment/thread.with.dots", {
headers: { accept: "text/html" },
});
assert.equal(yield* Effect.promise(() => dottedRoute.text()), "<html>app</html>");

const script = yield* request("/app.js?v=1");
assert.equal(yield* Effect.promise(() => script.text()), "export default 1;");
assert.include(script.headers.get("content-type") ?? "", "javascript");

assert.equal((yield* request("/missing.js")).status, 404);
assert.equal((yield* request("/%2e%2e%2fsecret.txt")).status, 404);
assert.equal((yield* request("/%invalid")).status, 400);
assert.equal((yield* request("/", { method: "POST" })).status, 405);
assert.equal(netFetchMock.mock.calls.length, 0);
}).pipe(Effect.provide(Layer.merge(protocolLayer, NodeServices.layer)), Effect.scoped),
);

it.effect("proxies the stable renderer origin to the current app server", () =>
Effect.gen(function* () {
let handler: ((request: Request) => Promise<Response>) | undefined;
Expand All @@ -37,7 +82,6 @@ describe("ElectronProtocol", () => {
yield* protocol.registerDesktopProtocol({
scheme: "t3code-dev",
targetOrigin: new URL("http://127.0.0.1:3773/"),
backendOrigin: new URL("http://127.0.0.1:3774/"),
clerkFrontendApiHostname: "clerk.t3.codes",
});
assert.isDefined(handler);
Expand Down Expand Up @@ -85,7 +129,7 @@ describe("ElectronProtocol", () => {
assert.isNull(forwardedHeaders.get("referer"));
assert.isNull(forwardedHeaders.get("sec-fetch-site"));
assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]);
}).pipe(Effect.provide(ElectronProtocol.layer)),
}).pipe(Effect.provide(protocolLayer)),
);

it.effect("rejects custom protocol requests for another host", () =>
Expand All @@ -101,7 +145,6 @@ describe("ElectronProtocol", () => {
yield* protocol.registerDesktopProtocol({
scheme: "t3code",
targetOrigin: new URL("http://127.0.0.1:3773/"),
backendOrigin: new URL("http://127.0.0.1:3773/"),
clerkFrontendApiHostname: undefined,
});
return yield* Effect.promise(() => handler!(new Request("t3code://other/")));
Expand All @@ -110,7 +153,7 @@ describe("ElectronProtocol", () => {

assert.equal(response.status, 404);
assert.equal(netFetchMock.mock.calls.length, 0);
}).pipe(Effect.provide(ElectronProtocol.layer)),
}).pipe(Effect.provide(protocolLayer)),
);

it.effect("retries transient renderer target failures", () =>
Expand All @@ -129,7 +172,6 @@ describe("ElectronProtocol", () => {
yield* protocol.registerDesktopProtocol({
scheme: "t3code-dev",
targetOrigin: new URL("http://127.0.0.1:5733/"),
backendOrigin: new URL("http://127.0.0.1:3773/"),
clerkFrontendApiHostname: undefined,
});
return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/")));
Expand All @@ -138,7 +180,7 @@ describe("ElectronProtocol", () => {

assert.equal(yield* Effect.promise(() => response.text()), "ready");
assert.equal(netFetchMock.mock.calls.length, 2);
}).pipe(Effect.provide(ElectronProtocol.layer)),
}).pipe(Effect.provide(protocolLayer)),
);

it.effect("preserves protocol registration failures", () =>
Expand All @@ -153,7 +195,6 @@ describe("ElectronProtocol", () => {
protocol.registerDesktopProtocol({
scheme: "t3code-dev",
targetOrigin: new URL("http://127.0.0.1:3773/"),
backendOrigin: new URL("http://127.0.0.1:3774/"),
clerkFrontendApiHostname: undefined,
}),
).pipe(Effect.flip);
Expand All @@ -162,7 +203,7 @@ describe("ElectronProtocol", () => {
assert.equal(error.scheme, "t3code-dev");
assert.strictEqual(error.cause, cause);
assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".');
}).pipe(Effect.provide(ElectronProtocol.layer)),
}).pipe(Effect.provide(protocolLayer)),
);

it.effect("preserves protocol unregistration failures", () =>
Expand All @@ -178,7 +219,6 @@ describe("ElectronProtocol", () => {
protocol.registerDesktopProtocol({
scheme: "t3code",
targetOrigin: new URL("http://127.0.0.1:3773/"),
backendOrigin: new URL("http://127.0.0.1:3773/"),
clerkFrontendApiHostname: undefined,
}),
),
Expand All @@ -192,14 +232,13 @@ describe("ElectronProtocol", () => {
assert.strictEqual(error.cause, cause);
assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".');
}
}).pipe(Effect.provide(ElectronProtocol.layer)),
}).pipe(Effect.provide(protocolLayer)),
);

it("keeps executable sources host-restricted while allowing runtime network resources", () => {
const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({
scheme: "t3code",
targetOrigin: new URL("http://127.0.0.1:3773/"),
backendOrigin: new URL("http://127.0.0.1:3773/"),
clerkFrontendApiHostname: "clerk.t3.codes",
});
const directives = Object.fromEntries(
Expand Down
64 changes: 57 additions & 7 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import Mime from "@effect/platform-node/Mime";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as NodeTimersPromises from "node:timers/promises";
import * as Path from "effect/Path";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
Expand Down Expand Up @@ -48,12 +51,12 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError<Elec
}
}

export interface DesktopProtocolRegistrationInput {
// The scheme either proxies to a dev server (`targetOrigin`) or serves the
// built client from disk (`assetDirectory`).
export type DesktopProtocolRegistrationInput = {
readonly scheme: string;
readonly targetOrigin: URL;
readonly backendOrigin: URL;
readonly clerkFrontendApiHostname: string | undefined;
}
} & ({ readonly targetOrigin: URL } | { readonly assetDirectory: string });

export class ElectronProtocol extends Context.Service<
ElectronProtocol,
Expand Down Expand Up @@ -189,6 +192,45 @@ async function proxyRequest(

const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const;

// Serves the packaged web client without a backend: files resolve within the
// asset directory, and any other path falls back to index.html so the SPA
// router handles it, except for asset-shaped misses (`/missing.js`) which 404.
const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (
request: Request,
assetDirectory: string,
) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const url = new URL(request.url);
if (url.host !== DESKTOP_HOST) return new Response(null, { status: 404 });
if (request.method !== "GET" && request.method !== "HEAD") {
return new Response(null, { status: 405 });
}
const pathname = yield* Effect.try(() => decodeURIComponent(url.pathname)).pipe(
Effect.orElseSucceed(() => null),
);
if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 });
const root = path.resolve(assetDirectory);
const assetPath = path.resolve(root, `.${pathname}`);
if (assetPath !== root && !assetPath.startsWith(root + path.sep)) {
return new Response(null, { status: 404 });
}
const stat = yield* fileSystem.stat(assetPath).pipe(Effect.orElseSucceed(() => null));
let filePath = assetPath;
if (stat?.type !== "File") {
const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false;
if (path.extname(assetPath) !== "" && !wantsHtml) {
return new Response(null, { status: 404 });
}
filePath = path.join(root, "index.html");
}
const contents = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null));
if (contents === null) return new Response(null, { status: 404 });
return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), {
headers: { "content-type": Mime.getType(filePath) ?? "application/octet-stream" },
});
});

async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<Response> {
let lastError: unknown;

Expand All @@ -210,6 +252,8 @@ async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<
/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* () {
const registered = yield* Ref.make(false);
const context = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
const runPromise = Effect.runPromiseWith(context);

const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")(
function* (input: DesktopProtocolRegistrationInput) {
Expand All @@ -220,9 +264,15 @@ export const make = Effect.gen(function* () {
yield* Effect.acquireRelease(
Effect.try({
try: () => {
Electron.protocol.handle(input.scheme, (request) =>
proxyRequest(request, input.targetOrigin, contentSecurityPolicy),
);
Electron.protocol.handle(input.scheme, async (request) => {
if ("assetDirectory" in input) {
return withContentSecurityPolicy(
await runPromise(serveDesktopAsset(request, input.assetDirectory)),
contentSecurityPolicy,
);
}
return proxyRequest(request, input.targetOrigin, contentSecurityPolicy);
});
},
catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }),
}).pipe(Effect.andThen(Ref.set(registered, true))),
Expand Down
Loading
Loading