authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 00:36:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 00:36:41-07:00
log7eda6ba5fce8290b3a13d1ab730827fc36a34e53
treeef3b23b49b1fc7dbc81a6e2acea428090f45ade1
parentc00e721c047ab0b832401196e64df26431fd9c98
signature Signed by PGP key B5690EEEBB952194

[codex] Migrate desktop shell and SSH Effect services (#3194)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 240 insertions(+), 160 deletions(-)

apps/desktop/src/shell/DesktopShellEnvironment.ts+22-23
...@@ -3,7 +3,8 @@ import * as Duration from "effect/Duration";...@@ -3,7 +3,8 @@ import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";6import * as ChildProcess from "effect/unstable/process/ChildProcess";
7import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
78
8import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";9import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
910
...@@ -19,13 +20,11 @@ interface WindowsProbeOptions {...@@ -19,13 +20,11 @@ interface WindowsProbeOptions {
19 readonly loadProfile: boolean;20 readonly loadProfile: boolean;
20}21}
2122
22export interface DesktopShellEnvironmentShape {
23 readonly installIntoProcess: Effect.Effect<void, never>;
24}
25
26export class DesktopShellEnvironment extends Context.Service<23export class DesktopShellEnvironment extends Context.Service<
27 DesktopShellEnvironment,24 DesktopShellEnvironment,
28 DesktopShellEnvironmentShape25 {
26 readonly installIntoProcess: Effect.Effect<void>;
27 }
29>()("@t3tools/desktop/shell/DesktopShellEnvironment") {}28>()("@t3tools/desktop/shell/DesktopShellEnvironment") {}
3029
31const LOGIN_SHELL_ENV_NAMES = [30const LOGIN_SHELL_ENV_NAMES = [
...@@ -336,20 +335,20 @@ const installShellEnvironment = (...@@ -336,20 +335,20 @@ const installShellEnvironment = (
336 return Effect.void;335 return Effect.void;
337};336};
338337
339export const layer = Layer.effect(338export const make = Effect.gen(function* () {
340 DesktopShellEnvironment,339 const environment = yield* DesktopEnvironment.DesktopEnvironment;
341 Effect.gen(function* () {340 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
342 const environment = yield* DesktopEnvironment.DesktopEnvironment;341 const installIntoProcess: DesktopShellEnvironment["Service"]["installIntoProcess"] =
343 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;342 installShellEnvironment({
344 return DesktopShellEnvironment.of({343 env: process.env,
345 installIntoProcess: installShellEnvironment({344 platform: environment.platform,
346 env: process.env,345 userShell: Option.none(),
347 platform: environment.platform,346 }).pipe(
348 userShell: Option.none(),347 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
349 }).pipe(348 Effect.withSpan("desktop.shellEnvironment.installIntoProcess"),
350 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),349 );
351 Effect.withSpan("desktop.shellEnvironment.installIntoProcess"),350
352 ),351 return DesktopShellEnvironment.of({ installIntoProcess });
353 });352});
354 }),353
355);354export const layer = Layer.effect(DesktopShellEnvironment, make);
apps/desktop/src/ssh/DesktopSshEnvironment.test.ts+14-1
...@@ -19,6 +19,20 @@ function makeTempHomeDir() {...@@ -19,6 +19,20 @@ function makeTempHomeDir() {
19}19}
2020
21describe("sshEnvironment", () => {21describe("sshEnvironment", () => {
22 it("keeps prompt presentation diagnostics distinct from the legacy wrapper message", () => {
23 const cause = new DesktopSshPasswordPrompts.DesktopSshPromptPresentationError({
24 requestId: "prompt-1",
25 destination: "devbox",
26 cause: new Error("renderer send failed"),
27 });
28
29 assert.equal(cause.message, "Failed to present SSH password prompt for devbox.");
30 assert.equal(
31 DesktopSshEnvironment.toSshPasswordPromptError(cause).message,
32 "T3 Code window is not available for SSH authentication.",
33 );
34 });
35
22 it("treats password prompt timeouts as cancellable authentication prompts", () => {36 it("treats password prompt timeouts as cancellable authentication prompts", () => {
23 assert.equal(37 assert.equal(
24 DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(38 DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(
...@@ -104,7 +118,6 @@ describe("sshEnvironment", () => {...@@ -104,7 +118,6 @@ describe("sshEnvironment", () => {
104 Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, {118 Layer.succeed(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, {
105 request: () => Effect.die("unexpected password prompt request"),119 request: () => Effect.die("unexpected password prompt request"),
106 resolve: () => Effect.die("unexpected password prompt resolution"),120 resolve: () => Effect.die("unexpected password prompt resolution"),
107 cancelPending: () => Effect.void,
108 }),121 }),
109 ),122 ),
110 Layer.provideMerge(NodeServices.layer),123 Layer.provideMerge(NodeServices.layer),
apps/desktop/src/ssh/DesktopSshEnvironment.ts+61-41
...@@ -4,11 +4,7 @@ import type {...@@ -4,11 +4,7 @@ import type {
4 DesktopSshEnvironmentTarget,4 DesktopSshEnvironmentTarget,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import * as NetService from "@t3tools/shared/Net";6import * as NetService from "@t3tools/shared/Net";
7import {7import * as SshAuth from "@t3tools/ssh/auth";
8 SshPasswordPrompt,
9 type SshPasswordPromptShape,
10 type SshPasswordRequest,
11} from "@t3tools/ssh/auth";
12import { discoverSshHosts } from "@t3tools/ssh/config";8import { discoverSshHosts } from "@t3tools/ssh/config";
13import {9import {
14 SshCommandError,10 SshCommandError,
...@@ -19,14 +15,14 @@ import {...@@ -19,14 +15,14 @@ import {
19 SshPasswordPromptError,15 SshPasswordPromptError,
20 SshReadinessError,16 SshReadinessError,
21} from "@t3tools/ssh/errors";17} from "@t3tools/ssh/errors";
22import { SshEnvironmentManager, type RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";18import * as SshTunnel from "@t3tools/ssh/tunnel";
23import * as Context from "effect/Context";19import * as Context from "effect/Context";
24import * as Effect from "effect/Effect";20import * as Effect from "effect/Effect";
25import * as FileSystem from "effect/FileSystem";21import * as FileSystem from "effect/FileSystem";
26import * as Layer from "effect/Layer";22import * as Layer from "effect/Layer";
27import * as Path from "effect/Path";23import * as Path from "effect/Path";
28import { HttpClient } from "effect/unstable/http";24import * as HttpClient from "effect/unstable/http/HttpClient";
29import { ChildProcessSpawner } from "effect/unstable/process";25import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
3026
31import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";27import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
3228
...@@ -52,27 +48,25 @@ export type DesktopSshEnvironmentError =...@@ -52,27 +48,25 @@ export type DesktopSshEnvironmentError =
52 | DesktopSshEnvironmentDiscoverError48 | DesktopSshEnvironmentDiscoverError
53 | DesktopSshEnvironmentOperationError;49 | DesktopSshEnvironmentOperationError;
5450
55export interface DesktopSshEnvironmentShape {
56 readonly discoverHosts: (input?: {
57 readonly homeDir?: string;
58 }) => Effect.Effect<readonly DesktopDiscoveredSshHost[], DesktopSshEnvironmentDiscoverError>;
59 readonly ensureEnvironment: (
60 target: DesktopSshEnvironmentTarget,
61 options?: { readonly issuePairingToken?: boolean },
62 ) => Effect.Effect<DesktopSshEnvironmentBootstrap, DesktopSshEnvironmentOperationError>;
63 readonly disconnectEnvironment: (
64 target: DesktopSshEnvironmentTarget,
65 ) => Effect.Effect<void, DesktopSshEnvironmentOperationError>;
66}
67
68export class DesktopSshEnvironment extends Context.Service<51export class DesktopSshEnvironment extends Context.Service<
69 DesktopSshEnvironment,52 DesktopSshEnvironment,
70 DesktopSshEnvironmentShape53 {
54 readonly discoverHosts: (input?: {
55 readonly homeDir?: string;
56 }) => Effect.Effect<readonly DesktopDiscoveredSshHost[], DesktopSshEnvironmentDiscoverError>;
57 readonly ensureEnvironment: (
58 target: DesktopSshEnvironmentTarget,
59 options?: { readonly issuePairingToken?: boolean },
60 ) => Effect.Effect<DesktopSshEnvironmentBootstrap, DesktopSshEnvironmentOperationError>;
61 readonly disconnectEnvironment: (
62 target: DesktopSshEnvironmentTarget,
63 ) => Effect.Effect<void, DesktopSshEnvironmentOperationError>;
64 }
71>()("@t3tools/desktop/ssh/DesktopSshEnvironment") {}65>()("@t3tools/desktop/ssh/DesktopSshEnvironment") {}
7266
73export interface DesktopSshEnvironmentLayerOptions {67export interface DesktopSshEnvironmentLayerOptions {
74 readonly resolveCliPackageSpec?: () => string;68 readonly resolveCliPackageSpec?: () => string;
75 readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>;69 readonly resolveCliRunner?: Effect.Effect<SshTunnel.RemoteT3RunnerOptions>;
76}70}
7771
78function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) {72function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) {
...@@ -88,27 +82,53 @@ export function isDesktopSshPasswordPromptCancellation(...@@ -88,27 +82,53 @@ export function isDesktopSshPasswordPromptCancellation(
88 );82 );
89}83}
9084
85function unexpectedPasswordPromptError(error: never): never {
86 throw new Error(`Unhandled desktop SSH password prompt error: ${String(error)}`);
87}
88
89export function toSshPasswordPromptError(
90 cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptRequestError,
91): SshPasswordPromptError {
92 let message: string;
93 switch (cause._tag) {
94 case "DesktopSshPromptRequestIdGenerationError":
95 message = "Secure randomness is unavailable.";
96 break;
97 case "DesktopSshPromptWindowUnavailableError":
98 case "DesktopSshPromptPresentationError":
99 message = "T3 Code window is not available for SSH authentication.";
100 break;
101 case "DesktopSshPromptTimedOutError":
102 message = `SSH authentication timed out for ${cause.destination}.`;
103 break;
104 case "DesktopSshPromptCancelledError":
105 message = `SSH authentication cancelled for ${cause.destination}.`;
106 break;
107 case "DesktopSshPromptWindowClosedError":
108 message = "SSH authentication was cancelled because the app window closed.";
109 break;
110 case "DesktopSshPromptServiceStoppedError":
111 message = "SSH password prompt service stopped.";
112 break;
113 default:
114 return unexpectedPasswordPromptError(cause);
115 }
116 return new SshPasswordPromptError({ message, cause });
117}
118
91const makePasswordPrompt = (119const makePasswordPrompt = (
92 prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPromptsShape,120 prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPrompts["Service"],
93): SshPasswordPromptShape => ({121): SshAuth.SshPasswordPrompt["Service"] => ({
94 isAvailable: true,122 isAvailable: true,
95 request: (request: SshPasswordRequest) =>123 request: (request: SshAuth.SshPasswordRequest) =>
96 prompts.request(request).pipe(124 prompts.request(request).pipe(Effect.mapError(toSshPasswordPromptError)),
97 Effect.mapError(
98 (cause) =>
99 new SshPasswordPromptError({
100 message: cause.message,
101 cause,
102 }),
103 ),
104 ),
105});125});
106126
107const make = Effect.gen(function* () {127export const make = Effect.gen(function* () {
108 const manager = yield* SshEnvironmentManager;128 const manager = yield* SshTunnel.SshEnvironmentManager;
109 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;129 const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
110 const runtimeContext = yield* Effect.context<DesktopSshEnvironmentRuntimeServices>();130 const runtimeContext = yield* Effect.context<DesktopSshEnvironmentRuntimeServices>();
111 const passwordPrompt = SshPasswordPrompt.of(makePasswordPrompt(prompts));131 const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts));
112132
113 return DesktopSshEnvironment.of({133 return DesktopSshEnvironment.of({
114 discoverHosts: (input) =>134 discoverHosts: (input) =>
...@@ -120,7 +140,7 @@ const make = Effect.gen(function* () {...@@ -120,7 +140,7 @@ const make = Effect.gen(function* () {
120 manager140 manager
121 .ensureEnvironment(target, ensureOptions)141 .ensureEnvironment(target, ensureOptions)
122 .pipe(142 .pipe(
123 Effect.provideService(SshPasswordPrompt, passwordPrompt),143 Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt),
124 Effect.provide(runtimeContext),144 Effect.provide(runtimeContext),
125 Effect.withSpan("desktop.ssh.ensureEnvironment"),145 Effect.withSpan("desktop.ssh.ensureEnvironment"),
126 ),146 ),
...@@ -128,7 +148,7 @@ const make = Effect.gen(function* () {...@@ -128,7 +148,7 @@ const make = Effect.gen(function* () {
128 manager148 manager
129 .disconnectEnvironment(target)149 .disconnectEnvironment(target)
130 .pipe(150 .pipe(
131 Effect.provideService(SshPasswordPrompt, passwordPrompt),151 Effect.provideService(SshAuth.SshPasswordPrompt, passwordPrompt),
132 Effect.provide(runtimeContext),152 Effect.provide(runtimeContext),
133 Effect.withSpan("desktop.ssh.disconnectEnvironment"),153 Effect.withSpan("desktop.ssh.disconnectEnvironment"),
134 ),154 ),
...@@ -138,7 +158,7 @@ const make = Effect.gen(function* () {...@@ -138,7 +158,7 @@ const make = Effect.gen(function* () {
138export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) =>158export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) =>
139 Layer.effect(DesktopSshEnvironment, make).pipe(159 Layer.effect(DesktopSshEnvironment, make).pipe(
140 Layer.provide(160 Layer.provide(
141 SshEnvironmentManager.layer({161 SshTunnel.SshEnvironmentManager.layer({
142 ...(options.resolveCliPackageSpec === undefined162 ...(options.resolveCliPackageSpec === undefined
143 ? {}163 ? {}
144 : { resolveCliPackageSpec: options.resolveCliPackageSpec }),164 : { resolveCliPackageSpec: options.resolveCliPackageSpec }),
apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts+143-95
...@@ -3,7 +3,6 @@ import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contract...@@ -3,7 +3,6 @@ import { DesktopSshPasswordPromptResolutionInputSchema } from "@t3tools/contract
3import type { SshPasswordRequest } from "@t3tools/ssh/auth";3import type { SshPasswordRequest } from "@t3tools/ssh/auth";
4import * as Context from "effect/Context";4import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";5import * as Crypto from "effect/Crypto";
6import * as Data from "effect/Data";
7import * as DateTime from "effect/DateTime";6import * as DateTime from "effect/DateTime";
8import * as Deferred from "effect/Deferred";7import * as Deferred from "effect/Deferred";
9import * as Duration from "effect/Duration";8import * as Duration from "effect/Duration";
...@@ -11,93 +10,134 @@ import * as Effect from "effect/Effect";...@@ -11,93 +10,134 @@ import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";11import * as Option from "effect/Option";
13import * as Ref from "effect/Ref";12import * as Ref from "effect/Ref";
13import * as Schema from "effect/Schema";
1414
15import * as IpcChannels from "../ipc/channels.ts";
16import * as ElectronWindow from "../electron/ElectronWindow.ts";15import * as ElectronWindow from "../electron/ElectronWindow.ts";
16import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts";
1717
18const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;18const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000;
19const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication.";
2019
21type DesktopSshPasswordPromptResolutionInput =20type DesktopSshPasswordPromptResolutionInput =
22 typeof DesktopSshPasswordPromptResolutionInputSchema.Type;21 typeof DesktopSshPasswordPromptResolutionInputSchema.Type;
2322
24export class DesktopSshPromptUnavailableError extends Data.TaggedError(23const WINDOW_UNAVAILABLE_MESSAGE = "T3 Code window is not available for SSH authentication.";
25 "DesktopSshPromptUnavailableError",24
26)<{25export class DesktopSshPromptRequestIdGenerationError extends Schema.TaggedErrorClass<DesktopSshPromptRequestIdGenerationError>()(
27 readonly reason: string;26 "DesktopSshPromptRequestIdGenerationError",
28}> {27 {
29 override get message() {28 destination: Schema.String,
30 return this.reason;29 cause: Schema.Defect(),
30 },
31) {
32 override get message(): string {
33 return "Secure randomness is unavailable.";
31 }34 }
32}35}
3336
34export class DesktopSshPromptWindowUnavailableError extends Data.TaggedError(37export class DesktopSshPromptWindowUnavailableError extends Schema.TaggedErrorClass<DesktopSshPromptWindowUnavailableError>()(
35 "DesktopSshPromptWindowUnavailableError",38 "DesktopSshPromptWindowUnavailableError",
36)<{39 {
37 readonly destination: string;40 destination: Schema.String,
38}> {41 },
39 override get message() {42) {
43 override get message(): string {
40 return WINDOW_UNAVAILABLE_MESSAGE;44 return WINDOW_UNAVAILABLE_MESSAGE;
41 }45 }
42}46}
4347
44export class DesktopSshPromptSendError extends Data.TaggedError("DesktopSshPromptSendError")<{48const isDesktopSshPromptWindowUnavailableError = Schema.is(DesktopSshPromptWindowUnavailableError);
45 readonly requestId: string;49
46 readonly destination: string;50export class DesktopSshPromptPresentationError extends Schema.TaggedErrorClass<DesktopSshPromptPresentationError>()(
47 readonly cause: unknown;51 "DesktopSshPromptPresentationError",
48}> {52 {
49 override get message() {53 requestId: Schema.String,
50 return WINDOW_UNAVAILABLE_MESSAGE;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}
5362
54export class DesktopSshPromptTimedOutError extends Data.TaggedError(63export class DesktopSshPromptTimedOutError extends Schema.TaggedErrorClass<DesktopSshPromptTimedOutError>()(
55 "DesktopSshPromptTimedOutError",64 "DesktopSshPromptTimedOutError",
56)<{65 {
57 readonly requestId: string;66 requestId: Schema.String,
58 readonly destination: string;67 destination: Schema.String,
59}> {68 },
60 override get message() {69) {
70 override get message(): string {
61 return `SSH authentication timed out for ${this.destination}.`;71 return `SSH authentication timed out for ${this.destination}.`;
62 }72 }
63}73}
6474
65export class DesktopSshPromptCancelledError extends Data.TaggedError(75export class DesktopSshPromptCancelledError extends Schema.TaggedErrorClass<DesktopSshPromptCancelledError>()(
66 "DesktopSshPromptCancelledError",76 "DesktopSshPromptCancelledError",
67)<{77 {
68 readonly requestId: string;78 requestId: Schema.String,
69 readonly destination: string;79 destination: Schema.String,
70 readonly reason: string;80 },
71}> {81) {
72 override get message() {82 override get message(): string {
73 return this.reason;83 return `SSH authentication cancelled for ${this.destination}.`;
74 }84 }
75}85}
7686
77export class DesktopSshPromptInvalidRequestIdError extends Data.TaggedError(87export 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
99export 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
111export class DesktopSshPromptInvalidRequestIdError extends Schema.TaggedErrorClass<DesktopSshPromptInvalidRequestIdError>()(
78 "DesktopSshPromptInvalidRequestIdError",112 "DesktopSshPromptInvalidRequestIdError",
79)<{113 {
80 readonly requestId: string;114 requestId: Schema.String,
81}> {115 },
82 override get message() {116) {
117 override get message(): string {
83 return "Invalid SSH password prompt id.";118 return "Invalid SSH password prompt id.";
84 }119 }
85}120}
86121
87export class DesktopSshPromptExpiredError extends Data.TaggedError("DesktopSshPromptExpiredError")<{122export class DesktopSshPromptExpiredError extends Schema.TaggedErrorClass<DesktopSshPromptExpiredError>()(
88 readonly requestId: string;123 "DesktopSshPromptExpiredError",
89}> {124 {
90 override get message() {125 requestId: Schema.String,
126 },
127) {
128 override get message(): string {
91 return "SSH password prompt expired. Try connecting again.";129 return "SSH password prompt expired. Try connecting again.";
92 }130 }
93}131}
94132
95export type DesktopSshPasswordPromptRequestError =133export type DesktopSshPasswordPromptRequestError =
96 | DesktopSshPromptUnavailableError134 | DesktopSshPromptRequestIdGenerationError
97 | DesktopSshPromptWindowUnavailableError135 | DesktopSshPromptWindowUnavailableError
98 | DesktopSshPromptSendError136 | DesktopSshPromptPresentationError
99 | DesktopSshPromptTimedOutError137 | DesktopSshPromptTimedOutError
100 | DesktopSshPromptCancelledError;138 | DesktopSshPromptCancelledError
139 | DesktopSshPromptWindowClosedError
140 | DesktopSshPromptServiceStoppedError;
101141
102export type DesktopSshPasswordPromptResolveError =142export type DesktopSshPasswordPromptResolveError =
103 | DesktopSshPromptInvalidRequestIdError143 | DesktopSshPromptInvalidRequestIdError
...@@ -107,28 +147,28 @@ export type DesktopSshPasswordPromptError =...@@ -107,28 +147,28 @@ export type DesktopSshPasswordPromptError =
107 | DesktopSshPasswordPromptRequestError147 | DesktopSshPasswordPromptRequestError
108 | DesktopSshPasswordPromptResolveError;148 | DesktopSshPasswordPromptResolveError;
109149
110export function isDesktopSshPasswordPromptCancellation(150export const DesktopSshPasswordPromptCancellation = Schema.Union([
111 error: unknown,151 DesktopSshPromptCancelledError,
112): error is DesktopSshPromptCancelledError | DesktopSshPromptTimedOutError {152 DesktopSshPromptWindowClosedError,
113 return (153 DesktopSshPromptServiceStoppedError,
114 error instanceof DesktopSshPromptCancelledError ||154 DesktopSshPromptTimedOutError,
115 error instanceof DesktopSshPromptTimedOutError155]);
116 );156export type DesktopSshPasswordPromptCancellation = typeof DesktopSshPasswordPromptCancellation.Type;
117}
118157
119export interface DesktopSshPasswordPromptsShape {158export const isDesktopSshPasswordPromptCancellation = Schema.is(
120 readonly request: (159 DesktopSshPasswordPromptCancellation,
121 request: SshPasswordRequest,160);
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}
128161
129export class DesktopSshPasswordPrompts extends Context.Service<162export class DesktopSshPasswordPrompts extends Context.Service<
130 DesktopSshPasswordPrompts,163 DesktopSshPasswordPrompts,
131 DesktopSshPasswordPromptsShape164 {
165 readonly request: (
166 request: SshPasswordRequest,
167 ) => Effect.Effect<string, DesktopSshPasswordPromptRequestError>;
168 readonly resolve: (
169 input: DesktopSshPasswordPromptResolutionInput,
170 ) => Effect.Effect<void, DesktopSshPasswordPromptResolveError>;
171 }
132>()("@t3tools/desktop/ssh/DesktopSshPasswordPrompts") {}172>()("@t3tools/desktop/ssh/DesktopSshPasswordPrompts") {}
133173
134interface PendingSshPasswordPrompt {174interface PendingSshPasswordPrompt {
...@@ -137,7 +177,7 @@ interface PendingSshPasswordPrompt {...@@ -137,7 +177,7 @@ interface PendingSshPasswordPrompt {
137 readonly deferred: Deferred.Deferred<string, DesktopSshPasswordPromptRequestError>;177 readonly deferred: Deferred.Deferred<string, DesktopSshPasswordPromptRequestError>;
138}178}
139179
140interface LayerOptions {180export interface DesktopSshPasswordPromptsOptions {
141 readonly passwordPromptTimeoutMs?: number;181 readonly passwordPromptTimeoutMs?: number;
142}182}
143183
...@@ -161,14 +201,16 @@ const failPending = (...@@ -161,14 +201,16 @@ const failPending = (
161 error: DesktopSshPasswordPromptRequestError,201 error: DesktopSshPasswordPromptRequestError,
162) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid);202) => Deferred.fail(pending.deferred, error).pipe(Effect.asVoid);
163203
164const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: LayerOptions = {}) {204export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (
205 options: DesktopSshPasswordPromptsOptions = {},
206) {
165 const electronWindow = yield* ElectronWindow.ElectronWindow;207 const electronWindow = yield* ElectronWindow.ElectronWindow;
166 const crypto = yield* Crypto.Crypto;208 const crypto = yield* Crypto.Crypto;
167 const pendingRef = yield* Ref.make(new Map<string, PendingSshPasswordPrompt>());209 const pendingRef = yield* Ref.make(new Map<string, PendingSshPasswordPrompt>());
168 const passwordPromptTimeoutMs =210 const passwordPromptTimeoutMs =
169 options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS;211 options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS;
170212
171 const cancelPending = (reason: string): Effect.Effect<void> =>213 const cancelPending = () =>
172 Ref.getAndSet(pendingRef, new Map()).pipe(214 Ref.getAndSet(pendingRef, new Map()).pipe(
173 Effect.flatMap((pending) =>215 Effect.flatMap((pending) =>
174 Effect.forEach(216 Effect.forEach(
...@@ -176,10 +218,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La...@@ -176,10 +218,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La
176 (entry) =>218 (entry) =>
177 failPending(219 failPending(
178 entry,220 entry,
179 new DesktopSshPromptCancelledError({221 new DesktopSshPromptServiceStoppedError({
180 requestId: entry.requestId,222 requestId: entry.requestId,
181 destination: entry.destination,223 destination: entry.destination,
182 reason,
183 }),224 }),
184 ),225 ),
185 { discard: true },226 { discard: true },
...@@ -188,13 +229,11 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La...@@ -188,13 +229,11 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La
188 Effect.asVoid,229 Effect.asVoid,
189 );230 );
190231
191 yield* Effect.addFinalizer(() =>232 yield* Effect.addFinalizer(() => cancelPending().pipe(Effect.ignore));
192 cancelPending("SSH password prompt service stopped.").pipe(Effect.ignore),
193 );
194233
195 const resolve = Effect.fn("desktop.sshPasswordPrompts.resolve")(function* (234 const resolve: DesktopSshPasswordPrompts["Service"]["resolve"] = Effect.fn(
196 input: DesktopSshPasswordPromptResolutionInput,235 "desktop.sshPasswordPrompts.resolve",
197 ): Effect.fn.Return<void, DesktopSshPasswordPromptResolveError> {236 )(function* (input) {
198 const requestId = input.requestId.trim();237 const requestId = input.requestId.trim();
199 if (requestId.length === 0) {238 if (requestId.length === 0) {
200 return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId });239 return yield* new DesktopSshPromptInvalidRequestIdError({ requestId: input.requestId });
...@@ -212,7 +251,6 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La...@@ -212,7 +251,6 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La
212 new DesktopSshPromptCancelledError({251 new DesktopSshPromptCancelledError({
213 requestId,252 requestId,
214 destination: entry.destination,253 destination: entry.destination,
215 reason: `SSH authentication cancelled for ${entry.destination}.`,
216 }),254 }),
217 );255 );
218 return;256 return;
...@@ -221,9 +259,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La...@@ -221,9 +259,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La
221 yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid);259 yield* Deferred.succeed(entry.deferred, input.password).pipe(Effect.asVoid);
222 });260 });
223261
224 const request = Effect.fn("desktop.sshPasswordPrompts.request")(function* (262 const request: DesktopSshPasswordPrompts["Service"]["request"] = Effect.fn(
225 input: SshPasswordRequest,263 "desktop.sshPasswordPrompts.request",
226 ): Effect.fn.Return<string, DesktopSshPasswordPromptRequestError> {264 )(function* (input) {
227 const window = yield* electronWindow.main;265 const window = yield* electronWindow.main;
228 if (Option.isNone(window) || window.value.isDestroyed()) {266 if (Option.isNone(window) || window.value.isDestroyed()) {
229 return yield* new DesktopSshPromptWindowUnavailableError({267 return yield* new DesktopSshPromptWindowUnavailableError({
...@@ -233,7 +271,11 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La...@@ -233,7 +271,11 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La
233271
234 const requestId = yield* crypto.randomUUIDv4.pipe(272 const requestId = yield* crypto.randomUUIDv4.pipe(
235 Effect.mapError(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 const now = yield* DateTime.now;281 const now = yield* DateTime.now;
...@@ -267,10 +309,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La...@@ -267,10 +309,9 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La
267 onSome: (pending) =>309 onSome: (pending) =>
268 failPending(310 failPending(
269 pending,311 pending,
270 new DesktopSshPromptCancelledError({312 new DesktopSshPromptWindowClosedError({
271 requestId,313 requestId,
272 destination: input.destination,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,36 +343,43 @@ const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* (options: La
302 return yield* Effect.try({343 return yield* Effect.try({
303 try: () => {344 try: () => {
304 if (window.value.isDestroyed()) {345 if (window.value.isDestroyed()) {
305 throw new Error(WINDOW_UNAVAILABLE_MESSAGE);346 throw new DesktopSshPromptWindowUnavailableError({
347 destination: input.destination,
348 });
306 }349 }
307 window.value.once("closed", cancelOnWindowClosed);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 if (window.value.isDestroyed()) {352 if (window.value.isDestroyed()) {
310 throw new Error(WINDOW_UNAVAILABLE_MESSAGE);353 throw new DesktopSshPromptWindowUnavailableError({
354 destination: input.destination,
355 });
311 }356 }
312 if (window.value.isMinimized()) {357 if (window.value.isMinimized()) {
313 window.value.restore();358 window.value.restore();
314 }359 }
315 if (window.value.isDestroyed()) {360 if (window.value.isDestroyed()) {
316 throw new Error(WINDOW_UNAVAILABLE_MESSAGE);361 throw new DesktopSshPromptWindowUnavailableError({
362 destination: input.destination,
363 });
317 }364 }
318 window.value.focus();365 window.value.focus();
319 },366 },
320 catch: (cause) =>367 catch: (cause) =>
321 new DesktopSshPromptSendError({368 isDesktopSshPromptWindowUnavailableError(cause)
322 requestId,369 ? cause
323 destination: input.destination,370 : new DesktopSshPromptPresentationError({
324 cause,371 requestId,
325 }),372 destination: input.destination,
373 cause,
374 }),
326 }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup));375 }).pipe(Effect.andThen(waitForPassword), Effect.ensuring(cleanup));
327 });376 });
328377
329 return DesktopSshPasswordPrompts.of({378 return DesktopSshPasswordPrompts.of({
330 request,379 request,
331 resolve,380 resolve,
332 cancelPending,
333 });381 });
334});382});
335383
336export const layer = (options: LayerOptions = {}) =>384export const layer = (options: DesktopSshPasswordPromptsOptions = {}) =>
337 Layer.effect(DesktopSshPasswordPrompts, make(options));385 Layer.effect(DesktopSshPasswordPrompts, make(options));