337 lines · 10.5 KB · TypeScript
RawDownload
import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts";import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contracts";import type { SshPasswordRequest } from "@t3tools/ssh/auth";import * as Context from "effect/Context";import * as Crypto from "effect/Crypto";import * as Data from "effect/Data";import * as DateTime from "effect/DateTime";import * as Deferred from "effect/Deferred";import * as Duration from "effect/Duration";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Ref from "effect/Ref"; import * as IpcChannels from "../ipc/channels.ts";import * as ElectronWindow from "../electron/ElectronWindow.ts"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; type DesktopSshPasswordPromptResolutionInput =  typeof DesktopSshPasswordPromptResolutionInputSchema.Type; export class DesktopSshPromptUnavailableError extends Data.TaggedError(  "DesktopSshPromptUnavailableError",)<{  readonly reason: string;}> {  override get message() {    return this.reason;  }} export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError(  "DesktopSshPromptWindowUnavailableError",)<{  readonly destination: string;}> {  override get message() {    return WINDOW_UNAVAILABLE_MESSAGE;  }} export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{  readonly requestId: string;  readonly destination: string;  readonly cause: unknown;}> {  override get message() {    return WINDOW_UNAVAILABLE_MESSAGE;  }} export class DesktopSshPromptTimedOutError extends Data.TaggedError(  "DesktopSshPromptTimedOutError",)<{  readonly requestId: string;  readonly destination: string;}> {  override get message() {    return `SSH authentication timed out for ${this.destination}.`;  }} export class DesktopSshPromptCancelledError extends Data.TaggedError(  "DesktopSshPromptCancelledError",)<{  readonly requestId: string;  readonly destination: string;  readonly reason: string;}> {  override get message() {    return this.reason;  }} export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError(  "DesktopSshPromptInvalidRequestIdError",)<{  readonly requestId: string;}> {  override get message() {    return "Invalid SSH password prompt id.";  }} export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{  readonly requestId: string;}> {  override get message() {    return "SSH password prompt expired. Try connecting again.";  }} export type DesktopSshPasswordPromptRequestError =  | DesktopSshPromptUnavailableError  | DesktopSshPromptWindowUnavailableError  | DesktopSshPromptSendError  | DesktopSshPromptTimedOutError  | DesktopSshPromptCancelledError; export type DesktopSshPasswordPromptResolveError =  | DesktopSshPromptInvalidRequestIdError  | DesktopSshPromptExpiredError; export type DesktopSshPasswordPromptError =  | DesktopSshPasswordPromptRequestError  | DesktopSshPasswordPromptResolveError; export function isDesktopSshPasswordPromptCancellation(  error: unknown,): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError {  return (    error instanceof DesktopSshPromptCancelledError ||    error instanceof DesktopSshPromptTimedOutError  );} export interface DesktopSshPasswordPromptsShape {  readonly request: (    request: SshPasswordRequest,  ) => Effect.Effect<string, DesktopSshPasswordPromptRequestError>;  readonly resolve: (    input: DesktopSshPasswordPromptResolutionInput,  ) => Effect.Effect<void, DesktopSshPasswordPromptResolveError>;  readonly cancelPending: (reason: string) => Effect.Effect<void>;} export class DesktopSshPasswordPrompts extends Context.Service<  DesktopSshPasswordPrompts,  DesktopSshPasswordPromptsShape>()("@t3tools/desktop/ssh/DesktopSshPasswordPrompts") {} interface PendingSshPasswordPrompt {  readonly requestId: string;  readonly destination: string;  readonly deferred: Deferred.Deferred<string, DesktopSshPasswordPromptRequestError>;} interface LayerOptions {  readonly passwordPromptTimeoutMs?: number;} const removePending = (  pendingRef: Ref.Ref<Map<string, PendingSshPasswordPrompt>>,  requestId: string,) =>  Ref.modify(pendingRef, (pending) => {    const entry = pending.get(requestId);    if (entry === undefined) {      return [Option.none<PendingSshPasswordPrompt>(), pending] as const;    }     const nextPending = new Map(pending);    nextPending.delete(requestId);    return [Option.some(entry), nextPending] as const;  }); const failPending = (  pending: PendingSshPasswordPrompt,  error: DesktopSshPasswordPromptRequestError,) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) {  const electronWindow = yield* ElectronWindow.ElectronWindow;  const crypto = yield* Crypto.Crypto;  const pendingRef = yield* Ref.make(new Map<string, PendingSshPasswordPrompt>());  const passwordPromptTimeoutMs =    options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS;   const cancelPending = (reason: string): Effect.Effect<void> =>    Ref.getAndSet(pendingRef, new Map()).pipe(      Effect.flatMap((pending) =>        Effect.forEach(          pending.values(),          (entry) =>            failPending(              entry,              new DesktopSshPromptCancelledError({                requestId: entry.requestId,                destination: entry.destination,                reason,              }),            ),          { discard: true },        ),      ),      Effect.asVoid,    );   yield* Effect.addFinalizer(() =>    cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore),  );   const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* (    input: DesktopSshPasswordPromptResolutionInput,  ): Effect.fn.Return<void, DesktopSshPasswordPromptResolveError> {    const requestId = input.requestId.trim();    if (requestId.length === 0) {      return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId });    }     const pending = yield* removePending(pendingRef, requestId);    if (Option.isNone(pending)) {      return yield* new DesktopSshPromptExpiredError({ requestId });    }     const entry = pending.value;    if (input.password === null) {      yield* failPending(        entry,        new DesktopSshPromptCancelledError({          requestId,          destination: entry.destination,          reason: `SSH authentication cancelled for ${entry.destination}.`,        }),      );      return;    }     yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid);  });   const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* (    input: SshPasswordRequest,  ): Effect.fn.Return<string, DesktopSshPasswordPromptRequestError> {    const window = yield* electronWindow.main;    if (Option.isNone(window) || window.value.isDestroyed()) {      return yield* new DesktopSshPromptWindowUnavailableError({        destination: input.destination,      });    }     const requestId = yield* crypto.randomUUIDv4.pipe(      Effect.mapError(        () => new DesktopSshPromptUnavailableError({ reason: "Secure randomness is unavailable." }),      ),    );    const now = yield* DateTime.now;    const expiresAt = DateTime.formatIso(      DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }),    );    const promptRequest: DesktopSshPasswordPromptRequest = {      requestId,      destination: input.destination,      username: input.username,      prompt: input.prompt,      expiresAt,    };    const deferred = yield* Deferred.make<string, DesktopSshPasswordPromptRequestError>();    const pending: PendingSshPasswordPrompt = {      requestId,      destination: input.destination,      deferred,    };    yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending));     const context = yield* Effect.context();    const runFork = Effect.runForkWith(context);     const cancelOnWindowClosed = () => {      runFork(        removePending(pendingRef, requestId).pipe(          Effect.flatMap((entry) =>            Option.match(entry, {              onNone: () => Effect.void,              onSome: (pending) =>                failPending(                  pending,                  new DesktopSshPromptCancelledError({                    requestId,                    destination: input.destination,                    reason: "SSH authentication was cancelled because the app window closed.",                  }),                ),            }),          ),        ),      );    };    const cleanup = Effect.sync(() => {      if (!window.value.isDestroyed()) {        window.value.removeListener("closed", cancelOnWindowClosed);      }    }).pipe(Effect.andThen(removePending(pendingRef, requestId)), Effect.asVoid);    const waitForPassword = Deferred.await(deferred).pipe(      Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)),      Effect.flatMap(        Option.match({          onNone: () =>            Effect.fail(              new DesktopSshPromptTimedOutError({                requestId,                destination: input.destination,              }),            ),          onSome: Effect.succeed,        }),      ),    );     return yield* Effect.try({      try: () => {        if (window.value.isDestroyed()) {          throw new Error(WINDOW_UNAVAILABLE_MESSAGE);        }        window.value.once("closed", cancelOnWindowClosed);        window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest);        if (window.value.isDestroyed()) {          throw new Error(WINDOW_UNAVAILABLE_MESSAGE);        }        if (window.value.isMinimized()) {          window.value.restore();        }        if (window.value.isDestroyed()) {          throw new Error(WINDOW_UNAVAILABLE_MESSAGE);        }        window.value.focus();      },      catch: (cause) =>        new DesktopSshPromptSendError({          requestId,          destination: input.destination,          cause,        }),    }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup));  });   return DesktopSshPasswordPrompts.of({    request,    resolve,    cancelPending,  });}); export const layer = (options: LayerOptions = {}) =>  Layer.effect(DesktopSshPasswordPrompts, make(options));