1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Mime from "effect/http/Mime";
8import * as Ref from "effect/Ref";
9import * as Schedule from "effect/Schedule";
10import * as Schema from "effect/Schema";
11import * as Scope from "effect/Scope";
12
13import * as Electron from "electron";
14
15export const DESKTOP_HOST = "app";
16const DESKTOP_PRODUCTION_SCHEME = "t3code";
17const DESKTOP_DEVELOPMENT_SCHEME = "t3code-dev";
18
19export function getDesktopScheme(isDevelopment: boolean): string {
20 return isDevelopment ? DESKTOP_DEVELOPMENT_SCHEME : DESKTOP_PRODUCTION_SCHEME;
21}
22
23function getDesktopOrigin(isDevelopment: boolean): string {
24 return `${getDesktopScheme(isDevelopment)}://${DESKTOP_HOST}`;
25}
26
27export function getDesktopUrl(isDevelopment: boolean): string {
28 return `${getDesktopOrigin(isDevelopment)}/`;
29}
30
31export class ElectronProtocolRegistrationError extends Schema.TaggedError<ElectronProtocolRegistrationError>()(
32 "ElectronProtocolRegistrationError",
33 {
34 scheme: Schema.String,
35 cause: Schema.Defect(),
36 },
37) {
38 override get message(): string {
39 return `Failed to register Electron protocol scheme "${this.scheme}".`;
40 }
41}
42
43export class ElectronProtocolUnregistrationError extends Schema.TaggedError<ElectronProtocolUnregistrationError>()(
44 "ElectronProtocolUnregistrationError",
45 {
46 scheme: Schema.String,
47 cause: Schema.Defect(),
48 },
49) {
50 override get message(): string {
51 return `Failed to unregister Electron protocol scheme "${this.scheme}".`;
52 }
53}
54
55// The scheme either proxies to a dev server (`targetOrigin`) or serves the
56// built client from disk (`assetDirectories`).
57export type DesktopProtocolRegistrationInput = {
58 readonly scheme: string;
59 readonly clerkFrontendApiHostname: string | undefined;
60} & (
61 | { readonly targetOrigin: URL }
62 | { readonly assetDirectories: Effect.Effect<readonly [string, ...string[]]> }
63);
64
65export class ElectronProtocol extends Context.Service<
66 ElectronProtocol,
67 {
68 readonly registerDesktopProtocol: (
69 input: DesktopProtocolRegistrationInput,
70 ) => Effect.Effect<void, ElectronProtocolRegistrationError, Scope.Scope>;
71 }
72>()("@t3tools/desktop/electron/ElectronProtocol") {}
73
74export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInput): string {
75 const clerkOrigin = input.clerkFrontendApiHostname
76 ? `https://${input.clerkFrontendApiHostname}`
77 : undefined;
78 const scriptSources = [
79 "'self'",
80 "'unsafe-inline'",
81 "'wasm-unsafe-eval'",
82 ...(clerkOrigin ? [clerkOrigin] : []),
83 "https://challenges.cloudflare.com",
84 ];
85
86 // The renderer connects directly to user-configured environments in addition to
87 // the build-configured Clerk, relay, and OTLP endpoints. Those environment
88 // origins are not known when this response policy is created, so restrict
89 // connections by the network schemes the client supports instead of by host.
90 // GLTFLoader fetches embedded textures through blob URLs after parsing the model.
91 const connectSources = ["'self'", "blob:", "http:", "https:", "ws:", "wss:"];
92
93 return [
94 "default-src 'self'",
95 `script-src ${scriptSources.join(" ")}`,
96 `connect-src ${connectSources.join(" ")}`,
97 `img-src 'self' ${input.scheme}: blob: data: http: https:`,
98 `media-src 'self' ${input.scheme}: blob: http: https:`,
99 "style-src 'self' 'unsafe-inline'",
100 `font-src 'self' ${input.scheme}: data:`,
101 "worker-src 'self' blob:",
102 // Document viewers use local Blob URLs and signed assets from runtime environments.
103 // HTML viewers retain their own sandbox; the renderer's script policy stays unchanged.
104 "frame-src 'self' blob: http: https:",
105 "form-action 'self'",
106 ].join("; ");
107}
108
109function withContentSecurityPolicy(response: Response, policy: string): Response {
110 const headers = new Headers(response.headers);
111 headers.set("Content-Security-Policy", policy);
112 return new Response(response.body, {
113 status: response.status,
114 statusText: response.statusText,
115 headers,
116 });
117}
118
119/**
120 * Must run synchronously during process bootstrap, before Electron emits `ready`.
121 */
122function registerDesktopSchemePrivilegesSync(): void {
123 Electron.protocol.registerSchemesAsPrivileged([
124 {
125 scheme: DESKTOP_PRODUCTION_SCHEME,
126 privileges: {
127 standard: true,
128 secure: true,
129 supportFetchAPI: true,
130 corsEnabled: true,
131 stream: true,
132 // Custom schemes skip Chromium's V8 code cache unless they opt in.
133 // Dev stays off: Vite serves changing code at stable URLs.
134 codeCache: true,
135 },
136 },
137 {
138 scheme: DESKTOP_DEVELOPMENT_SCHEME,
139 privileges: {
140 standard: true,
141 secure: true,
142 supportFetchAPI: true,
143 corsEnabled: true,
144 stream: true,
145 },
146 },
147 ]);
148}
149
150const registerDesktopSchemePrivileges = Effect.sync(registerDesktopSchemePrivilegesSync).pipe(
151 Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges"),
152);
153
154export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges);
155
156class ElectronProtocolFetchError extends Schema.TaggedError<ElectronProtocolFetchError>()(
157 "ElectronProtocolFetchError",
158 { cause: Schema.Defect() },
159) {}
160
161const netFetch = (url: string, init: RequestInit) =>
162 Effect.tryPromise({
163 try: () => Electron.net.fetch(url, init),
164 catch: (cause) => new ElectronProtocolFetchError({ cause }),
165 });
166
167// The dev renderer target can briefly refuse connections while Vite restarts:
168// retry idempotent requests after 50ms, then 150ms, and keep the last failure.
169const fetchWithTransientRetry = (url: string, init: RequestInit) =>
170 netFetch(url, init).pipe(
171 Effect.retry({ schedule: Schedule.exponential("50 millis", 3), times: 2 }),
172 );
173
174const proxyRequest = Effect.fn("desktop.protocol.proxyRequest")(function* (
175 request: Request,
176 targetOrigin: URL,
177 contentSecurityPolicy: string,
178) {
179 const requestUrl = new URL(request.url);
180 if (requestUrl.host !== DESKTOP_HOST) {
181 return new Response(null, { status: 404 });
182 }
183
184 const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, targetOrigin);
185 const headers = new Headers(request.headers);
186 const headersToRemove: string[] = [];
187 for (const name of headers.keys()) {
188 if (
189 name === "host" ||
190 name === "origin" ||
191 name === "referer" ||
192 name === "connection" ||
193 name === "content-length" ||
194 name === "accept-encoding" ||
195 name === "upgrade-insecure-requests" ||
196 name.startsWith("sec-fetch-")
197 ) {
198 headersToRemove.push(name);
199 }
200 }
201 for (const name of headersToRemove) {
202 headers.delete(name);
203 }
204 const init: RequestInit = {
205 method: request.method,
206 headers,
207 };
208 if (request.method !== "GET" && request.method !== "HEAD") {
209 init.body = request.body;
210 (init as RequestInit & { duplex: "half" }).duplex = "half";
211 }
212 const response =
213 request.method === "GET" || request.method === "HEAD"
214 ? yield* fetchWithTransientRetry(targetUrl.toString(), init)
215 : yield* netFetch(targetUrl.toString(), init);
216 return withContentSecurityPolicy(response, contentSecurityPolicy);
217});
218
219const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (
220 request: Request,
221 assetDirectories: readonly [string, ...string[]],
222) {
223 const fileSystem = yield* FileSystem.FileSystem;
224 const path = yield* Path.Path;
225 const url = new URL(request.url);
226 if (url.host !== DESKTOP_HOST) return new Response(null, { status: 404 });
227 if (request.method !== "GET" && request.method !== "HEAD") {
228 return new Response(null, { status: 405 });
229 }
230 const pathname = yield* Effect.try(() => decodeURIComponent(url.pathname)).pipe(
231 Effect.orElseSucceed(() => null),
232 );
233 if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 });
234 const root = path.resolve(assetDirectories[0]);
235 const assetPath = path.resolve(root, `.${pathname}`);
236 if (assetPath !== root && !assetPath.startsWith(root + path.sep)) {
237 return new Response(null, { status: 404 });
238 }
239 let filePath = assetPath;
240 let stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
241 // Old pages can still request lazy chunks while the renderer reloads.
242 if (path.extname(assetPath) !== "" && path.basename(assetPath) !== "index.html") {
243 for (const previous of assetDirectories.slice(1)) {
244 if (stat?.type === "File") break;
245 filePath = path.resolve(previous, `.${pathname}`);
246 stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
247 }
248 }
249 if (stat?.type !== "File") {
250 const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false;
251 if (path.extname(assetPath) !== "" && !wantsHtml) {
252 return new Response(null, { status: 404 });
253 }
254 filePath = path.join(root, "index.html");
255 }
256 const contents = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null));
257 if (contents === null) return new Response(null, { status: 404 });
258 return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), {
259 headers: {
260 "content-type": Option.getOrElse(Mime.getType(filePath), () => "application/octet-stream"),
261 ...(path.basename(filePath) === "index.html" ? { "cache-control": "no-store" } : {}),
262 },
263 });
264});
265
266/** @public Service construction is part of the canonical Effect module API. */
267export const make = Effect.gen(function* () {
268 const registered = yield* Ref.make(false);
269 const context = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
270 const runPromise = Effect.runPromiseWith(context);
271
272 const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")(
273 function* (input: DesktopProtocolRegistrationInput) {
274 if (yield* Ref.get(registered)) return;
275
276 const contentSecurityPolicy = makeDesktopContentSecurityPolicy(input);
277
278 yield* Effect.acquireRelease(
279 Effect.try({
280 try: () => {
281 Electron.protocol.handle(input.scheme, async (request) => {
282 if ("assetDirectories" in input) {
283 return withContentSecurityPolicy(
284 await runPromise(
285 Effect.flatMap(input.assetDirectories, (directories) =>
286 serveDesktopAsset(request, directories),
287 ),
288 ),
289 contentSecurityPolicy,
290 );
291 }
292 // Reject with net.fetch's own error, as an unproxied fetch would.
293 return runPromise(
294 proxyRequest(request, input.targetOrigin, contentSecurityPolicy).pipe(
295 Effect.catchTags({
296 ElectronProtocolFetchError: (error) => Effect.die(error.cause),
297 }),
298 ),
299 );
300 });
301 },
302 catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }),
303 }).pipe(Effect.andThen(Ref.set(registered, true))),
304 () =>
305 Effect.try({
306 try: () => Electron.protocol.unhandle(input.scheme),
307 catch: (cause) =>
308 new ElectronProtocolUnregistrationError({
309 scheme: input.scheme,
310 cause,
311 }),
312 }).pipe(Effect.andThen(Ref.set(registered, false)), Effect.orDie),
313 );
314 },
315 );
316
317 return ElectronProtocol.of({ registerDesktopProtocol });
318});
319
320export const layer = Layer.effect(ElectronProtocol, make);