| ... | ... | @@ -3,7 +3,6 @@ import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contract |
| 3 | 3 | import type { SshPasswordRequest } from "@t3tools/ssh/auth"; |
| 4 | 4 | import * as Context from "effect/Context"; |
| 5 | 5 | import * as Crypto from "effect/Crypto"; |
| 6 | | import * as Data from "effect/Data"; |
| 7 | 6 | import * as DateTime from "effect/DateTime"; |
| 8 | 7 | import * as Deferred from "effect/Deferred"; |
| 9 | 8 | import * as Duration from "effect/Duration"; |
| ... | ... | @@ -11,93 +10,134 @@ import * as Effect from "effect/Effect"; |
| 11 | 10 | import * as Layer from "effect/Layer"; |
| 12 | 11 | import * as Option from "effect/Option"; |
| 13 | 12 | import * as Ref from "effect/Ref"; |
| 13 | import * as Schema from "effect/Schema"; |
| 14 | 14 | |
| 15 | | import * as IpcChannels from "../ipc/channels.ts"; |
| 16 | 15 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 16 | import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; |
| 17 | 17 | |
| 18 | 18 | const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; |
| 19 | | const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; |
| 20 | 19 | |
| 21 | 20 | type DesktopSshPasswordPromptResolutionInput = |
| 22 | 21 | typeof DesktopSshPasswordPromptResolutionInputSchema.Type; |
| 23 | 22 | |
| 24 | | export class DesktopSshPromptUnavailableError extends Data.TaggedError( |
| 25 | | "DesktopSshPromptUnavailableError", |
| 26 | | )<{ |
| 27 | | readonly reason: string; |
| 28 | | }> { |
| 29 | | override get message() { |
| 30 | | return this.reason; |
| 23 | const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication."; |
| 24 | |
| 25 | export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass<DesktopSshPromptRequestIdGenerationError>()( |
| 26 | "DesktopSshPromptRequestIdGenerationError", |
| 27 | { |
| 28 | destination: Schema.String, |
| 29 | cause: Schema.Defect(), |
| 30 | }, |
| 31 | ) { |
| 32 | override get message(): string { |
| 33 | return "Secure randomness is unavailable."; |
| 31 | 34 | } |
| 32 | 35 | } |
| 33 | 36 | |
| 34 | | export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError( |
| 37 | export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorClass<DesktopSshPromptWindowUnavailableError>()( |
| 35 | 38 | "DesktopSshPromptWindowUnavailableError", |
| 36 | | )<{ |
| 37 | | readonly destination: string; |
| 38 | | }> { |
| 39 | | override get message() { |
| 39 | { |
| 40 | destination: Schema.String, |
| 41 | }, |
| 42 | ) { |
| 43 | override get message(): string { |
| 40 | 44 | return WINDOW_UNAVAILABLE_MESSAGE; |
| 41 | 45 | } |
| 42 | 46 | } |
| 43 | 47 | |
| 44 | | export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{ |
| 45 | | readonly requestId: string; |
| 46 | | readonly destination: string; |
| 47 | | readonly cause: unknown; |
| 48 | | }> { |
| 49 | | override get message() { |
| 50 | | return WINDOW_UNAVAILABLE_MESSAGE; |
| 48 | const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError); |
| 49 | |
| 50 | export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass<DesktopSshPromptPresentationError>()( |
| 51 | "DesktopSshPromptPresentationError", |
| 52 | { |
| 53 | requestId: Schema.String, |
| 54 | destination: Schema.String, |
| 55 | cause: Schema.Defect(), |
| 56 | }, |
| 57 | ) { |
| 58 | override get message(): string { |
| 59 | return `Failed to present SSH password prompt for ${this.destination}.`; |
| 51 | 60 | } |
| 52 | 61 | } |
| 53 | 62 | |
| 54 | | export class DesktopSshPromptTimedOutError extends Data.TaggedError( |
| 63 | export class DesktopSshPromptTimedOutError extends Schema.TaggedErrorClass<DesktopSshPromptTimedOutError>()( |
| 55 | 64 | "DesktopSshPromptTimedOutError", |
| 56 | | )<{ |
| 57 | | readonly requestId: string; |
| 58 | | readonly destination: string; |
| 59 | | }> { |
| 60 | | override get message() { |
| 65 | { |
| 66 | requestId: Schema.String, |
| 67 | destination: Schema.String, |
| 68 | }, |
| 69 | ) { |
| 70 | override get message(): string { |
| 61 | 71 | return `SSH authentication timed out for ${this.destination}.`; |
| 62 | 72 | } |
| 63 | 73 | } |
| 64 | 74 | |
| 65 | | export class DesktopSshPromptCancelledError extends Data.TaggedError( |
| 75 | export class DesktopSshPromptCancelledError extends Schema.TaggedErrorClass<DesktopSshPromptCancelledError>()( |
| 66 | 76 | "DesktopSshPromptCancelledError", |
| 67 | | )<{ |
| 68 | | readonly requestId: string; |
| 69 | | readonly destination: string; |
| 70 | | readonly reason: string; |
| 71 | | }> { |
| 72 | | override get message() { |
| 73 | | return this.reason; |
| 77 | { |
| 78 | requestId: Schema.String, |
| 79 | destination: Schema.String, |
| 80 | }, |
| 81 | ) { |
| 82 | override get message(): string { |
| 83 | return `SSH authentication cancelled for ${this.destination}.`; |
| 74 | 84 | } |
| 75 | 85 | } |
| 76 | 86 | |
| 77 | | export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError( |
| 87 | export class DesktopSshPromptWindowClosedError extends Schema.TaggedErrorClass<DesktopSshPromptWindowClosedError>()( |
| 88 | "DesktopSshPromptWindowClosedError", |
| 89 | { |
| 90 | requestId: Schema.String, |
| 91 | destination: Schema.String, |
| 92 | }, |
| 93 | ) { |
| 94 | override get message(): string { |
| 95 | return "SSH authentication was cancelled because the app window closed."; |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | export class DesktopSshPromptServiceStoppedError extends Schema.TaggedErrorClass<DesktopSshPromptServiceStoppedError>()( |
| 100 | "DesktopSshPromptServiceStoppedError", |
| 101 | { |
| 102 | requestId: Schema.String, |
| 103 | destination: Schema.String, |
| 104 | }, |
| 105 | ) { |
| 106 | override get message(): string { |
| 107 | return "SSH password prompt service stopped."; |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | export class DesktopSshPromptInvalidRequestIdError extends Schema.TaggedErrorClass<DesktopSshPromptInvalidRequestIdError>()( |
| 78 | 112 | "DesktopSshPromptInvalidRequestIdError", |
| 79 | | )<{ |
| 80 | | readonly requestId: string; |
| 81 | | }> { |
| 82 | | override get message() { |
| 113 | { |
| 114 | requestId: Schema.String, |
| 115 | }, |
| 116 | ) { |
| 117 | override get message(): string { |
| 83 | 118 | return "Invalid SSH password prompt id."; |
| 84 | 119 | } |
| 85 | 120 | } |
| 86 | 121 | |
| 87 | | export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{ |
| 88 | | readonly requestId: string; |
| 89 | | }> { |
| 90 | | override get message() { |
| 122 | export class DesktopSshPromptExpiredError extends Schema.TaggedErrorClass<DesktopSshPromptExpiredError>()( |
| 123 | "DesktopSshPromptExpiredError", |
| 124 | { |
| 125 | requestId: Schema.String, |
| 126 | }, |
| 127 | ) { |
| 128 | override get message(): string { |
| 91 | 129 | return "SSH password prompt expired. Try connecting again."; |
| 92 | 130 | } |
| 93 | 131 | } |
| 94 | 132 | |
| 95 | 133 | export type DesktopSshPasswordPromptRequestError = |
| 96 | | | DesktopSshPromptUnavailableError |
| 134 | | DesktopSshPromptRequestIdGenerationError |
| 97 | 135 | | DesktopSshPromptWindowUnavailableError |
| 98 | | | DesktopSshPromptSendError |
| 136 | | DesktopSshPromptPresentationError |
| 99 | 137 | | DesktopSshPromptTimedOutError |
| 100 | | | DesktopSshPromptCancelledError; |
| 138 | | DesktopSshPromptCancelledError |
| 139 | | DesktopSshPromptWindowClosedError |
| 140 | | DesktopSshPromptServiceStoppedError; |
| 101 | 141 | |
| 102 | 142 | export type DesktopSshPasswordPromptResolveError = |
| 103 | 143 | | DesktopSshPromptInvalidRequestIdError |
| ... | ... | @@ -107,28 +147,28 @@ export type DesktopSshPasswordPromptError = |
| 107 | 147 | | DesktopSshPasswordPromptRequestError |
| 108 | 148 | | DesktopSshPasswordPromptResolveError; |
| 109 | 149 | |
| 110 | | export function isDesktopSshPasswordPromptCancellation( |
| 111 | | error: unknown, |
| 112 | | ): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError { |
| 113 | | return ( |
| 114 | | error instanceof DesktopSshPromptCancelledError || |
| 115 | | error instanceof DesktopSshPromptTimedOutError |
| 116 | | ); |
| 117 | | } |
| 150 | export const DesktopSshPasswordPromptCancellation = Schema.Union([ |
| 151 | DesktopSshPromptCancelledError, |
| 152 | DesktopSshPromptWindowClosedError, |
| 153 | DesktopSshPromptServiceStoppedError, |
| 154 | DesktopSshPromptTimedOutError, |
| 155 | ]); |
| 156 | export type DesktopSshPasswordPromptCancellation = typeof DesktopSshPasswordPromptCancellation.Type; |
| 118 | 157 | |
| 119 | | export interface DesktopSshPasswordPromptsShape { |
| 120 | | readonly request: ( |
| 121 | | request: SshPasswordRequest, |
| 122 | | ) => Effect.Effect<string, DesktopSshPasswordPromptRequestError>; |
| 123 | | readonly resolve: ( |
| 124 | | input: DesktopSshPasswordPromptResolutionInput, |
| 125 | | ) => Effect.Effect<void, DesktopSshPasswordPromptResolveError>; |
| 126 | | readonly cancelPending: (reason: string) => Effect.Effect<void>; |
| 127 | | } |
| 158 | export const isDesktopSshPasswordPromptCancellation = Schema.is( |
| 159 | DesktopSshPasswordPromptCancellation, |
| 160 | ); |
| 128 | 161 | |
| 129 | 162 | export class DesktopSshPasswordPrompts extends Context.Service< |
| 130 | 163 | DesktopSshPasswordPrompts, |
| 131 | | DesktopSshPasswordPromptsShape |
| 164 | { |
| 165 | readonly request: ( |
| 166 | request: SshPasswordRequest, |
| 167 | ) => Effect.Effect<string, DesktopSshPasswordPromptRequestError>; |
| 168 | readonly resolve: ( |
| 169 | input: DesktopSshPasswordPromptResolutionInput, |
| 170 | ) => Effect.Effect<void, DesktopSshPasswordPromptResolveError>; |
| 171 | } |
| 132 | 172 | >()("@t3tools/desktop/ssh/DesktopSshPasswordPrompts") {} |
| 133 | 173 | |
| 134 | 174 | interface PendingSshPasswordPrompt { |
| ... | ... | @@ -137,7 +177,7 @@ interface PendingSshPasswordPrompt { |
| 137 | 177 | readonly deferred: Deferred.Deferred<string, DesktopSshPasswordPromptRequestError>; |
| 138 | 178 | } |
| 139 | 179 | |
| 140 | | interface LayerOptions { |
| 180 | export interface DesktopSshPasswordPromptsOptions { |
| 141 | 181 | readonly passwordPromptTimeoutMs?: number; |
| 142 | 182 | } |
| 143 | 183 | |
| ... | ... | @@ -161,14 +201,16 @@ const failPending = ( |
| 161 | 201 | error: DesktopSshPasswordPromptRequestError, |
| 162 | 202 | ) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid); |
| 163 | 203 | |
| 164 | | const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) { |
| 204 | export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( |
| 205 | options: DesktopSshPasswordPromptsOptions = {}, |
| 206 | ) { |
| 165 | 207 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 166 | 208 | const crypto = yield* Crypto.Crypto; |
| 167 | 209 | const pendingRef = yield* Ref.make(new Map<string, PendingSshPasswordPrompt>()); |
| 168 | 210 | const passwordPromptTimeoutMs = |
| 169 | 211 | options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; |
| 170 | 212 | |
| 171 | | const cancelPending = (reason: string): Effect.Effect<void> => |
| 213 | const cancelPending = () => |
| 172 | 214 | Ref.getAndSet(pendingRef, new Map()).pipe( |
| 173 | 215 | Effect.flatMap((pending) => |
| 174 | 216 | Effect.forEach( |
| ... | ... | @@ -176,10 +218,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La |
| 176 | 218 | (entry) => |
| 177 | 219 | failPending( |
| 178 | 220 | entry, |
| 179 | | new DesktopSshPromptCancelledError({ |
| 221 | new DesktopSshPromptServiceStoppedError({ |
| 180 | 222 | requestId: entry.requestId, |
| 181 | 223 | destination: entry.destination, |
| 182 | | reason, |
| 183 | 224 | }), |
| 184 | 225 | ), |
| 185 | 226 | { discard: true }, |
| ... | ... | @@ -188,13 +229,11 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La |
| 188 | 229 | Effect.asVoid, |
| 189 | 230 | ); |
| 190 | 231 | |
| 191 | | yield* Effect.addFinalizer(() => |
| 192 | | cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore), |
| 193 | | ); |
| 232 | yield* Effect.addFinalizer(() => cancelPending().pipe(Effect.ignore)); |
| 194 | 233 | |
| 195 | | const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* ( |
| 196 | | input: DesktopSshPasswordPromptResolutionInput, |
| 197 | | ): Effect.fn.Return<void, DesktopSshPasswordPromptResolveError> { |
| 234 | const resolve: DesktopSshPasswordPrompts["Service"]["resolve"] = Effect.fn( |
| 235 | "desktop.sshPasswordPrompts.resolve", |
| 236 | )(function* (input) { |
| 198 | 237 | const requestId = input.requestId.trim(); |
| 199 | 238 | if (requestId.length === 0) { |
| 200 | 239 | return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId }); |
| ... | ... | @@ -212,7 +251,6 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La |
| 212 | 251 | new DesktopSshPromptCancelledError({ |
| 213 | 252 | requestId, |
| 214 | 253 | destination: entry.destination, |
| 215 | | reason: `SSH authentication cancelled for ${entry.destination}.`, |
| 216 | 254 | }), |
| 217 | 255 | ); |
| 218 | 256 | return; |
| ... | ... | @@ -221,9 +259,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La |
| 221 | 259 | yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid); |
| 222 | 260 | }); |
| 223 | 261 | |
| 224 | | const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* ( |
| 225 | | input: SshPasswordRequest, |
| 226 | | ): Effect.fn.Return<string, DesktopSshPasswordPromptRequestError> { |
| 262 | const request: DesktopSshPasswordPrompts["Service"]["request"] = Effect.fn( |
| 263 | "desktop.sshPasswordPrompts.request", |
| 264 | )(function* (input) { |
| 227 | 265 | const window = yield* electronWindow.main; |
| 228 | 266 | if (Option.isNone(window) || window.value.isDestroyed()) { |
| 229 | 267 | return yield* new DesktopSshPromptWindowUnavailableError({ |
| ... | ... | @@ -233,7 +271,11 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La |
| 233 | 271 | |
| 234 | 272 | const requestId = yield* crypto.randomUUIDv4.pipe( |
| 235 | 273 | Effect.mapError( |
| 236 | | () => new DesktopSshPromptUnavailableError({ reason: "Secure randomness is unavailable." }), |
| 274 | (cause) => |
| 275 | new DesktopSshPromptRequestIdGenerationError({ |
| 276 | destination: input.destination, |
| 277 | cause, |
| 278 | }), |
| 237 | 279 | ), |
| 238 | 280 | ); |
| 239 | 281 | const now = yield* DateTime.now; |
| ... | ... | @@ -267,10 +309,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La |
| 267 | 309 | onSome: (pending) => |
| 268 | 310 | failPending( |
| 269 | 311 | pending, |
| 270 | | new DesktopSshPromptCancelledError({ |
| 312 | new DesktopSshPromptWindowClosedError({ |
| 271 | 313 | requestId, |
| 272 | 314 | destination: input.destination, |
| 273 | | reason: "SSH authentication was cancelled because the app window closed.", |
| 274 | 315 | }), |
| 275 | 316 | ), |
| 276 | 317 | }), |
| ... | ... | @@ -302,36 +343,43 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La |
| 302 | 343 | return yield* Effect.try({ |
| 303 | 344 | try: () => { |
| 304 | 345 | if (window.value.isDestroyed()) { |
| 305 | | throw new Error(WINDOW_UNAVAILABLE_MESSAGE); |
| 346 | throw new DesktopSshPromptWindowUnavailableError({ |
| 347 | destination: input.destination, |
| 348 | }); |
| 306 | 349 | } |
| 307 | 350 | window.value.once("closed", cancelOnWindowClosed); |
| 308 | | window.value.webContents.send(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); |
| 351 | window.value.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, promptRequest); |
| 309 | 352 | if (window.value.isDestroyed()) { |
| 310 | | throw new Error(WINDOW_UNAVAILABLE_MESSAGE); |
| 353 | throw new DesktopSshPromptWindowUnavailableError({ |
| 354 | destination: input.destination, |
| 355 | }); |
| 311 | 356 | } |
| 312 | 357 | if (window.value.isMinimized()) { |
| 313 | 358 | window.value.restore(); |
| 314 | 359 | } |
| 315 | 360 | if (window.value.isDestroyed()) { |
| 316 | | throw new Error(WINDOW_UNAVAILABLE_MESSAGE); |
| 361 | throw new DesktopSshPromptWindowUnavailableError({ |
| 362 | destination: input.destination, |
| 363 | }); |
| 317 | 364 | } |
| 318 | 365 | window.value.focus(); |
| 319 | 366 | }, |
| 320 | 367 | catch: (cause) => |
| 321 | | new DesktopSshPromptSendError({ |
| 322 | | requestId, |
| 323 | | destination: input.destination, |
| 324 | | cause, |
| 325 | | }), |
| 368 | isDesktopSshPromptWindowUnavailableError(cause) |
| 369 | ? cause |
| 370 | : new DesktopSshPromptPresentationError({ |
| 371 | requestId, |
| 372 | destination: input.destination, |
| 373 | cause, |
| 374 | }), |
| 326 | 375 | }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup)); |
| 327 | 376 | }); |
| 328 | 377 | |
| 329 | 378 | return DesktopSshPasswordPrompts.of({ |
| 330 | 379 | request, |
| 331 | 380 | resolve, |
| 332 | | cancelPending, |
| 333 | 381 | }); |
| 334 | 382 | }); |
| 335 | 383 | |
| 336 | | export const layer = (options: LayerOptions = {}) => |
| 384 | export const layer = (options: DesktopSshPasswordPromptsOptions = {}) => |
| 337 | 385 | Layer.effect(DesktopSshPasswordPrompts, make(options)); |