diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd86c5f05d2..dd7f44edf66 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -17,6 +17,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; +import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopObservability from "./DesktopObservability.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; @@ -220,6 +221,7 @@ const startup = Effect.gen(function* () { const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; const electronApp = yield* ElectronApp.ElectronApp; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; @@ -248,6 +250,7 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* applicationMenu.configure; yield* updates.configure; + yield* linuxUrlHandler.register; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopConfig.ts b/apps/desktop/src/app/DesktopConfig.ts index 4bf6b513306..d157a4c6ba4 100644 --- a/apps/desktop/src/app/DesktopConfig.ts +++ b/apps/desktop/src/app/DesktopConfig.ts @@ -35,6 +35,7 @@ const compactEnv = (env: Readonly>): Record; readonly userDataDirName: string; readonly legacyUserDataDirName: string; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; @@ -162,6 +164,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( ); const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + const linuxApplicationsDir = path.join( + Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")), + "applications", + ); const resourcesPath = input.resourcesPath; return DesktopEnvironment.of({ @@ -205,6 +211,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( ), linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", + linuxApplicationsDir, + appImagePath: config.appImagePath, userDataDirName, legacyUserDataDirName, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts new file mode 100644 index 00000000000..30183808a15 --- /dev/null +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -0,0 +1,229 @@ +import { assert, describe, it } from "@effect/vitest"; +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 PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; + +interface RecordedRegistration { + readonly directories: string[]; + readonly files: Array<{ readonly path: string; readonly content: string }>; + readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray }>; +} + +const makeEnvironment = (overrides: Record = {}) => + DesktopEnvironment.DesktopEnvironment.of({ + platform: "linux", + isPackaged: true, + isDevelopment: false, + displayName: "T3 Code (Alpha)", + linuxWmClass: "t3code", + linuxApplicationsDir: "/home/alice/.local/share/applications", + appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), + path: { join: (...parts: ReadonlyArray) => parts.join("/") }, + ...overrides, + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + +const mockProcess = (exitCode: number) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + +const makeHandlerLayer = ( + recorded: RecordedRegistration, + input: { + readonly environment?: Record; + readonly xdgMimeExitCode?: number; + readonly writeError?: PlatformError.PlatformError; + } = {}, +) => + DesktopLinuxUrlHandler.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(DesktopEnvironment.DesktopEnvironment, makeEnvironment(input.environment)), + FileSystem.layerNoop({ + makeDirectory: (path) => + Effect.sync(() => { + recorded.directories.push(path); + }), + writeFileString: (path, content) => + input.writeError + ? Effect.fail(input.writeError) + : Effect.sync(() => { + recorded.files.push({ path, content }); + }), + }), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + recorded.commands.push({ + command: childProcess.command, + args: childProcess.args, + }); + return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0)); + }), + ), + ), + ), + ); + +const runRegister = ( + recorded: RecordedRegistration, + input: Parameters[1] = {}, +) => + Effect.gen(function* () { + const handler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; + yield* handler.register; + }).pipe(Effect.provide(makeHandlerLayer(recorded, input))); + +const emptyRecording = (): RecordedRegistration => ({ + directories: [], + files: [], + commands: [], +}); + +describe("DesktopLinuxUrlHandler", () => { + it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => { + const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Nightly)", + execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage', + scheme: "t3code", + }); + + assert.include(entry, "[Desktop Entry]"); + assert.include(entry, "Name=T3 Code (Nightly)"); + // Exec composes both escaping layers: a literal backslash becomes four + // backslashes in the file, a quote three characters, a dollar sign two + // backslashes plus the sign. + assert.include( + entry, + 'Exec="/home/al ice/Apps/T3 \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', + ); + assert.include(entry, "NoDisplay=true"); + assert.notInclude(entry, "StartupWMClass="); + assert.include(entry, "MimeType=x-scheme-handler/t3code;"); + }); + + it("carries structured context on registration errors", () => { + const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ + step: "write-desktop-entry", + scheme: "t3code", + desktopEntryPath: "/home/alice/.local/share/applications/t3code-url-handler.desktop", + cause: new Error("boom"), + }); + assert.equal( + writeError.message, + "Failed to register the t3code:// URL handler (step: write-desktop-entry).", + ); + assert.equal( + writeError.desktopEntryPath, + "/home/alice/.local/share/applications/t3code-url-handler.desktop", + ); + + const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ + step: "set-default-handler", + scheme: "t3code", + exitCode: 4, + }); + assert.equal( + exitError.message, + "Failed to register the t3code:// URL handler (step: set-default-handler, xdg-mime exit code 4).", + ); + }); + + it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => { + const recorded = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(recorded); + + assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]); + assert.equal(recorded.files.length, 1); + assert.equal( + recorded.files[0]?.path, + "/home/alice/.local/share/applications/t3code-url-handler.desktop", + ); + assert.include( + recorded.files[0]?.content, + 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', + ); + assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); + assert.deepEqual(recorded.commands, [ + { + command: "xdg-mime", + args: ["default", "t3code-url-handler.desktop", "x-scheme-handler/t3code"], + }, + ]); + }); + }); + + it.effect("falls back to the process executable outside an AppImage", () => { + const recorded = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(recorded, { environment: { appImagePath: Option.none() } }); + + assert.include( + recorded.files[0]?.content, + `Exec=${DesktopLinuxUrlHandler.escapeDesktopEntryExecArgument(process.execPath)} %U`, + ); + }); + }); + + it.effect("does nothing on other platforms or unpackaged builds", () => { + const nonLinux = emptyRecording(); + const unpackaged = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(nonLinux, { environment: { platform: "darwin" } }); + yield* runRegister(unpackaged, { environment: { isPackaged: false } }); + + for (const recorded of [nonLinux, unpackaged]) { + assert.deepEqual(recorded.directories, []); + assert.deepEqual(recorded.files, []); + assert.deepEqual(recorded.commands, []); + } + }); + }); + + it.effect("never fails startup when registration cannot complete", () => { + const xdgMimeFailed = emptyRecording(); + const writeFailed = emptyRecording(); + + return Effect.gen(function* () { + yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 }); + yield* runRegister(writeFailed, { + writeError: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "writeFileString", + description: "read-only filesystem", + pathOrDescriptor: "/home/alice/.local/share/applications/t3code-url-handler.desktop", + }), + }); + + assert.equal(xdgMimeFailed.files.length, 1); + assert.deepEqual(writeFailed.commands, []); + }); + }); +}); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts new file mode 100644 index 00000000000..e531a54dfce --- /dev/null +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -0,0 +1,191 @@ +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. +export const URL_HANDLER_DESKTOP_ENTRY_NAME = "t3code-url-handler.desktop"; + +const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); + +export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedErrorClass()( + "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") {} + +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, + URL_HANDLER_DESKTOP_ENTRY_NAME, + ); + + 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); + yield* fileSystem.makeDirectory(environment.linuxApplicationsDir, { recursive: true }); + yield* fileSystem.writeFileString( + desktopEntryPath, + renderUrlHandlerDesktopEntry({ + displayName: environment.displayName, + execTarget, + scheme, + }), + ); + }).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", URL_HANDLER_DESKTOP_ENTRY_NAME, `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" || !environment.isPackaged) { + return; + } + yield* writeDesktopEntry; + 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); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ad370e36fdb..933a7e4d831 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -43,6 +43,7 @@ import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentA import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; +import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; import * as DesktopShutdown from "./app/DesktopShutdown.ts"; import * as DesktopObservability from "./app/DesktopObservability.ts"; import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; @@ -181,6 +182,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, DesktopApplicationMenu.layer, + DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, desktopSshLayer, ).pipe(