import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; 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 Scope from "effect/Scope"; import * as Electron from "electron"; import { DesktopEnvironment, type DesktopEnvironmentShape } from "../app/DesktopEnvironment.ts"; export const DESKTOP_SCHEME = "t3"; export class ElectronProtocolRegistrationError extends Data.TaggedError( "ElectronProtocolRegistrationError", )<{ readonly scheme: string; readonly cause: unknown; }> { override get message() { return `Failed to register ${this.scheme}: file protocol.`; } } export class ElectronProtocolStaticBundleMissingError extends Data.TaggedError( "ElectronProtocolStaticBundleMissingError", )<{}> { override get message() { return "Desktop static bundle missing. Build apps/server (with bundled client) first."; } } export interface ElectronProtocolShape { readonly registerFileProtocol: (input: { readonly scheme: string; readonly handler: ( request: Electron.ProtocolRequest, ) => Effect.Effect; readonly onFailure?: ( request: Electron.ProtocolRequest, cause: Cause.Cause, ) => Electron.ProtocolResponse; }) => Effect.Effect; readonly registerDesktopFileProtocol: Effect.Effect< void, ElectronProtocolRegistrationError | ElectronProtocolStaticBundleMissingError, FileSystem.FileSystem | DesktopEnvironment | Scope.Scope >; } export class ElectronProtocol extends Context.Service()( "@t3tools/desktop/electron/ElectronProtocol", ) {} export function normalizeDesktopProtocolPathname(rawPath: string): Option.Option { const segments: string[] = []; for (const segment of rawPath.split("/")) { if (segment.length === 0 || segment === ".") { continue; } if (segment === "..") { return Option.none(); } segments.push(segment); } return Option.some(segments.join("/")); } const registerDesktopSchemePrivileges = Effect.sync(() => { Electron.protocol.registerSchemesAsPrivileged([ { scheme: DESKTOP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, }, }, ]); }).pipe(Effect.withSpan("desktop.electron.protocol.registerSchemePrivileges")); export const layerSchemePrivileges = Layer.effectDiscard(registerDesktopSchemePrivileges); const resolveDesktopStaticDir: Effect.Effect< Option.Option, never, FileSystem.FileSystem | DesktopEnvironment > = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment; const candidates = [ environment.path.join(environment.appRoot, "apps/server/dist/client"), environment.path.join(environment.appRoot, "apps/web/dist"), ]; for (const candidate of candidates) { const hasIndex = yield* fileSystem .exists(environment.path.join(candidate, "index.html")) .pipe(Effect.orElseSucceed(() => false)); if (hasIndex) { return Option.some(candidate); } } return Option.none(); }); const resolveDesktopStaticPath = Effect.fn("desktop.electron.protocol.resolveDesktopStaticPath")( function* ( staticRoot: string, requestUrl: string, ): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment; const url = new URL(requestUrl); const rawPath = decodeURIComponent(url.pathname); const normalizedPath = normalizeDesktopProtocolPathname(rawPath); if (Option.isNone(normalizedPath)) { return environment.path.join(staticRoot, "index.html"); } const requestedPath = normalizedPath.value.length > 0 ? normalizedPath.value : "index.html"; const resolvedPath = environment.path.join(staticRoot, requestedPath); if (environment.path.extname(resolvedPath)) { return resolvedPath; } const nestedIndex = environment.path.join(resolvedPath, "index.html"); const nestedIndexExists = yield* fileSystem .exists(nestedIndex) .pipe(Effect.orElseSucceed(() => false)); if (nestedIndexExists) { return nestedIndex; } return environment.path.join(staticRoot, "index.html"); }, ); function isStaticAssetRequest(requestUrl: string, environment: DesktopEnvironmentShape): boolean { try { const url = new URL(requestUrl); return environment.path.extname(url.pathname).length > 0; } catch { return false; } } const make = Effect.gen(function* () { const registeredProtocols = yield* Ref.make>(new Set()); const registerFileProtocol = Effect.fn("desktop.electron.protocol.registerFileProtocol")( function* ({ scheme, handler, onFailure, }: { readonly scheme: string; readonly handler: ( request: Electron.ProtocolRequest, ) => Effect.Effect; readonly onFailure?: ( request: Electron.ProtocolRequest, cause: Cause.Cause, ) => Electron.ProtocolResponse; }): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ scheme }); const alreadyRegistered = yield* Ref.get(registeredProtocols).pipe( Effect.map((protocols) => protocols.has(scheme)), ); if (alreadyRegistered) { return; } const context = yield* Effect.context(); const runPromise = Effect.runPromiseWith(context); yield* Effect.acquireRelease( Effect.try({ try: () => { const registered = Electron.protocol.registerFileProtocol( scheme, (request, callback) => { const response = handler(request).pipe( Effect.withSpan("desktop.electron.protocol.handleFileRequest"), Effect.catchCause((cause) => Effect.succeed(onFailure?.(request, cause) ?? ({ error: -2 } as const)), ), ); void runPromise(response).then(callback, () => callback({ error: -2 })); }, ); if (!registered) { throw new ElectronProtocolRegistrationError({ scheme, cause: "registerFileProtocol returned false", }); } }, catch: (cause) => cause instanceof ElectronProtocolRegistrationError ? cause : new ElectronProtocolRegistrationError({ scheme, cause }), }).pipe( Effect.andThen( Ref.update(registeredProtocols, (protocols) => new Set(protocols).add(scheme)), ), ), () => Effect.sync(() => { Electron.protocol.unregisterProtocol(scheme); }).pipe( Effect.andThen( Ref.update(registeredProtocols, (protocols) => { const next = new Set(protocols); next.delete(scheme); return next; }), ), ), ); }, ); const registerDesktopFileProtocol = Effect.gen(function* () { const environment = yield* DesktopEnvironment; if (environment.isDevelopment) return; const staticRoot = yield* resolveDesktopStaticDir; if (Option.isNone(staticRoot)) { return yield* new ElectronProtocolStaticBundleMissingError(); } const staticRootResolved = environment.path.resolve(staticRoot.value); const staticRootPrefix = `${staticRootResolved}${environment.path.sep}`; const fallbackIndex = environment.path.join(staticRootResolved, "index.html"); yield* registerFileProtocol({ scheme: DESKTOP_SCHEME, handler: Effect.fn("desktop.electron.protocol.handleDesktopFileRequest")(function* (request) { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment; const candidate = yield* resolveDesktopStaticPath(staticRootResolved, request.url); const resolvedCandidate = environment.path.resolve(candidate); const isInRoot = resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); const isAssetRequest = isStaticAssetRequest(request.url, environment); const exists = yield* fileSystem .exists(resolvedCandidate) .pipe(Effect.orElseSucceed(() => false)); if (!isInRoot || !exists) { return isAssetRequest ? ({ error: -6 } as const) : ({ path: fallbackIndex } as const); } return { path: resolvedCandidate } as const; }), onFailure: () => ({ path: fallbackIndex }), }); }).pipe(Effect.withSpan("desktop.electron.protocol.registerDesktopFileProtocol")); return ElectronProtocol.of({ registerFileProtocol, registerDesktopFileProtocol, }); }); export const layer = Layer.effect(ElectronProtocol, make);