authorgravatar for me@limwa.ptAndré Lima <me@limwa.pt> 2026-09-28 23:40:42+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-29 04:10:42+05:30
log7733bc839e23e19467a70b11a0bcedf6ffe8f1a3
treed1ac910f080136005929cf30e9cc4326b4639762
parentb528a701102f95089544596856a3f208f4404613
signature Signed by PGP key B5690EEEBB952194

fix(desktop): make Linux URL handlers discoverable with the app icon (#8673)

Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

4 files changed, 311 insertions(+), 35 deletions(-)

apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts+168-30
......@@ -1,23 +1,29 @@
11import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
23import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
35import * as FileSystem from "effect/FileSystem";
46import * as Layer from "effect/Layer";
57import * as Option from "effect/Option";
8import * as Path from "effect/Path";
69import * as PlatformError from "effect/PlatformError";
710import * as Sink from "effect/Sink";
811import * as Stream from "effect/Stream";
12import * as TestClock from "effect/testing/TestClock";
913import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1014
1115import * as DesktopEnvironment from "./DesktopEnvironment.ts";
16import * as DesktopAssets from "./DesktopAssets.ts";
1217import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
1318
1419interface RecordedRegistration {
1520 readonly directories: string[];
1621 readonly files: Array<{ readonly path: string; readonly content: string }>;
1722 readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>;
23 readonly copies: Array<{ readonly source: string; readonly destination: string }>;
1824}
1925
20const makeEnvironment = (overrides: Record<string, unknown> = {}) =>
26const makeEnvironment = (path: Path.Path, overrides: Record<string, unknown> = {}) =>
2127 DesktopEnvironment.DesktopEnvironment.of({
2228 platform: "linux",
2329 isPackaged: true,
......@@ -27,14 +33,14 @@ const makeEnvironment = (overrides: Record<string, unknown> = {}) =>
2733 linuxWmClass: "t3code",
2834 linuxApplicationsDir: "/home/alice/.local/share/applications",
2935 appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"),
30 path: { join: (...parts: ReadonlyArray<string>) => parts.join("/") },
36 path,
3137 ...overrides,
3238 } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);
3339
34const mockProcess = (exitCode: number) =>
40const mockProcess = (exitCode: number, stalled = false) =>
3541 ChildProcessSpawner.makeHandle({
3642 pid: ChildProcessSpawner.ProcessId(1),
37 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
43 exitCode: stalled ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
3844 isRunning: Effect.succeed(false),
3945 kill: () => Effect.void,
4046 unref: Effect.succeed(Effect.void),
......@@ -50,16 +56,41 @@ const makeHandlerLayer = (
5056 recorded: RecordedRegistration,
5157 input: {
5258 readonly environment?: Record<string, unknown>;
59 readonly updateDesktopDatabaseExitCode?: number;
60 readonly updateDesktopDatabaseStalled?: boolean;
61 readonly updateDesktopDatabaseStarted?: Deferred.Deferred<void>;
5362 readonly xdgMimeExitCode?: number;
5463 readonly writeError?: PlatformError.PlatformError;
5564 readonly existingEntry?: string;
65 readonly iconSource?: string;
66 readonly iconCopyError?: PlatformError.PlatformError;
5667 } = {},
5768) =>
5869 DesktopLinuxUrlHandler.layer.pipe(
5970 Layer.provide(
6071 Layer.mergeAll(
61 Layer.succeed(DesktopEnvironment.DesktopEnvironment, makeEnvironment(input.environment)),
72 Layer.effect(
73 DesktopEnvironment.DesktopEnvironment,
74 Path.Path.pipe(
75 Effect.map((path) => makeEnvironment(path, input.environment)),
76 Effect.provide(Path.layer),
77 ),
78 ),
79 Layer.succeed(DesktopAssets.DesktopAssets, {
80 iconPaths: Effect.succeed({
81 png: Option.fromUndefinedOr(input.iconSource),
82 ico: Option.none(),
83 icns: Option.none(),
84 }),
85 resolveResourcePath: () => Effect.succeedNone,
86 }),
6287 FileSystem.layerNoop({
88 copyFile: (source, destination) =>
89 input.iconCopyError
90 ? Effect.fail(input.iconCopyError)
91 : Effect.sync(() => {
92 recorded.copies.push({ source, destination });
93 }),
6394 readFileString: () => Effect.succeed(input.existingEntry ?? ""),
6495 makeDirectory: (path) =>
6596 Effect.sync(() => {
......@@ -79,11 +110,31 @@ const makeHandlerLayer = (
79110 readonly command: string;
80111 readonly args: ReadonlyArray<string>;
81112 };
113 if (childProcess.command === "update-desktop-database") {
114 assert.isTrue(
115 recorded.files.length > 0 || input.existingEntry !== undefined,
116 "the desktop entry must exist before refreshing the MIME cache",
117 );
118 }
82119 recorded.commands.push({
83120 command: childProcess.command,
84121 args: childProcess.args,
85122 });
86 return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0));
123 const exitCode =
124 childProcess.command === "update-desktop-database"
125 ? (input.updateDesktopDatabaseExitCode ?? 0)
126 : (input.xdgMimeExitCode ?? 0);
127 const handle = mockProcess(
128 exitCode,
129 childProcess.command === "update-desktop-database" &&
130 input.updateDesktopDatabaseStalled === true,
131 );
132 return childProcess.command === "update-desktop-database" &&
133 input.updateDesktopDatabaseStarted
134 ? Deferred.succeed(input.updateDesktopDatabaseStarted, undefined).pipe(
135 Effect.as(handle),
136 )
137 : Effect.succeed(handle);
87138 }),
88139 ),
89140 ),
......@@ -103,6 +154,7 @@ const emptyRecording = (): RecordedRegistration => ({
103154 directories: [],
104155 files: [],
105156 commands: [],
157 copies: [],
106158});
107159
108160describe("DesktopLinuxUrlHandler", () => {
......@@ -111,6 +163,7 @@ describe("DesktopLinuxUrlHandler", () => {
111163 displayName: "T3 Code (Nightly)",
112164 execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage',
113165 scheme: "t3code",
166 iconPath: "/home/al ice/icons/T3\\x.png",
114167 });
115168
116169 assert.include(entry, "[Desktop Entry]");
......@@ -125,6 +178,7 @@ describe("DesktopLinuxUrlHandler", () => {
125178 assert.include(entry, "NoDisplay=true");
126179 assert.notInclude(entry, "StartupWMClass=");
127180 assert.include(entry, "MimeType=x-scheme-handler/t3code;");
181 assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png");
128182 });
129183
130184 it("carries structured context on registration errors", () => {
......@@ -154,31 +208,38 @@ describe("DesktopLinuxUrlHandler", () => {
154208 );
155209 });
156210
157 it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => {
158 const recorded = emptyRecording();
211 it.effect(
212 "writes the handler entry, refreshes the MIME cache, and claims the scheme default",
213 () => {
214 const recorded = emptyRecording();
159215
160 return Effect.gen(function* () {
161 yield* runRegister(recorded);
216 return Effect.gen(function* () {
217 yield* runRegister(recorded);
162218
163 assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]);
164 assert.equal(recorded.files.length, 1);
165 assert.equal(
166 recorded.files[0]?.path,
167 "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop",
168 );
169 assert.include(
170 recorded.files[0]?.content,
171 'Exec="/home/alice/Applications/T3-Code.AppImage" %U',
172 );
173 assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;");
174 assert.deepEqual(recorded.commands, [
175 {
176 command: "xdg-mime",
177 args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"],
178 },
179 ]);
180 });
181 });
219 assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]);
220 assert.equal(recorded.files.length, 1);
221 assert.equal(
222 recorded.files[0]?.path,
223 "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop",
224 );
225 assert.include(
226 recorded.files[0]?.content,
227 'Exec="/home/alice/Applications/T3-Code.AppImage" %U',
228 );
229 assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;");
230 assert.deepEqual(recorded.commands, [
231 {
232 command: "update-desktop-database",
233 args: ["/home/alice/.local/share/applications"],
234 },
235 {
236 command: "xdg-mime",
237 args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"],
238 },
239 ]);
240 });
241 },
242 );
182243
183244 it.effect("falls back to the process executable outside an AppImage", () => {
184245 const recorded = emptyRecording();
......@@ -202,12 +263,63 @@ describe("DesktopLinuxUrlHandler", () => {
202263 displayName: "T3 Code (Alpha)",
203264 execTarget: "/home/alice/Applications/T3-Code.AppImage",
204265 scheme: "t3code",
266 iconPath: "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png",
205267 }),
206268 });
207269
208270 assert.deepEqual(recorded.files, []);
209271 assert.deepEqual(recorded.directories, []);
210 assert.equal(recorded.commands.length, 1);
272 assert.deepEqual(recorded.commands, [
273 {
274 command: "update-desktop-database",
275 args: ["/home/alice/.local/share/applications"],
276 },
277 {
278 command: "xdg-mime",
279 args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"],
280 },
281 ]);
282 });
283 });
284
285 it.effect("installs a persistent icon even when the desktop entry is already current", () => {
286 const recorded = emptyRecording();
287 const iconPath = "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png";
288 return Effect.gen(function* () {
289 yield* runRegister(recorded, {
290 iconSource: "/tmp/.mount_T3/resources/icon.png",
291 existingEntry: DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({
292 displayName: "T3 Code (Alpha)",
293 execTarget: "/home/alice/Applications/T3-Code.AppImage",
294 scheme: "t3code",
295 iconPath,
296 }),
297 });
298 assert.deepEqual(recorded.files, []);
299 assert.deepEqual(recorded.copies, [
300 { source: "/tmp/.mount_T3/resources/icon.png", destination: iconPath },
301 ]);
302 assert.equal(recorded.commands.at(-1)?.command, "xdg-mime");
303 });
304 });
305
306 it.effect("still registers the handler when copying its icon fails", () => {
307 const recorded = emptyRecording();
308 return Effect.gen(function* () {
309 yield* runRegister(recorded, {
310 iconSource: "/tmp/.mount_T3/resources/icon.png",
311 iconCopyError: PlatformError.systemError({
312 _tag: "PermissionDenied",
313 module: "FileSystem",
314 method: "copyFile",
315 description: "read-only icon directory",
316 }),
317 });
318 assert.equal(recorded.files.length, 1);
319 assert.deepEqual(
320 recorded.commands.map(({ command }) => command),
321 ["update-desktop-database", "xdg-mime"],
322 );
211323 });
212324 });
213325
......@@ -234,10 +346,12 @@ describe("DesktopLinuxUrlHandler", () => {
234346 });
235347
236348 it.effect("never fails startup when registration cannot complete", () => {
349 const desktopDatabaseFailed = emptyRecording();
237350 const xdgMimeFailed = emptyRecording();
238351 const writeFailed = emptyRecording();
239352
240353 return Effect.gen(function* () {
354 yield* runRegister(desktopDatabaseFailed, { updateDesktopDatabaseExitCode: 1 });
241355 yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 });
242356 yield* runRegister(writeFailed, {
243357 writeError: PlatformError.systemError({
......@@ -249,8 +363,32 @@ describe("DesktopLinuxUrlHandler", () => {
249363 }),
250364 });
251365
366 assert.deepEqual(
367 desktopDatabaseFailed.commands.map(({ command }) => command),
368 ["update-desktop-database", "xdg-mime"],
369 );
252370 assert.equal(xdgMimeFailed.files.length, 1);
253371 assert.deepEqual(writeFailed.commands, []);
254372 });
255373 });
374
375 it.effect("continues to xdg-mime when the desktop MIME cache refresh stalls", () =>
376 Effect.gen(function* () {
377 const recorded = emptyRecording();
378 const started = yield* Deferred.make<void>();
379 const registration = yield* runRegister(recorded, {
380 updateDesktopDatabaseStalled: true,
381 updateDesktopDatabaseStarted: started,
382 }).pipe(Effect.forkChild);
383
384 yield* Deferred.await(started);
385 yield* TestClock.adjust("5 seconds");
386 yield* Fiber.join(registration);
387
388 assert.deepEqual(
389 recorded.commands.map(({ command }) => command),
390 ["update-desktop-database", "xdg-mime"],
391 );
392 }),
393 );
256394});
apps/desktop/src/app/DesktopLinuxUrlHandler.ts+86-4
......@@ -8,6 +8,7 @@ import * as ChildProcess from "effect/unstable/process/ChildProcess";
88import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
99
1010import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
11import * as DesktopAssets from "./DesktopAssets.ts";
1112import * as DesktopEnvironment from "./DesktopEnvironment.ts";
1213import { makeComponentLogger } from "./DesktopObservability.ts";
1314
......@@ -17,9 +18,9 @@ import { makeComponentLogger } from "./DesktopObservability.ts";
1718// Electron's app.setAsDefaultProtocolClient resolves the desktop id from
1819// setDesktopName, which cannot match those files — so the browser keeps
1920// prompting "Choose an application" for every OAuth callback. Instead, write
20// our own handler entry pointing at the current AppImage and claim the
21// scheme default via xdg-mime, exactly what the file manager's "set as
22// default" checkbox would record in mimeapps.list.
21// our own handler entry pointing at the current AppImage, refresh the desktop
22// MIME cache so desktop environments recognize that entry as a handler, and
23// use xdg-mime to record it as the scheme default in mimeapps.list.
2324const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler");
2425
2526export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError<DesktopLinuxUrlHandlerRegistrationError>()(
......@@ -40,6 +41,23 @@ export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError<
4041
4142const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError);
4243
44export class DesktopLinuxUrlHandlerCacheRefreshError extends Schema.TaggedError<DesktopLinuxUrlHandlerCacheRefreshError>()(
45 "DesktopLinuxUrlHandlerCacheRefreshError",
46 {
47 applicationsDir: Schema.String,
48 exitCode: Schema.optionalKey(Schema.Number),
49 cause: Schema.optionalKey(Schema.Defect()),
50 },
51) {
52 override get message(): string {
53 const exitCode =
54 this.exitCode === undefined ? "" : `, update-desktop-database exit code ${this.exitCode}`;
55 return `Failed to refresh the desktop MIME cache at ${this.applicationsDir}${exitCode}.`;
56 }
57}
58
59const isCacheRefreshError = Schema.is(DesktopLinuxUrlHandlerCacheRefreshError);
60
4361const escapeDesktopEntryString = (value: string): string =>
4462 value
4563 .replaceAll("\\", "\\\\")
......@@ -63,18 +81,20 @@ export function escapeDesktopEntryExecArgument(value: string): string {
6381 return escapeDesktopEntryString(`"${quoted}"`);
6482}
6583
66// The AppImage integration entry owns the window identity and icon. This
84// The AppImage integration entry owns the window identity. This
6785// hidden URL-only entry must not compete with it for StartupWMClass matching.
6886export function renderUrlHandlerDesktopEntry(input: {
6987 readonly displayName: string;
7088 readonly execTarget: string;
7189 readonly scheme: string;
90 readonly iconPath?: string;
7291}): string {
7392 return [
7493 "[Desktop Entry]",
7594 "Type=Application",
7695 `Name=${escapeDesktopEntryString(input.displayName)}`,
7796 `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`,
97 ...(input.iconPath === undefined ? [] : [`Icon=${escapeDesktopEntryString(input.iconPath)}`]),
7898 "Terminal=false",
7999 "NoDisplay=true",
80100 "StartupNotify=false",
......@@ -95,12 +115,15 @@ export const make = Effect.gen(function* () {
95115 const environment = yield* DesktopEnvironment.DesktopEnvironment;
96116 const fileSystem = yield* FileSystem.FileSystem;
97117 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
118 const assets = yield* DesktopAssets.DesktopAssets;
98119
99120 const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment);
100121 const desktopEntryPath = environment.path.join(
101122 environment.linuxApplicationsDir,
102123 environment.linuxDesktopEntryName,
103124 );
125 const iconsDir = environment.path.join(environment.linuxApplicationsDir, "..", "icons");
126 const iconPath = environment.path.join(iconsDir, `${environment.linuxDesktopEntryName}.png`);
104127
105128 const writeDesktopEntry = Effect.gen(function* () {
106129 // Inside the mounted AppImage, process.execPath points at a transient
......@@ -110,6 +133,7 @@ export const make = Effect.gen(function* () {
110133 displayName: environment.displayName,
111134 execTarget,
112135 scheme,
136 ...(environment.isPackaged ? { iconPath } : {}),
113137 });
114138 // Pre-ready setup normally wrote this already. Avoid truncating a valid
115139 // entry while the portal may be reading it during startup.
......@@ -131,6 +155,37 @@ export const make = Effect.gen(function* () {
131155 ),
132156 );
133157
158 const updateDesktopDatabase = Effect.scoped(
159 Effect.gen(function* () {
160 const command = ChildProcess.make(
161 "update-desktop-database",
162 [environment.linuxApplicationsDir],
163 {
164 stdin: "ignore",
165 stdout: "ignore",
166 stderr: "ignore",
167 },
168 );
169 const handle = yield* spawner.spawn(command);
170 const exitCode = yield* handle.exitCode.pipe(Effect.timeout("5 seconds"));
171 if (exitCode !== 0) {
172 return yield* new DesktopLinuxUrlHandlerCacheRefreshError({
173 applicationsDir: environment.linuxApplicationsDir,
174 exitCode,
175 });
176 }
177 }),
178 ).pipe(
179 Effect.mapError((error) =>
180 isCacheRefreshError(error)
181 ? error
182 : new DesktopLinuxUrlHandlerCacheRefreshError({
183 applicationsDir: environment.linuxApplicationsDir,
184 cause: error,
185 }),
186 ),
187 );
188
134189 const setDefaultHandler = Effect.scoped(
135190 Effect.gen(function* () {
136191 const command = ChildProcess.make(
......@@ -170,6 +225,33 @@ export const make = Effect.gen(function* () {
170225 }
171226 yield* writeDesktopEntry;
172227 if (!environment.isPackaged) return;
228
229 yield* Effect.gen(function* () {
230 const { png } = yield* assets.iconPaths;
231 if (Option.isNone(png)) return;
232 // The AppImage mount is temporary; the chooser needs the icon after exit.
233 yield* fileSystem.makeDirectory(iconsDir, { recursive: true });
234 yield* fileSystem.copyFile(png.value, iconPath);
235 }).pipe(
236 Effect.catch((error) =>
237 logWarning("URL handler icon copy failed", { iconPath, category: error.reason._tag }),
238 ),
239 );
240
241 yield* updateDesktopDatabase.pipe(
242 // Some MIME implementations, including GIO, use mimeinfo.cache to verify
243 // that a desktop entry is associated with a scheme. Cache refresh is
244 // independently best-effort so a missing update-desktop-database executable
245 // does not prevent xdg-mime from recording the requested default.
246 Effect.catch((error) =>
247 logWarning("desktop MIME cache refresh failed", {
248 applicationsDir: environment.linuxApplicationsDir,
249 message: error.message,
250 ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }),
251 }),
252 ),
253 );
254
173255 yield* setDefaultHandler;
174256 yield* logInfo("registered URL scheme handler", { scheme });
175257 }).pipe(
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts+34-1
......@@ -13,6 +13,7 @@ const {
1313 setDesktopNameMock,
1414 mkdirSyncMock,
1515 writeFileSyncMock,
16 copyFileSyncMock,
1617} = vi.hoisted(() => ({
1718 appendSwitchMock: vi.fn(),
1819 getSwitchValueMock: vi.fn(),
......@@ -21,12 +22,15 @@ const {
2122 setDesktopNameMock: vi.fn(),
2223 mkdirSyncMock: vi.fn(),
2324 writeFileSyncMock: vi.fn(),
25 copyFileSyncMock: vi.fn(),
2426}));
2527
2628vi.mock("electron", () => ({
2729 app: {
2830 setDesktopName: setDesktopNameMock,
2931 getVersion: () => "0.0.37",
32 isPackaged: true,
33 getAppPath: () => "/tmp/.mount_T3/resources/app.asar",
3034 commandLine: {
3135 appendSwitch: appendSwitchMock,
3236 getSwitchValue: getSwitchValueMock,
......@@ -42,6 +46,7 @@ vi.mock("node:fs", () => ({
4246 readFileSync: () => "{}",
4347 mkdirSync: mkdirSyncMock,
4448 writeFileSync: writeFileSyncMock,
49 copyFileSync: copyFileSyncMock,
4550}));
4651
4752import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
......@@ -55,6 +60,7 @@ describe("DesktopPreReadyPlatform", () => {
5560 setDesktopNameMock.mockReset();
5661 mkdirSyncMock.mockReset();
5762 writeFileSyncMock.mockReset();
63 copyFileSyncMock.mockReset();
5864 });
5965
6066 it.effect("preserves an explicit Linux password-store switch", () => {
......@@ -85,6 +91,10 @@ describe("DesktopPreReadyPlatform", () => {
8591 getSwitchValueMock.mockReturnValue("");
8692 let desktopName = "t3code.desktop";
8793 let desktopEntry = previousEntry;
94 let iconInstalled = false;
95 copyFileSyncMock.mockImplementation((_source: string, destination: string) => {
96 iconInstalled = destination === "/xdg/icons/com.t3tools.T3Code.desktop.png";
97 });
8898 setDesktopNameMock.mockImplementation((name: string) => {
8999 desktopName = name;
90100 });
......@@ -94,7 +104,11 @@ describe("DesktopPreReadyPlatform", () => {
94104
95105 return Effect.scoped(
96106 Effect.gen(function* () {
97 const portalIdentity = Promise.resolve().then(() => ({ desktopName, desktopEntry }));
107 const portalIdentity = Promise.resolve().then(() => ({
108 desktopName,
109 desktopEntry,
110 iconInstalled,
111 }));
98112 yield* Layer.build(
99113 DesktopPreReadyPlatform.layer.pipe(
100114 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
......@@ -105,6 +119,11 @@ describe("DesktopPreReadyPlatform", () => {
105119 assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U');
106120 assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)");
107121 assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;");
122 assert.include(
123 identity.desktopEntry ?? "",
124 "Icon=/xdg/icons/com.t3tools.T3Code.desktop.png",
125 );
126 assert.isTrue(identity.iconInstalled);
108127 }),
109128 ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())));
110129 },
......@@ -123,6 +142,20 @@ describe("DesktopPreReadyPlatform", () => {
123142 );
124143 });
125144
145 it.effect("still prepares the portal entry when the bundled icon cannot be copied", () => {
146 getSwitchValueMock.mockReturnValue("");
147 copyFileSyncMock.mockImplementation(() => {
148 throw new Error("missing bundled icon");
149 });
150 return Effect.gen(function* () {
151 yield* DesktopPreReadyPlatform.make;
152 const contents = writeFileSyncMock.mock.calls[0]?.[1];
153 assert.include(contents, "MimeType=x-scheme-handler/t3code;");
154 assert.include(contents, "Icon=");
155 assert.equal(setDesktopNameMock.mock.calls.length, 1);
156 }).pipe(Effect.provideService(HostProcessPlatform, "linux"));
157 });
158
126159 it.effect(
127160 "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer",
128161 () =>
apps/desktop/src/app/DesktopPreReadyPlatform.ts+23
......@@ -68,6 +68,28 @@ export const make = Effect.gen(function* () {
6868 "applications",
6969 );
7070 NodeFS.mkdirSync(applicationsDir, { recursive: true });
71 const iconPath = Electron.app.isPackaged
72 ? NodePath.posix.join(
73 applicationsDir,
74 "..",
75 "icons",
76 `${linux.linuxDesktopEntryName}.png`,
77 )
78 : undefined;
79 if (iconPath !== undefined) {
80 try {
81 NodeFS.mkdirSync(NodePath.posix.dirname(iconPath), { recursive: true });
82 NodeFS.copyFileSync(
83 NodePath.posix.join(
84 Electron.app.getAppPath(),
85 "apps/desktop/prod-resources/icon.png",
86 ),
87 iconPath,
88 );
89 } catch {
90 // Icon installation is optional; registration retries after readiness.
91 }
92 }
7193 NodeFS.writeFileSync(
7294 NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName),
7395 renderUrlHandlerDesktopEntry({
......@@ -77,6 +99,7 @@ export const make = Effect.gen(function* () {
7799 }).displayName,
78100 execTarget: process.env.APPIMAGE?.trim() || process.execPath,
79101 scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment),
102 ...(iconPath === undefined ? {} : { iconPath }),
80103 }),
81104 "utf8",
82105 );