| author | |
| committer | |
| log | 7733bc839e23e19467a70b11a0bcedf6ffe8f1a3 |
| tree | d1ac910f080136005929cf30e9cc4326b4639762 |
| parent | b528a701102f95089544596856a3f208f4404613 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Deferred from "effect/Deferred"; | ||
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import * as Path from "effect/Path"; | ||
| 6 | import * as PlatformError from "effect/PlatformError"; | 9 | import * as PlatformError from "effect/PlatformError"; |
| 7 | import * as Sink from "effect/Sink"; | 10 | import * as Sink from "effect/Sink"; |
| 8 | import * as Stream from "effect/Stream"; | 11 | import * as Stream from "effect/Stream"; |
| 12 | import * as TestClock from "effect/testing/TestClock"; | ||
| 9 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 13 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 10 | 14 | ||
| 11 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 15 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 16 | import * as DesktopAssets from "./DesktopAssets.ts"; | ||
| 12 | import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; | 17 | import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; |
| 13 | 18 | ||
| 14 | interface RecordedRegistration { | 19 | interface 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 | } |
| 19 | 25 | ||
| 20 | const makeEnvironment = (overrides: Record<string, unknown> = {}) => | 26 | const 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"]); |
| 33 | 39 | ||
| 34 | const mockProcess = (exitCode: number) => | 40 | const 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 | }); |
| 107 | 159 | ||
| 108 | describe("DesktopLinuxUrlHandler", () => { | 160 | describe("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 | }); |
| 115 | 168 | ||
| 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 | }); |
| 129 | 183 | ||
| 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 | }); |
| 156 | 210 | ||
| 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(); | ||
| 159 | 215 | ||
| 160 | return Effect.gen(function* () { | 216 | return Effect.gen(function* () { |
| 161 | yield* runRegister(recorded); | 217 | yield* runRegister(recorded); |
| 162 | 218 | ||
| 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 | ); | ||
| 182 | 243 | ||
| 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 | }); |
| 207 | 269 | ||
| 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 | }); |
| 213 | 325 | ||
| ... | @@ -234,10 +346,12 @@ describe("DesktopLinuxUrlHandler", () => { | ... | @@ -234,10 +346,12 @@ describe("DesktopLinuxUrlHandler", () => { |
| 234 | }); | 346 | }); |
| 235 | 347 | ||
| 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(); |
| 239 | 352 | ||
| 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 | }); |
| 251 | 365 | ||
| 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"; |
| 8 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 8 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 9 | 9 | ||
| 10 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; | 10 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; |
| 11 | import * as DesktopAssets from "./DesktopAssets.ts"; | ||
| 11 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 12 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 12 | import { makeComponentLogger } from "./DesktopObservability.ts"; | 13 | import { makeComponentLogger } from "./DesktopObservability.ts"; |
| 13 | 14 | ||
| ... | @@ -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 from | 18 | // Electron's app.setAsDefaultProtocolClient resolves the desktop id from |
| 18 | // setDesktopName, which cannot match those files — so the browser keeps | 19 | // setDesktopName, which cannot match those files — so the browser keeps |
| 19 | // prompting "Choose an application" for every OAuth callback. Instead, write | 20 | // prompting "Choose an application" for every OAuth callback. Instead, write |
| 20 | // our own handler entry pointing at the current AppImage and claim the | 21 | // 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 as | 22 | // 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. |
| 23 | const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); | 24 | const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); |
| 24 | 25 | ||
| 25 | export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError<DesktopLinuxUrlHandlerRegistrationError>()( | 26 | export 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< |
| 40 | 41 | ||
| 41 | const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError); | 42 | const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError); |
| 42 | 43 | ||
| 44 | export 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 | |||
| 59 | const isCacheRefreshError = Schema.is(DesktopLinuxUrlHandlerCacheRefreshError); | ||
| 60 | |||
| 43 | const escapeDesktopEntryString = (value: string): string => | 61 | const escapeDesktopEntryString = (value: string): string => |
| 44 | value | 62 | 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 | } |
| 65 | 83 | ||
| 66 | // The AppImage integration entry owns the window identity and icon. This | 84 | // 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. |
| 68 | export function renderUrlHandlerDesktopEntry(input: { | 86 | export 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; | ||
| 98 | 119 | ||
| 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`); | ||
| 104 | 127 | ||
| 105 | const writeDesktopEntry = Effect.gen(function* () { | 128 | const writeDesktopEntry = Effect.gen(function* () { |
| 106 | // Inside the mounted AppImage, process.execPath points at a transient | 129 | // 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 valid | 138 | // 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 | ); |
| 133 | 157 | ||
| 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 | })); |
| 25 | 27 | ||
| 26 | vi.mock("electron", () => ({ | 28 | vi.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 | })); |
| 46 | 51 | ||
| 47 | import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; | 52 | import * 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 | }); |
| 59 | 65 | ||
| 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", () => { |
| 94 | 104 | ||
| 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 | }); |
| 125 | 144 | ||
| 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 | ); |