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