| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Deferred from "effect/Deferred"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Path from "effect/Path"; |
| 9 | import * as PlatformError from "effect/PlatformError"; |
| 10 | import * as Sink from "effect/Sink"; |
| 11 | import * as Stream from "effect/Stream"; |
| 12 | import * as TestClock from "effect/testing/TestClock"; |
| 13 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 14 | |
| 15 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 16 | import * as DesktopAssets from "./DesktopAssets.ts"; |
| 17 | import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; |
| 18 | |
| 19 | interface 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 | |
| 26 | const 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 | |
| 40 | const 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 | |
| 55 | const 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 | |
| 144 | const 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 | |
| 153 | const emptyRecording = (): RecordedRegistration => ({ |
| 154 | directories: [], |
| 155 | files: [], |
| 156 | commands: [], |
| 157 | copies: [], |
| 158 | }); |
| 159 | |
| 160 | describe("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 | }); |