From 7a18e2accb70b113fdeb02d24a4d2ed53ea005ed Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Wed, 9 Sep 2026 18:07:09 -0400 Subject: [PATCH] fix(desktop): Restore Linux icons and GNOME dock pinning --- .../src/app/DesktopLinuxUrlHandler.test.ts | 33 +++++ .../desktop/src/app/DesktopLinuxUrlHandler.ts | 54 ++++++- .../src/app/DesktopPreReadyPlatform.test.ts | 135 ++++++++++++++++++ .../src/app/DesktopPreReadyPlatform.ts | 79 +++++++++- packaging/aur/t3code-bin/PKGBUILD | 2 +- packaging/aur/t3code-nightly-bin/PKGBUILD | 2 +- scripts/build-desktop-artifact.test.ts | 3 + scripts/build-desktop-artifact.ts | 3 +- 8 files changed, 297 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts index f0b88101587c..579d32d315c3 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts @@ -71,6 +71,7 @@ const makeHandlerLayer = ( : Effect.sync(() => { recorded.files.push({ path, content }); }), + remove: () => Effect.void, }), Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -124,9 +125,41 @@ describe("DesktopLinuxUrlHandler", () => { ); assert.include(entry, "NoDisplay=true"); assert.notInclude(entry, "StartupWMClass="); + assert.include(entry, "Icon=t3code"); assert.include(entry, "MimeType=x-scheme-handler/t3code;"); }); + it("installs packaged t3code icons under the Icon name and Wayland app id", () => { + const operations = DesktopLinuxUrlHandler.linuxDesktopIconInstallOperations({ + packagedHicolorRoot: "/tmp/.mount_app/usr/share/icons/hicolor", + dataHome: "/home/alice/.local/share", + desktopEntryName: "com.t3tools.T3Code.desktop", + }); + + assert.deepEqual(operations, [ + { + sourcePath: "/tmp/.mount_app/usr/share/icons/hicolor/256x256/apps/t3code.png", + targetPath: "/home/alice/.local/share/icons/hicolor/256x256/apps/t3code.png", + }, + { + sourcePath: "/tmp/.mount_app/usr/share/icons/hicolor/256x256/apps/t3code.png", + targetPath: "/home/alice/.local/share/icons/hicolor/256x256/apps/com.t3tools.t3code.png", + }, + ]); + }); + + it("uses lowercase icon names only, even when the desktop id is mixed-case", () => { + assert.deepEqual(DesktopLinuxUrlHandler.linuxDesktopIconNames("t3code.desktop"), ["t3code"]); + assert.deepEqual(DesktopLinuxUrlHandler.linuxDesktopIconNames("com.t3tools.T3Code.desktop"), [ + "t3code", + "com.t3tools.t3code", + ]); + assert.deepEqual( + DesktopLinuxUrlHandler.linuxDesktopIconNames("com.t3tools.T3Code.Development.desktop"), + ["t3code", "com.t3tools.t3code.development"], + ); + }); + it("carries structured context on registration errors", () => { const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ step: "write-desktop-entry", diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts index 404aff34c6bf..97d091294e60 100644 --- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts +++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts @@ -63,8 +63,50 @@ 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. +const LINUX_DESKTOP_ICON_NAME = "t3code"; + +// One 256px raster. Qt, GTK, and the common Quickshell shells scale it. +const LINUX_DESKTOP_ICON_SIZES = [256] as const; + +function posixJoin(root: string, ...segments: ReadonlyArray): string { + return [root.replace(/\/+$/u, ""), ...segments].join("/"); +} + +export function linuxDesktopIconNames(desktopEntryName: string): readonly string[] { + const appId = desktopEntryName.replace(/\.desktop$/u, "").toLowerCase(); + const names = [LINUX_DESKTOP_ICON_NAME]; + if (!names.includes(appId)) names.push(appId); + return names; +} + +export function linuxDesktopIconInstallOperations(input: { + readonly packagedHicolorRoot: string; + readonly dataHome: string; + readonly desktopEntryName: string; +}): ReadonlyArray<{ readonly sourcePath: string; readonly targetPath: string }> { + const names = linuxDesktopIconNames(input.desktopEntryName); + const operations: Array<{ readonly sourcePath: string; readonly targetPath: string }> = []; + for (const size of LINUX_DESKTOP_ICON_SIZES) { + const sizeDir = `${size}x${size}`; + const sourcePath = posixJoin( + input.packagedHicolorRoot, + sizeDir, + "apps", + `${LINUX_DESKTOP_ICON_NAME}.png`, + ); + for (const name of names) { + operations.push({ + sourcePath, + targetPath: posixJoin(input.dataHome, "icons/hicolor", sizeDir, "apps", `${name}.png`), + }); + } + } + return operations; +} + +// The visible launcher owns StartupWMClass matching on X11 and GNOME Wayland. +// Keep it off this hidden entry so GNOME does not prefer it over the launcher. +// Icon= still serves shells that look up the Wayland app_id by desktop filename. export function renderUrlHandlerDesktopEntry(input: { readonly displayName: string; readonly execTarget: string; @@ -78,6 +120,7 @@ export function renderUrlHandlerDesktopEntry(input: { "Terminal=false", "NoDisplay=true", "StartupNotify=false", + `Icon=${LINUX_DESKTOP_ICON_NAME}`, `MimeType=x-scheme-handler/${input.scheme};`, "", ].join("\n"); @@ -116,9 +159,10 @@ export const make = Effect.gen(function* () { const existing = yield* fileSystem .readFileString(desktopEntryPath) .pipe(Effect.orElseSucceed(() => null)); - if (existing === content) return; - yield* fileSystem.makeDirectory(environment.linuxApplicationsDir, { recursive: true }); - yield* fileSystem.writeFileString(desktopEntryPath, content); + if (existing !== content) { + yield* fileSystem.makeDirectory(environment.linuxApplicationsDir, { recursive: true }); + yield* fileSystem.writeFileString(desktopEntryPath, content); + } }).pipe( Effect.mapError( (cause) => diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index 9ddaf40caa0a..e9d25d8ea7e2 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -13,6 +13,11 @@ const { setDesktopNameMock, mkdirSyncMock, writeFileSyncMock, + existsSyncMock, + copyFileSyncMock, + spawnMock, + statSyncMock, + unlinkSyncMock, } = vi.hoisted(() => ({ appendSwitchMock: vi.fn(), getSwitchValueMock: vi.fn(), @@ -21,6 +26,15 @@ const { setDesktopNameMock: vi.fn(), mkdirSyncMock: vi.fn(), writeFileSyncMock: vi.fn(), + existsSyncMock: vi.fn(), + copyFileSyncMock: vi.fn(), + spawnMock: vi.fn(() => ({ + unref: vi.fn(), + on: vi.fn(), + kill: vi.fn(), + })), + statSyncMock: vi.fn(), + unlinkSyncMock: vi.fn(), })); vi.mock("electron", () => ({ @@ -38,10 +52,18 @@ vi.mock("electron", () => ({ }, })); +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + vi.mock("node:fs", () => ({ readFileSync: () => "{}", mkdirSync: mkdirSyncMock, writeFileSync: writeFileSyncMock, + existsSync: existsSyncMock, + copyFileSync: copyFileSyncMock, + statSync: statSyncMock, + unlinkSync: unlinkSyncMock, })); import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; @@ -55,6 +77,12 @@ describe("DesktopPreReadyPlatform", () => { setDesktopNameMock.mockReset(); mkdirSyncMock.mockReset(); writeFileSyncMock.mockReset(); + existsSyncMock.mockReset(); + existsSyncMock.mockReturnValue(false); + copyFileSyncMock.mockReset(); + spawnMock.mockClear(); + statSyncMock.mockReset(); + unlinkSyncMock.mockReset(); }); it.effect("preserves an explicit Linux password-store switch", () => { @@ -104,6 +132,7 @@ describe("DesktopPreReadyPlatform", () => { assert.equal(identity.desktopName, "com.t3tools.T3Code.desktop"); assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U'); assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)"); + assert.include(identity.desktopEntry ?? "", "Icon=t3code"); assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;"); }), ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); @@ -111,6 +140,71 @@ describe("DesktopPreReadyPlatform", () => { ); } + it.effect("copies packaged Linux icons into the user hicolor theme before startup yields", () => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); + vi.stubEnv("APPDIR", "/mnt/app"); + getSwitchValueMock.mockReturnValue(""); + existsSyncMock.mockImplementation( + (path: string) => path === "/mnt/app/usr/share/icons/hicolor/256x256/apps/t3code.png", + ); + statSyncMock.mockReturnValue({ size: 42841 }); + const copied: Array = []; + copyFileSyncMock.mockImplementation((source: string, target: string) => { + copied.push([source, target]); + }); + + return Effect.scoped( + Effect.gen(function* () { + yield* Layer.build( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ), + ); + assert.deepEqual(copied, [ + [ + "/mnt/app/usr/share/icons/hicolor/256x256/apps/t3code.png", + "/xdg/icons/hicolor/256x256/apps/t3code.png", + ], + [ + "/mnt/app/usr/share/icons/hicolor/256x256/apps/t3code.png", + "/xdg/icons/hicolor/256x256/apps/com.t3tools.t3code.png", + ], + ]); + assert.equal( + mkdirSyncMock.mock.calls.filter(([path]) => String(path).includes("/icons/hicolor")) + .length, + 1, + ); + assert.equal(spawnMock.mock.calls.length, 1); + const spawnCall = spawnMock.mock.calls.at(0) as unknown as [string, string[]]; + assert.equal(spawnCall[0], "gtk-update-icon-cache"); + assert.deepEqual(spawnCall[1], ["-f", "-t", "/xdg/icons/hicolor"]); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }); + + it.effect("skips Linux icon copies and cache refresh when dest sizes already match", () => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); + vi.stubEnv("APPDIR", "/mnt/app"); + getSwitchValueMock.mockReturnValue(""); + existsSyncMock.mockReturnValue(true); + statSyncMock.mockReturnValue({ size: 42841 }); + + return Effect.scoped( + Effect.gen(function* () { + yield* Layer.build( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ), + ); + assert.equal(copyFileSyncMock.mock.calls.length, 0); + assert.equal(spawnMock.mock.calls.length, 0); + }), + ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }); + it.effect("keeps startup available when the early desktop entry cannot be written", () => { getSwitchValueMock.mockReturnValue(""); mkdirSyncMock.mockImplementation(() => { @@ -123,6 +217,47 @@ describe("DesktopPreReadyPlatform", () => { ); }); + it.effect("copies Linux icons before Clerk-shaped async work can run", () => { + vi.stubEnv("VITE_DEV_SERVER_URL", ""); + vi.stubEnv("XDG_DATA_HOME", "/xdg"); + vi.stubEnv("APPDIR", "/mnt/app"); + getSwitchValueMock.mockReturnValue(""); + existsSyncMock.mockImplementation( + (path: string) => path === "/mnt/app/usr/share/icons/hicolor/256x256/apps/t3code.png", + ); + statSyncMock.mockReturnValue({ size: 42841 }); + const events: Array = []; + copyFileSyncMock.mockImplementation(() => { + events.push("icon-copy"); + }); + + class LinuxClerkShaped extends Context.Service()( + "@t3tools/desktop/app/DesktopPreReadyPlatform.test/LinuxClerkShaped", + ) {} + + const preReadyLayer = DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ); + const clerkShapedLayer = Layer.effect( + LinuxClerkShaped, + Effect.promise(() => Promise.resolve()).pipe( + Effect.map(() => { + events.push("clerk"); + return { ready: true as const }; + }), + ), + ); + const runtimeLayer = clerkShapedLayer.pipe( + Layer.flatMap((clerkContext) => Layer.succeedContext(clerkContext)), + Layer.provideMerge(preReadyLayer), + ); + + return Effect.gen(function* () { + yield* LinuxClerkShaped.pipe(Effect.provide(runtimeLayer)); + assert.deepEqual(events, ["icon-copy", "icon-copy", "clerk"]); + }).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs()))); + }); + 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..f4f6d0c3a5e5 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -1,4 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off - pre-ready Electron setup reads settings and prepares the Linux desktop entry synchronously before app services are available. +// @effect-diagnostics globalTimers:off -- Bounded SIGKILL for a fire-and-forget icon-cache helper before app services exist. +import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -11,7 +13,10 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts"; import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts"; -import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts"; +import { + linuxDesktopIconInstallOperations, + renderUrlHandlerDesktopEntry, +} from "./DesktopLinuxUrlHandler.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; export interface DesktopPreReadyCommandLineReader { @@ -19,6 +24,57 @@ export interface DesktopPreReadyCommandLineReader { readonly getSwitchValue: (switchName: string) => string; } +function linuxDesktopIconNeedsCopy(sourcePath: string, targetPath: string): boolean { + if (!NodeFS.existsSync(sourcePath)) return false; + if (!NodeFS.existsSync(targetPath)) return true; + try { + return NodeFS.statSync(sourcePath).size !== NodeFS.statSync(targetPath).size; + } catch { + return true; + } +} + +function refreshLinuxDesktopIconCache(cacheDir: string): void { + try { + const child = NodeChildProcess.spawn("gtk-update-icon-cache", ["-f", "-t", cacheDir], { + stdio: "ignore", + }); + child.unref(); + const timer = setTimeout(() => { + child.kill("SIGKILL"); + }, 2000); + timer.unref(); + const stop = () => clearTimeout(timer); + child.on("error", stop); + child.on("exit", stop); + } catch { + // Icon files are already in place; a missing cache tool is not fatal. + } +} + +function installLinuxDesktopIconsFromAppImage(input: { + readonly appDir: string; + readonly dataHome: string; + readonly desktopEntryName: string; +}): void { + const pending = linuxDesktopIconInstallOperations({ + packagedHicolorRoot: NodePath.posix.join(input.appDir, "usr/share/icons/hicolor"), + dataHome: input.dataHome, + desktopEntryName: input.desktopEntryName, + }).filter((operation) => linuxDesktopIconNeedsCopy(operation.sourcePath, operation.targetPath)); + if (pending.length === 0) return; + const directories = new Set( + pending.map((operation) => NodePath.posix.dirname(operation.targetPath)), + ); + for (const directory of directories) { + NodeFS.mkdirSync(directory, { recursive: true }); + } + for (const operation of pending) { + NodeFS.copyFileSync(operation.sourcePath, operation.targetPath); + } + refreshLinuxDesktopIconCache(NodePath.posix.join(input.dataHome, "icons/hicolor")); +} + function readCommandLineSwitchValue( commandLine: DesktopPreReadyCommandLineReader, switchName: string, @@ -62,11 +118,10 @@ export const make = Effect.gen(function* () { // The portal also requires a valid desktop entry. An AppImage update may // have removed the executable referenced by the previous launch's entry. try { - const applicationsDir = NodePath.posix.join( + const dataHome = process.env.XDG_DATA_HOME?.trim() || - NodePath.posix.join(NodeOS.homedir(), ".local", "share"), - "applications", - ); + NodePath.posix.join(NodeOS.homedir(), ".local", "share"); + const applicationsDir = NodePath.posix.join(dataHome, "applications"); NodeFS.mkdirSync(applicationsDir, { recursive: true }); NodeFS.writeFileSync( NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName), @@ -80,8 +135,20 @@ export const make = Effect.gen(function* () { }), "utf8", ); + const appDir = process.env.APPDIR?.trim(); + if (appDir) { + // Stay inside this Effect.sync. Awaiting fs.promises here returns to + // the event loop, Electron emits ready, and Clerk's + // registerSchemesAsPrivileged then throws. + installLinuxDesktopIconsFromAppImage({ + appDir, + dataHome, + desktopEntryName: linux.linuxDesktopEntryName, + }); + } } catch { - // The URL handler retries with the full environment and logs failures. + // Later URL-handler registration retries the desktop entry and logs failures. + // Icon install is best-effort and is not retried. } // Chromium caches its portal registration during startup. Set the identity // before any asynchronous work can initialize it with Electron's default. diff --git a/packaging/aur/t3code-bin/PKGBUILD b/packaging/aur/t3code-bin/PKGBUILD index c5219666bf08..2efc7fdc00f7 100644 --- a/packaging/aur/t3code-bin/PKGBUILD +++ b/packaging/aur/t3code-bin/PKGBUILD @@ -94,7 +94,7 @@ TryExec=t3code Terminal=false Type=Application Icon=t3code -StartupWMClass=t3code +StartupWMClass=com.t3tools.T3Code Categories=Development; MimeType=x-scheme-handler/t3code; EOF diff --git a/packaging/aur/t3code-nightly-bin/PKGBUILD b/packaging/aur/t3code-nightly-bin/PKGBUILD index f3b61c7d5223..fbe560175e4f 100644 --- a/packaging/aur/t3code-nightly-bin/PKGBUILD +++ b/packaging/aur/t3code-nightly-bin/PKGBUILD @@ -95,7 +95,7 @@ TryExec=t3code-nightly Terminal=false Type=Application Icon=t3code-nightly -StartupWMClass=t3code +StartupWMClass=com.t3tools.T3Code Categories=Development; MimeType=x-scheme-handler/t3code; EOF diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index b601d3997fd7..7ff7e7307ad1 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -678,6 +678,9 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual((linux.linux as Record).protocols, [ { name: "T3 Code", schemes: ["t3code", "t3code-dev"] }, ]); + assert.deepStrictEqual((linux.linux as Record).desktop, { + entry: { StartupWMClass: "com.t3tools.T3Code" }, + }); assert.deepStrictEqual(mac.files, [...DESKTOP_FILE_EXCLUSIONS, ...MAC_FILE_EXCLUSIONS]); assert.deepStrictEqual(linux.files, DESKTOP_FILE_EXCLUSIONS); assert.deepStrictEqual(win.files, DESKTOP_FILE_EXCLUSIONS); diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 44d5f0ab12f4..b87a156edaf7 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2772,7 +2772,8 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( ], desktop: { entry: { - StartupWMClass: "t3code", + // GNOME matches this before the hidden portal desktop entry on Wayland. + StartupWMClass: "com.t3tools.T3Code", }, }, };