195 lines · 7.3 KB · TypeScript
RawDownload
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 Option from "effect/Option";import * as Schema from "effect/Schema";import * as ChildProcess from "effect/unstable/process/ChildProcess";import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts";import * as DesktopEnvironment from "./DesktopEnvironment.ts";import { makeComponentLogger } from "./DesktopObservability.ts"; // Linux ships as an AppImage, so the .desktop entry users end up with is// created by whatever integration tool they use (AppImageLauncher names it// appimagekit_<hash>-….desktop) and its filename is not under our control.// Electron's app.setAsDefaultProtocolClient resolves the desktop id from// setDesktopName, which cannot match those files — so the browser keeps// prompting "Choose an application" for every OAuth callback. Instead, write// our own handler entry pointing at the current AppImage and claim the// scheme default via xdg-mime, exactly what the file manager's "set as// default" checkbox would record in mimeapps.list.const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError<DesktopLinuxUrlHandlerRegistrationError>()(  "DesktopLinuxUrlHandlerRegistrationError",  {    step: Schema.Literals(["write-desktop-entry", "set-default-handler"]),    scheme: Schema.String,    desktopEntryPath: Schema.optionalKey(Schema.String),    exitCode: Schema.optionalKey(Schema.Number),    cause: Schema.optionalKey(Schema.Defect()),  },) {  override get message(): string {    const exitCode = this.exitCode === undefined ? "" : `, xdg-mime exit code ${this.exitCode}`;    return `Failed to register the ${this.scheme}:// URL handler (step: ${this.step}${exitCode}).`;  }} const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError); const escapeDesktopEntryString = (value: string): string =>  value    .replaceAll("\\", "\\\\")    .replaceAll("\n", "\\n")    .replaceAll("\r", "\\r")    .replaceAll("\t", "\\t"); // Exec values are unescaped twice by implementations: first the general// string-value rules, then the Exec quoting rules — so writing composes the// layers in reverse. The argument is double-quoted with reserved characters// backslash-escaped and literal percent signs doubled (field codes), and the// general string escaping is applied on top: a literal backslash ends up as// four backslashes in the file, a quote as \\", a dollar sign as \\$.export function escapeDesktopEntryExecArgument(value: string): string {  const quoted = value    .replaceAll("\\", () => "\\\\")    .replaceAll("`", () => "\\`")    .replaceAll("$", () => "\\$")    .replaceAll('"', () => '\\"')    .replaceAll("%", () => "%%");  return escapeDesktopEntryString(`"${quoted}"`);} // The AppImage integration entry owns the window identity and icon. This// hidden URL-only entry must not compete with it for StartupWMClass matching.export function renderUrlHandlerDesktopEntry(input: {  readonly displayName: string;  readonly execTarget: string;  readonly scheme: string;}): string {  return [    "[Desktop Entry]",    "Type=Application",    `Name=${escapeDesktopEntryString(input.displayName)}`,    `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`,    "Terminal=false",    "NoDisplay=true",    "StartupNotify=false",    `MimeType=x-scheme-handler/${input.scheme};`,    "",  ].join("\n");} export class DesktopLinuxUrlHandler extends Context.Service<  DesktopLinuxUrlHandler,  {    readonly register: Effect.Effect<void>;  }>()("@t3tools/desktop/app/DesktopLinuxUrlHandler") {} /** @public Service construction is part of the canonical Effect module API. */export const make = Effect.gen(function* () {  const environment = yield* DesktopEnvironment.DesktopEnvironment;  const fileSystem = yield* FileSystem.FileSystem;  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;   const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment);  const desktopEntryPath = environment.path.join(    environment.linuxApplicationsDir,    environment.linuxDesktopEntryName,  );   const writeDesktopEntry = Effect.gen(function* () {    // Inside the mounted AppImage, process.execPath points at a transient    // /tmp/.mount_* path — the handler must launch the AppImage itself.    const execTarget = Option.getOrElse(environment.appImagePath, () => process.execPath);    const content = renderUrlHandlerDesktopEntry({      displayName: environment.displayName,      execTarget,      scheme,    });    // Pre-ready setup normally wrote this already. Avoid truncating a valid    // entry while the portal may be reading it during startup.    const existing = yield* fileSystem      .readFileString(desktopEntryPath)      .pipe(Effect.orElseSucceed(() => null));    if (existing === content) return;    yield* fileSystem.makeDirectory(environment.linuxApplicationsDir, { recursive: true });    yield* fileSystem.writeFileString(desktopEntryPath, content);  }).pipe(    Effect.mapError(      (cause) =>        new DesktopLinuxUrlHandlerRegistrationError({          step: "write-desktop-entry",          scheme,          desktopEntryPath,          cause,        }),    ),  );   const setDefaultHandler = Effect.scoped(    Effect.gen(function* () {      const command = ChildProcess.make(        "xdg-mime",        ["default", environment.linuxDesktopEntryName, `x-scheme-handler/${scheme}`],        {          stdin: "ignore",          stdout: "ignore",          stderr: "ignore",        },      );      const handle = yield* spawner.spawn(command);      const exitCode = yield* handle.exitCode;      if ((exitCode as unknown as number) !== 0) {        return yield* new DesktopLinuxUrlHandlerRegistrationError({          step: "set-default-handler",          scheme,          exitCode: Number(exitCode),        });      }    }),  ).pipe(    Effect.mapError((error) =>      isRegistrationError(error)        ? error        : new DesktopLinuxUrlHandlerRegistrationError({            step: "set-default-handler",            scheme,            cause: error,          }),    ),  );   const register = Effect.gen(function* () {    if (environment.platform !== "linux") {      return;    }    yield* writeDesktopEntry;    if (!environment.isPackaged) return;    yield* setDefaultHandler;    yield* logInfo("registered URL scheme handler", { scheme });  }).pipe(    // Registration is best-effort: a missing xdg-mime or read-only home must    // never block startup — the OS chooser remains as fallback.    Effect.catch((error) =>      logWarning("URL scheme handler registration failed", {        scheme,        step: error.step,        message: error.message,        ...(error.desktopEntryPath === undefined          ? {}          : { desktopEntryPath: error.desktopEntryPath }),        ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }),      }),    ),    Effect.withSpan("desktop.linuxUrlHandler.register"),  );   return DesktopLinuxUrlHandler.of({ register });}); export const layer = Layer.effect(DesktopLinuxUrlHandler, make);