From a12ed615cd401f9d85a39078f1278f0e4b2e9f67 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Wed, 14 Oct 2026 13:31:40 -0500 Subject: [PATCH 1/4] fix(desktop): install the Linux icon without extracting it from the ASAR Copying the packaged icon out of app.asar makes Electron extract it to a temporary file that is never removed, so every second instance left one more icon PNG behind. Read the icon bytes instead, and write the persistent copy only when its contents differ. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/app/DesktopLinuxUrlHandler.ts | 13 ++++++++++++- apps/desktop/src/app/DesktopPreReadyPlatform.ts | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index e67cdcefe9fa..ad7a39f26ab1 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -231,7 +231,18 @@ export const make = Effect.gen(function* () { 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); + // Read rather than copy: copying out of the ASAR extracts the icon to a + // temporary file that is never removed. + const icon = yield* fileSystem.readFile(png.value); + const current = yield* fileSystem.readFile(iconPath).pipe(Effect.orElseSucceed(() => null)); + if ( + current !== null && + current.length === icon.length && + current.every((byte, index) => byte === icon[index]) + ) { + return; + } + yield* fileSystem.writeFile(iconPath, icon, { mode: 0o600 }); }).pipe( Effect.catch((error) => logWarning("URL handler icon copy failed", { iconPath, category: error.reason._tag }), diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index b021ae02e826..466f2afc9b0b 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -82,13 +82,23 @@ export const make = Effect.gen(function* () { if (iconPath !== undefined) { try { NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); - NodeFS.copyFileSync( + // Copying out of the ASAR makes Electron extract the icon to a + // temporary file that is never removed; reading does not. + const icon = NodeFS.readFileSync( NodePath.posix.join( Electron.app.getAppPath(), "apps/desktop/prod-resources/icon.png", ), - iconPath, ); + let current: Buffer | null = null; + try { + current = NodeFS.readFileSync(iconPath); + } catch { + // A missing or unreadable icon is replaced below. + } + if (current === null || !current.equals(icon)) { + NodeFS.writeFileSync(iconPath, icon, { mode: 0o600 }); + } } catch { // Icon installation is optional; registration retries after readiness. } From 228919b6b9a2972aadc0e663ecc7824115915e91 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Wed, 14 Oct 2026 13:39:36 -0500 Subject: [PATCH 2/4] test(desktop): cover ASAR-free Linux icon install --- .../src/app/DesktopLinuxUrlHandler.test.ts | 179 ++++++++++++++---- .../src/app/DesktopPreReadyPlatform.test.ts | 162 ++++++++++++++-- 2 files changed, 294 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index cbabe14dd6fc..90c3af82019b 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -21,8 +21,31 @@ interface RecordedRegistration { 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 }>; + readonly binaryReads: string[]; + readonly binaryWrites: Array<{ + readonly path: string; + readonly bytes: Uint8Array; + readonly mode: number | undefined; + }>; } +const iconSource = "/tmp/.mount_T3/resources/app.asar/apps/desktop/prod-resources/icon.png"; +const iconPath = "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png"; +const iconBytes = new Uint8Array([0x89, 0x50, 0x00, 0xff, 0x80, 0x0a]); +const currentDesktopEntry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ + displayName: "T3 Code (Alpha)", + execTarget: "/home/alice/Applications/T3-Code.AppImage", + scheme: "t3code", + iconPath, +}); +const registrationCommands = [ + { command: "update-desktop-database", args: ["/home/alice/.local/share/applications"] }, + { + command: "xdg-mime", + args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"], + }, +]; + const makeEnvironment = (path: Path.Path, overrides: Record = {}) => DesktopEnvironment.DesktopEnvironment.of({ platform: "linux", @@ -63,7 +86,9 @@ const layerHandler = ( readonly writeError?: PlatformError.PlatformError; readonly existingEntry?: string; readonly iconSource?: string; - readonly iconCopyError?: PlatformError.PlatformError; + readonly existingIcon?: Uint8Array; + readonly iconReadError?: PlatformError.PlatformError; + readonly iconWriteError?: PlatformError.PlatformError; } = {}, ) => DesktopLinuxUrlHandler.layer.pipe( @@ -86,11 +111,35 @@ const layerHandler = ( }), FileSystem.layerNoop({ copyFile: (source, destination) => - input.iconCopyError - ? Effect.fail(input.iconCopyError) - : Effect.sync(() => { - recorded.copies.push({ source, destination }); - }), + Effect.sync(() => { + recorded.copies.push({ source, destination }); + }), + readFile: (path) => + Effect.gen(function* () { + recorded.binaryReads.push(path); + if (path === input.iconSource) { + if (input.iconReadError) return yield* input.iconReadError; + return new Uint8Array(iconBytes); + } + if (path === iconPath && input.existingIcon !== undefined) { + return input.existingIcon; + } + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: path, + }); + }), + writeFile: (path, bytes, options) => + Effect.gen(function* () { + recorded.binaryWrites.push({ + path, + bytes: new Uint8Array(bytes), + mode: options?.mode, + }); + if (input.iconWriteError) return yield* input.iconWriteError; + }), readFileString: () => Effect.succeed(input.existingEntry ?? ""), makeDirectory: (path) => Effect.sync(() => { @@ -155,6 +204,8 @@ const emptyRecording = (): RecordedRegistration => ({ files: [], commands: [], copies: [], + binaryReads: [], + binaryWrites: [], }); describe("DesktopLinuxUrlHandler", () => { @@ -227,6 +278,9 @@ describe("DesktopLinuxUrlHandler", () => { 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', ); assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); + assert.deepEqual(recorded.binaryReads, []); + assert.deepEqual(recorded.binaryWrites, []); + assert.deepEqual(recorded.copies, []); assert.deepEqual(recorded.commands, [ { command: "update-desktop-database", @@ -284,56 +338,112 @@ 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, - }), + iconSource, + existingEntry: currentDesktopEntry, }); 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"); + assert.deepEqual(recorded.copies, []); + assert.deepEqual(recorded.binaryReads, [iconSource, iconPath]); + assert.deepEqual(recorded.binaryWrites, [{ path: iconPath, bytes: iconBytes, mode: 0o600 }]); + assert.deepEqual(recorded.commands, registrationCommands); }); }); - it.effect("still registers the handler when copying its icon fails", () => { + it.effect.each([ + { + label: "same length with different bytes", + current: new Uint8Array([0x89, 0x50, 0x00, 0xfe, 0x80, 0x0a]), + }, + { label: "shorter", current: iconBytes.subarray(0, -1) }, + { label: "longer", current: new Uint8Array([...iconBytes, 0x00]) }, + ])("replaces a $label icon independently of a current desktop entry", ({ current }) => { 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", - }), + iconSource, + existingEntry: currentDesktopEntry, + existingIcon: current, }); - assert.equal(recorded.files.length, 1); - assert.deepEqual( - recorded.commands.map(({ command }) => command), - ["update-desktop-database", "xdg-mime"], - ); + assert.deepEqual(recorded.files, []); + assert.deepEqual(recorded.binaryReads, [iconSource, iconPath]); + assert.deepEqual(recorded.binaryWrites, [{ path: iconPath, bytes: iconBytes, mode: 0o600 }]); + assert.deepEqual(recorded.copies, []); + assert.deepEqual(recorded.commands, registrationCommands); }); }); + it.effect.each([ + { label: "current", existingEntry: currentDesktopEntry }, + { label: "missing", existingEntry: undefined }, + ])( + "skips equal icon writes and still registers with a $label desktop entry", + ({ existingEntry }) => { + const recorded = emptyRecording(); + return Effect.gen(function* () { + for (let registration = 0; registration < 2; registration++) { + yield* runRegister(recorded, { + iconSource, + ...(existingEntry === undefined ? {} : { existingEntry }), + existingIcon: new Uint8Array(iconBytes), + }); + } + assert.equal(recorded.files.length, existingEntry === undefined ? 2 : 0); + assert.deepEqual(recorded.binaryReads, [iconSource, iconPath, iconSource, iconPath]); + assert.deepEqual(recorded.binaryWrites, []); + assert.deepEqual(recorded.copies, []); + assert.deepEqual(recorded.commands, [...registrationCommands, ...registrationCommands]); + }); + }, + ); + + it.effect.each(["readFile", "writeFile"] as const)( + "still registers the handler when the icon %s fails", + (method) => { + const recorded = emptyRecording(); + const previousIcon = new Uint8Array([0xff, 0x00]); + const error = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method, + description: "icon unavailable", + pathOrDescriptor: method === "readFile" ? iconSource : iconPath, + }); + return Effect.gen(function* () { + yield* runRegister(recorded, { + iconSource, + existingIcon: previousIcon, + ...(method === "readFile" ? { iconReadError: error } : { iconWriteError: error }), + }); + assert.deepEqual( + recorded.binaryReads, + method === "readFile" ? [iconSource] : [iconSource, iconPath], + ); + assert.deepEqual( + recorded.binaryWrites, + method === "readFile" ? [] : [{ path: iconPath, bytes: iconBytes, mode: 0o600 }], + ); + assert.deepEqual(previousIcon, new Uint8Array([0xff, 0x00])); + assert.deepEqual(recorded.copies, []); + assert.equal(recorded.files.length, 1); + assert.deepEqual(recorded.commands, registrationCommands); + }); + }, + ); + it.effect("writes the portal identity without claiming the URL scheme in development", () => { const nonLinux = emptyRecording(); const unpackaged = emptyRecording(); return Effect.gen(function* () { - yield* runRegister(nonLinux, { environment: { platform: "darwin" } }); + yield* runRegister(nonLinux, { environment: { platform: "darwin" }, iconSource }); yield* runRegister(unpackaged, { environment: { isPackaged: false, linuxDesktopEntryName: "com.t3tools.T3Code.Development.desktop", }, + iconSource, }); assert.deepEqual(nonLinux.files, []); @@ -342,6 +452,11 @@ describe("DesktopLinuxUrlHandler", () => { "/home/alice/.local/share/applications/com.t3tools.T3Code.Development.desktop", ); assert.deepEqual(unpackaged.commands, []); + for (const recorded of [nonLinux, unpackaged]) { + assert.deepEqual(recorded.binaryReads, []); + assert.deepEqual(recorded.binaryWrites, []); + assert.deepEqual(recorded.copies, []); + } }); }); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 29fa706698e0..1757cf05f367 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -12,9 +12,11 @@ const { registerSchemesMock, setDesktopNameMock, mkdirSyncMock, + readFileSyncMock, writeFileSyncMock, copyFileSyncMock, execFileSyncMock, + electronState, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -22,16 +24,20 @@ const { registerSchemesMock: vi.fn(), setDesktopNameMock: vi.fn(), mkdirSyncMock: vi.fn(), + readFileSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), copyFileSyncMock: vi.fn(), execFileSyncMock: vi.fn(), + electronState: { isPackaged: true }, })); vi.mock("electron", () => ({ app: { setDesktopName: setDesktopNameMock, getVersion: () => "0.0.37", - isPackaged: true, + get isPackaged() { + return electronState.isPackaged; + }, getAppPath: () => "/tmp/.mount_T3/resources/app.asar", commandLine: { appendSwitch: appendSwitchMock, @@ -45,7 +51,7 @@ vi.mock("electron", () => ({ })); vi.mock("node:fs", () => ({ - readFileSync: () => "{}", + readFileSync: readFileSyncMock, mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, copyFileSync: copyFileSyncMock, @@ -57,6 +63,11 @@ vi.mock("node:child_process", () => ({ import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; +const iconSource = "/tmp/.mount_T3/resources/app.asar/apps/desktop/prod-resources/icon.png"; +const iconPath = "/xdg/icons/com.t3tools.T3Code.desktop.png"; +const desktopEntryPath = "/xdg/applications/com.t3tools.T3Code.desktop"; +const iconBytes = Buffer.from([0x89, 0x50, 0x00, 0xff, 0x80, 0x0a]); + describe("DesktopPreReadyPlatform", () => { beforeEach(() => { appendSwitchMock.mockReset(); @@ -65,9 +76,16 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockReset(); setDesktopNameMock.mockReset(); mkdirSyncMock.mockReset(); + readFileSyncMock.mockReset(); writeFileSyncMock.mockReset(); copyFileSyncMock.mockReset(); execFileSyncMock.mockReset(); + electronState.isPackaged = true; + readFileSyncMock.mockImplementation((path: string) => { + if (path.endsWith("/desktop-settings.json")) return "{}"; + if (path === iconSource) return Buffer.from(iconBytes); + throw new Error(`missing file: ${path}`); + }); }); it.effect("preserves an explicit Linux password-store switch", () => { @@ -150,14 +168,13 @@ describe("DesktopPreReadyPlatform", () => { 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; }); - writeFileSyncMock.mockImplementation((path: string, contents: string) => { - if (path === "/xdg/applications/com.t3tools.T3Code.desktop") desktopEntry = contents; + writeFileSyncMock.mockImplementation((path: string, contents: string | Buffer) => { + if (path === iconPath) + iconInstalled = Buffer.isBuffer(contents) && contents.equals(iconBytes); + if (path === desktopEntryPath && typeof contents === "string") desktopEntry = contents; }); return Effect.scoped( @@ -182,6 +199,15 @@ describe("DesktopPreReadyPlatform", () => { "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png", ); assert.isTrue(identity.iconInstalled); + assert.deepEqual( + readFileSyncMock.mock.calls.filter(([path]) => path === iconSource), + [[iconSource]], + ); + assert.deepEqual( + writeFileSyncMock.mock.calls.filter(([path]) => path === iconPath), + [[iconPath, iconBytes, { mode: 0o600 }]], + ); + assert.equal(copyFileSyncMock.mock.calls.length, 0); }), ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); }); @@ -198,18 +224,121 @@ describe("DesktopPreReadyPlatform", () => { ); }); - it.effect("still prepares the portal entry when the bundled icon cannot be copied", () => { + it.effect.each([ + { label: "missing", current: undefined }, + { label: "equal", current: Buffer.from(iconBytes) }, + { label: "changed", current: Buffer.from([0x89, 0x50, 0x00, 0xfe, 0x80, 0x0a]) }, + { label: "shorter", current: iconBytes.subarray(0, -1) }, + ])("installs binary icon bytes only for a $label destination", ({ current }) => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); getSwitchValueMock.mockReturnValue(""); - copyFileSyncMock.mockImplementation(() => { - throw new Error("missing bundled icon"); + const readFile = readFileSyncMock.getMockImplementation()!; + readFileSyncMock.mockImplementation((path: string) => { + if (path === iconPath && current !== undefined) return current; + return readFile(path); }); + 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(HostProcess.Platform, "linux")); + assert.equal(copyFileSyncMock.mock.calls.length, 0); + assert.deepEqual( + readFileSyncMock.mock.calls.filter(([path]) => path === iconSource), + [[iconSource]], + ); + assert.deepEqual( + readFileSyncMock.mock.calls.filter(([path]) => path === iconPath), + [[iconPath]], + ); + assert.deepEqual( + writeFileSyncMock.mock.calls.filter(([path]) => path === iconPath), + current?.equals(iconBytes) ? [] : [[iconPath, iconBytes, { mode: 0o600 }]], + ); + assert.equal( + writeFileSyncMock.mock.calls.filter(([path]) => path === desktopEntryPath).length, + 1, + ); + assert.deepEqual(setDesktopNameMock.mock.calls, [["com.t3tools.T3Code.desktop"]]); + }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + ); + }); + + it.effect.each(["readFileSync", "writeFileSync"] as const)( + "still prepares the portal entry when the icon %s fails", + (method) => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); + getSwitchValueMock.mockReturnValue(""); + const readFile = readFileSyncMock.getMockImplementation()!; + let installedIcon = Buffer.from([0xff, 0x00]); + let failures = 0; + readFileSyncMock.mockImplementation((path: string) => { + if (path === iconSource && method === "readFileSync") { + failures++; + throw new Error("unreadable bundled icon"); + } + if (path === iconPath) return installedIcon; + return readFile(path); + }); + writeFileSyncMock.mockImplementation((path: string, contents: Buffer) => { + if (path !== iconPath) return; + if (method === "writeFileSync") { + failures++; + throw new Error("read-only icon directory"); + } + installedIcon = Buffer.from(contents); + }); + + return Effect.gen(function* () { + yield* DesktopPreReadyPlatform.make; + assert.equal(failures, 1, "the failing filesystem operation must be reached"); + assert.deepEqual(installedIcon, Buffer.from([0xff, 0x00])); + assert.equal( + writeFileSyncMock.mock.calls.filter(([path]) => path === iconPath).length, + method === "readFileSync" ? 0 : 1, + ); + assert.equal(copyFileSyncMock.mock.calls.length, 0); + const contents = writeFileSyncMock.mock.calls.find( + ([path]) => path === desktopEntryPath, + )?.[1]; + assert.include(contents, "MimeType=x-scheme-handler/t3code;"); + assert.include(contents, "Icon="); + assert.deepEqual(setDesktopNameMock.mock.calls, [["com.t3tools.T3Code.desktop"]]); + }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + ); + }, + ); + + it.effect("keeps unpackaged development setup free of icon reads and writes", () => { + vi.stubEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); + electronState.isPackaged = false; + getSwitchValueMock.mockReturnValue(""); + + return DesktopPreReadyPlatform.make.pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.map(() => { + assert.deepEqual( + readFileSyncMock.mock.calls.filter(([path]) => path === iconSource), + [], + ); + assert.equal(copyFileSyncMock.mock.calls.length, 0); + assert.equal(writeFileSyncMock.mock.calls.length, 1); + assert.equal( + writeFileSyncMock.mock.calls[0]?.[0], + "/xdg/applications/com.t3tools.T3Code.Development.desktop", + ); + assert.notInclude(writeFileSyncMock.mock.calls[0]?.[1], "Icon="); + assert.deepEqual(setDesktopNameMock.mock.calls, [ + ["com.t3tools.T3Code.Development.desktop"], + ]); + }), + Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + ); }); it.effect( @@ -260,6 +389,9 @@ describe("DesktopPreReadyPlatform", () => { assert.equal(registerSchemesMock.mock.calls.length, 1); assert.equal(appendSwitchMock.mock.calls.length, 0); assert.equal(setDesktopNameMock.mock.calls.length, 0); + assert.equal(readFileSyncMock.mock.calls.length, 0); + assert.equal(writeFileSyncMock.mock.calls.length, 0); + assert.equal(copyFileSyncMock.mock.calls.length, 0); }), ); }); From 5f2a9213223e853dec9f41b35d59a826816eebe4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:51:16 +0000 Subject: [PATCH 3/4] test(desktop): trim Linux icon installation regressions --- .../src/app/DesktopLinuxUrlHandler.test.ts | 111 ++++----------- .../src/app/DesktopPreReadyPlatform.test.ts | 131 +++++------------- 2 files changed, 62 insertions(+), 180 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index 202b5474a527..185f69aebde0 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -89,7 +89,6 @@ const layerHandler = ( readonly iconSource?: string; readonly existingIcon?: Uint8Array; readonly iconReadError?: PlatformError.PlatformError; - readonly iconWriteError?: PlatformError.PlatformError; } = {}, ) => DesktopLinuxUrlHandler.layer.pipe( @@ -133,13 +132,12 @@ const layerHandler = ( }); }), writeFile: (path, bytes, options) => - Effect.gen(function* () { + Effect.sync(() => { recorded.binaryWrites.push({ path, bytes: new Uint8Array(bytes), mode: options?.mode, }); - if (input.iconWriteError) return yield* input.iconWriteError; }), readFileString: () => Effect.succeed(input.existingEntry ?? ""), makeDirectory: (path) => @@ -276,9 +274,6 @@ describe("DesktopLinuxUrlHandler", () => { 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', ); assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); - assert.deepEqual(recorded.binaryReads, []); - assert.deepEqual(recorded.binaryWrites, []); - assert.deepEqual(recorded.copies, []); assert.deepEqual(recorded.commands, [ { command: "update-desktop-database", @@ -349,99 +344,58 @@ describe("DesktopLinuxUrlHandler", () => { }); }); - it.effect.each([ - { - label: "same length with different bytes", - current: new Uint8Array([0x89, 0x50, 0x00, 0xfe, 0x80, 0x0a]), - }, - { label: "shorter", current: iconBytes.subarray(0, -1) }, - { label: "longer", current: new Uint8Array([...iconBytes, 0x00]) }, - ])("replaces a $label icon independently of a current desktop entry", ({ current }) => { + it.effect("refreshes a stale icon and skips equal bytes on the next registration", () => { const recorded = emptyRecording(); return Effect.gen(function* () { yield* runRegister(recorded, { iconSource, existingEntry: currentDesktopEntry, - existingIcon: current, + existingIcon: new Uint8Array([0x89, 0x50, 0x00, 0xfe, 0x80, 0x0a]), }); - assert.deepEqual(recorded.files, []); - assert.deepEqual(recorded.binaryReads, [iconSource, iconPath]); assert.deepEqual(recorded.binaryWrites, [{ path: iconPath, bytes: iconBytes, mode: 0o600 }]); + yield* runRegister(recorded, { + iconSource, + existingEntry: currentDesktopEntry, + existingIcon: new Uint8Array(recorded.binaryWrites[0]!.bytes), + }); + assert.equal(recorded.binaryWrites.length, 1); assert.deepEqual(recorded.copies, []); - assert.deepEqual(recorded.commands, registrationCommands); + assert.deepEqual(recorded.files, []); + assert.deepEqual(recorded.commands, [...registrationCommands, ...registrationCommands]); }); }); - it.effect.each([ - { label: "current", existingEntry: currentDesktopEntry }, - { label: "missing", existingEntry: undefined }, - ])( - "skips equal icon writes and still registers with a $label desktop entry", - ({ existingEntry }) => { - const recorded = emptyRecording(); - return Effect.gen(function* () { - for (let registration = 0; registration < 2; registration++) { - yield* runRegister(recorded, { - iconSource, - ...(existingEntry === undefined ? {} : { existingEntry }), - existingIcon: new Uint8Array(iconBytes), - }); - } - assert.equal(recorded.files.length, existingEntry === undefined ? 2 : 0); - assert.deepEqual(recorded.binaryReads, [iconSource, iconPath, iconSource, iconPath]); - assert.deepEqual(recorded.binaryWrites, []); - assert.deepEqual(recorded.copies, []); - assert.deepEqual(recorded.commands, [...registrationCommands, ...registrationCommands]); - }); - }, - ); - - it.effect.each(["readFile", "writeFile"] as const)( - "still registers the handler when the icon %s fails", - (method) => { - const recorded = emptyRecording(); - const previousIcon = new Uint8Array([0xff, 0x00]); - const error = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method, - description: "icon unavailable", - pathOrDescriptor: method === "readFile" ? iconSource : iconPath, - }); - return Effect.gen(function* () { - yield* runRegister(recorded, { - iconSource, - existingIcon: previousIcon, - ...(method === "readFile" ? { iconReadError: error } : { iconWriteError: error }), - }); - assert.deepEqual( - recorded.binaryReads, - method === "readFile" ? [iconSource] : [iconSource, iconPath], - ); - assert.deepEqual( - recorded.binaryWrites, - method === "readFile" ? [] : [{ path: iconPath, bytes: iconBytes, mode: 0o600 }], - ); - assert.deepEqual(previousIcon, new Uint8Array([0xff, 0x00])); - assert.deepEqual(recorded.copies, []); - assert.equal(recorded.files.length, 1); - assert.deepEqual(recorded.commands, registrationCommands); + it.effect("still registers the handler when reading its icon fails", () => { + const recorded = emptyRecording(); + return Effect.gen(function* () { + yield* runRegister(recorded, { + iconSource, + iconReadError: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + description: "unreadable bundled icon", + }), }); - }, - ); + assert.deepEqual(recorded.binaryReads, [iconSource]); + assert.deepEqual(recorded.binaryWrites, []); + assert.deepEqual(recorded.copies, []); + assert.equal(recorded.files.length, 1); + assert.deepEqual(recorded.commands, registrationCommands); + }); + }); it.effect("writes the portal identity without claiming the URL scheme in development", () => { const nonLinux = emptyRecording(); const unpackaged = emptyRecording(); return Effect.gen(function* () { - yield* runRegister(nonLinux, { environment: { platform: "darwin" }, iconSource }); + yield* runRegister(nonLinux, { environment: { platform: "darwin" } }); yield* runRegister(unpackaged, { environment: { isPackaged: false, linuxDesktopEntryName: "com.t3tools.T3Code.Development.desktop", }, - iconSource, }); assert.deepEqual(nonLinux.files, []); @@ -450,11 +404,6 @@ describe("DesktopLinuxUrlHandler", () => { "/home/alice/.local/share/applications/com.t3tools.T3Code.Development.desktop", ); assert.deepEqual(unpackaged.commands, []); - for (const recorded of [nonLinux, unpackaged]) { - assert.deepEqual(recorded.binaryReads, []); - assert.deepEqual(recorded.binaryWrites, []); - assert.deepEqual(recorded.copies, []); - } }); }); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 2fb08e1b2812..3670a698c4a3 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -16,7 +16,6 @@ const { writeFileSyncMock, copyFileSyncMock, execFileSyncMock, - electronState, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -28,16 +27,13 @@ const { writeFileSyncMock: vi.fn(), copyFileSyncMock: vi.fn(), execFileSyncMock: vi.fn(), - electronState: { isPackaged: true }, })); vi.mock("electron", () => ({ app: { setDesktopName: setDesktopNameMock, getVersion: () => "0.0.37", - get isPackaged() { - return electronState.isPackaged; - }, + isPackaged: true, getAppPath: () => "/tmp/.mount_T3/resources/app.asar", commandLine: { appendSwitch: appendSwitchMock, @@ -80,7 +76,6 @@ describe("DesktopPreReadyPlatform", () => { writeFileSyncMock.mockReset(); copyFileSyncMock.mockReset(); execFileSyncMock.mockReset(); - electronState.isPackaged = true; readFileSyncMock.mockImplementation((path: string) => { if (path.endsWith("/desktop-settings.json")) return "{}"; if (path === iconSource) return Buffer.from(iconBytes); @@ -224,119 +219,60 @@ describe("DesktopPreReadyPlatform", () => { ); }); - it.effect.each([ - { label: "missing", current: undefined }, - { label: "equal", current: Buffer.from(iconBytes) }, - { label: "changed", current: Buffer.from([0x89, 0x50, 0x00, 0xfe, 0x80, 0x0a]) }, - { label: "shorter", current: iconBytes.subarray(0, -1) }, - ])("installs binary icon bytes only for a $label destination", ({ current }) => { + it.effect("refreshes a stale icon once across repeated launches", () => { vi.stubEnv("VITE_DEV_SERVER_URL", ""); vi.stubEnv("XDG_DATA_HOME", "/xdg"); getSwitchValueMock.mockReturnValue(""); - const readFile = readFileSyncMock.getMockImplementation()!; + let installedIcon = Buffer.from([0x89, 0x50, 0x00, 0xfe, 0x80, 0x0a]); readFileSyncMock.mockImplementation((path: string) => { - if (path === iconPath && current !== undefined) return current; - return readFile(path); + if (path === iconSource) return Buffer.from(iconBytes); + if (path === iconPath) return Buffer.from(installedIcon); + return "{}"; + }); + writeFileSyncMock.mockImplementation((path: string, contents: Buffer) => { + if (path === iconPath) installedIcon = Buffer.from(contents); }); return Effect.gen(function* () { yield* DesktopPreReadyPlatform.make; - assert.equal(copyFileSyncMock.mock.calls.length, 0); - assert.deepEqual( - readFileSyncMock.mock.calls.filter(([path]) => path === iconSource), - [[iconSource]], - ); - assert.deepEqual( - readFileSyncMock.mock.calls.filter(([path]) => path === iconPath), - [[iconPath]], - ); + assert.deepEqual(installedIcon, iconBytes); + yield* DesktopPreReadyPlatform.make; assert.deepEqual( writeFileSyncMock.mock.calls.filter(([path]) => path === iconPath), - current?.equals(iconBytes) ? [] : [[iconPath, iconBytes, { mode: 0o600 }]], + [[iconPath, iconBytes, { mode: 0o600 }]], ); + assert.equal(copyFileSyncMock.mock.calls.length, 0); assert.equal( writeFileSyncMock.mock.calls.filter(([path]) => path === desktopEntryPath).length, - 1, + 2, ); - assert.deepEqual(setDesktopNameMock.mock.calls, [["com.t3tools.T3Code.desktop"]]); + assert.equal(setDesktopNameMock.mock.calls.length, 2); }).pipe( Effect.provideService(HostProcess.Platform, "linux"), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), ); }); - it.effect.each(["readFileSync", "writeFileSync"] as const)( - "still prepares the portal entry when the icon %s fails", - (method) => { - vi.stubEnv("VITE_DEV_SERVER_URL", ""); - vi.stubEnv("XDG_DATA_HOME", "/xdg"); - getSwitchValueMock.mockReturnValue(""); - const readFile = readFileSyncMock.getMockImplementation()!; - let installedIcon = Buffer.from([0xff, 0x00]); - let failures = 0; - readFileSyncMock.mockImplementation((path: string) => { - if (path === iconSource && method === "readFileSync") { - failures++; - throw new Error("unreadable bundled icon"); - } - if (path === iconPath) return installedIcon; - return readFile(path); - }); - writeFileSyncMock.mockImplementation((path: string, contents: Buffer) => { - if (path !== iconPath) return; - if (method === "writeFileSync") { - failures++; - throw new Error("read-only icon directory"); - } - installedIcon = Buffer.from(contents); - }); - - return Effect.gen(function* () { - yield* DesktopPreReadyPlatform.make; - assert.equal(failures, 1, "the failing filesystem operation must be reached"); - assert.deepEqual(installedIcon, Buffer.from([0xff, 0x00])); - assert.equal( - writeFileSyncMock.mock.calls.filter(([path]) => path === iconPath).length, - method === "readFileSync" ? 0 : 1, - ); - assert.equal(copyFileSyncMock.mock.calls.length, 0); - const contents = writeFileSyncMock.mock.calls.find( - ([path]) => path === desktopEntryPath, - )?.[1]; - assert.include(contents, "MimeType=x-scheme-handler/t3code;"); - assert.include(contents, "Icon="); - assert.deepEqual(setDesktopNameMock.mock.calls, [["com.t3tools.T3Code.desktop"]]); - }).pipe( - Effect.provideService(HostProcess.Platform, "linux"), - Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), - ); - }, - ); - - it.effect("keeps unpackaged development setup free of icon reads and writes", () => { - vi.stubEnv("VITE_DEV_SERVER_URL", "http://localhost:5173"); + it.effect("still prepares the portal entry when the bundled icon cannot be read", () => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); vi.stubEnv("XDG_DATA_HOME", "/xdg"); - electronState.isPackaged = false; getSwitchValueMock.mockReturnValue(""); - - return DesktopPreReadyPlatform.make.pipe( + readFileSyncMock.mockImplementation((path: string) => { + if (path === iconSource) throw new Error("unreadable bundled icon"); + return "{}"; + }); + return Effect.gen(function* () { + yield* DesktopPreReadyPlatform.make; + assert.isTrue(readFileSyncMock.mock.calls.some(([path]) => path === iconSource)); + assert.equal(copyFileSyncMock.mock.calls.length, 0); + assert.equal(writeFileSyncMock.mock.calls.length, 1); + const [path, contents] = writeFileSyncMock.mock.calls[0]!; + assert.equal(path, desktopEntryPath); + assert.include(contents, "MimeType=x-scheme-handler/t3code;"); + assert.include(contents, `Icon=${iconPath}`); + assert.equal(setDesktopNameMock.mock.calls.length, 1); + }).pipe( Effect.provideService(HostProcess.Platform, "linux"), - Effect.map(() => { - assert.deepEqual( - readFileSyncMock.mock.calls.filter(([path]) => path === iconSource), - [], - ); - assert.equal(copyFileSyncMock.mock.calls.length, 0); - assert.equal(writeFileSyncMock.mock.calls.length, 1); - assert.equal( - writeFileSyncMock.mock.calls[0]?.[0], - "/xdg/applications/com.t3tools.T3Code.Development.desktop", - ); - assert.notInclude(writeFileSyncMock.mock.calls[0]?.[1], "Icon="); - assert.deepEqual(setDesktopNameMock.mock.calls, [ - ["com.t3tools.T3Code.Development.desktop"], - ]); - }), Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), ); }); @@ -389,9 +325,6 @@ describe("DesktopPreReadyPlatform", () => { assert.equal(registerSchemesMock.mock.calls.length, 1); assert.equal(appendSwitchMock.mock.calls.length, 0); assert.equal(setDesktopNameMock.mock.calls.length, 0); - assert.equal(readFileSyncMock.mock.calls.length, 0); - assert.equal(writeFileSyncMock.mock.calls.length, 0); - assert.equal(copyFileSyncMock.mock.calls.length, 0); }), ); }); From de78093fc3dfbaf5aa949da280633f8a97ef98c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:53:50 +0000 Subject: [PATCH 4/4] fix(desktop): avoid temporary ASAR icon extraction --- apps/desktop/src/app/DesktopLinuxUrlHandler.ts | 17 +++++------------ apps/desktop/src/app/DesktopPreReadyPlatform.ts | 5 ++--- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 7b96d9d0b0e2..9cb3e43dfe3d 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -295,23 +295,16 @@ export const make = Effect.gen(function* () { yield* Effect.gen(function* () { const { png } = yield* assets.iconPaths; if (Option.isNone(png)) return; + // Electron's copyFile extracts ASAR entries to temporary files it never removes. + const icon = yield* fileSystem.readFile(png.value); + const current = yield* fileSystem.readFile(iconPath).pipe(Effect.option); + if (Option.isSome(current) && sameBytes(current.value, icon)) return; // The AppImage mount is temporary; the chooser needs the icon after exit. yield* fileSystem.makeDirectory(iconsDir, { recursive: true }); - // Read rather than copy: copying out of the ASAR extracts the icon to a - // temporary file that is never removed. - const icon = yield* fileSystem.readFile(png.value); - const current = yield* fileSystem.readFile(iconPath).pipe(Effect.orElseSucceed(() => null)); - if ( - current !== null && - current.length === icon.length && - current.every((byte, index) => byte === icon[index]) - ) { - return; - } yield* fileSystem.writeFile(iconPath, icon, { mode: 0o600 }); }).pipe( Effect.catch((error) => - logWarning("URL handler icon copy failed", { iconPath, category: error.reason._tag }), + logWarning("URL handler icon install failed", { iconPath, category: error.reason._tag }), ), ); diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 9c03c397446d..eb85284d879e 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -81,9 +81,7 @@ export const make = Effect.gen(function* () { : undefined; if (iconPath !== undefined) { try { - NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); - // Copying out of the ASAR makes Electron extract the icon to a - // temporary file that is never removed; reading does not. + // Electron's copyFile extracts ASAR entries to temporary files it never removes. const icon = NodeFS.readFileSync( NodePath.posix.join( Electron.app.getAppPath(), @@ -95,6 +93,7 @@ export const make = Effect.gen(function* () { current = NodeFS.readFileSync(iconPath); } catch {} if (current === null || !current.equals(icon)) { + NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true }); NodeFS.writeFileSync(iconPath, icon, { mode: 0o600 }); } } catch {}