1// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat, an atomic rename, and a directory watch at the Node adapter boundary.
2import { pipe } from "effect/Function";
3import * as NodeCrypto from "node:crypto";
4import * as NodeFS from "node:fs";
5import * as NodeFSP from "node:fs/promises";
6import * as NodeNet from "node:net";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9
10import {
11 DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
12 DesktopAppActivationRequest,
13 type DesktopAppActivationResponse,
14} from "@t3tools/contracts";
15import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
16import * as HostProcess from "@t3tools/shared/HostProcess";
17import * as Context from "effect/Context";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import * as Scope from "effect/Scope";
24
25import type * as Electron from "electron";
26
27import * as ElectronWindow from "../electron/ElectronWindow.ts";
28import { DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL } from "../ipc/channels.ts";
29import * as DesktopWindow from "../window/DesktopWindow.ts";
30import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts";
31import * as DesktopEnvironment from "./DesktopEnvironment.ts";
32import { makeComponentLogger } from "./DesktopObservability.ts";
33
34const MAX_REQUEST_BYTES = 64 * 1024;
35const REQUEST_TIMEOUT_MS = 15_000;
36const isDesktopAppActivationRequest = Schema.is(DesktopAppActivationRequest);
37
38export class DesktopAppActivationStartError extends Schema.TaggedError<DesktopAppActivationStartError>()(
39 "DesktopAppActivationStartError",
40 {
41 address: Schema.String,
42 cause: Schema.Defect(),
43 },
44) {
45 override get message(): string {
46 return `Could not start the desktop app control socket at ${this.address}.`;
47 }
48}
49
50interface RunningControlServer {
51 readonly reclaim: () => Promise<void>;
52 readonly close: () => Promise<void>;
53}
54
55function invalidResponse(requestId: string, message: string): DesktopAppActivationResponse {
56 return {
57 version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
58 requestId,
59 ok: false,
60 code: "invalid-request",
61 message,
62 };
63}
64
65function requestIdFromUnknown(value: unknown): string {
66 if (
67 typeof value === "object" &&
68 value !== null &&
69 "requestId" in value &&
70 typeof value.requestId === "string" &&
71 value.requestId.trim().length > 0
72 ) {
73 return value.requestId;
74 }
75 return "invalid-request";
76}
77
78async function prepareUnixDirectory(input: {
79 readonly directory: string;
80 readonly userId: number | undefined;
81}): Promise<boolean> {
82 const created = await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 });
83 const stat = await NodeFSP.lstat(input.directory);
84 if (!stat.isDirectory() || stat.isSymbolicLink()) {
85 throw new Error(`${input.directory} is not a directory.`);
86 }
87 if (input.userId !== undefined && stat.uid !== input.userId) {
88 throw new Error(`${input.directory} is owned by another user.`);
89 }
90 await NodeFSP.chmod(input.directory, 0o700);
91 return created !== undefined;
92}
93
94async function inodeAt(path: string): Promise<number | null> {
95 return NodeFSP.lstat(path).then(
96 (stat) => stat.ino,
97 (error: NodeJS.ErrnoException) => {
98 if (error.code === "ENOENT") return null;
99 throw error;
100 },
101 );
102}
103
104function closeServer(server: NodeNet.Server): Promise<void> {
105 return new Promise((resolve) => server.close(() => resolve()));
106}
107
108export async function startDesktopAppControlServer(input: {
109 readonly address: string;
110 readonly directory: string | null;
111 readonly userId: number | undefined;
112 readonly handle: (request: DesktopAppActivationRequest) => Promise<DesktopAppActivationResponse>;
113 readonly cancel: (requestId: string) => void;
114 readonly onReclaimError: (error: unknown) => void;
115}): Promise<RunningControlServer> {
116 const sockets = new Set<NodeNet.Socket>();
117 const handleConnection = (socket: NodeNet.Socket) => {
118 sockets.add(socket);
119 socket.setEncoding("utf8");
120 let buffer = "";
121 let handled = false;
122 let responseSent = false;
123 let activeRequestId: string | null = null;
124
125 socket.setTimeout(5_000, () => socket.destroy());
126
127 const finish = (response: DesktopAppActivationResponse) => {
128 responseSent = true;
129 if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`);
130 };
131
132 socket.on("data", (chunk) => {
133 if (handled) return;
134 buffer += chunk;
135 if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_BYTES) {
136 handled = true;
137 finish(invalidResponse("invalid-request", "The desktop app request is too large."));
138 return;
139 }
140
141 const newline = buffer.indexOf("\n");
142 if (newline === -1) return;
143 handled = true;
144 socket.setTimeout(0);
145 const line = buffer.slice(0, newline);
146 let parsed: unknown;
147 try {
148 parsed = JSON.parse(line);
149 } catch {
150 finish(invalidResponse("invalid-request", "The desktop app request is not valid JSON."));
151 return;
152 }
153
154 if (!isDesktopAppActivationRequest(parsed)) {
155 pipe(
156 requestIdFromUnknown(parsed),
157 (requestId) => invalidResponse(requestId, "The desktop app request is invalid."),
158 finish,
159 );
160 return;
161 }
162 activeRequestId = parsed.requestId;
163 void input.handle(parsed).then(finish, () => {
164 finish(
165 invalidResponse(parsed.requestId, "T3 Code could not process the desktop app request."),
166 );
167 });
168 });
169 socket.on("error", () => socket.destroy());
170 socket.on("close", () => {
171 sockets.delete(socket);
172 if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId);
173 });
174 };
175
176 const listen = (address: string) =>
177 new Promise<NodeNet.Server>((resolve, reject) => {
178 const server = NodeNet.createServer(handleConnection);
179 const onError = (error: Error) => {
180 server.removeListener("listening", onListening);
181 reject(error);
182 };
183 const onListening = () => {
184 server.removeListener("error", onError);
185 resolve(server);
186 };
187 server.once("error", onError);
188 server.once("listening", onListening);
189 server.listen(address);
190 });
191
192 // Closing unlinks the original bind path, even if another app replaced it. Bind a staging path first.
193
194 const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => {
195 const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`);
196 const server = await listen(staging);
197 try {
198 await NodeFSP.chmod(staging, 0o600);
199 const inode = await inodeAt(staging);
200 if (mode === "take-over") {
201 await NodeFSP.rename(staging, input.address);
202 } else {
203 await NodeFSP.link(staging, input.address);
204 await NodeFSP.unlink(staging);
205 }
206 return { server, inode };
207 } catch (error) {
208 await closeServer(server);
209 throw error;
210 }
211 };
212
213 let server: NodeNet.Server;
214 let inode: number | null = null;
215 if (input.directory === null) {
216 server = await listen(input.address);
217 } else {
218 await prepareUnixDirectory({ directory: input.directory, userId: input.userId });
219 ({ server, inode } = await bindUnix(input.directory, "take-over"));
220 }
221
222 let closed = false;
223 const reclaimOnce = async () => {
224 // Replacing an existing socket would let two apps trade the path indefinitely.
225 if (closed || input.directory === null || (await inodeAt(input.address)) !== null) return;
226 if (await prepareUnixDirectory({ directory: input.directory, userId: input.userId })) {
227 // Directory watches follow inodes, so recreate the watch after directory replacement.
228 watchDirectory(input.directory);
229 }
230 const next = await bindUnix(input.directory, "claim-free").catch(
231 (error: NodeJS.ErrnoException) => {
232 if (error.code === "EEXIST") return null;
233 throw error;
234 },
235 );
236 if (next === null) return;
237 const previous = server;
238 ({ server, inode } = next);
239 previous.close();
240 };
241 let pendingReclaim = Promise.resolve();
242 const reclaim = () => {
243 const run = pendingReclaim.then(reclaimOnce);
244 pendingReclaim = run.catch(() => undefined);
245 return run;
246 };
247 let watcher: NodeFS.FSWatcher | null = null;
248 const watchDirectory = (directory: string) => {
249 watcher?.close();
250 watcher = null;
251 try {
252 watcher = NodeFS.watch(directory, { persistent: false }, () => {
253 reclaim().catch(input.onReclaimError);
254 });
255 watcher.on("error", input.onReclaimError);
256 } catch (error) {
257 input.onReclaimError(error);
258 }
259 };
260 if (input.directory !== null) {
261 watchDirectory(input.directory);
262 // Catch a removal that happened before the watcher started.
263 reclaim().catch(input.onReclaimError);
264 }
265
266 return {
267 reclaim,
268 close: async () => {
269 if (closed) return;
270 closed = true;
271 // Reclaim can replace the watcher; close it afterward.
272 await pendingReclaim;
273 watcher?.close();
274 for (const socket of sockets) socket.destroy();
275 await closeServer(server);
276 server.removeAllListeners();
277 if (inode !== null && (await inodeAt(input.address)) === inode) {
278 await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => {
279 if (error.code !== "ENOENT") throw error;
280 });
281 }
282 },
283 };
284}
285
286export class DesktopAppActivation extends Context.Service<
287 DesktopAppActivation,
288 {
289 readonly start: Effect.Effect<void, DesktopAppActivationStartError, Scope.Scope>;
290 readonly setRendererReady: (ready: boolean) => Effect.Effect<void>;
291 readonly complete: (response: DesktopAppActivationResponse) => Effect.Effect<void>;
292 }
293>()("@t3tools/desktop/app/DesktopAppActivation") {}
294
295const { logWarning } = makeComponentLogger("desktop-app-activation");
296
297/** @public */
298export const make = Effect.gen(function* () {
299 const desktopEnvironment = yield* DesktopEnvironment.DesktopEnvironment;
300 const desktopWindow = yield* DesktopWindow.DesktopWindow;
301 const electronWindow = yield* ElectronWindow.ElectronWindow;
302 const path = yield* Path.Path;
303 const userId = yield* HostProcess.UserId;
304 const runPromise = Effect.runPromiseWith(yield* Effect.context<never>());
305 const address = resolveDesktopAppControlAddress({
306 stateDir: path.resolve(desktopEnvironment.stateDir),
307 platform: desktopEnvironment.platform,
308 tempDir: NodeOS.tmpdir(),
309 userId,
310 joinPath: path.join,
311 });
312 let registeredWebContents: Electron.WebContents | null = null;
313 let detachRendererListeners: (() => void) | null = null;
314
315 const broker = new DesktopAppActivationBroker({
316 requestTimeoutMs: REQUEST_TIMEOUT_MS,
317 activate: () => {
318 void runPromise(
319 desktopWindow.activate.pipe(
320 Effect.catchCause((cause) => logWarning("failed to focus the desktop window", { cause })),
321 ),
322 );
323 },
324 });
325
326 const clearRegisteredRenderer = () => {
327 detachRendererListeners?.();
328 detachRendererListeners = null;
329 registeredWebContents = null;
330 broker.clearRenderer();
331 };
332
333 return DesktopAppActivation.of({
334 start: Effect.acquireRelease(
335 Effect.tryPromise({
336 try: () =>
337 startDesktopAppControlServer({
338 ...address,
339 userId,
340 handle: (request) => broker.request(request),
341 cancel: (requestId) => broker.cancel(requestId),
342 onReclaimError: (cause) =>
343 void runPromise(
344 logWarning("failed to restore the desktop app control socket", { cause }),
345 ),
346 }),
347 catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }),
348 }),
349 (server) =>
350 Effect.promise(() => server.close()).pipe(
351 Effect.catchCause((cause) =>
352 logWarning("failed to close the desktop app control socket", { cause }),
353 ),
354 Effect.ensuring(Effect.sync(() => broker.close())),
355 ),
356 ).pipe(Effect.asVoid),
357 setRendererReady: Effect.fn("DesktopAppActivation.setRendererReady")(function* (ready) {
358 if (!ready) {
359 clearRegisteredRenderer();
360 return;
361 }
362 const main = yield* electronWindow.main;
363 if (Option.isNone(main)) return;
364 const webContents = main.value.webContents;
365 if (webContents.isDestroyed()) return;
366
367 if (registeredWebContents !== webContents) {
368 clearRegisteredRenderer();
369 registeredWebContents = webContents;
370 const onUnavailable = () => clearRegisteredRenderer();
371 const onNavigation = (
372 event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
373 ) => {
374 if (event.isMainFrame && !event.isSameDocument) clearRegisteredRenderer();
375 };
376 webContents.on("did-start-navigation", onNavigation);
377 webContents.once("destroyed", onUnavailable);
378 detachRendererListeners = () => {
379 webContents.removeListener("did-start-navigation", onNavigation);
380 webContents.removeListener("destroyed", onUnavailable);
381 };
382 }
383
384 broker.registerRenderer((request) => {
385 webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request);
386 });
387 }),
388 // The IPC handler owns the activation-completion span.
389 complete: Effect.fnUntraced(function* (response) {
390 return yield* Effect.sync(() => broker.complete(response));
391 }),
392 });
393});
394
395export const layer = Layer.effect(DesktopAppActivation, make);