From 1b8e9bb625239741703bc57031e23ec84a6eaa67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Lima?= Date: Sat, 29 Aug 2026 21:19:28 +0100 Subject: [PATCH 1/5] fix(desktop): refresh Linux MIME handler cache --- .../src/app/DesktopLinuxUrlHandler.test.ts | 30 +++++++- .../desktop/src/app/DesktopLinuxUrlHandler.ts | 69 ++++++++++++++++++- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index f0b88101587c..ca0ff212b340 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -50,6 +50,7 @@ const makeHandlerLayer = ( recorded: RecordedRegistration, input: { readonly environment?: Record; + readonly updateDesktopDatabaseExitCode?: number; readonly xdgMimeExitCode?: number; readonly writeError?: PlatformError.PlatformError; readonly existingEntry?: string; @@ -83,7 +84,11 @@ const makeHandlerLayer = ( command: childProcess.command, args: childProcess.args, }); - return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0)); + const exitCode = + childProcess.command === "update-desktop-database" + ? (input.updateDesktopDatabaseExitCode ?? 0) + : (input.xdgMimeExitCode ?? 0); + return Effect.succeed(mockProcess(exitCode)); }), ), ), @@ -154,7 +159,7 @@ describe("DesktopLinuxUrlHandler", () => { ); }); - it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => { + it.effect("writes the handler entry, refreshes the MIME cache, and claims the scheme default", () => { const recorded = emptyRecording(); return Effect.gen(function* () { @@ -172,6 +177,10 @@ describe("DesktopLinuxUrlHandler", () => { ); assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); assert.deepEqual(recorded.commands, [ + { + command: "update-desktop-database", + args: ["/home/alice/.local/share/applications"], + }, { command: "xdg-mime", args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], @@ -207,7 +216,16 @@ describe("DesktopLinuxUrlHandler", () => { assert.deepEqual(recorded.files, []); assert.deepEqual(recorded.directories, []); - assert.equal(recorded.commands.length, 1); + assert.deepEqual(recorded.commands, [ + { + command: "update-desktop-database", + args: ["/home/alice/.local/share/applications"], + }, + { + command: "xdg-mime", + args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], + }, + ]); }); }); @@ -234,10 +252,12 @@ describe("DesktopLinuxUrlHandler", () => { }); it.effect("never fails startup when registration cannot complete", () => { + const desktopDatabaseFailed = emptyRecording(); const xdgMimeFailed = emptyRecording(); const writeFailed = emptyRecording(); return Effect.gen(function* () { + yield* runRegister(desktopDatabaseFailed, { updateDesktopDatabaseExitCode: 1 }); yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 }); yield* runRegister(writeFailed, { writeError: PlatformError.systemError({ @@ -249,6 +269,10 @@ describe("DesktopLinuxUrlHandler", () => { }), }); + assert.deepEqual( + desktopDatabaseFailed.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); 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 index 404aff34c6bf..efb802fb1a90 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -17,9 +17,9 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // 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. +// our own handler entry pointing at the current AppImage, refresh the desktop +// MIME cache so desktop environments recognize that entry as a handler, and +// use xdg-mime to record it as the scheme default in mimeapps.list. const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError()( @@ -40,6 +40,23 @@ export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError< const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError); +export class DesktopLinuxUrlHandlerCacheRefreshError extends Schema.TaggedError()( + "DesktopLinuxUrlHandlerCacheRefreshError", + { + applicationsDir: Schema.String, + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + const exitCode = + this.exitCode === undefined ? "" : `, update-desktop-database exit code ${this.exitCode}`; + return `Failed to refresh the desktop MIME cache at ${this.applicationsDir}${exitCode}.`; + } +} + +const isCacheRefreshError = Schema.is(DesktopLinuxUrlHandlerCacheRefreshError); + const escapeDesktopEntryString = (value: string): string => value .replaceAll("\\", "\\\\") @@ -131,6 +148,37 @@ export const make = Effect.gen(function* () { ), ); + const updateDesktopDatabase = Effect.scoped( + Effect.gen(function* () { + const command = ChildProcess.make( + "update-desktop-database", + [environment.linuxApplicationsDir], + { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }, + ); + const handle = yield* spawner.spawn(command); + const exitCode = yield* handle.exitCode; + if (exitCode !== 0) { + return yield* new DesktopLinuxUrlHandlerCacheRefreshError({ + applicationsDir: environment.linuxApplicationsDir, + exitCode, + }); + } + }), + ).pipe( + Effect.mapError((error) => + isCacheRefreshError(error) + ? error + : new DesktopLinuxUrlHandlerCacheRefreshError({ + applicationsDir: environment.linuxApplicationsDir, + cause: error, + }), + ), + ); + const setDefaultHandler = Effect.scoped( Effect.gen(function* () { const command = ChildProcess.make( @@ -170,6 +218,21 @@ export const make = Effect.gen(function* () { } yield* writeDesktopEntry; if (!environment.isPackaged) return; + + yield* updateDesktopDatabase.pipe( + // Some MIME implementations, including GIO, use mimeinfo.cache to verify + // that a desktop entry is associated with a scheme. Cache refresh is + // independently best-effort so a missing update-desktop-database executable + // does not prevent xdg-mime from recording the requested default. + Effect.catch((error) => + logWarning("desktop MIME cache refresh failed", { + applicationsDir: environment.linuxApplicationsDir, + message: error.message, + ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }), + }), + ), + ); + yield* setDefaultHandler; yield* logInfo("registered URL scheme handler", { scheme }); }).pipe( From 21a30a5ec4ae881d8ab3d094c1ec877881435cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Lima?= Date: Mon, 28 Sep 2026 21:32:15 +0100 Subject: [PATCH 2/5] fix(desktop): bound Linux MIME cache refresh wait --- .../src/app/DesktopLinuxUrlHandler.test.ts | 98 +++++++++++++------ .../desktop/src/app/DesktopLinuxUrlHandler.ts | 2 +- 2 files changed, 69 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index ca0ff212b340..3a656a2f30c4 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -1,11 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; 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 TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -31,10 +34,10 @@ const makeEnvironment = (overrides: Record = {}) => ...overrides, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); -const mockProcess = (exitCode: number) => +const mockProcess = (exitCode: number, stalled = false) => ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + exitCode: stalled ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), @@ -51,6 +54,8 @@ const makeHandlerLayer = ( input: { readonly environment?: Record; readonly updateDesktopDatabaseExitCode?: number; + readonly updateDesktopDatabaseStalled?: boolean; + readonly updateDesktopDatabaseStarted?: Deferred.Deferred; readonly xdgMimeExitCode?: number; readonly writeError?: PlatformError.PlatformError; readonly existingEntry?: string; @@ -88,7 +93,17 @@ const makeHandlerLayer = ( childProcess.command === "update-desktop-database" ? (input.updateDesktopDatabaseExitCode ?? 0) : (input.xdgMimeExitCode ?? 0); - return Effect.succeed(mockProcess(exitCode)); + const handle = mockProcess( + exitCode, + childProcess.command === "update-desktop-database" && + input.updateDesktopDatabaseStalled === true, + ); + return childProcess.command === "update-desktop-database" && + input.updateDesktopDatabaseStarted + ? Deferred.succeed(input.updateDesktopDatabaseStarted, undefined).pipe( + Effect.as(handle), + ) + : Effect.succeed(handle); }), ), ), @@ -159,35 +174,38 @@ describe("DesktopLinuxUrlHandler", () => { ); }); - it.effect("writes the handler entry, refreshes the MIME cache, and claims the scheme default", () => { - const recorded = emptyRecording(); + it.effect( + "writes the handler entry, refreshes the MIME cache, and claims the scheme default", + () => { + const recorded = emptyRecording(); - return Effect.gen(function* () { - yield* runRegister(recorded); + 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/com.t3tools.T3Code.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: "update-desktop-database", - args: ["/home/alice/.local/share/applications"], - }, - { - command: "xdg-mime", - args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], - }, - ]); - }); - }); + 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/com.t3tools.T3Code.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: "update-desktop-database", + args: ["/home/alice/.local/share/applications"], + }, + { + command: "xdg-mime", + args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], + }, + ]); + }); + }, + ); it.effect("falls back to the process executable outside an AppImage", () => { const recorded = emptyRecording(); @@ -277,4 +295,24 @@ describe("DesktopLinuxUrlHandler", () => { assert.deepEqual(writeFailed.commands, []); }); }); + + it.effect("continues to xdg-mime when the desktop MIME cache refresh stalls", () => + Effect.gen(function* () { + const recorded = emptyRecording(); + const started = yield* Deferred.make(); + const registration = yield* runRegister(recorded, { + updateDesktopDatabaseStalled: true, + updateDesktopDatabaseStarted: started, + }).pipe(Effect.forkChild); + + yield* Deferred.await(started); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(registration); + + assert.deepEqual( + recorded.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index efb802fb1a90..53569a59fdf5 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -160,7 +160,7 @@ export const make = Effect.gen(function* () { }, ); const handle = yield* spawner.spawn(command); - const exitCode = yield* handle.exitCode; + const exitCode = yield* handle.exitCode.pipe(Effect.timeout("5 seconds")); if (exitCode !== 0) { return yield* new DesktopLinuxUrlHandlerCacheRefreshError({ applicationsDir: environment.linuxApplicationsDir, From 5262e13b1d5276cabc1b5cfdeed521ed2b0b9801 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:02:42 +0000 Subject: [PATCH 3/5] fix(desktop): show the app icon in Linux URL choosers --- .../src/app/DesktopLinuxUrlHandler.test.ts | 82 ++++++++++++++++++- .../desktop/src/app/DesktopLinuxUrlHandler.ts | 17 +++- .../src/app/DesktopPreReadyPlatform.test.ts | 35 +++++++- .../src/app/DesktopPreReadyPlatform.ts | 23 ++++++ 4 files changed, 152 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 3a656a2f30c4..893c1aaebe1d 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -12,15 +13,17 @@ import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopAssets from "./DesktopAssets.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 }>; + readonly copies: Array<{ readonly source: string; readonly destination: string }>; } -const makeEnvironment = (overrides: Record = {}) => +const makeEnvironment = (path: Path.Path, overrides: Record = {}) => DesktopEnvironment.DesktopEnvironment.of({ platform: "linux", isPackaged: true, @@ -30,7 +33,7 @@ const makeEnvironment = (overrides: Record = {}) => linuxWmClass: "t3code", linuxApplicationsDir: "/home/alice/.local/share/applications", appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), - path: { join: (...parts: ReadonlyArray) => parts.join("/") }, + path, ...overrides, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); @@ -59,13 +62,35 @@ const makeHandlerLayer = ( readonly xdgMimeExitCode?: number; readonly writeError?: PlatformError.PlatformError; readonly existingEntry?: string; + readonly iconSource?: string; + readonly iconCopyError?: PlatformError.PlatformError; } = {}, ) => DesktopLinuxUrlHandler.layer.pipe( Layer.provide( Layer.mergeAll( - Layer.succeed(DesktopEnvironment.DesktopEnvironment, makeEnvironment(input.environment)), + Layer.effect( + DesktopEnvironment.DesktopEnvironment, + Path.Path.pipe( + Effect.map((path) => makeEnvironment(path, input.environment)), + Effect.provide(Path.layer), + ), + ), + Layer.succeed(DesktopAssets.DesktopAssets, { + iconPaths: Effect.succeed({ + png: Option.fromUndefinedOr(input.iconSource), + ico: Option.none(), + icns: Option.none(), + }), + resolveResourcePath: () => Effect.succeedNone, + }), FileSystem.layerNoop({ + copyFile: (source, destination) => + input.iconCopyError + ? Effect.fail(input.iconCopyError) + : Effect.sync(() => { + recorded.copies.push({ source, destination }); + }), readFileString: () => Effect.succeed(input.existingEntry ?? ""), makeDirectory: (path) => Effect.sync(() => { @@ -85,6 +110,12 @@ const makeHandlerLayer = ( readonly command: string; readonly args: ReadonlyArray; }; + if (childProcess.command === "update-desktop-database") { + assert.isTrue( + recorded.files.length > 0 || input.existingEntry !== undefined, + "the desktop entry must exist before refreshing the MIME cache", + ); + } recorded.commands.push({ command: childProcess.command, args: childProcess.args, @@ -123,6 +154,7 @@ const emptyRecording = (): RecordedRegistration => ({ directories: [], files: [], commands: [], + copies: [], }); describe("DesktopLinuxUrlHandler", () => { @@ -131,6 +163,7 @@ describe("DesktopLinuxUrlHandler", () => { displayName: "T3 Code (Nightly)", execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage', scheme: "t3code", + iconPath: "/home/al ice/icons/T3\\x.png", }); assert.include(entry, "[Desktop Entry]"); @@ -145,6 +178,7 @@ describe("DesktopLinuxUrlHandler", () => { assert.include(entry, "NoDisplay=true"); assert.notInclude(entry, "StartupWMClass="); assert.include(entry, "MimeType=x-scheme-handler/t3code;"); + assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png"); }); it("carries structured context on registration errors", () => { @@ -229,6 +263,7 @@ describe("DesktopLinuxUrlHandler", () => { displayName: "T3 Code (Alpha)", execTarget: "/home/alice/Applications/T3-Code.AppImage", scheme: "t3code", + iconPath: "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png", }), }); @@ -247,6 +282,47 @@ describe("DesktopLinuxUrlHandler", () => { }); }); + it.effect("installs a persistent icon even when the desktop entry is already current", () => { + const recorded = emptyRecording(); + const iconPath = "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png"; + return Effect.gen(function* () { + yield* runRegister(recorded, { + iconSource: "/tmp/.mount_T3/resources/icon.png", + existingEntry: DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Alpha)", + execTarget: "/home/alice/Applications/T3-Code.AppImage", + scheme: "t3code", + iconPath, + }), + }); + assert.deepEqual(recorded.files, []); + assert.deepEqual(recorded.copies, [ + { source: "/tmp/.mount_T3/resources/icon.png", destination: iconPath }, + ]); + assert.equal(recorded.commands.at(-1)?.command, "xdg-mime"); + }); + }); + + it.effect("still registers the handler when copying its icon fails", () => { + const recorded = emptyRecording(); + return Effect.gen(function* () { + yield* runRegister(recorded, { + iconSource: "/tmp/.mount_T3/resources/icon.png", + iconCopyError: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "copyFile", + description: "read-only icon directory", + }), + }); + assert.equal(recorded.files.length, 1); + assert.deepEqual( + recorded.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); + }); + }); + it.effect("writes the portal identity without claiming the URL scheme in development", () => { const nonLinux = emptyRecording(); const unpackaged = emptyRecording(); diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 53569a59fdf5..99ec910cb3bf 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -8,6 +8,7 @@ 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 DesktopAssets from "./DesktopAssets.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { makeComponentLogger } from "./DesktopObservability.ts"; @@ -80,18 +81,20 @@ export function escapeDesktopEntryExecArgument(value: string): string { return escapeDesktopEntryString(`"${quoted}"`); } -// The AppImage integration entry owns the window identity and icon. This +// The AppImage integration entry owns the window identity. 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; + readonly iconPath?: string; }): string { return [ "[Desktop Entry]", "Type=Application", `Name=${escapeDesktopEntryString(input.displayName)}`, `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`, + ...(input.iconPath === undefined ? [] : [`Icon=${escapeDesktopEntryString(input.iconPath)}`]), "Terminal=false", "NoDisplay=true", "StartupNotify=false", @@ -112,12 +115,15 @@ export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const assets = yield* DesktopAssets.DesktopAssets; const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); const desktopEntryPath = environment.path.join( environment.linuxApplicationsDir, environment.linuxDesktopEntryName, ); + const iconsDir = environment.path.join(environment.linuxApplicationsDir, "..", "icons"); + const iconPath = environment.path.join(iconsDir, `${environment.linuxDesktopEntryName}.png`); const writeDesktopEntry = Effect.gen(function* () { // Inside the mounted AppImage, process.execPath points at a transient @@ -127,6 +133,7 @@ export const make = Effect.gen(function* () { displayName: environment.displayName, execTarget, scheme, + ...(environment.isPackaged ? { iconPath } : {}), }); // Pre-ready setup normally wrote this already. Avoid truncating a valid // entry while the portal may be reading it during startup. @@ -219,6 +226,14 @@ export const make = Effect.gen(function* () { yield* writeDesktopEntry; if (!environment.isPackaged) return; + yield* Effect.gen(function* () { + const { png } = yield* assets.iconPaths; + if (Option.isNone(png)) return; + // The AppImage mount is temporary; the chooser needs the icon after exit. + yield* fileSystem.makeDirectory(iconsDir, { recursive: true }); + yield* fileSystem.copyFile(png.value, iconPath); + }).pipe(Effect.catch(() => logWarning("URL handler icon copy failed", { iconPath }))); + yield* updateDesktopDatabase.pipe( // Some MIME implementations, including GIO, use mimeinfo.cache to verify // that a desktop entry is associated with a scheme. Cache refresh is diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 9ddaf40caa0a..7e859aaf981a 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -13,6 +13,7 @@ const { setDesktopNameMock, mkdirSyncMock, writeFileSyncMock, + copyFileSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -21,12 +22,15 @@ const { setDesktopNameMock: vi.fn(), mkdirSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), + copyFileSyncMock: vi.fn(), })); vi.mock("electron", () => ({ app: { setDesktopName: setDesktopNameMock, getVersion: () => "0.0.37", + isPackaged: true, + getAppPath: () => "/tmp/.mount_T3/resources/app.asar", commandLine: { appendSwitch: appendSwitchMock, getSwitchValue: getSwitchValueMock, @@ -42,6 +46,7 @@ vi.mock("node:fs", () => ({ readFileSync: () => "{}", mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, + copyFileSync: copyFileSyncMock, })); import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; @@ -55,6 +60,7 @@ describe("DesktopPreReadyPlatform", () => { setDesktopNameMock.mockReset(); mkdirSyncMock.mockReset(); writeFileSyncMock.mockReset(); + copyFileSyncMock.mockReset(); }); it.effect("preserves an explicit Linux password-store switch", () => { @@ -85,6 +91,10 @@ describe("DesktopPreReadyPlatform", () => { getSwitchValueMock.mockReturnValue(""); let desktopName = "t3code.desktop"; let desktopEntry = previousEntry; + let iconInstalled = false; + copyFileSyncMock.mockImplementation((_source: string, destination: string) => { + iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png"; + }); setDesktopNameMock.mockImplementation((name: string) => { desktopName = name; }); @@ -94,7 +104,11 @@ describe("DesktopPreReadyPlatform", () => { return Effect.scoped( Effect.gen(function* () { - const portalIdentity = Promise.resolve().then(() => ({ desktopName, desktopEntry })); + const portalIdentity = Promise.resolve().then(() => ({ + desktopName, + desktopEntry, + iconInstalled, + })); yield* Layer.build( DesktopPreReadyPlatform.layer.pipe( Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), @@ -105,6 +119,11 @@ describe("DesktopPreReadyPlatform", () => { assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); + assert.include( + identity.desktopEntry ?? "", + "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", + ); + assert.isTrue(identity.iconInstalled); }), ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }, @@ -123,6 +142,20 @@ describe("DesktopPreReadyPlatform", () => { ); }); + it.effect("still prepares the portal entry when the bundled icon cannot be copied", () => { + getSwitchValueMock.mockReturnValue(""); + copyFileSyncMock.mockImplementation(() => { + throw new Error("missing bundled icon"); + }); + return Effect.gen(function* () { + yield* DesktopPreReadyPlatform.make; + const contents = writeFileSyncMock.mock.calls[0]?.[1]; + assert.include(contents, "MimeType=x-scheme-handler/t3code;"); + assert.include(contents, "Icon="); + assert.equal(setDesktopNameMock.mock.calls.length, 1); + }).pipe(Effect.provideService(HostProcessPlatform, "linux")); + }); + it.effect( "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer", () => diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index c07334f33bbb..9b73a5d825bf 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -68,6 +68,28 @@ export const make = Effect.gen(function* () { "applications", ); NodeFS.mkdirSync(applicationsDir, { recursive: true }); + const iconPath = Electron.app.isPackaged + ? NodePath.posix.join( + applicationsDir, + "..", + "icons", + `${linux.linuxDesktopEntryName}.png`, + ) + : undefined; + if (iconPath !== undefined) { + try { + NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); + NodeFS.copyFileSync( + NodePath.posix.join( + Electron.app.getAppPath(), + "apps/desktop/prod-resources/icon.png", + ), + iconPath, + ); + } catch { + // Icon installation is optional; registration retries after readiness. + } + } NodeFS.writeFileSync( NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), renderUrlHandlerDesktopEntry({ @@ -77,6 +99,7 @@ export const make = Effect.gen(function* () { }).displayName, execTarget: process.env.APPIMAGE?.trim() || process.execPath, scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment), + ...(iconPath === undefined ? {} : { iconPath }), }), "utf8", ); From aa9e519629f610484e2c22bf3e1d09c24bfdcb82 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:09:53 +0000 Subject: [PATCH 4/5] fix(desktop): log the Linux handler icon copy error --- apps/desktop/src/app/DesktopLinuxUrlHandler.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 99ec910cb3bf..d1f088734751 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -232,7 +232,11 @@ export const make = Effect.gen(function* () { // The AppImage mount is temporary; the chooser needs the icon after exit. yield* fileSystem.makeDirectory(iconsDir, { recursive: true }); yield* fileSystem.copyFile(png.value, iconPath); - }).pipe(Effect.catch(() => logWarning("URL handler icon copy failed", { iconPath }))); + }).pipe( + Effect.catch((error) => + logWarning("URL handler icon copy failed", { iconPath, message: error.message }), + ), + ); yield* updateDesktopDatabase.pipe( // Some MIME implementations, including GIO, use mimeinfo.cache to verify From 8b5b5b73eccdbfe84958d45c7932f52fd2fc59ee Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:16:47 +0000 Subject: [PATCH 5/5] fix(desktop): bound icon copy error annotations --- apps/desktop/src/app/DesktopLinuxUrlHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index d1f088734751..f01e81d890bc 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -234,7 +234,7 @@ export const make = Effect.gen(function* () { yield* fileSystem.copyFile(png.value, iconPath); }).pipe( Effect.catch((error) => - logWarning("URL handler icon copy failed", { iconPath, message: error.message }), + logWarning("URL handler icon copy failed", { iconPath, category: error.reason._tag }), ), );