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// Linux ships as an AppImage, so the .desktop entry users end up with is
16// created by whatever integration tool they use (AppImageLauncher names it
17// appimagekit_<hash>-….desktop) and its filename is not under our control.
18// Electron's app.setAsDefaultProtocolClient resolves the desktop id from
19// setDesktopName, which cannot match those files — so the browser keeps
20// prompting "Choose an application" for every OAuth callback. Instead, write
21// our own handler entry pointing at the current AppImage, refresh the desktop
22// MIME cache so desktop environments recognize that entry as a handler, and
23// use xdg-mime to record it as the scheme default in mimeapps.list.
24const { logInfo, logWarning } = makeComponentLogger("desktop-linux-url-handler");
25
26export class DesktopLinuxUrlHandlerRegistrationError extends Schema.TaggedError<DesktopLinuxUrlHandlerRegistrationError>()(
27 "DesktopLinuxUrlHandlerRegistrationError",
28 {
29 step: Schema.Literals(["write-desktop-entry", "set-default-handler"]),
30 scheme: Schema.String,
31 desktopEntryPath: Schema.optionalKey(Schema.String),
32 exitCode: Schema.optionalKey(Schema.Number),
33 cause: Schema.optionalKey(Schema.Defect()),
34 },
35) {
36 override get message(): string {
37 const exitCode = this.exitCode === undefined ? "" : `, xdg-mime exit code ${this.exitCode}`;
38 return `Failed to register the ${this.scheme}:// URL handler (step: ${this.step}${exitCode}).`;
39 }
40}
41
42const isRegistrationError = Schema.is(DesktopLinuxUrlHandlerRegistrationError);
43
44export 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
59const isCacheRefreshError = Schema.is(DesktopLinuxUrlHandlerCacheRefreshError);
60
61const escapeDesktopEntryString = (value: string): string =>
62 value
63 .replaceAll("\\", "\\\\")
64 .replaceAll("\n", "\\n")
65 .replaceAll("\r", "\\r")
66 .replaceAll("\t", "\\t");
67
68// Exec values are unescaped twice by implementations: first the general
69// string-value rules, then the Exec quoting rules — so writing composes the
70// layers in reverse. The argument is double-quoted with reserved characters
71// backslash-escaped and literal percent signs doubled (field codes), and the
72// general string escaping is applied on top: a literal backslash ends up as
73// four backslashes in the file, a quote as \\", a dollar sign as \\$.
74export function escapeDesktopEntryExecArgument(value: string): string {
75 const quoted = value
76 .replaceAll("\\", () => "\\\\")
77 .replaceAll("`", () => "\\`")
78 .replaceAll("$", () => "\\$")
79 .replaceAll('"', () => '\\"')
80 .replaceAll("%", () => "%%");
81 return escapeDesktopEntryString(`"${quoted}"`);
82}
83
84// The AppImage integration entry owns the window identity. This
85// hidden URL-only entry must not compete with it for StartupWMClass matching.
86export function renderUrlHandlerDesktopEntry(input: {
87 readonly displayName: string;
88 readonly execTarget: string;
89 readonly scheme: string;
90 readonly iconPath?: string;
91}): string {
92 return [
93 "[Desktop Entry]",
94 "Type=Application",
95 `Name=${escapeDesktopEntryString(input.displayName)}`,
96 `Exec=${escapeDesktopEntryExecArgument(input.execTarget)} %U`,
97 ...(input.iconPath === undefined ? [] : [`Icon=${escapeDesktopEntryString(input.iconPath)}`]),
98 "Terminal=false",
99 "NoDisplay=true",
100 "StartupNotify=false",
101 `MimeType=x-scheme-handler/${input.scheme};`,
102 "",
103 ].join("\n");
104}
105
106export class DesktopLinuxUrlHandler extends Context.Service<
107 DesktopLinuxUrlHandler,
108 {
109 readonly register: Effect.Effect<void>;
110 }
111>()("@t3tools/desktop/app/DesktopLinuxUrlHandler") {}
112
113/** @public Service construction is part of the canonical Effect module API. */
114export const make = Effect.gen(function* () {
115 const environment = yield* DesktopEnvironment.DesktopEnvironment;
116 const fileSystem = yield* FileSystem.FileSystem;
117 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
118 const assets = yield* DesktopAssets.DesktopAssets;
119
120 const scheme = ElectronProtocol.getDesktopScheme(environment.isDevelopment);
121 const desktopEntryPath = environment.path.join(
122 environment.linuxApplicationsDir,
123 environment.linuxDesktopEntryName,
124 );
125 const iconsDir = environment.path.join(environment.linuxApplicationsDir, "..", "icons");
126 const iconPath = environment.path.join(iconsDir, `${environment.linuxDesktopEntryName}.png`);
127
128 const writeDesktopEntry = Effect.gen(function* () {
129 // Inside the mounted AppImage, process.execPath points at a transient
130 // /tmp/.mount_* path — the handler must launch the AppImage itself.
131 const execTarget = Option.getOrElse(environment.appImagePath, () => process.execPath);
132 const content = renderUrlHandlerDesktopEntry({
133 displayName: environment.displayName,
134 execTarget,
135 scheme,
136 ...(environment.isPackaged ? { iconPath } : {}),
137 });
138 // Pre-ready setup normally wrote this already. Avoid truncating a valid
139 // entry while the portal may be reading it during startup.
140 const existing = yield* fileSystem
141 .readFileString(desktopEntryPath)
142 .pipe(Effect.orElseSucceed(() => null));
143 if (existing === content) return;
144 yield* fileSystem.makeDirectory(environment.linuxApplicationsDir, { recursive: true });
145 yield* fileSystem.writeFileString(desktopEntryPath, content);
146 }).pipe(
147 Effect.mapError(
148 (cause) =>
149 new DesktopLinuxUrlHandlerRegistrationError({
150 step: "write-desktop-entry",
151 scheme,
152 desktopEntryPath,
153 cause,
154 }),
155 ),
156 );
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
189 const setDefaultHandler = Effect.scoped(
190 Effect.gen(function* () {
191 const command = ChildProcess.make(
192 "xdg-mime",
193 ["default", environment.linuxDesktopEntryName, `x-scheme-handler/${scheme}`],
194 {
195 stdin: "ignore",
196 stdout: "ignore",
197 stderr: "ignore",
198 },
199 );
200 const handle = yield* spawner.spawn(command);
201 const exitCode = yield* handle.exitCode;
202 if ((exitCode as unknown as number) !== 0) {
203 return yield* new DesktopLinuxUrlHandlerRegistrationError({
204 step: "set-default-handler",
205 scheme,
206 exitCode: Number(exitCode),
207 });
208 }
209 }),
210 ).pipe(
211 Effect.mapError((error) =>
212 isRegistrationError(error)
213 ? error
214 : new DesktopLinuxUrlHandlerRegistrationError({
215 step: "set-default-handler",
216 scheme,
217 cause: error,
218 }),
219 ),
220 );
221
222 const register = Effect.gen(function* () {
223 if (environment.platform !== "linux") {
224 return;
225 }
226 yield* writeDesktopEntry;
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
255 yield* setDefaultHandler;
256 yield* logInfo("registered URL scheme handler", { scheme });
257 }).pipe(
258 // Registration is best-effort: a missing xdg-mime or read-only home must
259 // never block startup — the OS chooser remains as fallback.
260 Effect.catch((error) =>
261 logWarning("URL scheme handler registration failed", {
262 scheme,
263 step: error.step,
264 message: error.message,
265 ...(error.desktopEntryPath === undefined
266 ? {}
267 : { desktopEntryPath: error.desktopEntryPath }),
268 ...(error.exitCode === undefined ? {} : { exitCode: error.exitCode }),
269 }),
270 ),
271 Effect.withSpan("desktop.linuxUrlHandler.register"),
272 );
273
274 return DesktopLinuxUrlHandler.of({ register });
275});
276
277export const layer = Layer.effect(DesktopLinuxUrlHandler, make);