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
289 changes: 289 additions & 0 deletions apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -392,3 +393,291 @@ describe("DesktopLinuxUrlHandler", () => {
}),
);
});

interface ThemeIconRun {
readonly root: string;
readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>;
readonly iconCacheOptions?: Array<Record<string, unknown>>;
readonly sourceMissing?: boolean;
readonly iconCacheExitCode?: number;
readonly iconCacheSpawnFails?: boolean;
readonly iconCacheStalled?: boolean;
readonly iconCacheStarted?: Deferred.Deferred<void>;
// Stands in for gtk-update-icon-cache rewriting the cache on success.
readonly onIconCacheRefresh?: Effect.Effect<void>;
}

const registerWithRealFileSystem = (run: ThemeIconRun) =>
Effect.gen(function* () {
const handler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler;
yield* handler.register;
}).pipe(
Effect.provide(
DesktopLinuxUrlHandler.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.effect(
DesktopEnvironment.DesktopEnvironment,
Path.Path.pipe(
Effect.map((path) =>
makeEnvironment(path, {
linuxApplicationsDir: path.join(run.root, "applications"),
}),
),
),
),
Layer.effect(
DesktopAssets.DesktopAssets,
Path.Path.pipe(
Effect.map((path) => ({
iconPaths: Effect.succeed({
png: Option.none(),
ico: Option.none(),
icns: Option.none(),
}),
resolveResourcePath: (fileName: string) =>
Effect.succeed(
fileName === "icons/256x256.png"
? Option.some(
path.join(
run.root,
run.sourceMissing ? "missing" : "resources",
fileName,
),
)
: Option.none(),
),
})),
),
),
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make((command) => {
const childProcess = command as unknown as {
readonly command: string;
readonly args: ReadonlyArray<string>;
};
run.commands.push({ command: childProcess.command, args: childProcess.args });
if (childProcess.command !== "gtk-update-icon-cache") {
return Effect.succeed(mockProcess(0));
}
run.iconCacheOptions?.push(
(command as unknown as { readonly options: Record<string, unknown> }).options,
);
if (run.iconCacheSpawnFails) {
return Effect.fail(
PlatformError.systemError({
_tag: "NotFound",
module: "ChildProcess",
method: "spawn",
description: "gtk-update-icon-cache is not installed",
}),
);
}
const exitCode = run.iconCacheExitCode ?? 0;
const handle = mockProcess(exitCode, run.iconCacheStalled === true);
const refreshed =
exitCode === 0 && run.onIconCacheRefresh ? run.onIconCacheRefresh : Effect.void;
return refreshed.pipe(
Effect.andThen(
run.iconCacheStarted
? Deferred.succeed(run.iconCacheStarted, undefined)
: Effect.void,
),
Effect.as(handle),
);
}),
),
),
),
),
),
);

const themeIconFixture = (source: Uint8Array) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-theme-icons-" });
yield* fileSystem.makeDirectory(path.join(root, "resources", "icons"), { recursive: true });
yield* fileSystem.writeFile(path.join(root, "resources", "icons", "256x256.png"), source);
const hicolorDir = path.join(root, "icons", "hicolor");
const appsDir = path.join(hicolorDir, "256x256", "apps");
const cachePath = path.join(hicolorDir, "icon-theme.cache");
// A fixed time in the past, in whole seconds as fs.utimes expects.
const pastSeconds = 1_600_000_000;
// An existing cache that predates any installed icon.
const seedCache = Effect.gen(function* () {
yield* fileSystem.makeDirectory(appsDir, { recursive: true });
yield* fileSystem.writeFile(cachePath, new Uint8Array([0]));
yield* fileSystem.utimes(cachePath, pastSeconds, pastSeconds);
});
const rewriteCache = fileSystem
.writeFile(cachePath, new Uint8Array([1]))
.pipe(Effect.orDie, Effect.provideService(FileSystem.FileSystem, fileSystem));
return { root, hicolorDir, appsDir, cachePath, pastSeconds, seedCache, rewriteCache };
});

const iconCacheRuns = (run: ThemeIconRun) =>
run.commands.filter(({ command }) => command === "gtk-update-icon-cache").length;

it.layer(NodeServices.layer)("DesktopLinuxUrlHandler theme icons", (it) => {
it.effect("installs both app id spellings without creating an icon cache", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const icon = new Uint8Array([137, 80, 78, 71, 1, 2, 3]);
const fixture = yield* themeIconFixture(icon);
const run: ThemeIconRun = { root: fixture.root, commands: [] };

yield* registerWithRealFileSystem(run);
assert.deepEqual([...(yield* fileSystem.readDirectory(fixture.appsDir))].sort(), [
"com.t3tools.T3Code.png",
"com.t3tools.t3code.png",
]);
for (const name of ["com.t3tools.t3code", "com.t3tools.T3Code"]) {
const target = path.join(fixture.appsDir, `${name}.png`);
assert.deepEqual([...(yield* fileSystem.readFile(target))], [...icon]);
yield* fileSystem.utimes(target, fixture.pastSeconds, fixture.pastSeconds);
}

yield* registerWithRealFileSystem(run);
for (const name of ["com.t3tools.t3code", "com.t3tools.T3Code"]) {
const info = yield* fileSystem.stat(path.join(fixture.appsDir, `${name}.png`));
assert.equal(Option.getOrThrow(info.mtime).getTime(), fixture.pastSeconds * 1000);
}
assert.isFalse(yield* fileSystem.exists(fixture.cachePath));
assert.equal(iconCacheRuns(run), 0);
}).pipe(Effect.scoped),
);

