| author | |
| committer | |
| log | 69dfb7f09a473d270a8b127cb1c39836fa1c6bc4 |
| tree | db0c72f18160769c0fb6b468c0a740301ea02896 |
| parent | e60821f0e0d82a5d671ca3b94719c49d333921c8 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 434 insertions(+), 0 deletions(-)
apps/desktop/src/app/DesktopApp.ts+3| ... | @@ -17,6 +17,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; | ... | @@ -17,6 +17,7 @@ import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 17 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; | 17 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; |
| 18 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | 18 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 19 | import * as DesktopLifecycle from "./DesktopLifecycle.ts"; | 19 | import * as DesktopLifecycle from "./DesktopLifecycle.ts"; |
| 20 | import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; | ||
| 20 | import * as DesktopObservability from "./DesktopObservability.ts"; | 21 | import * as DesktopObservability from "./DesktopObservability.ts"; |
| 21 | import * as DesktopShutdown from "./DesktopShutdown.ts"; | 22 | import * as DesktopShutdown from "./DesktopShutdown.ts"; |
| 22 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; | 23 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; |
| ... | @@ -220,6 +221,7 @@ const startup = Effect.gen(function* () { | ... | @@ -220,6 +221,7 @@ const startup = Effect.gen(function* () { |
| 220 | const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; | 221 | const applicationMenu = yield* DesktopApplicationMenu.DesktopApplicationMenu; |
| 221 | const electronApp = yield* ElectronApp.ElectronApp; | 222 | const electronApp = yield* ElectronApp.ElectronApp; |
| 222 | const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; | 223 | const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; |
| 224 | const linuxUrlHandler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; | ||
| 223 | const clerk = yield* DesktopClerk.DesktopClerk; | 225 | const clerk = yield* DesktopClerk.DesktopClerk; |
| 224 | const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; | 226 | const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; |
| 225 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; | 227 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| ... | @@ -248,6 +250,7 @@ const startup = Effect.gen(function* () { | ... | @@ -248,6 +250,7 @@ const startup = Effect.gen(function* () { |
| 248 | yield* appIdentity.configure; | 250 | yield* appIdentity.configure; |
| 249 | yield* applicationMenu.configure; | 251 | yield* applicationMenu.configure; |
| 250 | yield* updates.configure; | 252 | yield* updates.configure; |
| 253 | yield* linuxUrlHandler.register; | ||
| 251 | yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); | 254 | yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); |
| 252 | }).pipe(Effect.withSpan("desktop.startup")); | 255 | }).pipe(Effect.withSpan("desktop.startup")); |
| 253 | 256 |
apps/desktop/src/app/DesktopConfig.ts+1| ... | @@ -35,6 +35,7 @@ const compactEnv = (env: Readonly<Record<string, string | undefined>>): Record<s | ... | @@ -35,6 +35,7 @@ const compactEnv = (env: Readonly<Record<string, string | undefined>>): Record<s |
| 35 | export const DesktopConfig = Config.all({ | 35 | export const DesktopConfig = Config.all({ |
| 36 | appDataDirectory: trimmedString("APPDATA"), | 36 | appDataDirectory: trimmedString("APPDATA"), |
| 37 | xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), | 37 | xdgConfigHome: trimmedString("XDG_CONFIG_HOME"), |
| 38 | xdgDataHome: trimmedString("XDG_DATA_HOME"), | ||
| 38 | t3Home: trimmedString("T3CODE_HOME"), | 39 | t3Home: trimmedString("T3CODE_HOME"), |
| 39 | devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option), | 40 | devServerUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option), |
| 40 | appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"), | 41 | appUserModelIdOverride: trimmedString("T3CODE_DESKTOP_APP_USER_MODEL_ID"), |
apps/desktop/src/app/DesktopEnvironment.ts+8| ... | @@ -66,6 +66,8 @@ export class DesktopEnvironment extends Context.Service< | ... | @@ -66,6 +66,8 @@ export class DesktopEnvironment extends Context.Service< |
| 66 | readonly appUserModelId: string; | 66 | readonly appUserModelId: string; |
| 67 | readonly linuxDesktopEntryName: string; | 67 | readonly linuxDesktopEntryName: string; |
| 68 | readonly linuxWmClass: string; | 68 | readonly linuxWmClass: string; |
| 69 | readonly linuxApplicationsDir: string; | ||
| 70 | readonly appImagePath: Option.Option<string>; | ||
| 69 | readonly userDataDirName: string; | 71 | readonly userDataDirName: string; |
| 70 | readonly legacyUserDataDirName: string; | 72 | readonly legacyUserDataDirName: string; |
| 71 | readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; | 73 | readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; |
| ... | @@ -162,6 +164,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( | ... | @@ -162,6 +164,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( |
| 162 | ); | 164 | ); |
| 163 | const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; | 165 | const userDataDirName = isDevelopment ? "t3code-dev" : "t3code"; |
| 164 | const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | 166 | const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; |
| 167 | const linuxApplicationsDir = path.join( | ||
| 168 | Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")), | ||
| 169 | "applications", | ||
| 170 | ); | ||
| 165 | const resourcesPath = input.resourcesPath; | 171 | const resourcesPath = input.resourcesPath; |
| 166 | 172 | ||
| 167 | return DesktopEnvironment.of({ | 173 | return DesktopEnvironment.of({ |
| ... | @@ -205,6 +211,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( | ... | @@ -205,6 +211,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( |
| 205 | ), | 211 | ), |
| 206 | linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", | 212 | linuxDesktopEntryName: isDevelopment ? "t3code-dev.desktop" : "t3code.desktop", |
| 207 | linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", | 213 | linuxWmClass: isDevelopment ? "t3code-dev" : "t3code", |
| 214 | linuxApplicationsDir, | ||
| 215 | appImagePath: config.appImagePath, | ||
| 208 | userDataDirName, | 216 | userDataDirName, |
| 209 | legacyUserDataDirName, | 217 | legacyUserDataDirName, |
| 210 | defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), | 218 | defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), |
apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts created+229| ... | @@ -0,0 +1,229 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as PlatformError from "effect/PlatformError"; | ||
| 7 | import * as Sink from "effect/Sink"; | ||
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 10 | |||
| 11 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 12 | import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; | ||
| 13 | |||
| 14 | interface RecordedRegistration { | ||
| 15 | readonly directories: string[]; | ||
| 16 | readonly files: Array<{ readonly path: string; readonly content: string }>; | ||
| 17 | readonly commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>; | ||
| 18 | } | ||
| 19 | |||
| 20 | const makeEnvironment = (overrides: Record<string, unknown> = {}) => | ||
| 21 | DesktopEnvironment.DesktopEnvironment.of({ | ||
| 22 | platform: "linux", | ||
| 23 | isPackaged: true, | ||
| 24 | isDevelopment: false, | ||
| 25 | displayName: "T3 Code (Alpha)", | ||
| 26 | linuxWmClass: "t3code", | ||
| 27 | linuxApplicationsDir: "/home/alice/.local/share/applications", | ||
| 28 | appImagePath: Option.some("/home/alice/Applications/T3-Code.AppImage"), | ||
| 29 | path: { join: (...parts: ReadonlyArray<string>) => parts.join("/") }, | ||
| 30 | ...overrides, | ||
| 31 | } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); | ||
| 32 | |||
| 33 | const mockProcess = (exitCode: number) => | ||
| 34 | ChildProcessSpawner.makeHandle({ | ||
| 35 | pid: ChildProcessSpawner.ProcessId(1), | ||
| 36 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), | ||
| 37 | isRunning: Effect.succeed(false), | ||
| 38 | kill: () => Effect.void, | ||
| 39 | unref: Effect.succeed(Effect.void), | ||
| 40 | stdin: Sink.drain, | ||
| 41 | stdout: Stream.empty, | ||
| 42 | stderr: Stream.empty, | ||
| 43 | all: Stream.empty, | ||
| 44 | getInputFd: () => Sink.drain, | ||
| 45 | getOutputFd: () => Stream.empty, | ||
| 46 | }); | ||
| 47 | |||
| 48 | const makeHandlerLayer = ( | ||
| 49 | recorded: RecordedRegistration, | ||
| 50 | input: { | ||
| 51 | readonly environment?: Record<string, unknown>; | ||
| 52 | readonly xdgMimeExitCode?: number; | ||
| 53 | readonly writeError?: PlatformError.PlatformError; | ||
| 54 | } = {}, | ||
| 55 | ) => | ||
| 56 | DesktopLinuxUrlHandler.layer.pipe( | ||
| 57 | Layer.provide( | ||
| 58 | Layer.mergeAll( | ||
| 59 | Layer.succeed(DesktopEnvironment.DesktopEnvironment, makeEnvironment(input.environment)), | ||
| 60 | FileSystem.layerNoop({ | ||
| 61 | makeDirectory: (path) => | ||
| 62 | Effect.sync(() => { | ||
| 63 | recorded.directories.push(path); | ||
| 64 | }), | ||
| 65 | writeFileString: (path, content) => | ||
| 66 | input.writeError | ||
| 67 | ? Effect.fail(input.writeError) | ||
| 68 | : Effect.sync(() => { | ||
| 69 | recorded.files.push({ path, content }); | ||
| 70 | }), | ||
| 71 | }), | ||
| 72 | Layer.succeed( | ||
| 73 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 74 | ChildProcessSpawner.make((command) => { | ||
| 75 | const childProcess = command as unknown as { | ||
| 76 | readonly command: string; | ||
| 77 | readonly args: ReadonlyArray<string>; | ||
| 78 | }; | ||
| 79 | recorded.commands.push({ | ||
| 80 | command: childProcess.command, | ||
| 81 | args: childProcess.args, | ||
| 82 | }); | ||
| 83 | return Effect.succeed(mockProcess(input.xdgMimeExitCode ?? 0)); | ||
| 84 | }), | ||
| 85 | ), | ||
| 86 | ), | ||
| 87 | ), | ||
| 88 | ); | ||
| 89 | |||
| 90 | const runRegister = ( | ||
| 91 | recorded: RecordedRegistration, | ||
| 92 | input: Parameters<typeof makeHandlerLayer>[1] = {}, | ||
| 93 | ) => | ||
| 94 | Effect.gen(function* () { | ||
| 95 | const handler = yield* DesktopLinuxUrlHandler.DesktopLinuxUrlHandler; | ||
| 96 | yield* handler.register; | ||
| 97 | }).pipe(Effect.provide(makeHandlerLayer(recorded, input))); | ||
| 98 | |||
| 99 | const emptyRecording = (): RecordedRegistration => ({ | ||
| 100 | directories: [], | ||
| 101 | files: [], | ||
| 102 | commands: [], | ||
| 103 | }); | ||
| 104 | |||
| 105 | describe("DesktopLinuxUrlHandler", () => { | ||
| 106 | it("renders a scheme-handler desktop entry with freedesktop Exec quoting", () => { | ||
| 107 | const entry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({ | ||
| 108 | displayName: "T3 Code (Nightly)", | ||
| 109 | execTarget: '/home/al ice/Apps/T3 "100%" $HOME\\x.AppImage', | ||
| 110 | scheme: "t3code", | ||
| 111 | }); | ||
| 112 | |||
| 113 | assert.include(entry, "[Desktop Entry]"); | ||
| 114 | assert.include(entry, "Name=T3 Code (Nightly)"); | ||
| 115 | // Exec composes both escaping layers: a literal backslash becomes four | ||
| 116 | // backslashes in the file, a quote three characters, a dollar sign two | ||
| 117 | // backslashes plus the sign. | ||
| 118 | assert.include( | ||
| 119 | entry, | ||
| 120 | 'Exec="/home/al ice/Apps/T3 \\\\"100%%\\\\" \\\\$HOME\\\\\\\\x.AppImage" %U', | ||
| 121 | ); | ||
| 122 | assert.include(entry, "NoDisplay=true"); | ||
| 123 | assert.notInclude(entry, "StartupWMClass="); | ||
| 124 | assert.include(entry, "MimeType=x-scheme-handler/t3code;"); | ||
| 125 | }); | ||
| 126 | |||
| 127 | it("carries structured context on registration errors", () => { | ||
| 128 | const writeError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ | ||
| 129 | step: "write-desktop-entry", | ||
| 130 | scheme: "t3code", | ||
| 131 | desktopEntryPath: "/home/alice/.local/share/applications/t3code-url-handler.desktop", | ||
| 132 | cause: new Error("boom"), | ||
| 133 | }); | ||
| 134 | assert.equal( | ||
| 135 | writeError.message, | ||
| 136 | "Failed to register the t3code:// URL handler (step: write-desktop-entry).", | ||
| 137 | ); | ||
| 138 | assert.equal( | ||
| 139 | writeError.desktopEntryPath, | ||
| 140 | "/home/alice/.local/share/applications/t3code-url-handler.desktop", | ||
| 141 | ); | ||
| 142 | |||
| 143 | const exitError = new DesktopLinuxUrlHandler.DesktopLinuxUrlHandlerRegistrationError({ | ||
| 144 | step: "set-default-handler", | ||
| 145 | scheme: "t3code", | ||
| 146 | exitCode: 4, | ||
| 147 | }); | ||
| 148 | assert.equal( | ||
| 149 | exitError.message, | ||
| 150 | "Failed to register the t3code:// URL handler (step: set-default-handler, xdg-mime exit code 4).", | ||
| 151 | ); | ||
| 152 | }); | ||
| 153 | |||
| 154 | it.effect("writes the handler entry and claims the scheme default via xdg-mime", () => { | ||
| 155 | const recorded = emptyRecording(); | ||
| 156 | |||
| 157 | return Effect.gen(function* () { | ||
| 158 | yield* runRegister(recorded); | ||
| 159 | |||
| 160 | assert.deepEqual(recorded.directories, ["/home/alice/.local/share/applications"]); | ||
| 161 | assert.equal(recorded.files.length, 1); | ||
| 162 | assert.equal( | ||
| 163 | recorded.files[0]?.path, | ||
| 164 | "/home/alice/.local/share/applications/t3code-url-handler.desktop", | ||
| 165 | ); | ||
| 166 | assert.include( | ||
| 167 | recorded.files[0]?.content, | ||
| 168 | 'Exec="/home/alice/Applications/T3-Code.AppImage" %U', | ||
| 169 | ); | ||
| 170 | assert.include(recorded.files[0]?.content, "MimeType=x-scheme-handler/t3code;"); | ||
| 171 | assert.deepEqual(recorded.commands, [ | ||
| 172 | { | ||
| 173 | command: "xdg-mime", | ||
| 174 | args: ["default", "t3code-url-handler.desktop", "x-scheme-handler/t3code"], | ||
| 175 | }, | ||
| 176 | ]); | ||
| 177 | }); | ||
| 178 | }); | ||
| 179 | |||
| 180 | it.effect("falls back to the process executable outside an AppImage", () => { | ||
| 181 | const recorded = emptyRecording(); | ||
| 182 | |||
| 183 | return Effect.gen(function* () { | ||
| 184 | yield* runRegister(recorded, { environment: { appImagePath: Option.none() } }); | ||
| 185 | |||
| 186 | assert.include( | ||
| 187 | recorded.files[0]?.content, | ||
| 188 | `Exec=${DesktopLinuxUrlHandler.escapeDesktopEntryExecArgument(process.execPath)} %U`, | ||
| 189 | ); | ||
| 190 | }); | ||
| 191 | }); | ||
| 192 | |||
| 193 | it.effect("does nothing on other platforms or unpackaged builds", () => { | ||
| 194 | const nonLinux = emptyRecording(); | ||
| 195 | const unpackaged = emptyRecording(); | ||
| 196 | |||
| 197 | return Effect.gen(function* () { | ||
| 198 | yield* runRegister(nonLinux, { environment: { platform: "darwin" } }); | ||
| 199 | yield* runRegister(unpackaged, { environment: { isPackaged: false } }); | ||
| 200 | |||
| 201 | for (const recorded of [nonLinux, unpackaged]) { | ||
| 202 | assert.deepEqual(recorded.directories, []); | ||
| 203 | assert.deepEqual(recorded.files, []); | ||
| 204 | assert.deepEqual(recorded.commands, []); | ||
| 205 | } | ||
| 206 | }); | ||
| 207 | }); | ||
| 208 | |||
| 209 | it.effect("never fails startup when registration cannot complete", () => { | ||
| 210 | const xdgMimeFailed = emptyRecording(); | ||
| 211 | const writeFailed = emptyRecording(); | ||
| 212 | |||
| 213 | return Effect.gen(function* () { | ||
| 214 | yield* runRegister(xdgMimeFailed, { xdgMimeExitCode: 1 }); | ||
| 215 | yield* runRegister(writeFailed, { | ||
| 216 | writeError: PlatformError.systemError({ | ||
| 217 | _tag: "PermissionDenied", | ||
| 218 | module: "FileSystem", | ||
| 219 | method: "writeFileString", | ||
| 220 | description: "read-only filesystem", | ||
| 221 | pathOrDescriptor: "/home/alice/.local/share/applications/t3code-url-handler.desktop", | ||
| 222 | }), | ||
| 223 | }); | ||
| 224 | |||
| 225 | assert.equal(xdgMimeFailed.files.length, 1); | ||
| 226 | assert.deepEqual(writeFailed.commands, []); | ||
| 227 | }); | ||
| 228 | }); | ||
| 229 | }); | ||
apps/desktop/src/app/DesktopLinuxUrlHandler.ts created+191| ... | @@ -0,0 +1,191 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | ||
| 8 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 9 | |||
| 10 | import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; | ||
| 11 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; | ||
| 12 | import { makeComponentLogger } from "./DesktopObservability.ts"; | ||
| 13 | |||
| 14 | // Linux ships as an AppImage, so the .desktop entry users end up with is | ||
| 15 | // created by whatever integration tool they use (AppImageLauncher names it | ||
| 16 | // appimagekit_<hash>-….desktop) and its filename is not under our control. | ||
| 17 | // Electron's app.setAsDefaultProtocolClient resolves the desktop id from | ||
| 18 | // setDesktopName, which cannot match those files — so the browser keeps | ||
| 19 | // prompting "Choose an application" for every OAuth callback. Instead, write | ||
| 20 | // our own handler entry pointing at the current AppImage and claim the | ||
| 21 | // scheme default via xdg-mime, exactly what the file manager's "set as | ||
| 22 | // default" checkbox would record in mimeapps.list. | ||
| 23 | export const URL_HANDLER_DESKTOP_ENTRY_NAME = "t3code-url-handler.desktop"; | ||
| 24 | |||
| 25 | const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler"); | ||
| 26 | |||
| 27 | export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedErrorClass<DesktopLinuxUrlHandlerRegistrationError>()( | ||
| 28 | "DesktopLinuxUrlHandlerRegistrationError", | ||
| 29 | { | ||
| 30 | step: Schema.Literals(["write-desktop-entry", "set-default-handler"]), | ||
| 31 | scheme: Schema.String, | ||
| 32 | desktopEntryPath: Schema.optionalKey(Schema.String), | ||
| 33 | exitCode: Schema.optionalKey(Schema.Number), | ||
| 34 | cause: Schema.optionalKey(Schema.Defect()), | ||
| 35 | }, | ||
| 36 | ) { | ||
| 37 | override get message(): string { | ||
| 38 | const exitCode = this.exitCode === undefined ? "" : `, xdg-mime exit code ${this.exitCode}`; | ||
| 39 | return `Failed to register the ${this.scheme}:// URL handler (step: ${this.step}${exitCode}).`; | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError); | ||
| 44 | |||
| 45 | const escapeDesktopEntryString = (value: string): string => | ||
| 46 | value | ||
| 47 | .replaceAll("\\", "\\\\") | ||
| 48 | .replaceAll("\n", "\\n") | ||
| 49 | .replaceAll("\r", "\\r") | ||
| 50 | .replaceAll("\t", "\\t"); | ||
| 51 | |||
| 52 | // Exec values are unescaped twice by implementations: first the general | ||
| 53 | // string-value rules, then the Exec quoting rules — so writing composes the | ||
| 54 | // layers in reverse. The argument is double-quoted with reserved characters | ||
| 55 | // backslash-escaped and literal percent signs doubled (field codes), and the | ||
| 56 | // general string escaping is applied on top: a literal backslash ends up as | ||
| 57 | // four backslashes in the file, a quote as \\", a dollar sign as \\$. | ||
| 58 | export function escapeDesktopEntryExecArgument(value: string): string { | ||
| 59 | const quoted = value | ||
| 60 | .replaceAll("\\", () => "\\\\") | ||
| 61 | .replaceAll("`", () => "\\`") | ||
| 62 | .replaceAll("$", () => "\\$") | ||
| 63 | .replaceAll('"', () => '\\"') | ||
| 64 | .replaceAll("%", () => "%%"); | ||
| 65 | return escapeDesktopEntryString(`"${quoted}"`); | ||
| 66 | } | ||
| 67 | |||
| 68 | // The AppImage integration entry owns the window identity and icon. This | ||
| 69 | // hidden URL-only entry must not compete with it for StartupWMClass matching. | ||
| 70 | export function renderUrlHandlerDesktopEntry(input: { | ||
| 71 | readonly displayName: string; | ||
| 72 | readonly execTarget: string; | ||
| 73 | readonly scheme: string; | ||
| 74 | }): string { | ||
| 75 | return [ | ||
| 76 | "[Desktop Entry]", | ||
| 77 | "Type=Application", | ||
| 78 | `Name=${escapeDesktopEntryString(input.displayName)}`, | ||
| 79 | `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`, | ||
| 80 | "Terminal=false", | ||
| 81 | "NoDisplay=true", | ||
| 82 | "StartupNotify=false", | ||
| 83 | `MimeType=x-scheme-handler/${input.scheme};`, | ||
| 84 | "", | ||
| 85 | ].join("\n"); | ||
| 86 | } | ||
| 87 | |||
| 88 | export class DesktopLinuxUrlHandler extends Context.Service< | ||
| 89 | DesktopLinuxUrlHandler, | ||
| 90 | { | ||
| 91 | readonly register: Effect.Effect<void>; | ||
| 92 | } | ||
| 93 | >()("@t3tools/desktop/app/DesktopLinuxUrlHandler") {} | ||
| 94 | |||
| 95 | export const make = Effect.gen(function* () { | ||
| 96 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | ||
| 97 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 98 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 99 | |||
| 100 | const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment); | ||
| 101 | const desktopEntryPath = environment.path.join( | ||
| 102 | environment.linuxApplicationsDir, | ||
| 103 | URL_HANDLER_DESKTOP_ENTRY_NAME, | ||
| 104 | ); | ||
| 105 | |||
| 106 | const writeDesktopEntry = Effect.gen(function* () { | ||
| 107 | // Inside the mounted AppImage, process.execPath points at a transient | ||
| 108 | // /tmp/.mount_* path — the handler must launch the AppImage itself. | ||
| 109 | const execTarget = Option.getOrElse(environment.appImagePath, () => process.execPath); | ||
| 110 | yield* fileSystem.makeDirectory(environment.linuxApplicationsDir, { recursive: true }); | ||
| 111 | yield* fileSystem.writeFileString( | ||
| 112 | desktopEntryPath, | ||
| 113 | renderUrlHandlerDesktopEntry({ | ||
| 114 | displayName: environment.displayName, | ||
| 115 | execTarget, | ||
| 116 | scheme, | ||
| 117 | }), | ||
| 118 | ); | ||
| 119 | }).pipe( | ||
| 120 | Effect.mapError( | ||
| 121 | (cause) => | ||
| 122 | new DesktopLinuxUrlHandlerRegistrationError({ | ||
| 123 | step: "write-desktop-entry", | ||
| 124 | scheme, | ||
| 125 | desktopEntryPath, | ||
| 126 | cause, | ||
| 127 | }), | ||
| 128 | ), | ||
| 129 | ); | ||
| 130 | |||
| 131 | const setDefaultHandler = Effect.scoped( | ||
| 132 | Effect.gen(function* () { | ||
| 133 | const command = ChildProcess.make( | ||
| 134 | "xdg-mime", | ||
| 135 | ["default", URL_HANDLER_DESKTOP_ENTRY_NAME, `x-scheme-handler/${scheme}`], | ||
| 136 | { | ||
| 137 | stdin: "ignore", | ||
| 138 | stdout: "ignore", | ||
| 139 | stderr: "ignore", | ||
| 140 | }, | ||
| 141 | ); | ||
| 142 | const handle = yield* spawner.spawn(command); | ||
| 143 | const exitCode = yield* handle.exitCode; | ||
| 144 | if ((exitCode as unknown as number) !== 0) { | ||
| 145 | return yield* new DesktopLinuxUrlHandlerRegistrationError({ | ||
| 146 | step: "set-default-handler", | ||
| 147 | scheme, | ||
| 148 | exitCode: Number(exitCode), | ||
| 149 | }); | ||
| 150 | } | ||
| 151 | }), | ||
| 152 | ).pipe( | ||
| 153 | Effect.mapError((error) => | ||
| 154 | isRegistrationError(error) | ||
| 155 | ? error | ||
| 156 | : new DesktopLinuxUrlHandlerRegistrationError({ | ||
| 157 | step: "set-default-handler", | ||
| 158 | scheme, | ||
| 159 | cause: error, | ||
| 160 | }), | ||
| 161 | ), | ||
| 162 | ); | ||
| 163 | |||
| 164 | const register = Effect.gen(function* () { | ||
| 165 | if (environment.platform !== "linux" || !environment.isPackaged) { | ||
| 166 | return; | ||
| 167 | } | ||
| 168 | yield* writeDesktopEntry; | ||
| 169 | yield* setDefaultHandler; | ||
| 170 | yield* logInfo("registered URL scheme handler", { scheme }); | ||
| 171 | }).pipe( | ||
| 172 | // Registration is best-effort: a missing xdg-mime or read-only home must | ||
| 173 | // never block startup — the OS chooser remains as fallback. | ||
| 174 | Effect.catch((error) => | ||
| 175 | logWarning("URL scheme handler registration failed", { | ||
| 176 | scheme, | ||
| 177 | step: error.step, | ||
| 178 | message: error.message, | ||
| 179 | ...(error.desktopEntryPath === undefined | ||
| 180 | ? {} | ||
| 181 | : { desktopEntryPath: error.desktopEntryPath }), | ||
| 182 | ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }), | ||
| 183 | }), | ||
| 184 | ), | ||
| 185 | Effect.withSpan("desktop.linuxUrlHandler.register"), | ||
| 186 | ); | ||
| 187 | |||
| 188 | return DesktopLinuxUrlHandler.of({ register }); | ||
| 189 | }); | ||
| 190 | |||
| 191 | export const layer = Layer.effect(DesktopLinuxUrlHandler, make); | ||
apps/desktop/src/main.ts+2| ... | @@ -43,6 +43,7 @@ import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentA | ... | @@ -43,6 +43,7 @@ import * as DesktopLocalEnvironmentAuth from "./backend/DesktopLocalEnvironmentA |
| 43 | import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; | 43 | import * as DesktopNetworkInterfaces from "./backend/DesktopNetworkInterfaces.ts"; |
| 44 | import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; | 44 | import * as DesktopEnvironment from "./app/DesktopEnvironment.ts"; |
| 45 | import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; | 45 | import * as DesktopLifecycle from "./app/DesktopLifecycle.ts"; |
| 46 | import * as DesktopLinuxUrlHandler from "./app/DesktopLinuxUrlHandler.ts"; | ||
| 46 | import * as DesktopShutdown from "./app/DesktopShutdown.ts"; | 47 | import * as DesktopShutdown from "./app/DesktopShutdown.ts"; |
| 47 | import * as DesktopObservability from "./app/DesktopObservability.ts"; | 48 | import * as DesktopObservability from "./app/DesktopObservability.ts"; |
| 48 | import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; | 49 | import * as DesktopServerExposure from "./backend/DesktopServerExposure.ts"; |
| ... | @@ -181,6 +182,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( | ... | @@ -181,6 +182,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( |
| 181 | const desktopApplicationLayer = Layer.mergeAll( | 182 | const desktopApplicationLayer = Layer.mergeAll( |
| 182 | DesktopLifecycle.layer, | 183 | DesktopLifecycle.layer, |
| 183 | DesktopApplicationMenu.layer, | 184 | DesktopApplicationMenu.layer, |
| 185 | DesktopLinuxUrlHandler.layer, | ||
| 184 | DesktopShellEnvironment.layer, | 186 | DesktopShellEnvironment.layer, |
| 185 | desktopSshLayer, | 187 | desktopSshLayer, |
| 186 | ).pipe( | 188 | ).pipe( |