Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const makeHandlerLayer = (
: Effect.sync(() => {
recorded.files.push({ path, content });
}),
remove: () => Effect.void,
}),
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
Expand Down Expand Up @@ -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",
Expand Down
54 changes: 49 additions & 5 deletions apps/desktop/src/app/DesktopLinuxUrlHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): 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;
Expand All @@ -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");
Expand Down Expand Up @@ -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) =>
Expand Down
135 changes: 135 additions & 0 deletions apps/desktop/src/app/DesktopPreReadyPlatform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const {
setDesktopNameMock,
mkdirSyncMock,
writeFileSyncMock,
existsSyncMock,
copyFileSyncMock,
spawnMock,
statSyncMock,
unlinkSyncMock,
} = vi.hoisted(() => ({
appendSwitchMock: vi.fn(),
getSwitchValueMock: vi.fn(),
Expand All @@ -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", () => ({
Expand All @@ -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";
Expand All @@ -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", () => {
Expand Down Expand Up @@ -104,13 +132,79 @@ 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())));
},
);
}

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<readonly [string, string]> = [];
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(() => {
Expand All @@ -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<string> = [];
copyFileSyncMock.mockImplementation(() => {
events.push("icon-copy");
});

class LinuxClerkShaped extends Context.Service<LinuxClerkShaped, { readonly ready: true }>()(
"@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",
() =>
Expand Down
Loading
Loading