it.effect("replaces same-length stale icons and refreshes an existing cache once", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3]));
yield* fixture.seedCache;
const lower = path.join(fixture.appsDir, "com.t3tools.t3code.png");
const upper = path.join(fixture.appsDir, "com.t3tools.T3Code.png");
yield* fileSystem.writeFile(lower, new Uint8Array([3, 2, 1]));
yield* fileSystem.writeFile(upper, new Uint8Array([1, 2, 3]));
for (const target of [lower, upper]) {
yield* fileSystem.utimes(target, fixture.pastSeconds, fixture.pastSeconds);
}
const run: ThemeIconRun = {
root: fixture.root,
commands: [],
onIconCacheRefresh: fixture.rewriteCache,
};

yield* registerWithRealFileSystem(run);
yield* registerWithRealFileSystem(run);

assert.deepEqual([...(yield* fileSystem.readFile(lower))], [1, 2, 3]);
const upperInfo = yield* fileSystem.stat(upper);
assert.equal(Option.getOrThrow(upperInfo.mtime).getTime(), fixture.pastSeconds * 1000);
assert.deepEqual(
run.commands.filter(({ command }) => command === "gtk-update-icon-cache"),
[{ command: "gtk-update-icon-cache", args: ["-f", "-t", fixture.hicolorDir] }],
);
}).pipe(Effect.scoped),
);

it.effect("refreshes when this launch changed an icon even if the cache looks newer", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3]));
yield* fixture.seedCache;
const run: ThemeIconRun = {
root: fixture.root,
commands: [],
onIconCacheRefresh: fixture.rewriteCache,
};
// A cache stamped later than the new icons, as after a clock correction.
const future = fixture.pastSeconds + 10 * 365 * 24 * 60 * 60;
yield* fileSystem.utimes(fixture.cachePath, future, future);

yield* registerWithRealFileSystem(run);

assert.isTrue(yield* fileSystem.exists(path.join(fixture.appsDir, "com.t3tools.T3Code.png")));
assert.equal(iconCacheRuns(run), 1);
}).pipe(Effect.scoped),
);

it.effect("retries a failed icon cache refresh on the next launch", () =>
Effect.gen(function* () {
const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3]));
yield* fixture.seedCache;
const failing: ThemeIconRun = { root: fixture.root, commands: [], iconCacheExitCode: 1 };
yield* registerWithRealFileSystem(failing);
assert.equal(iconCacheRuns(failing), 1);
assert.deepEqual(failing.commands.map(({ command }) => command).slice(1), [
"update-desktop-database",
"xdg-mime",
]);

const recovering: ThemeIconRun = {
root: fixture.root,
commands: [],
onIconCacheRefresh: fixture.rewriteCache,
};
yield* registerWithRealFileSystem(recovering);
yield* registerWithRealFileSystem(recovering);
assert.equal(iconCacheRuns(recovering), 1);
}).pipe(Effect.scoped),
);

it.effect("still registers the handler when the theme icon install fails", () =>
Effect.gen(function* () {
const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3]));
const missingSource: ThemeIconRun = { root: fixture.root, commands: [], sourceMissing: true };
yield* registerWithRealFileSystem(missingSource);
assert.deepEqual(
missingSource.commands.map(({ command }) => command),
["update-desktop-database", "xdg-mime"],
);

yield* fixture.seedCache;
const missingTool: ThemeIconRun = {
root: fixture.root,
commands: [],
iconCacheSpawnFails: true,
};
yield* registerWithRealFileSystem(missingTool);
assert.deepEqual(
missingTool.commands.map(({ command }) => command),
["gtk-update-icon-cache", "update-desktop-database", "xdg-mime"],
);
}).pipe(Effect.scoped),
);

