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_-….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", { 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; } >()("@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);