From 50bebd9680d8d8160d23077fdf9aae9e76fd6588 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 16:03:12 -0700 Subject: [PATCH 1/4] fix(desktop): claim the t3code:// scheme default on Linux at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AppImage's integrated .desktop entry now advertises MimeType=x-scheme-handler/t3code, so the app shows up in the browser's "Choose an application" dialog — but nothing records it as the default handler, so every OAuth callback prompts the user again (and the entry's filename is decided by the integration tool, e.g. AppImageLauncher's appimagekit_-….desktop, so Electron's setAsDefaultProtocolClient cannot claim it via setDesktopName). Write a dedicated t3code-url-handler.desktop into $XDG_DATA_HOME/applications pointing at the current $APPIMAGE and set it as the scheme default via xdg-mime — the same association the file manager's "set as default" checkbox records. Registration is best-effort and never blocks startup. Co-Authored-By: Claude Fable 5 --- apps/desktop/src/app/DesktopApp.ts | 3 + apps/desktop/src/app/DesktopConfig.ts | 1 + apps/desktop/src/app/DesktopEnvironment.ts | 8 + .../src/app/DesktopLinuxUrlHandler.test.ts | 197 ++++++++++++++++++ .../desktop/src/app/DesktopLinuxUrlHandler.ts | 177 ++++++++++++++++ apps/desktop/src/main.ts | 2 + 6 files changed, 388 insertions(+) create mode 100644 apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts create mode 100644 apps/desktop/src/app/DesktopLinuxUrlHandler.ts 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..82a3254dcac --- /dev/null +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -0,0 +1,197 @@ +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', + wmClass: "t3code", + scheme: "t3code", + }); + + assert.include(entry, "[Desktop Entry]"); + assert.include(entry, "Name=T3 Code (Nightly)"); + assert.include(entry, 'Exec="/home/al ice/Apps/T3 \\"100%%\\" \\$HOME\\\\x.AppImage" %U'); + assert.include(entry, "NoDisplay=true"); + assert.include(entry, "StartupWMClass=t3code"); + assert.include(entry, "MimeType=x-scheme-handler/t3code;"); + }); + + 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..0c7d1c7cc95 --- /dev/null +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -0,0 +1,177 @@ +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, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to register the ${this.scheme}:// URL handler (step: ${this.step}).`; + } +} + +const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError); + +const escapeDesktopEntryString = (value: string): string => + value + .replaceAll("\\", "\\\\") + .replaceAll("\n", "\\n") + .replaceAll("\r", "\\r") + .replaceAll("\t", "\\t"); + +// Exec values follow the freedesktop quoting rules: the argument is +// double-quoted, reserved characters are backslash-escaped inside the quotes, +// and literal percent signs are doubled so they are not read as field codes. +export function escapeDesktopEntryExecArgument(value: string): string { + const escaped = value + .replaceAll("\\", () => "\\\\") + .replaceAll("`", () => "\\`") + .replaceAll("$", () => "\\$") + .replaceAll('"', () => '\\"') + .replaceAll("%", () => "%%"); + return `"${escaped}"`; +} + +export function renderUrlHandlerDesktopEntry(input: { + readonly displayName: string; + readonly execTarget: string; + readonly wmClass: 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", + `StartupWMClass=${escapeDesktopEntryString(input.wmClass)}`, + `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 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( + environment.path.join(environment.linuxApplicationsDir, URL_HANDLER_DESKTOP_ENTRY_NAME), + renderUrlHandlerDesktopEntry({ + displayName: environment.displayName, + execTarget, + wmClass: environment.linuxWmClass, + scheme, + }), + ); + }).pipe( + Effect.mapError( + (cause) => + new DesktopLinuxUrlHandlerRegistrationError({ + step: "write-desktop-entry", + scheme, + 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, + cause: new Error(`xdg-mime exited with code ${String(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, + }), + ), + 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( From c7535e45a02dff3079e5e2b8da0cff1e45d200e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 16:07:09 -0700 Subject: [PATCH 2/4] refactor(desktop): carry structured context on URL handler registration errors Add desktopEntryPath and exitCode fields to DesktopLinuxUrlHandlerRegistrationError so failures log the resolved entry path and the xdg-mime exit status instead of burying them in a synthetic Error cause; cause is now optional for the domain failure. Co-Authored-By: Claude Fable 5 --- .../src/app/DesktopLinuxUrlHandler.test.ts | 27 +++++++++++++++++++ .../desktop/src/app/DesktopLinuxUrlHandler.ts | 20 +++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 82a3254dcac..b785b7be508 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -119,6 +119,33 @@ describe("DesktopLinuxUrlHandler", () => { 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(); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 0c7d1c7cc95..85bdddf5e6e 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -29,11 +29,14 @@ export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedErrorC { step: Schema.Literals(["write-desktop-entry", "set-default-handler"]), scheme: Schema.String, - cause: Schema.Defect(), + desktopEntryPath: Schema.optionalKey(Schema.String), + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), }, ) { override get message(): string { - return `Failed to register the ${this.scheme}:// URL handler (step: ${this.step}).`; + const exitCode = this.exitCode === undefined ? "" : `, xdg-mime exit code ${this.exitCode}`; + return `Failed to register the ${this.scheme}:// URL handler (step: ${this.step}${exitCode}).`; } } @@ -92,6 +95,10 @@ export const make = Effect.gen(function* () { 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 @@ -99,7 +106,7 @@ export const make = Effect.gen(function* () { const execTarget = Option.getOrElse(environment.appImagePath, () => process.execPath); yield* fileSystem.makeDirectory(environment.linuxApplicationsDir, { recursive: true }); yield* fileSystem.writeFileString( - environment.path.join(environment.linuxApplicationsDir, URL_HANDLER_DESKTOP_ENTRY_NAME), + desktopEntryPath, renderUrlHandlerDesktopEntry({ displayName: environment.displayName, execTarget, @@ -113,6 +120,7 @@ export const make = Effect.gen(function* () { new DesktopLinuxUrlHandlerRegistrationError({ step: "write-desktop-entry", scheme, + desktopEntryPath, cause, }), ), @@ -135,7 +143,7 @@ export const make = Effect.gen(function* () { return yield* new DesktopLinuxUrlHandlerRegistrationError({ step: "set-default-handler", scheme, - cause: new Error(`xdg-mime exited with code ${String(exitCode)}`), + exitCode: Number(exitCode), }); } }), @@ -166,6 +174,10 @@ export const make = Effect.gen(function* () { scheme, step: error.step, message: error.message, + ...(error.desktopEntryPath === undefined + ? {} + : { desktopEntryPath: error.desktopEntryPath }), + ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }), }), ), Effect.withSpan("desktop.linuxUrlHandler.register"), From be4a82688839f444a95044714caaa783dcb55852 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 16:10:23 -0700 Subject: [PATCH 3/4] fix(desktop): compose both .desktop escaping layers in Exec values Implementations unescape a desktop entry's Exec value twice: the general string-value rules first, then the Exec quoting rules. Writing therefore has to apply Exec quoting first and general string escaping on top, so a literal backslash in the AppImage path lands as four backslashes in the file, a double quote as \\" and a dollar sign as \\$. The previous single-layer escaping produced a malformed command for paths containing those characters. Co-Authored-By: Claude Fable 5 --- apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts | 8 +++++++- apps/desktop/src/app/DesktopLinuxUrlHandler.ts | 13 ++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index b785b7be508..6f5faec4f6d 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -113,7 +113,13 @@ describe("DesktopLinuxUrlHandler", () => { assert.include(entry, "[Desktop Entry]"); assert.include(entry, "Name=T3 Code (Nightly)"); - assert.include(entry, 'Exec="/home/al ice/Apps/T3 \\"100%%\\" \\$HOME\\\\x.AppImage" %U'); + // 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.include(entry, "StartupWMClass=t3code"); assert.include(entry, "MimeType=x-scheme-handler/t3code;"); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 85bdddf5e6e..8a0f77d47ec 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -49,17 +49,20 @@ const escapeDesktopEntryString = (value: string): string => .replaceAll("\r", "\\r") .replaceAll("\t", "\\t"); -// Exec values follow the freedesktop quoting rules: the argument is -// double-quoted, reserved characters are backslash-escaped inside the quotes, -// and literal percent signs are doubled so they are not read as field codes. +// 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 escaped = value + const quoted = value .replaceAll("\\", () => "\\\\") .replaceAll("`", () => "\\`") .replaceAll("$", () => "\\$") .replaceAll('"', () => '\\"') .replaceAll("%", () => "%%"); - return `"${escaped}"`; + return escapeDesktopEntryString(`"${quoted}"`); } export function renderUrlHandlerDesktopEntry(input: { From 76e80dd1f23136bb7352971bb8efccdd7cda42f7 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Sun, 2 Aug 2026 17:50:32 -0400 Subject: [PATCH 4/4] fix(desktop): Preserve Linux workspace icons (#5249) --- apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts | 3 +-- apps/desktop/src/app/DesktopLinuxUrlHandler.ts | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 6f5faec4f6d..30183808a15 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -107,7 +107,6 @@ describe("DesktopLinuxUrlHandler", () => { const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ displayName: "T3 Code (Nightly)", execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage', - wmClass: "t3code", scheme: "t3code", }); @@ -121,7 +120,7 @@ describe("DesktopLinuxUrlHandler", () => { 'Exec="/home/al ice/Apps/T3 \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', ); assert.include(entry, "NoDisplay=true"); - assert.include(entry, "StartupWMClass=t3code"); + assert.notInclude(entry, "StartupWMClass="); assert.include(entry, "MimeType=x-scheme-handler/t3code;"); }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 8a0f77d47ec..e531a54dfce 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -65,10 +65,11 @@ export function escapeDesktopEntryExecArgument(value: string): string { 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 wmClass: string; readonly scheme: string; }): string { return [ @@ -79,7 +80,6 @@ export function renderUrlHandlerDesktopEntry(input: { "Terminal=false", "NoDisplay=true", "StartupNotify=false", - `StartupWMClass=${escapeDesktopEntryString(input.wmClass)}`, `MimeType=x-scheme-handler/${input.scheme};`, "", ].join("\n"); @@ -113,7 +113,6 @@ export const make = Effect.gen(function* () { renderUrlHandlerDesktopEntry({ displayName: environment.displayName, execTarget, - wmClass: environment.linuxWmClass, scheme, }), );