1import { assert, describe, it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as PlatformError from "effect/PlatformError";
10import * as Sink from "effect/Sink";
11import * as Stream from "effect/Stream";
12import * as TestClock from "effect/testing/TestClock";
13import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
14
15import * as DesktopEnvironment from "./DesktopEnvironment.ts";
16import * as DesktopAssets from "./DesktopAssets.ts";
17import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts";
18
19interface RecordedRegistration {
20 readonly directories: string[];
21 readonly files: Array<{ readonly path: string; readonly content: string }>;
22 readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>;
23 readonly copies: Array<{ readonly source: string; readonly destination: string }>;
24}
25
26const makeEnvironment = (path: Path.Path, overrides: Record<string, unknown> = {}) =>
27 DesktopEnvironment.DesktopEnvironment.of({
28 platform: "linux",
29 isPackaged: true,
30 isDevelopment: false,
31 displayName: "T3 Code (Alpha)",
32 linuxDesktopEntryName: "com.t3tools.T3Code.desktop",
33 linuxWmClass: "t3code",
34 linuxApplicationsDir: "/home/alice/.local/share/applications",
35 appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"),
36 path,
37 ...overrides,
38 } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);
39
40const mockProcess = (exitCode: number, stalled = false) =>
41 ChildProcessSpawner.makeHandle({
42 pid: ChildProcessSpawner.ProcessId(1),
43 exitCode: stalled ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
44 isRunning: Effect.succeed(false),
45 kill: () => Effect.void,
46 unref: Effect.succeed(Effect.void),
47 stdin: Sink.drain,
48 stdout: Stream.empty,
49 stderr: Stream.empty,
50 all: Stream.empty,
51 getInputFd: () => Sink.drain,
52 getOutputFd: () => Stream.empty,
53 });
54
55const layerHandler = (
56 recorded: RecordedRegistration,
57 input: {
58 readonly environment?: Record<string, unknown>;
59 readonly updateDesktopDatabaseExitCode?: number;
60 readonly updateDesktopDatabaseStalled?: boolean;
61 readonly updateDesktopDatabaseStarted?: Deferred.Deferred<void>;
62 readonly xdgMimeExitCode?: number;
63 readonly writeError?: PlatformError.PlatformError;
64 readonly existingEntry?: string;
65 readonly iconSource?: string;
66 readonly iconCopyError?: PlatformError.PlatformError;
67 } = {},
68) =>
69 DesktopLinuxUrlHandler.layer.pipe(
70 Layer.provide(
71 Layer.mergeAll(
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 }),
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 }),
94 readFileString: () => Effect.succeed(input.existingEntry ?? ""),
95 makeDirectory: (path) =>
96 Effect.sync(() => {
97 recorded.directories.push(path);
98 }),
99 writeFileString: (path, content) =>
100 input.writeError
101 ? Effect.fail(input.writeError)
102 : Effect.sync(() => {
103 recorded.files.push({ path, content });
104 }),
105 }),
106 Layer.succeed(
107 ChildProcessSpawner.ChildProcessSpawner,
108 ChildProcessSpawner.make((command) => {
109 const childProcess = command as unknown as {
110 readonly command: string;
111 readonly args: ReadonlyArray<string>;
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 }
119 recorded.commands.push({
120 command: childProcess.command,
121 args: childProcess.args,
122 });
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);
138 }),
139 ),
140 ),
141 ),
142 );
143
144const runRegister = (
145 recorded: RecordedRegistration,
146 input: Parameters<typeof layerHandler>[1] = {},
147) =>
148 Effect.gen(function* () {
149 const handler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler;
150 yield* handler.register;
151 }).pipe(Effect.provide(layerHandler(recorded, input)));
152
153const emptyRecording = (): RecordedRegistration => ({
154 directories: [],
155 files: [],
156 commands: [],
157 copies: [],
158});
159
160describe("DesktopLinuxUrlHandler", () => {
161 it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => {
162 const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({
163 displayName: "T3 Code (Nightly)",
164 execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage',
165 scheme: "t3code",
166 iconPath: "/home/al ice/icons/T3\\x.png",
167 });
168
169 assert.include(entry, "[Desktop Entry]");
170 assert.include(entry, "Name=T3 Code (Nightly)");
171 // Exec composes both escaping layers: a literal backslash becomes four
172 // backslashes in the file, a quote three characters, a dollar sign two
173 // backslashes plus the sign.
174 assert.include(
175 entry,
176 'Exec="/home/al ice/Apps/T3 \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U',
177 );
178 assert.include(entry, "NoDisplay=true");
179 assert.notInclude(entry, "StartupWMClass=");
180 assert.include(entry, "MimeType=x-scheme-handler/t3code;");
181 assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png");
182 });
183
184 it("carries structured context on registration errors", () => {
185 const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({
186 step: "write-desktop-entry",
187 scheme: "t3code",
188 desktopEntryPath: "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop",
189 cause: new Error("boom"),
190 });
191 assert.equal(
192 writeError.message,
193 "Failed to register the t3code:// URL handler (step: write-desktop-entry).",
194 );
195 assert.equal(
196 writeError.desktopEntryPath,
197 "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop",
198 );
199
200 const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({
201 step: "set-default-handler",
202 scheme: "t3code",
203 exitCode: 4,
204 });
205 assert.equal(
206 exitError.message,
207 "Failed to register the t3code:// URL handler (step: set-default-handler, xdg-mime exit code 4).",
208 );
209 });
210
211 it.effect(
212 "writes the handler entry, refreshes the MIME cache, and claims the scheme default",
213 () => {
214 const recorded = emptyRecording();
215
216 return Effect.gen(function* () {
217 yield* runRegister(recorded);
218
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 );
243
244 it.effect("falls back to the process executable outside an AppImage", () => {
245 const recorded = emptyRecording();
246
247 return Effect.gen(function* () {
248 yield* runRegister(recorded, { environment: { appImagePath: Option.none() } });
249
250 assert.include(
251 recorded.files[0]?.content,
252 `Exec=${DesktopLinuxUrlHandler.escapeDesktopEntryExecArgument(process.execPath)} %U`,
253 );
254 });
255 });
256
257 it.effect("does not rewrite the pre-ready entry while the portal can be reading it", () => {
258 const recorded = emptyRecording();
259
260 return Effect.gen(function* () {
261 yield* runRegister(recorded, {
262 existingEntry: DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({
263 displayName: "T3 Code (Alpha)",
264 execTarget: "/home/alice/Applications/T3-Code.AppImage",
265 scheme: "t3code",
266 iconPath: "/home/alice/.local/share/icons/com.t3tools.T3Code.desktop.png",
267 }),
268 });
269
270 assert.deepEqual(recorded.files, []);
271 assert.deepEqual(recorded.directories, []);
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 );
323 });
324 });
325
326 it.effect("writes the portal identity without claiming the URL scheme in development", () => {
327 const nonLinux = emptyRecording();
328 const unpackaged = emptyRecording();
329
330 return Effect.gen(function* () {
331 yield* runRegister(nonLinux, { environment: { platform: "darwin" } });
332 yield* runRegister(unpackaged, {
333 environment: {
334 isPackaged: false,
335 linuxDesktopEntryName: "com.t3tools.T3Code.Development.desktop",
336 },
337 });
338
339 assert.deepEqual(nonLinux.files, []);
340 assert.equal(
341 unpackaged.files[0]?.path,
342 "/home/alice/.local/share/applications/com.t3tools.T3Code.Development.desktop",
343 );
344 assert.deepEqual(unpackaged.commands, []);
345 });
346 });
347
348 it.effect("never fails startup when registration cannot complete", () => {
349 const desktopDatabaseFailed = emptyRecording();
350 const xdgMimeFailed = emptyRecording();
351 const writeFailed = emptyRecording();
352
353 return Effect.gen(function* () {
354 yield* runRegister(desktopDatabaseFailed, { updateDesktopDatabaseExitCode: 1 });
355 yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 });
356 yield* runRegister(writeFailed, {
357 writeError: PlatformError.systemError({
358 _tag: "PermissionDenied",
359 module: "FileSystem",
360 method: "writeFileString",
361 description: "read-only filesystem",
362 pathOrDescriptor: "/home/alice/.local/share/applications/com.t3tools.T3Code.desktop",
363 }),
364 });
365
366 assert.deepEqual(
367 desktopDatabaseFailed.commands.map(({ command }) => command),
368 ["update-desktop-database", "xdg-mime"],
369 );
370 assert.equal(xdgMimeFailed.files.length, 1);
371 assert.deepEqual(writeFailed.commands, []);
372 });
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 );
394});