Skip to content
Source
Sign inSign up
Open History to see the last commit

DesktopLinuxUrlHandler.ts

374 lines14.0 KB

1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as ChildProcess from "effect/process/ChildProcess";
8import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
9​
10import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
11import * as DesktopAssets from "./DesktopAssets.ts";
12import * as DesktopEnvironment from "./DesktopEnvironment.ts";
13import { makeComponentLogger } from "./DesktopObservability.ts";
14​
15// AppImage integrators choose the .desktop filename, so setDesktopName cannot identify it for protocol registration.
16const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler");
17​
18export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError<DesktopLinuxUrlHandlerRegistrationError>()(
19 "DesktopLinuxUrlHandlerRegistrationError",
20 {
21 step: Schema.Literals(["write-desktop-entry", "set-default-handler"]),
22 scheme: Schema.String,
23 desktopEntryPath: Schema.optionalKey(Schema.String),
24 exitCode: Schema.optionalKey(Schema.Number),
25 cause: Schema.optionalKey(Schema.Defect()),
26 },
27) {
28 override get message(): string {
29 const exitCode = this.exitCode === undefined ? "" : `, xdg-mime exit code ${this.exitCode}`;
30 return `Failed to register the ${this.scheme}:// URL handler (step: ${this.step}${exitCode}).`;
31 }
33​
34const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError);
35​
36export class DesktopLinuxUrlHandlerCacheRefreshError extends Schema.TaggedError<DesktopLinuxUrlHandlerCacheRefreshError>()(
37 "DesktopLinuxUrlHandlerCacheRefreshError",
38 {
39 applicationsDir: Schema.String,
40 exitCode: Schema.optionalKey(Schema.Number),
41 cause: Schema.optionalKey(Schema.Defect()),
42 },
43) {
44 override get message(): string {
45 const exitCode =
46 this.exitCode === undefined ? "" : `, update-desktop-database exit code ${this.exitCode}`;
47 return `Failed to refresh the desktop MIME cache at ${this.applicationsDir}${exitCode}.`;
48 }
50​
51const isCacheRefreshError = Schema.is(DesktopLinuxUrlHandlerCacheRefreshError);
52​
53const escapeDesktopEntryString = (value: string): string =>
54 value
55 .replaceAll("\\", "\\\\")
56 .replaceAll("\n", "\\n")
57 .replaceAll("\r", "\\r")
58 .replaceAll("\t", "\\t");
59​
60// Exec values undergo general unescaping then Exec unquoting; escape in reverse order.
61export function escapeDesktopEntryExecArgument(value: string): string {
62 const quoted = value
63 .replaceAll("\\", () => "\\\\")
64 .replaceAll("`", () => "\\`")
65 .replaceAll("$", () => "\\$")
66 .replaceAll('"', () => '\\"')
67 .replaceAll("%", () => "%%");
68 return escapeDesktopEntryString(`"${quoted}"`);
70​
71// Legacy xdg-open looks up the first Exec word without unquoting it.
72const escapeUrlHandlerExecTarget = (value: string): string =>
73 /[ \t\n"'\\><~|&;$*?#()`]/.test(value)
74 ? escapeDesktopEntryExecArgument(value)
75 : escapeDesktopEntryString(value.replaceAll("%", "%%"));
76​
77// The AppImage integration entry owns the window identity. This
78// hidden URL-only entry must not compete with it for StartupWMClass matching.
79export function renderUrlHandlerDesktopEntry(input: {
80 readonly displayName: string;
81 readonly execTarget: string;
82 readonly scheme: string;
83 readonly iconPath?: string;
84}): string {
85 return [
86 "[Desktop Entry]",
87 "Type=Application",
88 `Name=${escapeDesktopEntryString(input.displayName)}`,
89 `Exec=${escapeUrlHandlerExecTarget(input.execTarget)} %U`,
90 ...(input.iconPath === undefined ? [] : [`Icon=${escapeDesktopEntryString(input.iconPath)}`]),
91 "Terminal=false",
92 "NoDisplay=true",
93 "StartupNotify=false",
94 `MimeType=x-scheme-handler/${input.scheme};`,
95 "",
96 ].join("\n");
98​
99function parseUrlHandlerExecTarget(entry: string): string | null {
100 const exec = /^Exec=(.*) %U$/mu.exec(entry)?.[1];
101 if (exec === undefined) return null;
102 const unescaped = exec.replace(/\\([\\nrt])/gu, (_, char: string) =>
103 char === "n" ? "\n" : char === "r" ? "\r" : char === "t" ? "\t" : char,
104 );
105 const quoted = /^"(.*)"$/su.exec(unescaped)?.[1];
106 return (
107 quoted === undefined ? unescaped : quoted.replace(/\\([\\`$"])/gu, (_, char: string) => char)
108 ).replaceAll("%%", "%");
110​
111const APPIMAGE_NAME_VERSION = /\d+\.\d+\.\d+/u;
112​
113// An updater from before version-free AppImage names deletes its own file and installs the update
114// beside it under the new name. The previous launch's entry still names the path launchers use.
115export function replacedVersionedAppImagePath(input: {
116 readonly previousEntry: string;
117 readonly appImagePath: string;
118}): string | null {
119 const previous = parseUrlHandlerExecTarget(input.previousEntry);
120 if (previous === null) return null;
121 const previousName = previous.slice(previous.lastIndexOf("/") + 1);
122 const currentName = input.appImagePath.slice(input.appImagePath.lastIndexOf("/") + 1);
123 return previous.startsWith("/") &&
124 previous.slice(0, -previousName.length) === input.appImagePath.slice(0, -currentName.length) &&
125 APPIMAGE_NAME_VERSION.test(previousName) &&
126 !APPIMAGE_NAME_VERSION.test(currentName)
127 ? previous
128 : null;
130​
131// Docks may look up the Wayland app id without a desktop entry; icon theme lookups are case-sensitive.
132const LINUX_THEME_ICON_SIZE = "256x256";
133​
134function linuxThemeIconNames(desktopEntryName: string): readonly string[] {
135 const appId = desktopEntryName.replace(/\.desktop$/u, "");
136 return [...new Set([appId.toLowerCase(), appId])];
138​
139const sameBytes = (left: Uint8Array, right: Uint8Array): boolean =>
140 left.length === right.length && left.every((byte, index) => byte === right[index]);
141​
142export class DesktopLinuxUrlHandler extends Context.Service<
143 DesktopLinuxUrlHandler,
144 {
145 readonly register: Effect.Effect<void>;
146 }
147>()("@t3tools/desktop/app/DesktopLinuxUrlHandler") {}
148​
149/** @public */
150export const make = Effect.gen(function* () {
151 const environment = yield* DesktopEnvironment.DesktopEnvironment;
152 const fileSystem = yield* FileSystem.FileSystem;
153 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
154 const assets = yield* DesktopAssets.DesktopAssets;
155​
156 const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment);
157 const desktopEntryPath = environment.path.join(
158 environment.linuxApplicationsDir,
159 environment.linuxDesktopEntryName,
160 );
161 const iconsDir = environment.path.join(environment.linuxApplicationsDir, "..", "icons");
162 const iconPath = environment.path.join(iconsDir, `${environment.linuxDesktopEntryName}.png`);
163 const hicolorDir = environment.path.join(iconsDir, "hicolor");
164 const themeIconDir = environment.path.join(hicolorDir, LINUX_THEME_ICON_SIZE, "apps");
165​
166 const writeDesktopEntry = Effect.gen(function* () {
167 // Distribution packages that run on a shared Electron set
168 // T3CODE_DESKTOP_LAUNCHER_PATH, because process.execPath is that Electron.
169 // Inside the mounted AppImage, process.execPath points at a transient
170 // /tmp/.mount_* path — the handler must launch the AppImage itself.
171 const execTarget = environment.desktopLauncherPath.pipe(
172 Option.orElse(() => environment.appImagePath),
173 Option.getOrElse(() => process.execPath),
174 );
175 const content = renderUrlHandlerDesktopEntry({
176 displayName: environment.displayName,
177 execTarget,
178 scheme,
179 ...(environment.isPackaged ? { iconPath } : {}),
180 });
181 // Avoid truncating the pre-ready entry while the portal may be reading it.
182 const existing = yield* fileSystem
183 .readFileString(desktopEntryPath)
184 .pipe(Effect.orElseSucceed(() => null));
185 if (existing === content) return;
186 yield* fileSystem.makeDirectory(environment.linuxApplicationsDir, { recursive: true });
187 yield* fileSystem.writeFileString(desktopEntryPath, content);
188 }).pipe(
189 Effect.mapError(
190 (cause) =>
191 new DesktopLinuxUrlHandlerRegistrationError({
192 step: "write-desktop-entry",
193 scheme,
194 desktopEntryPath,
195 cause,
196 }),
197 ),
198 );
199​
200 const updateDesktopDatabase = Effect.scoped(
201 Effect.gen(function* () {
202 const command = ChildProcess.make(
203 "update-desktop-database",
204 [environment.linuxApplicationsDir],
205 {
206 stdin: "ignore",
207 stdout: "ignore",
208 stderr: "ignore",
209 forceKillAfter: "1 second",
210 },
211 );
212 const handle = yield* spawner.spawn(command);
213 const exitCode = yield* handle.exitCode.pipe(Effect.timeout("5 seconds"));
214 if (exitCode !== 0) {
215 return yield* new DesktopLinuxUrlHandlerCacheRefreshError({
216 applicationsDir: environment.linuxApplicationsDir,
217 exitCode,
218 });
219 }
220 }),
221 ).pipe(
222 Effect.mapError((error) =>
223 isCacheRefreshError(error)
224 ? error
225 : new DesktopLinuxUrlHandlerCacheRefreshError({
226 applicationsDir: environment.linuxApplicationsDir,
227 cause: error,
228 }),
229 ),
230 );
231​
232 const refreshIconThemeCache = Effect.scoped(
233 Effect.gen(function* () {
234 const command = ChildProcess.make("gtk-update-icon-cache", ["-f", "-t", hicolorDir], {
235 stdin: "ignore",
236 stdout: "ignore",
237 stderr: "ignore",
238 // Releasing a timed-out helper otherwise waits on SIGTERM indefinitely.
239 forceKillAfter: "1 second",
240 });
241 const handle = yield* spawner.spawn(command);
242 return yield* handle.exitCode.pipe(Effect.timeout("5 seconds"));
243 }),
244 );
245​
246 const modifiedAt = (filePath: string) =>
247 fileSystem
248 .stat(filePath)
249 .pipe(Effect.map((info) => Option.getOrUndefined(info.mtime)?.getTime() ?? 0));
250​
251 const installThemeIcons = Effect.gen(function* () {
252 const source = yield* assets.resolveResourcePath(`icons/${LINUX_THEME_ICON_SIZE}.png`);
253 if (Option.isNone(source)) return;
254 const icon = yield* fileSystem.readFile(source.value);
255 const targets = linuxThemeIconNames(environment.linuxDesktopEntryName).map((name) =>
256 environment.path.join(themeIconDir, `${name}.png`),
257 );
258 let changed = false;
259 for (const target of targets) {
260 const current = yield* fileSystem.readFile(target).pipe(Effect.option);
261 if (Option.isSome(current) && sameBytes(current.value, icon)) continue;
262 yield* fileSystem.makeDirectory(themeIconDir, { recursive: true });
263 yield* fileSystem.writeFile(target, icon);
264 changed = true;
265 }
266 // Creating a cache would leave GTK reading stale icons from other installers.
267 // A newer icon retries a refresh that failed on an earlier launch.
268 const cachePath = environment.path.join(hicolorDir, "icon-theme.cache");
269 const cachedAt = yield* modifiedAt(cachePath).pipe(Effect.option);
270 if (Option.isNone(cachedAt)) return;
271 let stale = changed;
272 for (const target of targets) {
273 if (stale) break;
274 if ((yield* modifiedAt(target)) > cachedAt.value) stale = true;
275 }
276 if (!stale) return;
277 const exitCode = yield* refreshIconThemeCache;
278 if (exitCode !== 0) {
279 yield* logWarning("icon theme cache refresh failed", { hicolorDir, exitCode });
280 }
281 }).pipe(
282 Effect.catch((error) =>
283 logWarning("app id theme icon install failed", { themeIconDir, category: error._tag }),
284 ),
285 );
286​
287 const setDefaultHandler = Effect.scoped(
288 Effect.gen(function* () {
289 const command = ChildProcess.make(
290 "xdg-mime",
291 ["default", environment.linuxDesktopEntryName, `x-scheme-handler/${scheme}`],
292 {
293 stdin: "ignore",
294 stdout: "ignore",
295 stderr: "ignore",
296 forceKillAfter: "1 second",
297 },
298 );
299 const handle = yield* spawner.spawn(command);
300 const exitCode = yield* handle.exitCode.pipe(Effect.timeout("5 seconds"));
301 if (exitCode !== 0) {
302 return yield* new DesktopLinuxUrlHandlerRegistrationError({
303 step: "set-default-handler",
304 scheme,
305 exitCode,
306 });
307 }
308 }),
309 ).pipe(
310 Effect.mapError((error) =>
311 isRegistrationError(error)
312 ? error
313 : new DesktopLinuxUrlHandlerRegistrationError({
314 step: "set-default-handler",
315 scheme,
316 cause: error,
317 }),
318 ),
319 );
320​
321 const register = Effect.gen(function* () {
322 if (environment.platform !== "linux") {
323 return;
324 }
325 yield* writeDesktopEntry;
326 if (!environment.isPackaged) return;
327​
328 yield* Effect.gen(function* () {
329 const { png } = yield* assets.iconPaths;
330 if (Option.isNone(png)) return;
331 // The AppImage mount is temporary; the chooser needs the icon after exit.
332 yield* fileSystem.makeDirectory(iconsDir, { recursive: true });
333 // Electron's copyFile from app.asar extracts to a temp file it never deletes.
334 yield* fileSystem.writeFile(iconPath, yield* fileSystem.readFile(png.value));
335 }).pipe(
336 Effect.catch((error) =>
337 logWarning("URL handler icon install failed", { iconPath, category: error.reason._tag }),
338 ),
339 );
340​
341 yield* installThemeIcons;
342​
343 yield* updateDesktopDatabase.pipe(
344 // GIO verifies scheme associations through mimeinfo.cache. A missing cache updater must not block xdg-mime.
345 Effect.catch((error) =>
346 logWarning("desktop MIME cache refresh failed", {
347 applicationsDir: environment.linuxApplicationsDir,
348 message: error.message,
349 ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }),
350 }),
351 ),
352 );
353​
354 yield* setDefaultHandler;
355 yield* logInfo("registered URL scheme handler", { scheme });
356 }).pipe(
357 Effect.catch((error) =>
358 logWarning("URL scheme handler registration failed", {
359 scheme,
360 step: error.step,
361 message: error.message,
362 ...(error.desktopEntryPath === undefined
363 ? {}
364 : { desktopEntryPath: error.desktopEntryPath }),
365 ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }),
366 }),
367 ),
368 Effect.withSpan("desktop.linuxUrlHandler.register"),
369 );
370​
371 return DesktopLinuxUrlHandler.of({ register });
372});
373​
374export const layer = Layer.effect(DesktopLinuxUrlHandler, make);