diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index f0b88101587c..893c1aaebe1d 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -1,23 +1,29 @@ 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 Path from "effect/Path"; 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"; +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, @@ -27,14 +33,14 @@ 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"]); -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), @@ -50,16 +56,41 @@ const makeHandlerLayer = ( recorded: RecordedRegistration, input: { readonly environment?: Record; + readonly updateDesktopDatabaseExitCode?: number; + readonly updateDesktopDatabaseStalled?: boolean; + readonly updateDesktopDatabaseStarted?: Deferred.Deferred; 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(() => { @@ -79,11 +110,31 @@ 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, }); - return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0)); + const exitCode = + childProcess.command === "update-desktop-database" + ? (input.updateDesktopDatabaseExitCode ?? 0) + : (input.xdgMimeExitCode ?? 0); + 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); }), ), ), @@ -103,6 +154,7 @@ const emptyRecording = (): RecordedRegistration => ({ directories: [], files: [], commands: [], + copies: [], }); describe("DesktopLinuxUrlHandler", () => { @@ -111,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]"); @@ -125,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", () => { @@ -154,31 +208,38 @@ describe("DesktopLinuxUrlHandler", () => { ); }); - it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => { - 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: "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(); @@ -202,12 +263,63 @@ 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", }), }); 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"], + }, + ]); + }); + }); + + 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"], + ); }); }); @@ -234,10 +346,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,8 +363,32 @@ describe("DesktopLinuxUrlHandler", () => { }), }); + assert.deepEqual( + desktopDatabaseFailed.commands.map(({ command }) => command), + ["update-desktop-database", "xdg-mime"], + ); assert.equal(xdgMimeFailed.files.length, 1); 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 404aff34c6bf..f01e81d890bc 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"; @@ -17,9 +18,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 +41,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("\\", "\\\\") @@ -63,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", @@ -95,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 @@ -110,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. @@ -131,6 +155,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.pipe(Effect.timeout("5 seconds")); + 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 +225,33 @@ 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((error) => + logWarning("URL handler icon copy failed", { iconPath, category: error.reason._tag }), + ), + ); + + 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( 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", );