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 @@...@@ -1,23 +1,29 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
3import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
6import * as PlatformError from "effect/PlatformError";9import * as PlatformError from "effect/PlatformError";
7import * as Sink from "effect/Sink";10import * as Sink from "effect/Sink";
8import * as Stream from "effect/Stream";11import * as Stream from "effect/Stream";
12import * as TestClock from "effect/testing/TestClock";
9import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";13import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
1014
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";15import * as DesktopEnvironment from "./DesktopEnvironment.ts";
16import * as DesktopAssets from "./DesktopAssets.ts";
12import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";17import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
1318
14interface RecordedRegistration {19interface RecordedRegistration {
15 readonly directories: string[];20 readonly directories: string[];
16 readonly files: Array<{ readonly path: string; readonly content: string }>;21 readonly files: Array<{ readonly path: string; readonly content: string }>;
17 readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>;22 readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>;
23 readonly copies: Array<{ readonly source: string; readonly destination: string }>;
18}24}
1925
20const makeEnvironment = (overrides: Record<string, unknown> = {}) =>26const makeEnvironment = (path: Path.Path, overrides: Record<string, unknown> = {}) =>
21 DesktopEnvironment.DesktopEnvironment.of({27 DesktopEnvironment.DesktopEnvironment.of({
22 platform: "linux",28 platform: "linux",
23 isPackaged: true,29 isPackaged: true,
...@@ -27,14 +33,14 @@ const makeEnvironment = (overrides: Record<string, unknown> = {}) =>...@@ -27,14 +33,14 @@ const makeEnvironment = (overrides: Record<string, unknown> = {}) =>
27 linuxWmClass: "t3code",33 linuxWmClass: "t3code",
28 linuxApplicationsDir: "/home/alice/.local/share/applications",34 linuxApplicationsDir: "/home/alice/.local/share/applications",
29 appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"),35 appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"),
30 path: { join: (...parts: ReadonlyArray<string>) => parts.join("/") },36 path,
31 ...overrides,37 ...overrides,
32 } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);38 } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);
3339
34const mockProcess = (exitCode: number) =>40const mockProcess = (exitCode: number, stalled = false) =>
35 ChildProcessSpawner.makeHandle({41 ChildProcessSpawner.makeHandle({
36 pid: ChildProcessSpawner.ProcessId(1),42 pid: ChildProcessSpawner.ProcessId(1),
37 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),43 exitCode: stalled ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
38 isRunning: Effect.succeed(false),44 isRunning: Effect.succeed(false),
39 kill: () => Effect.void,45 kill: () => Effect.void,
40 unref: Effect.succeed(Effect.void),46 unref: Effect.succeed(Effect.void),
...@@ -50,16 +56,41 @@ const makeHandlerLayer = (...@@ -50,16 +56,41 @@ const makeHandlerLayer = (
50 recorded: RecordedRegistration,56 recorded: RecordedRegistration,
51 input: {57 input: {
52 readonly environment?: Record<string, unknown>;58 readonly environment?: Record<string, unknown>;
59 readonly updateDesktopDatabaseExitCode?: number;
60 readonly updateDesktopDatabaseStalled?: boolean;
61 readonly updateDesktopDatabaseStarted?: Deferred.Deferred<void>;
53 readonly xdgMimeExitCode?: number;62 readonly xdgMimeExitCode?: number;
54 readonly writeError?: PlatformError.PlatformError;63 readonly writeError?: PlatformError.PlatformError;
55 readonly existingEntry?: string;64 readonly existingEntry?: string;
65 readonly iconSource?: string;
66 readonly iconCopyError?: PlatformError.PlatformError;
56 } = {},67 } = {},
57) =>68) =>
58 DesktopLinuxUrlHandler.layer.pipe(69 DesktopLinuxUrlHandler.layer.pipe(
59 Layer.provide(70 Layer.provide(
60 Layer.mergeAll(71 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 }),
62 FileSystem.layerNoop({87 FileSystem.layerNoop({
88 copyFile: (source, destination) =>
89 input.iconCopyError
90 ? Effect.fail(input.iconCopyError)
91 : Effect.sync(() => {
92 recorded.copies.push({ source, destination });
93 }),
63 readFileString: () => Effect.succeed(input.existingEntry ?? ""),94 readFileString: () => Effect.succeed(input.existingEntry ?? ""),
64 makeDirectory: (path) =>95 makeDirectory: (path) =>
65 Effect.sync(() => {96 Effect.sync(() => {
...@@ -79,11 +110,31 @@ const makeHandlerLayer = (...@@ -79,11 +110,31 @@ const makeHandlerLayer = (
79 readonly command: string;110 readonly command: string;
80 readonly args: ReadonlyArray<string>;111 readonly args: ReadonlyArray<string>;
81 };112 };
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 }
82 recorded.commands.push({119 recorded.commands.push({
83 command: childProcess.command,120 command: childProcess.command,
84 args: childProcess.args,121 args: childProcess.args,
85 });122 });
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);
87 }),138 }),
88 ),139 ),
89 ),140 ),
...@@ -103,6 +154,7 @@ const emptyRecording = (): RecordedRegistration => ({...@@ -103,6 +154,7 @@ const emptyRecording = (): RecordedRegistration => ({
103 directories: [],154 directories: [],
104 files: [],155 files: [],
105 commands: [],156 commands: [],
157 copies: [],
106});158});
107159
108describe("DesktopLinuxUrlHandler", () => {160describe("DesktopLinuxUrlHandler", () => {
...@@ -111,6 +163,7 @@ describe("DesktopLinuxUrlHandler", () => {...@@ -111,6 +163,7 @@ describe("DesktopLinuxUrlHandler", () => {
111 displayName: "T3 Code (Nightly)",163 displayName: "T3 Code (Nightly)",
112 execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage',164 execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage',
113 scheme: "t3code",165 scheme: "t3code",
166 iconPath: "/home/al ice/icons/T3\\x.png",
114 });167 });
115168
116 assert.include(entry, "[Desktop Entry]");169 assert.include(entry, "[Desktop Entry]");
...@@ -125,6 +178,7 @@ describe("DesktopLinuxUrlHandler", () => {...@@ -125,6 +178,7 @@ describe("DesktopLinuxUrlHandler", () => {
125 assert.include(entry, "NoDisplay=true");178 assert.include(entry, "NoDisplay=true");
126 assert.notInclude(entry, "StartupWMClass=");179 assert.notInclude(entry, "StartupWMClass=");
127 assert.include(entry, "MimeType=x-scheme-handler/t3code;");180 assert.include(entry, "MimeType=x-scheme-handler/t3code;");
181 assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png");
128 });182 });
129183
130 it("carries structured context on registration errors", () => {184 it("carries structured context on registration errors", () => {
...@@ -154,31 +208,38 @@ describe("DesktopLinuxUrlHandler", () => {...@@ -154,31 +208,38 @@ describe("DesktopLinuxUrlHandler", () => {
154 );208 );
155 });209 });
156210
157 it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => {211 it.effect(
158 const recorded = emptyRecording();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* () {216 return Effect.gen(function* () {
161 yield* runRegister(recorded);217 yield* runRegister(recorded);
162218
163 assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]);219 assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]);
164 assert.equal(recorded.files.length, 1);220 assert.equal(recorded.files.length, 1);
165 assert.equal(221 assert.equal(
166 recorded.files[0]?.path,222 recorded.files[0]?.path,
167 "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop",223 "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop",
168 );224 );
169 assert.include(225 assert.include(
170 recorded.files[0]?.content,226 recorded.files[0]?.content,
171 'Exec="/home/alice/Applications/T3-Code.AppImage" %U',227 'Exec="/home/alice/Applications/T3-Code.AppImage" %U',
172 );228 );
173 assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;");229 assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;");
174 assert.deepEqual(recorded.commands, [230 assert.deepEqual(recorded.commands, [
175 {231 {
176 command: "xdg-mime",232 command: "update-desktop-database",
177 args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"],233 args: ["/home/alice/.local/share/applications"],
178 },234 },
179 ]);235 {
180 });236 command: "xdg-mime",
181 });237 args: ["default", "com.t3tools.T3Code.desktop", "x-scheme-handler/t3code"],
238 },
239 ]);
240 });
241 },
242 );
182243
183 it.effect("falls back to the process executable outside an AppImage", () => {244 it.effect("falls back to the process executable outside an AppImage", () => {
184 const recorded = emptyRecording();245 const recorded = emptyRecording();
...@@ -202,12 +263,63 @@ describe("DesktopLinuxUrlHandler", () => {...@@ -202,12 +263,63 @@ describe("DesktopLinuxUrlHandler", () => {
202 displayName: "T3 Code (Alpha)",263 displayName: "T3 Code (Alpha)",
203 execTarget: "/home/alice/Applications/T3-Code.AppImage",264 execTarget: "/home/alice/Applications/T3-Code.AppImage",
204 scheme: "t3code",265 scheme: "t3code",
266 iconPath: "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png",
205 }),267 }),
206 });268 });
207269
208 assert.deepEqual(recorded.files, []);270 assert.deepEqual(recorded.files, []);
209 assert.deepEqual(recorded.directories, []);271 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 );
211 });323 });
212 });324 });
213325
...@@ -234,10 +346,12 @@ describe("DesktopLinuxUrlHandler", () => {...@@ -234,10 +346,12 @@ describe("DesktopLinuxUrlHandler", () => {
234 });346 });
235347
236 it.effect("never fails startup when registration cannot complete", () => {348 it.effect("never fails startup when registration cannot complete", () => {
349 const desktopDatabaseFailed = emptyRecording();
237 const xdgMimeFailed = emptyRecording();350 const xdgMimeFailed = emptyRecording();
238 const writeFailed = emptyRecording();351 const writeFailed = emptyRecording();
239352
240 return Effect.gen(function* () {353 return Effect.gen(function* () {
354 yield* runRegister(desktopDatabaseFailed, { updateDesktopDatabaseExitCode: 1 });
241 yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 });355 yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 });
242 yield* runRegister(writeFailed, {356 yield* runRegister(writeFailed, {
243 writeError: PlatformError.systemError({357 writeError: PlatformError.systemError({
...@@ -249,8 +363,32 @@ describe("DesktopLinuxUrlHandler", () => {...@@ -249,8 +363,32 @@ describe("DesktopLinuxUrlHandler", () => {
249 }),363 }),
250 });364 });
251365
366 assert.deepEqual(
367 desktopDatabaseFailed.commands.map(({ command }) => command),
368 ["update-desktop-database", "xdg-mime"],
369 );
252 assert.equal(xdgMimeFailed.files.length, 1);370 assert.equal(xdgMimeFailed.files.length, 1);
253 assert.deepEqual(writeFailed.commands, []);371 assert.deepEqual(writeFailed.commands, []);
254 });372 });
255 });373 });
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 );
256});394});
apps/desktop/src/app/DesktopLinuxUrlHandler.ts+86-4
...@@ -8,6 +8,7 @@ import * as ChildProcess from "effect/unstable/process/ChildProcess";...@@ -8,6 +8,7 @@ import * as ChildProcess from "effect/unstable/process/ChildProcess";
8import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";8import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
99
10import * as ElectronProtocol from "../electron/ElectronProtocol.ts";10import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
11import * as DesktopAssets from "./DesktopAssets.ts";
11import * as DesktopEnvironment from "./DesktopEnvironment.ts";12import * as DesktopEnvironment from "./DesktopEnvironment.ts";
12import { makeComponentLogger } from "./DesktopObservability.ts";13import { makeComponentLogger } from "./DesktopObservability.ts";
1314
...@@ -17,9 +18,9 @@ import { makeComponentLogger } from "./DesktopObservability.ts";...@@ -17,9 +18,9 @@ import { makeComponentLogger } from "./DesktopObservability.ts";
17// Electron's app.setAsDefaultProtocolClient resolves the desktop id from18// Electron's app.setAsDefaultProtocolClient resolves the desktop id from
18// setDesktopName, which cannot match those files — so the browser keeps19// setDesktopName, which cannot match those files — so the browser keeps
19// prompting "Choose an application" for every OAuth callback. Instead, write20// prompting "Choose an application" for every OAuth callback. Instead, write
20// our own handler entry pointing at the current AppImage and claim the21// our own handler entry pointing at the current AppImage, refresh the desktop
21// scheme default via xdg-mime, exactly what the file manager's "set as22// MIME cache so desktop environments recognize that entry as a handler, and
22// default" checkbox would record in mimeapps.list.23// use xdg-mime to record it as the scheme default in mimeapps.list.
23const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler");24const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler");
2425
25export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError<DesktopLinuxUrlHandlerRegistrationError>()(26export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError<DesktopLinuxUrlHandlerRegistrationError>()(
...@@ -40,6 +41,23 @@ export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError<...@@ -40,6 +41,23 @@ export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError<
4041
41const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError);42const 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
43const escapeDesktopEntryString = (value: string): string =>61const escapeDesktopEntryString = (value: string): string =>
44 value62 value
45 .replaceAll("\\", "\\\\")63 .replaceAll("\\", "\\\\")
...@@ -63,18 +81,20 @@ export function escapeDesktopEntryExecArgument(value: string): string {...@@ -63,18 +81,20 @@ export function escapeDesktopEntryExecArgument(value: string): string {
63 return escapeDesktopEntryString(`"${quoted}"`);81 return escapeDesktopEntryString(`"${quoted}"`);
64}82}
6583
66// The AppImage integration entry owns the window identity and icon. This84// The AppImage integration entry owns the window identity. This
67// hidden URL-only entry must not compete with it for StartupWMClass matching.85// hidden URL-only entry must not compete with it for StartupWMClass matching.
68export function renderUrlHandlerDesktopEntry(input: {86export function renderUrlHandlerDesktopEntry(input: {
69 readonly displayName: string;87 readonly displayName: string;
70 readonly execTarget: string;88 readonly execTarget: string;
71 readonly scheme: string;89 readonly scheme: string;
90 readonly iconPath?: string;
72}): string {91}): string {
73 return [92 return [
74 "[Desktop Entry]",93 "[Desktop Entry]",
75 "Type=Application",94 "Type=Application",
76 `Name=${escapeDesktopEntryString(input.displayName)}`,95 `Name=${escapeDesktopEntryString(input.displayName)}`,
77 `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`,96 `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`,
97 ...(input.iconPath === undefined ? [] : [`Icon=${escapeDesktopEntryString(input.iconPath)}`]),
78 "Terminal=false",98 "Terminal=false",
79 "NoDisplay=true",99 "NoDisplay=true",
80 "StartupNotify=false",100 "StartupNotify=false",
...@@ -95,12 +115,15 @@ export const make = Effect.gen(function* () {...@@ -95,12 +115,15 @@ export const make = Effect.gen(function* () {
95 const environment = yield* DesktopEnvironment.DesktopEnvironment;115 const environment = yield* DesktopEnvironment.DesktopEnvironment;
96 const fileSystem = yield* FileSystem.FileSystem;116 const fileSystem = yield* FileSystem.FileSystem;
97 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;117 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
118 const assets = yield* DesktopAssets.DesktopAssets;
98119
99 const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment);120 const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment);
100 const desktopEntryPath = environment.path.join(121 const desktopEntryPath = environment.path.join(
101 environment.linuxApplicationsDir,122 environment.linuxApplicationsDir,
102 environment.linuxDesktopEntryName,123 environment.linuxDesktopEntryName,
103 );124 );
125 const iconsDir = environment.path.join(environment.linuxApplicationsDir, "..", "icons");
126 const iconPath = environment.path.join(iconsDir, `${environment.linuxDesktopEntryName}.png`);
104127
105 const writeDesktopEntry = Effect.gen(function* () {128 const writeDesktopEntry = Effect.gen(function* () {
106 // Inside the mounted AppImage, process.execPath points at a transient129 // Inside the mounted AppImage, process.execPath points at a transient
...@@ -110,6 +133,7 @@ export const make = Effect.gen(function* () {...@@ -110,6 +133,7 @@ export const make = Effect.gen(function* () {
110 displayName: environment.displayName,133 displayName: environment.displayName,
111 execTarget,134 execTarget,
112 scheme,135 scheme,
136 ...(environment.isPackaged ? { iconPath } : {}),
113 });137 });
114 // Pre-ready setup normally wrote this already. Avoid truncating a valid138 // Pre-ready setup normally wrote this already. Avoid truncating a valid
115 // entry while the portal may be reading it during startup.139 // entry while the portal may be reading it during startup.
...@@ -131,6 +155,37 @@ export const make = Effect.gen(function* () {...@@ -131,6 +155,37 @@ export const make = Effect.gen(function* () {
131 ),155 ),
132 );156 );
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
134 const setDefaultHandler = Effect.scoped(189 const setDefaultHandler = Effect.scoped(
135 Effect.gen(function* () {190 Effect.gen(function* () {
136 const command = ChildProcess.make(191 const command = ChildProcess.make(
...@@ -170,6 +225,33 @@ export const make = Effect.gen(function* () {...@@ -170,6 +225,33 @@ export const make = Effect.gen(function* () {
170 }225 }
171 yield* writeDesktopEntry;226 yield* writeDesktopEntry;
172 if (!environment.isPackaged) return;227 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
173 yield* setDefaultHandler;255 yield* setDefaultHandler;
174 yield* logInfo("registered URL scheme handler", { scheme });256 yield* logInfo("registered URL scheme handler", { scheme });
175 }).pipe(257 }).pipe(
apps/desktop/src/app/DesktopPreReadyPlatform.test.ts+34-1
...@@ -13,6 +13,7 @@ const {...@@ -13,6 +13,7 @@ const {
13 setDesktopNameMock,13 setDesktopNameMock,
14 mkdirSyncMock,14 mkdirSyncMock,
15 writeFileSyncMock,15 writeFileSyncMock,
16 copyFileSyncMock,
16} = vi.hoisted(() => ({17} = vi.hoisted(() => ({
17 appendSwitchMock: vi.fn(),18 appendSwitchMock: vi.fn(),
18 getSwitchValueMock: vi.fn(),19 getSwitchValueMock: vi.fn(),
...@@ -21,12 +22,15 @@ const {...@@ -21,12 +22,15 @@ const {
21 setDesktopNameMock: vi.fn(),22 setDesktopNameMock: vi.fn(),
22 mkdirSyncMock: vi.fn(),23 mkdirSyncMock: vi.fn(),
23 writeFileSyncMock: vi.fn(),24 writeFileSyncMock: vi.fn(),
25 copyFileSyncMock: vi.fn(),
24}));26}));
2527
26vi.mock("electron", () => ({28vi.mock("electron", () => ({
27 app: {29 app: {
28 setDesktopName: setDesktopNameMock,30 setDesktopName: setDesktopNameMock,
29 getVersion: () => "0.0.37",31 getVersion: () => "0.0.37",
32 isPackaged: true,
33 getAppPath: () => "/tmp/.mount_T3/resources/app.asar",
30 commandLine: {34 commandLine: {
31 appendSwitch: appendSwitchMock,35 appendSwitch: appendSwitchMock,
32 getSwitchValue: getSwitchValueMock,36 getSwitchValue: getSwitchValueMock,
...@@ -42,6 +46,7 @@ vi.mock("node:fs", () => ({...@@ -42,6 +46,7 @@ vi.mock("node:fs", () => ({
42 readFileSync: () => "{}",46 readFileSync: () => "{}",
43 mkdirSync: mkdirSyncMock,47 mkdirSync: mkdirSyncMock,
44 writeFileSync: writeFileSyncMock,48 writeFileSync: writeFileSyncMock,
49 copyFileSync: copyFileSyncMock,
45}));50}));
4651
47import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";52import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts";
...@@ -55,6 +60,7 @@ describe("DesktopPreReadyPlatform", () => {...@@ -55,6 +60,7 @@ describe("DesktopPreReadyPlatform", () => {
55 setDesktopNameMock.mockReset();60 setDesktopNameMock.mockReset();
56 mkdirSyncMock.mockReset();61 mkdirSyncMock.mockReset();
57 writeFileSyncMock.mockReset();62 writeFileSyncMock.mockReset();
63 copyFileSyncMock.mockReset();
58 });64 });
5965
60 it.effect("preserves an explicit Linux password-store switch", () => {66 it.effect("preserves an explicit Linux password-store switch", () => {
...@@ -85,6 +91,10 @@ describe("DesktopPreReadyPlatform", () => {...@@ -85,6 +91,10 @@ describe("DesktopPreReadyPlatform", () => {
85 getSwitchValueMock.mockReturnValue("");91 getSwitchValueMock.mockReturnValue("");
86 let desktopName = "t3code.desktop";92 let desktopName = "t3code.desktop";
87 let desktopEntry = previousEntry;93 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 });
88 setDesktopNameMock.mockImplementation((name: string) => {98 setDesktopNameMock.mockImplementation((name: string) => {
89 desktopName = name;99 desktopName = name;
90 });100 });
...@@ -94,7 +104,11 @@ describe("DesktopPreReadyPlatform", () => {...@@ -94,7 +104,11 @@ describe("DesktopPreReadyPlatform", () => {
94104
95 return Effect.scoped(105 return Effect.scoped(
96 Effect.gen(function* () {106 Effect.gen(function* () {
97 const portalIdentity = Promise.resolve().then(() => ({ desktopName, desktopEntry }));107 const portalIdentity = Promise.resolve().then(() => ({
108 desktopName,
109 desktopEntry,
110 iconInstalled,
111 }));
98 yield* Layer.build(112 yield* Layer.build(
99 DesktopPreReadyPlatform.layer.pipe(113 DesktopPreReadyPlatform.layer.pipe(
100 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),114 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
...@@ -105,6 +119,11 @@ describe("DesktopPreReadyPlatform", () => {...@@ -105,6 +119,11 @@ describe("DesktopPreReadyPlatform", () => {
105 assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U');119 assert.include(identity.desktopEntry ?? "", 'Exec="/Applications/current.AppImage" %U');
106 assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)");120 assert.include(identity.desktopEntry ?? "", "Name=T3 Code (Alpha)");
107 assert.include(identity.desktopEntry ?? "", "MimeType=x-scheme-handler/t3code;");121 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);
108 }),127 }),
109 ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())));128 ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())));
110 },129 },
...@@ -123,6 +142,20 @@ describe("DesktopPreReadyPlatform", () => {...@@ -123,6 +142,20 @@ describe("DesktopPreReadyPlatform", () => {
123 );142 );
124 });143 });
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
126 it.effect(159 it.effect(
127 "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer",160 "acquires a synchronous pre-ready layer before an asynchronous Clerk-shaped layer",
128 () =>161 () =>
apps/desktop/src/app/DesktopPreReadyPlatform.ts+23
...@@ -68,6 +68,28 @@ export const make = Effect.gen(function* () {...@@ -68,6 +68,28 @@ export const make = Effect.gen(function* () {
68 "applications",68 "applications",
69 );69 );
70 NodeFS.mkdirSync(applicationsDir, { recursive: true });70 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 }
71 NodeFS.writeFileSync(93 NodeFS.writeFileSync(
72 NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName),94 NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName),
73 renderUrlHandlerDesktopEntry({95 renderUrlHandlerDesktopEntry({
...@@ -77,6 +99,7 @@ export const make = Effect.gen(function* () {...@@ -77,6 +99,7 @@ export const make = Effect.gen(function* () {
77 }).displayName,99 }).displayName,
78 execTarget: process.env.APPIMAGE?.trim() || process.execPath,100 execTarget: process.env.APPIMAGE?.trim() || process.execPath,
79 scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment),101 scheme: ElectronProtocol.getDesktopScheme(linux.isDevelopment),
102 ...(iconPath === undefined ? {} : { iconPath }),
80 }),103 }),
81 "utf8",104 "utf8",
82 );105 );