1// @effect-diagnostics globalTimers:off -- The Node socket client owns its response deadline and clears it on every completion path.
2import * as NodeNet from "node:net";
3import * as NodeOS from "node:os";
4
5import {
6 DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
7 DesktopAppActivationErrorCode,
8 DesktopAppActivationResponse,
9 type DesktopAppActivationPlatform,
10 type DesktopAppActivationRequest,
11} from "@t3tools/contracts";
12import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
13import * as HostProcess from "@t3tools/shared/HostProcess";
14import * as Config from "effect/Config";
15import * as Console from "effect/Console";
16import * as Crypto from "effect/Crypto";
17import * as Effect from "effect/Effect";
18import * as Option from "effect/Option";
19import * as Path from "effect/Path";
20import * as Schema from "effect/Schema";
21import { Argument, Command } from "effect/cli";
22
23import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
24import { baseDirFlag } from "./config.ts";
25
26const CLI_RESPONSE_TIMEOUT_MS = 17_000;
27const MAX_RESPONSE_BYTES = 64 * 1024;
28const isDesktopAppActivationResponse = Schema.is(DesktopAppActivationResponse);
29
30export 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
39export 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
48export 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
62export 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
76function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppActivationPlatform {
77 return platform === "darwin" || platform === "linux" || platform === "win32";
78}
79
80function 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
176const 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
182const 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
250export 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);