| 1 | // @effect-diagnostics globalTimers:off -- The Node socket client owns its response deadline and clears it on every completion path. |
| 2 | import * as NodeNet from "node:net"; |
| 3 | import * as NodeOS from "node:os"; |
| 4 | |
| 5 | import { |
| 6 | DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, |
| 7 | DesktopAppActivationErrorCode, |
| 8 | DesktopAppActivationResponse, |
| 9 | type DesktopAppActivationPlatform, |
| 10 | type DesktopAppActivationRequest, |
| 11 | } from "@t3tools/contracts"; |
| 12 | import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl"; |
| 13 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 14 | import * as Config from "effect/Config"; |
| 15 | import * as Console from "effect/Console"; |
| 16 | import * as Crypto from "effect/Crypto"; |
| 17 | import * as Effect from "effect/Effect"; |
| 18 | import * as Option from "effect/Option"; |
| 19 | import * as Path from "effect/Path"; |
| 20 | import * as Schema from "effect/Schema"; |
| 21 | import { Argument, Command } from "effect/cli"; |
| 22 | |
| 23 | import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; |
| 24 | import { baseDirFlag } from "./config.ts"; |
| 25 | |
| 26 | const CLI_RESPONSE_TIMEOUT_MS = 17_000; |
| 27 | const MAX_RESPONSE_BYTES = 64 * 1024; |
| 28 | const isDesktopAppActivationResponse = Schema.is(DesktopAppActivationResponse); |
| 29 | |
| 30 | export class DesktopAppSshUnsupportedError extends Schema.TaggedError<DesktopAppSshUnsupportedError>()( |
| 31 | "DesktopAppSshUnsupportedError", |
| 32 | {}, |
| 33 | ) { |
| 34 | override get message(): string { |
| 35 | return "`t3 app` only controls a desktop app on the same machine. It cannot run over SSH."; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | export class DesktopAppPlatformUnsupportedError extends Schema.TaggedError<DesktopAppPlatformUnsupportedError>()( |
| 40 | "DesktopAppPlatformUnsupportedError", |
| 41 | { platform: Schema.String }, |
| 42 | ) { |
| 43 | override get message(): string { |
| 44 | return `\`t3 app\` is not supported on ${this.platform}.`; |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | export class DesktopAppUnreachableError extends Schema.TaggedError<DesktopAppUnreachableError>()( |
| 49 | "DesktopAppUnreachableError", |
| 50 | { |
| 51 | candidateAddresses: Schema.Array(Schema.String), |
| 52 | requestId: Schema.String, |
| 53 | workspaceRoot: Schema.String, |
| 54 | cause: Schema.Defect(), |
| 55 | }, |
| 56 | ) { |
| 57 | override get message(): string { |
| 58 | return "Could not reach the T3 Code desktop app. Start or update the desktop app on this machine, then run `t3 app` again. A running T3 Code server is not enough."; |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | export class DesktopAppRequestFailedError extends Schema.TaggedError<DesktopAppRequestFailedError>()( |
| 63 | "DesktopAppRequestFailedError", |
| 64 | { |
| 65 | code: DesktopAppActivationErrorCode, |
| 66 | requestId: Schema.String, |
| 67 | workspaceRoot: Schema.String, |
| 68 | cause: Schema.Defect(), |
| 69 | }, |
| 70 | ) { |
| 71 | override get message(): string { |
| 72 | return `T3 Code could not open ${this.workspaceRoot} (${this.code}).`; |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppActivationPlatform { |
| 77 | return platform === "darwin" || platform === "linux" || platform === "win32"; |
| 78 | } |
| 79 | |
| 80 | function sendDesktopAppActivationRequest(input: { |
| 81 | readonly address: string; |
| 82 | readonly fallbackAddress?: string; |
| 83 | readonly request: DesktopAppActivationRequest; |
| 84 | readonly timeoutMs?: number; |
| 85 | }): Promise<DesktopAppActivationResponse> { |
| 86 | return new Promise((resolve, reject) => { |
| 87 | const socket = NodeNet.createConnection(input.address); |
| 88 | socket.setEncoding("utf8"); |
| 89 | let buffer = ""; |
| 90 | let settled = false; |
| 91 | let connected = false; |
| 92 | |
| 93 | const finish = ( |
| 94 | result: |
| 95 | | { readonly type: "success"; readonly response: DesktopAppActivationResponse } |
| 96 | | { readonly type: "failure"; readonly error: Error }, |
| 97 | ) => { |
| 98 | if (settled) return; |
| 99 | settled = true; |
| 100 | clearTimeout(timeout); |
| 101 | socket.destroy(); |
| 102 | if (result.type === "success") resolve(result.response); |
| 103 | else reject(result.error); |
| 104 | }; |
| 105 | |
| 106 | const timeout = setTimeout(() => { |
| 107 | finish({ |
| 108 | type: "failure", |
| 109 | error: new Error("The desktop app did not respond in time."), |
| 110 | }); |
| 111 | }, input.timeoutMs ?? CLI_RESPONSE_TIMEOUT_MS); |
| 112 | |
| 113 | socket.once("connect", () => { |
| 114 | connected = true; |
| 115 | socket.write(`${JSON.stringify(input.request)}\n`); |
| 116 | }); |
| 117 | socket.on("data", (chunk) => { |
| 118 | buffer += chunk; |
| 119 | if (Buffer.byteLength(buffer, "utf8") > MAX_RESPONSE_BYTES) { |
| 120 | finish({ type: "failure", error: new Error("The desktop app response is too large.") }); |
| 121 | return; |
| 122 | } |
| 123 | const newline = buffer.indexOf("\n"); |
| 124 | if (newline === -1) return; |
| 125 | |
| 126 | let parsed: unknown; |
| 127 | try { |
| 128 | parsed = JSON.parse(buffer.slice(0, newline)); |
| 129 | } catch { |
| 130 | finish({ |
| 131 | type: "failure", |
| 132 | error: new Error("The desktop app response is not valid JSON."), |
| 133 | }); |
| 134 | return; |
| 135 | } |
| 136 | if (!isDesktopAppActivationResponse(parsed)) { |
| 137 | finish({ type: "failure", error: new Error("The desktop app response is invalid.") }); |
| 138 | return; |
| 139 | } |
| 140 | if (parsed.requestId !== input.request.requestId) { |
| 141 | finish({ |
| 142 | type: "failure", |
| 143 | error: new Error("The desktop app response did not match this request."), |
| 144 | }); |
| 145 | return; |
| 146 | } |
| 147 | finish({ type: "success", response: parsed }); |
| 148 | }); |
| 149 | socket.once("error", (error: NodeJS.ErrnoException) => { |
| 150 | if ( |
| 151 | !settled && |
| 152 | !connected && |
| 153 | input.fallbackAddress !== undefined && |
| 154 | (error.code === "ENOENT" || error.code === "ECONNREFUSED") |
| 155 | ) { |
| 156 | settled = true; |
| 157 | clearTimeout(timeout); |
| 158 | socket.destroy(); |
| 159 | resolve( |
| 160 | sendDesktopAppActivationRequest({ |
| 161 | address: input.fallbackAddress, |
| 162 | request: input.request, |
| 163 | ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }), |
| 164 | }), |
| 165 | ); |
| 166 | return; |
| 167 | } |
| 168 | finish({ type: "failure", error }); |
| 169 | }); |
| 170 | socket.once("end", () => { |
| 171 | finish({ type: "failure", error: new Error("The desktop app closed the connection.") }); |
| 172 | }); |
| 173 | }); |
| 174 | } |
| 175 | |
| 176 | const appEnvironment = Config.all({ |
| 177 | t3Home: Config.String("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), |
| 178 | sshConnection: Config.String("SSH_CONNECTION").pipe(Config.option), |
| 179 | sshTty: Config.String("SSH_TTY").pipe(Config.option), |
| 180 | }); |
| 181 | |
| 182 | const runAppCommand = Effect.fn("cli.app")(function* (flags: { |
| 183 | readonly baseDir: Option.Option<string>; |
| 184 | readonly workspaceRoot: Option.Option<string>; |
| 185 | }) { |
| 186 | const environment = yield* appEnvironment; |
| 187 | const hostPlatform = yield* HostProcess.Platform; |
| 188 | if (Option.isSome(environment.sshConnection) || Option.isSome(environment.sshTty)) { |
| 189 | return yield* new DesktopAppSshUnsupportedError({}); |
| 190 | } |
| 191 | if (!isDesktopPlatform(hostPlatform)) { |
| 192 | return yield* new DesktopAppPlatformUnsupportedError({ platform: hostPlatform }); |
| 193 | } |
| 194 | |
| 195 | const path = yield* Path.Path; |
| 196 | const configuredBaseDir = Option.getOrUndefined(flags.baseDir) ?? environment.t3Home; |
| 197 | const baseDir = yield* resolveBaseDir(configuredBaseDir); |
| 198 | const allowDevFallback = Option.isNone(flags.baseDir) && !environment.t3Home?.trim(); |
| 199 | const rawWorkspaceRoot = |
| 200 | Option.getOrUndefined(flags.workspaceRoot) ?? (yield* HostProcess.WorkingDirectory); |
| 201 | const workspaceRoot = path.resolve(yield* expandHomePath(rawWorkspaceRoot)); |
| 202 | const userId = yield* HostProcess.UserId; |
| 203 | const resolveAddress = (stateSubdirectory: "userdata" | "dev") => |
| 204 | resolveDesktopAppControlAddress({ |
| 205 | stateDir: path.join(baseDir, stateSubdirectory), |
| 206 | platform: hostPlatform, |
| 207 | tempDir: NodeOS.tmpdir(), |
| 208 | userId, |
| 209 | joinPath: path.join, |
| 210 | }).address; |
| 211 | const crypto = yield* Crypto.Crypto; |
| 212 | const requestId = yield* crypto.randomUUIDv4().pipe(Effect.orDie); |
| 213 | const request: DesktopAppActivationRequest = { |
| 214 | version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, |
| 215 | requestId, |
| 216 | type: "open-workspace", |
| 217 | workspaceRoot, |
| 218 | platform: hostPlatform, |
| 219 | }; |
| 220 | const address = resolveAddress("userdata"); |
| 221 | const fallbackAddress = allowDevFallback ? resolveAddress("dev") : undefined; |
| 222 | |
| 223 | const response = yield* Effect.tryPromise({ |
| 224 | try: () => |
| 225 | sendDesktopAppActivationRequest({ |
| 226 | address, |
| 227 | ...(fallbackAddress === undefined ? {} : { fallbackAddress }), |
| 228 | request, |
| 229 | }), |
| 230 | catch: (cause) => |
| 231 | new DesktopAppUnreachableError({ |
| 232 | candidateAddresses: fallbackAddress === undefined ? [address] : [address, fallbackAddress], |
| 233 | requestId: request.requestId, |
| 234 | workspaceRoot, |
| 235 | cause, |
| 236 | }), |
| 237 | }); |
| 238 | if (!response.ok) { |
| 239 | return yield* new DesktopAppRequestFailedError({ |
| 240 | code: response.code, |
| 241 | requestId: response.requestId, |
| 242 | workspaceRoot, |
| 243 | cause: response, |
| 244 | }); |
| 245 | } |
| 246 | |
| 247 | yield* Console.log(`Opened ${workspaceRoot} in T3 Code.`); |
| 248 | }); |
| 249 | |
| 250 | export const appCommand = Command.make("app", { |
| 251 | baseDir: baseDirFlag, |
| 252 | workspaceRoot: Argument.String("path").pipe( |
| 253 | Argument.withDescription("Project directory. Default: current directory."), |
| 254 | Argument.optional, |
| 255 | ), |
| 256 | }).pipe( |
| 257 | Command.withDescription("Open a project in the running T3 Code desktop app."), |
| 258 | Command.withHandler(runAppCommand), |
| 259 | ); |