1import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts";
2import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contracts";
3import type { SshPasswordRequest } from "@t3tools/ssh/auth";
4import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";
6import * as DateTime from "effect/DateTime";
7import * as Deferred from "effect/Deferred";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Ref from "effect/Ref";
13import * as Schema from "effect/Schema";
14
15import * as ElectronWindow from "../electron/ElectronWindow.ts";
16import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts";
17
18const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;
19
20type DesktopSshPasswordPromptResolutionInput =
21 typeof DesktopSshPasswordPromptResolutionInputSchema.Type;
22
23const DesktopSshPromptWindowAvailabilityStage = Schema.Literals([
24 "before-request",
25 "before-presentation",
26 "after-send",
27 "after-restore",
28]);
29
30const DesktopSshPromptPresentationOperation = Schema.Literals([
31 "check-window-before-request",
32 "check-window-before-presentation",
33 "register-window-close-listener",
34 "send-prompt-request",
35 "check-window-after-send",
36 "check-window-minimized",
37 "restore-window",
38 "check-window-after-restore",
39 "focus-window",
40 "remove-window-close-listener",
41]);
42type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type;
43
44export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedError<DesktopSshPromptRequestIdGenerationError>()(
45 "DesktopSshPromptRequestIdGenerationError",
46 {
47 destination: Schema.String,
48 cause: Schema.Defect(),
49 },
50) {
51 override get message(): string {
52 return "Secure randomness is unavailable.";
53 }
54}
55
56export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedError<DesktopSshPromptWindowUnavailableError>()(
57 "DesktopSshPromptWindowUnavailableError",
58 {
59 destination: Schema.String,
60 requestId: Schema.NullOr(Schema.String),
61 stage: DesktopSshPromptWindowAvailabilityStage,
62 },
63) {
64 override get message(): string {
65 const request = this.requestId === null ? "before a request id was assigned" : this.requestId;
66 return `The Knife window is unavailable during ${this.stage} for SSH authentication to ${this.destination} (request: ${request}).`;
67 }
68}
69
70export class DesktopSshPromptPresentationError extends Schema.TaggedError<DesktopSshPromptPresentationError>()(
71 "DesktopSshPromptPresentationError",
72 {
73 requestId: Schema.NullOr(Schema.String),
74 destination: Schema.String,
75 operation: DesktopSshPromptPresentationOperation,
76 cause: Schema.Defect(),
77 },
78) {
79 override get message(): string {
80 return `Failed to present SSH password prompt for ${this.destination}.`;
81 }
82}
83
84export class DesktopSshPromptTimedOutError extends Schema.TaggedError<DesktopSshPromptTimedOutError>()(
85 "DesktopSshPromptTimedOutError",
86 {
87 requestId: Schema.String,
88 destination: Schema.String,
89 },
90) {
91 override get message(): string {
92 return `SSH authentication timed out for ${this.destination}.`;
93 }
94}
95
96export class DesktopSshPromptCancelledError extends Schema.TaggedError<DesktopSshPromptCancelledError>()(
97 "DesktopSshPromptCancelledError",
98 {
99 requestId: Schema.String,
100 destination: Schema.String,
101 },
102) {
103 override get message(): string {
104 return `SSH authentication cancelled for ${this.destination}.`;
105 }
106}
107
108export class DesktopSshPromptWindowClosedError extends Schema.TaggedError<DesktopSshPromptWindowClosedError>()(
109 "DesktopSshPromptWindowClosedError",
110 {
111 requestId: Schema.String,
112 destination: Schema.String,
113 },
114) {
115 override get message(): string {
116 return "SSH authentication was cancelled because the app window closed.";
117 }
118}
119
120export class DesktopSshPromptServiceStoppedError extends Schema.TaggedError<DesktopSshPromptServiceStoppedError>()(
121 "DesktopSshPromptServiceStoppedError",
122 {
123 requestId: Schema.String,
124 destination: Schema.String,
125 },
126) {
127 override get message(): string {
128 return "SSH password prompt service stopped.";
129 }
130}
131
132export class DesktopSshPromptInvalidRequestIdError extends Schema.TaggedError<DesktopSshPromptInvalidRequestIdError>()(
133 "DesktopSshPromptInvalidRequestIdError",
134 {
135 requestId: Schema.String,
136 },
137) {
138 override get message(): string {
139 return "Invalid SSH password prompt id.";
140 }
141}
142
143export class DesktopSshPromptExpiredError extends Schema.TaggedError<DesktopSshPromptExpiredError>()(
144 "DesktopSshPromptExpiredError",
145 {
146 requestId: Schema.String,
147 },
148) {
149 override get message(): string {
150 return "SSH password prompt expired. Try connecting again.";
151 }
152}
153
154export type DesktopSshPasswordPromptRequestError =
155 | DesktopSshPromptRequestIdGenerationError
156 | DesktopSshPromptWindowUnavailableError
157 | DesktopSshPromptPresentationError
158 | DesktopSshPromptTimedOutError
159 | DesktopSshPromptCancelledError
160 | DesktopSshPromptWindowClosedError
161 | DesktopSshPromptServiceStoppedError;
162
163export type DesktopSshPasswordPromptResolveError =
164 | DesktopSshPromptInvalidRequestIdError
165 | DesktopSshPromptExpiredError;
166
167export const DesktopSshPasswordPromptCancellation = Schema.Union([
168 DesktopSshPromptCancelledError,
169 DesktopSshPromptWindowClosedError,
170 DesktopSshPromptServiceStoppedError,
171 DesktopSshPromptTimedOutError,
172]);
173export type DesktopSshPasswordPromptCancellation = typeof DesktopSshPasswordPromptCancellation.Type;
174
175export const isDesktopSshPasswordPromptCancellation = Schema.is(
176 DesktopSshPasswordPromptCancellation,
177);
178
179export class DesktopSshPasswordPrompts extends Context.Service<
180 DesktopSshPasswordPrompts,
181 {
182 readonly request: (
183 request: SshPasswordRequest,
184 ) => Effect.Effect<string, DesktopSshPasswordPromptRequestError>;
185 readonly resolve: (
186 input: DesktopSshPasswordPromptResolutionInput,
187 ) => Effect.Effect<void, DesktopSshPasswordPromptResolveError>;
188 }
189>()("@t3tools/desktop/ssh/DesktopSshPasswordPrompts") {}
190
191interface PendingSshPasswordPrompt {
192 readonly requestId: string;
193 readonly destination: string;
194 readonly deferred: Deferred.Deferred<string, DesktopSshPasswordPromptRequestError>;
195}
196
197export interface DesktopSshPasswordPromptsOptions {
198 readonly passwordPromptTimeoutMs?: number;
199}
200
201const removePending = (
202 pendingRef: Ref.Ref<Map<string, PendingSshPasswordPrompt>>,
203 requestId: string,
204) =>
205 Ref.modify(pendingRef, (pending) => {
206 const entry = pending.get(requestId);
207 if (entry === undefined) {
208 return [Option.none<PendingSshPasswordPrompt>(), pending] as const;
209 }
210
211 const nextPending = new Map(pending);
212 nextPending.delete(requestId);
213 return [Option.some(entry), nextPending] as const;
214 });
215
216const failPending = (
217 pending: PendingSshPasswordPrompt,
218 error: DesktopSshPasswordPromptRequestError,
219) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid);
220
221/** @public Service construction is part of the canonical Effect module API. */
222export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
223 options: DesktopSshPasswordPromptsOptions = {},
224) {
225 const electronWindow = yield* ElectronWindow.ElectronWindow;
226 const crypto = yield* Crypto.Crypto;
227 const pendingRef = yield* Ref.make(new Map<string, PendingSshPasswordPrompt>());
228 const passwordPromptTimeoutMs =
229 options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS;
230
231 const cancelPending = () =>
232 Ref.getAndSet(pendingRef, new Map()).pipe(
233 Effect.flatMap((pending) =>
234 Effect.forEach(
235 pending.values(),
236 (entry) =>
237 failPending(
238 entry,
239 new DesktopSshPromptServiceStoppedError({
240 requestId: entry.requestId,
241 destination: entry.destination,
242 }),
243 ),
244 { discard: true },
245 ),
246 ),
247 Effect.asVoid,
248 );
249
250 yield* Effect.addFinalizer(() => cancelPending().pipe(Effect.ignore));
251
252 const resolve: DesktopSshPasswordPrompts["Service"]["resolve"] = Effect.fn(
253 "desktop.sshPasswordPrompts.resolve",
254 )(function* (input) {
255 const requestId = input.requestId.trim();
256 if (requestId.length === 0) {
257 return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId });
258 }
259
260 const pending = yield* removePending(pendingRef, requestId);
261 if (Option.isNone(pending)) {
262 return yield* new DesktopSshPromptExpiredError({ requestId });
263 }
264
265 const entry = pending.value;
266 if (input.password === null) {
267 yield* failPending(
268 entry,
269 new DesktopSshPromptCancelledError({
270 requestId,
271 destination: entry.destination,
272 }),
273 );
274 return;
275 }
276
277 yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid);
278 });
279
280 const request: DesktopSshPasswordPrompts["Service"]["request"] = Effect.fn(
281 "desktop.sshPasswordPrompts.request",
282 )(function* (input) {
283 const window = yield* electronWindow.main;
284 if (Option.isNone(window)) {
285 return yield* new DesktopSshPromptWindowUnavailableError({
286 destination: input.destination,
287 requestId: null,
288 stage: "before-request",
289 });
290 }
291
292 const unavailableBeforeRequest = yield* Effect.try({
293 try: () => window.value.isDestroyed(),
294 catch: (cause) =>
295 new DesktopSshPromptPresentationError({
296 requestId: null,
297 destination: input.destination,
298 operation: "check-window-before-request",
299 cause,
300 }),
301 });
302 if (unavailableBeforeRequest) {
303 return yield* new DesktopSshPromptWindowUnavailableError({
304 destination: input.destination,
305 requestId: null,
306 stage: "before-request",
307 });
308 }
309
310 const requestId = yield* crypto.randomUUIDv4().pipe(
311 Effect.mapError(
312 (cause) =>
313 new DesktopSshPromptRequestIdGenerationError({
314 destination: input.destination,
315 cause,
316 }),
317 ),
318 );
319 const now = yield* DateTime.now;
320 const expiresAt = DateTime.formatIso(
321 DateTime.add(now, { milliseconds: passwordPromptTimeoutMs }),
322 );
323 const promptRequest: DesktopSshPasswordPromptRequest = {
324 requestId,
325 destination: input.destination,
326 username: input.username,
327 prompt: input.prompt,
328 expiresAt,
329 };
330 const deferred = yield* Deferred.make<string, DesktopSshPasswordPromptRequestError>();
331 const pending: PendingSshPasswordPrompt = {
332 requestId,
333 destination: input.destination,
334 deferred,
335 };
336 yield* Ref.update(pendingRef, (entries) => new Map(entries).set(requestId, pending));
337
338 const context = yield* Effect.context();
339 const runFork = Effect.runForkWith(context);
340
341 const cancelOnWindowClosed = () => {
342 runFork(
343 removePending(pendingRef, requestId).pipe(
344 Effect.flatMap((entry) =>
345 Option.match(entry, {
346 onNone: () => Effect.void,
347 onSome: (pending) =>
348 failPending(
349 pending,
350 new DesktopSshPromptWindowClosedError({
351 requestId,
352 destination: input.destination,
353 }),
354 ),
355 }),
356 ),
357 ),
358 );
359 };
360 const runPresentationOperation = <A>(
361 operation: DesktopSshPromptPresentationOperation,
362 evaluate: () => A,
363 ) =>
364 Effect.try({
365 try: evaluate,
366 catch: (cause) =>
367 new DesktopSshPromptPresentationError({
368 requestId,
369 destination: input.destination,
370 operation,
371 cause,
372 }),
373 });
374 const cleanup = runPresentationOperation("remove-window-close-listener", () => {
375 if (!window.value.isDestroyed()) {
376 window.value.removeListener("closed", cancelOnWindowClosed);
377 }
378 }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid);
379 const waitForPassword = Deferred.await(deferred).pipe(
380 Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)),
381 Effect.flatMap(
382 Option.match({
383 onNone: () =>
384 Effect.fail(
385 new DesktopSshPromptTimedOutError({
386 requestId,
387 destination: input.destination,
388 }),
389 ),
390 onSome: Effect.succeed,
391 }),
392 ),
393 );
394 const preferSubmittedPassword = (error: DesktopSshPasswordPromptRequestError) =>
395 Deferred.poll(deferred).pipe(
396 Effect.flatMap(
397 Option.match({
398 onSome: (completion) => completion,
399 onNone: () =>
400 Ref.get(pendingRef).pipe(
401 Effect.flatMap((entries) =>
402 entries.has(requestId) ? Effect.fail(error) : Deferred.await(deferred),
403 ),
404 ),
405 }),
406 ),
407 );
408
409 return yield* Effect.gen(function* () {
410 const unavailableBeforePresentation = yield* runPresentationOperation(
411 "check-window-before-presentation",
412 () => window.value.isDestroyed(),
413 );
414 if (unavailableBeforePresentation) {
415 return yield* new DesktopSshPromptWindowUnavailableError({
416 destination: input.destination,
417 requestId,
418 stage: "before-presentation",
419 });
420 }
421 yield* runPresentationOperation("register-window-close-listener", () =>
422 window.value.once("closed", cancelOnWindowClosed),
423 );
424 return yield* Effect.gen(function* () {
425 yield* runPresentationOperation("send-prompt-request", () =>
426 window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest),
427 );
428 yield* Effect.yieldNow;
429 const unavailableAfterSend = yield* runPresentationOperation(
430 "check-window-after-send",
431 () => window.value.isDestroyed(),
432 );
433 if (unavailableAfterSend) {
434 return yield* new DesktopSshPromptWindowUnavailableError({
435 destination: input.destination,
436 requestId,
437 stage: "after-send",
438 });
439 }
440 const minimized = yield* runPresentationOperation("check-window-minimized", () =>
441 window.value.isMinimized(),
442 );
443 if (minimized) {
444 yield* runPresentationOperation("restore-window", () => window.value.restore());
445 }
446 const unavailableAfterRestore = yield* runPresentationOperation(
447 "check-window-after-restore",
448 () => window.value.isDestroyed(),
449 );
450 if (unavailableAfterRestore) {
451 return yield* new DesktopSshPromptWindowUnavailableError({
452 destination: input.destination,
453 requestId,
454 stage: "after-restore",
455 });
456 }
457 yield* runPresentationOperation("focus-window", () => window.value.focus());
458 return yield* waitForPassword;
459 }).pipe(Effect.catch(preferSubmittedPassword));
460 }).pipe(Effect.ensuring(cleanup));
461 });
462
463 return DesktopSshPasswordPrompts.of({
464 request,
465 resolve,
466 });
467});
468
469export const layer = (options: DesktopSshPasswordPromptsOptions = {}) =>
470 Layer.effect(DesktopSshPasswordPrompts, make(options));