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