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);
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);