it.effect("continues to xdg-mime when the icon cache refresh stalls", () =>
Effect.gen(function* () {
const fixture = yield* themeIconFixture(new Uint8Array([1, 2, 3]));
yield* fixture.seedCache;
const started = yield* Deferred.make<void>();
const iconCacheOptions: Array<Record<string, unknown>> = [];
const run: ThemeIconRun = {
root: fixture.root,
commands: [],
iconCacheOptions,
iconCacheStalled: true,
iconCacheStarted: started,
};

const registration = yield* registerWithRealFileSystem(run).pipe(Effect.forkChild);
yield* Deferred.await(started);
yield* TestClock.adjust("5 seconds");
yield* Fiber.join(registration);

assert.deepEqual(
run.commands.map(({ command }) => command),
["gtk-update-icon-cache", "update-desktop-database", "xdg-mime"],
);
// Without a forced kill, releasing a helper that ignores SIGTERM after
// the timeout would still wait for it to exit.
assert.equal(iconCacheOptions[0]?.forceKillAfter, "1 second");
}).pipe(Effect.scoped),
);
});
74 changes: 74 additions & 0 deletions apps/desktop/src/app/DesktopLinuxUrlHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,19 @@ export function renderUrlHandlerDesktopEntry(input: {
].join("\n");
}

// Some docks and bars look the Wayland app id up in the icon theme when no
// visible desktop entry matches the window, for example an AppImage run without
// integration. Theme lookups are case-sensitive, so install both spellings.
const LINUX_THEME_ICON_SIZE = "256x256";

function linuxThemeIconNames(desktopEntryName: string): readonly string[] {
const appId = desktopEntryName.replace(/\.desktop$/u, "");
return [...new Set([appId.toLowerCase(), appId])];
}

const sameBytes = (left: Uint8Array, right: Uint8Array): boolean =>
left.length === right.length && left.every((byte, index) => byte === right[index]);

export class DesktopLinuxUrlHandler extends Context.Service<
DesktopLinuxUrlHandler,
{
Expand All @@ -124,6 +137,8 @@ export const make = Effect.gen(function* () {
);
const iconsDir = environment.path.join(environment.linuxApplicationsDir, "..", "icons");
const iconPath = environment.path.join(iconsDir, `${environment.linuxDesktopEntryName}.png`);
const hicolorDir = environment.path.join(iconsDir, "hicolor");
const themeIconDir = environment.path.join(hicolorDir, LINUX_THEME_ICON_SIZE, "apps");

const writeDesktopEntry = Effect.gen(function* () {
// Inside the mounted AppImage, process.execPath points at a transient
Expand Down Expand Up @@ -186,6 +201,63 @@ export const make = Effect.gen(function* () {
),
);

const refreshIconThemeCache = Effect.scoped(
Effect.gen(function* () {
const command = ChildProcess.make("gtk-update-icon-cache", ["-f", "-t", hicolorDir], {
stdin: "ignore",
stdout: "ignore",
stderr: "ignore",
// Releasing a timed-out helper otherwise waits on SIGTERM indefinitely.
forceKillAfter: "1 second",
});
const handle = yield* spawner.spawn(command);
return yield* handle.exitCode.pipe(Effect.timeout("5 seconds"));
}),
);

const modifiedAt = (filePath: string) =>
fileSystem
.stat(filePath)
.pipe(Effect.map((info) => Option.getOrUndefined(info.mtime)?.getTime() ?? 0));

const installThemeIcons = Effect.gen(function* () {
const source = yield* assets.resolveResourcePath(`icons/${LINUX_THEME_ICON_SIZE}.png`);
if (Option.isNone(source)) return;
const icon = yield* fileSystem.readFile(source.value);
Comment thread
mwolson marked this conversation as resolved.
const targets = linuxThemeIconNames(environment.linuxDesktopEntryName).map((name) =>
environment.path.join(themeIconDir, `${name}.png`),
);
let changed = false;
for (const target of targets) {
const current = yield* fileSystem.readFile(target).pipe(Effect.option);
if (Option.isSome(current) && sameBytes(current.value, icon)) continue;
yield* fileSystem.makeDirectory(themeIconDir, { recursive: true });
yield* fileSystem.writeFile(target, icon);
changed = true;
}
// Only keep an existing cache current. Creating one would leave GTK reading
// a cache that other tools installing user icons do not update. An icon
// newer than the cache also retries a refresh that failed on an earlier
// launch.
const cachePath = environment.path.join(hicolorDir, "icon-theme.cache");
const cachedAt = yield* modifiedAt(cachePath).pipe(Effect.option);
if (Option.isNone(cachedAt)) return;
let stale = changed;
for (const target of targets) {
if (stale) break;
if ((yield* modifiedAt(target)) > cachedAt.value) stale = true;
}
if (!stale) return;
const exitCode = yield* refreshIconThemeCache;
if (exitCode !== 0) {
yield* logWarning("icon theme cache refresh failed", { hicolorDir, exitCode });
}
}).pipe(
Effect.catch((error) =>
logWarning("app id theme icon install failed", { themeIconDir, category: error._tag }),
),
);

const setDefaultHandler = Effect.scoped(
Effect.gen(function* () {
const command = ChildProcess.make(
Expand Down Expand Up @@ -238,6 +310,8 @@ export const make = Effect.gen(function* () {
),
);

yield* installThemeIcons;

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
Expand Down
Loading