307 lines · 10.2 KB · TypeScript
RawDownload
// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat uid and an atomic stale-socket unlink at the Node adapter boundary.import * as NodeFSP from "node:fs/promises";import * as NodeNet from "node:net";import * as NodeOS from "node:os"; import {  DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,  DesktopAppActivationRequest,  type DesktopAppActivationResponse,} from "@t3tools/contracts";import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";import { HostProcessUserId } from "@t3tools/shared/hostProcess";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope"; import type * as Electron from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts";import { DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL } from "../ipc/channels.ts";import * as DesktopWindow from "../window/DesktopWindow.ts";import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts";import * as DesktopEnvironment from "./DesktopEnvironment.ts";import { makeComponentLogger } from "./DesktopObservability.ts"; const MAX_REQUEST_BYTES = 64 * 1024;const REQUEST_TIMEOUT_MS = 15_000;const isDesktopAppActivationRequest = Schema.is(DesktopAppActivationRequest); export class DesktopAppActivationStartError extends Schema.TaggedError<DesktopAppActivationStartError>()(  "DesktopAppActivationStartError",  {    address: Schema.String,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Could not start the desktop app control socket at ${this.address}.`;  }} interface RunningControlServer {  readonly close: () => Promise<void>;} function invalidResponse(requestId: string, message: string): DesktopAppActivationResponse {  return {    version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,    requestId,    ok: false,    code: "invalid-request",    message,  };} function requestIdFromUnknown(value: unknown): string {  if (    typeof value === "object" &&    value !== null &&    "requestId" in value &&    typeof value.requestId === "string" &&    value.requestId.trim().length > 0  ) {    return value.requestId;  }  return "invalid-request";} async function prepareUnixSocket(input: {  readonly address: string;  readonly directory: string;  readonly userId: number | undefined;}): Promise<void> {  await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 });  const stat = await NodeFSP.lstat(input.directory);  if (!stat.isDirectory() || stat.isSymbolicLink()) {    throw new Error(`${input.directory} is not a directory.`);  }  if (input.userId !== undefined && stat.uid !== input.userId) {    throw new Error(`${input.directory} is owned by another user.`);  }  await NodeFSP.chmod(input.directory, 0o700);  await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => {    if (error.code !== "ENOENT") throw error;  });} export async function startDesktopAppControlServer(input: {  readonly address: string;  readonly directory: string | null;  readonly userId: number | undefined;  readonly handle: (request: DesktopAppActivationRequest) => Promise<DesktopAppActivationResponse>;  readonly cancel: (requestId: string) => void;}): Promise<RunningControlServer> {  if (input.directory !== null) {    await prepareUnixSocket({      address: input.address,      directory: input.directory,      userId: input.userId,    });  }   const sockets = new Set<NodeNet.Socket>();  const server = NodeNet.createServer((socket) => {    sockets.add(socket);    socket.setEncoding("utf8");    let buffer = "";    let handled = false;    let responseSent = false;    let activeRequestId: string | null = null;     socket.setTimeout(5_000, () => socket.destroy());     const finish = (response: DesktopAppActivationResponse) => {      responseSent = true;      if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`);    };     socket.on("data", (chunk) => {      if (handled) return;      buffer += chunk;      if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_BYTES) {        handled = true;        finish(invalidResponse("invalid-request", "The desktop app request is too large."));        return;      }       const newline = buffer.indexOf("\n");      if (newline === -1) return;      handled = true;      socket.setTimeout(0);      const line = buffer.slice(0, newline);      let parsed: unknown;      try {        parsed = JSON.parse(line);      } catch {        finish(invalidResponse("invalid-request", "The desktop app request is not valid JSON."));        return;      }       if (!isDesktopAppActivationRequest(parsed)) {        finish(          invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."),        );        return;      }      activeRequestId = parsed.requestId;      void input.handle(parsed).then(finish, () => {        finish(          invalidResponse(parsed.requestId, "T3 Code could not process the desktop app request."),        );      });    });    socket.on("error", () => socket.destroy());    socket.on("close", () => {      sockets.delete(socket);      if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId);    });  });   await new Promise<void>((resolve, reject) => {    const onError = (error: Error) => {      server.removeListener("listening", onListening);      reject(error);    };    const onListening = () => {      server.removeListener("error", onError);      resolve();    };    server.once("error", onError);    server.once("listening", onListening);    server.listen(input.address);  });   try {    if (input.directory !== null) {      await NodeFSP.chmod(input.address, 0o600);    }  } catch (error) {    await new Promise<void>((resolve) => server.close(() => resolve()));    throw error;  }   let closed = false;  return {    close: async () => {      if (closed) return;      closed = true;      for (const socket of sockets) socket.destroy();      await new Promise<void>((resolve) => server.close(() => resolve()));      server.removeAllListeners();      if (input.directory !== null) {        await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => {          if (error.code !== "ENOENT") throw error;        });      }    },  };} export class DesktopAppActivation extends Context.Service<  DesktopAppActivation,  {    readonly start: Effect.Effect<void, DesktopAppActivationStartError, Scope.Scope>;    readonly setRendererReady: (ready: boolean) => Effect.Effect<void>;    readonly complete: (response: DesktopAppActivationResponse) => Effect.Effect<void>;  }>()("@t3tools/desktop/app/DesktopAppActivation") {} const { logWarning } = makeComponentLogger("desktop-app-activation"); /** @public Service construction is part of the canonical Effect module API. */export const make = Effect.gen(function* () {  const desktopEnvironment = yield* DesktopEnvironment.DesktopEnvironment;  const desktopWindow = yield* DesktopWindow.DesktopWindow;  const electronWindow = yield* ElectronWindow.ElectronWindow;  const path = yield* Path.Path;  const userId = yield* HostProcessUserId;  const runPromise = Effect.runPromiseWith(yield* Effect.context<never>());  const address = resolveDesktopAppControlAddress({    stateDir: path.resolve(desktopEnvironment.stateDir),    platform: desktopEnvironment.platform,    tempDir: NodeOS.tmpdir(),    userId,    joinPath: path.join,  });  let registeredWebContents: Electron.WebContents | null = null;  let detachRendererListeners: (() => void) | null = null;   const broker = new DesktopAppActivationBroker({    requestTimeoutMs: REQUEST_TIMEOUT_MS,    activate: () => {      void runPromise(        desktopWindow.activate.pipe(          Effect.catchCause((cause) => logWarning("failed to focus the desktop window", { cause })),        ),      );    },  });   const clearRegisteredRenderer = () => {    detachRendererListeners?.();    detachRendererListeners = null;    registeredWebContents = null;    broker.clearRenderer();  };   return DesktopAppActivation.of({    start: Effect.acquireRelease(      Effect.tryPromise({        try: () =>          startDesktopAppControlServer({            ...address,            userId,            handle: (request) => broker.request(request),            cancel: (requestId) => broker.cancel(requestId),          }),        catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }),      }),      (server) =>        Effect.promise(() => server.close()).pipe(          Effect.catchCause((cause) =>            logWarning("failed to close the desktop app control socket", { cause }),          ),          Effect.ensuring(Effect.sync(() => broker.close())),        ),    ).pipe(Effect.asVoid),    setRendererReady: Effect.fn("DesktopAppActivation.setRendererReady")(function* (ready) {      if (!ready) {        clearRegisteredRenderer();        return;      }      const main = yield* electronWindow.main;      if (Option.isNone(main)) return;      const webContents = main.value.webContents;      if (webContents.isDestroyed()) return;       if (registeredWebContents !== webContents) {        clearRegisteredRenderer();        registeredWebContents = webContents;        const onUnavailable = () => clearRegisteredRenderer();        const onNavigation = (          event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,        ) => {          if (event.isMainFrame && !event.isSameDocument) clearRegisteredRenderer();        };        webContents.on("did-start-navigation", onNavigation);        webContents.once("destroyed", onUnavailable);        detachRendererListeners = () => {          webContents.removeListener("did-start-navigation", onNavigation);          webContents.removeListener("destroyed", onUnavailable);        };      }       broker.registerRenderer((request) => {        webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request);      });    }),    complete: (response) => Effect.sync(() => broker.complete(response)),  });}); export const layer = Layer.effect(DesktopAppActivation, make);