authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 16:15:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 16:15:55-07:00
log04efa7907e9ec207e2d6af459ce3b2ffd55f6107
treef836cccae4742dd1543a228166f12e4b6dbd1174
parentbeae2147a9487ec47ac992319f2216914b4cb62d
signature Signed by PGP key B5690EEEBB952194

feat(cli): open projects in the running desktop app (#8824)


26 files changed, 1890 insertions(+), 3 deletions(-)

apps/desktop/src/app/DesktopApp.ts+6
......@@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts";
1111import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
1212import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
1313import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
14import * as DesktopAppActivation from "./DesktopAppActivation.ts";
1415import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
1516import * as DesktopClerk from "./DesktopClerk.ts";
1617import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
......@@ -148,6 +149,7 @@ const bootstrap = Effect.gen(function* () {
148149 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
149150 const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
150151 const desktopWindow = yield* DesktopWindow.DesktopWindow;
152 const appActivation = yield* DesktopAppActivation.DesktopAppActivation;
151153 yield* logBootstrapInfo("bootstrap start");
152154
153155 if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
......@@ -210,6 +212,10 @@ const bootstrap = Effect.gen(function* () {
210212 }
211213 yield* primaryBackend.start;
212214 yield* logBootstrapInfo("bootstrap backend start requested");
215 yield* appActivation.start.pipe(
216 Effect.tap(() => logBootstrapInfo("desktop app control socket ready")),
217 Effect.catch((error) => logStartupError("desktop app control socket unavailable", { error })),
218 );
213219 // Bring up the WSL backend if the user previously enabled it. The
214220 // primary is already starting; reconcile fires off the WSL register
215221 // in parallel rather than blocking primary readiness on a possibly
apps/desktop/src/app/DesktopAppActivation.test.ts created+140
......@@ -0,0 +1,140 @@
1// @effect-diagnostics nodeBuiltinImport:off -- This adapter test binds a real local socket or Windows named pipe and verifies its cleanup.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeNet from "node:net";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7import {
8 ProjectId,
9 ThreadId,
10 type DesktopAppActivationRequest,
11 type DesktopAppActivationResponse,
12} from "@t3tools/contracts";
13import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
14import { HostProcessPlatform, HostProcessUserId } from "@t3tools/shared/hostProcess";
15import { it } from "@effect/vitest";
16import * as Effect from "effect/Effect";
17import { afterEach, describe, expect } from "vite-plus/test";
18
19import { startDesktopAppControlServer } from "./DesktopAppActivation.ts";
20
21const openServers: Array<{ close: () => Promise<void> }> = [];
22
23afterEach(async () => {
24 await Promise.all(openServers.splice(0).map((server) => server.close()));
25});
26
27function makeTarget(stateDir: string, platform: NodeJS.Platform, userId: number | undefined) {
28 return resolveDesktopAppControlAddress({
29 stateDir,
30 platform,
31 tempDir: NodeOS.tmpdir(),
32 userId,
33 joinPath: NodePath.join,
34 });
35}
36
37function request(requestId: string, platform: NodeJS.Platform): DesktopAppActivationRequest {
38 return {
39 version: 1,
40 requestId,
41 type: "open-workspace",
42 workspaceRoot: NodePath.join(NodeOS.tmpdir(), "project"),
43 platform: platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : "linux",
44 };
45}
46
47function exchange(address: string, payload: DesktopAppActivationRequest) {
48 return new Promise<DesktopAppActivationResponse>((resolve, reject) => {
49 const socket = NodeNet.createConnection(address);
50 socket.setEncoding("utf8");
51 let buffer = "";
52 socket.once("error", reject);
53 socket.once("connect", () => socket.write(`${JSON.stringify(payload)}\n`));
54 socket.on("data", (chunk) => {
55 buffer += chunk;
56 const newline = buffer.indexOf("\n");
57 if (newline === -1) return;
58 socket.destroy();
59 resolve(JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationResponse);
60 });
61 });
62}
63
64describe("desktop app control server", () => {
65 it.effect("roundtrips a request and removes its socket on shutdown", () =>
66 Effect.gen(function* () {
67 const platform = yield* HostProcessPlatform;
68 const userId = yield* HostProcessUserId;
69 yield* Effect.promise(async () => {
70 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-control-test-"));
71 const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
72 const received: DesktopAppActivationRequest[] = [];
73 const server = await startDesktopAppControlServer({
74 ...target,
75 userId,
76 handle: async (input) => {
77 received.push(input);
78 return {
79 version: 1,
80 requestId: input.requestId,
81 ok: true,
82 projectId: ProjectId.make("project-1"),
83 threadId: ThreadId.make("thread-1"),
84 };
85 },
86 cancel: () => undefined,
87 });
88 openServers.push(server);
89
90 const response = await exchange(target.address, request("request-1", platform));
91
92 expect(received).toHaveLength(1);
93 expect(response).toMatchObject({ ok: true, requestId: "request-1" });
94 await server.close();
95 openServers.splice(openServers.indexOf(server), 1);
96 if (target.directory !== null) {
97 await expect(NodeFSP.stat(target.address)).rejects.toMatchObject({ code: "ENOENT" });
98 }
99 await NodeFSP.rm(root, { recursive: true, force: true });
100 });
101 }),
102 );
103
104 it.effect("cancels a queued request when the client disconnects", () =>
105 Effect.gen(function* () {
106 const platform = yield* HostProcessPlatform;
107 const userId = yield* HostProcessUserId;
108 yield* Effect.promise(async () => {
109 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-cancel-test-"));
110 const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
111 let resolveCanceled: (requestId: string) => void = () => undefined;
112 const canceled = new Promise<string>((resolve) => {
113 resolveCanceled = resolve;
114 });
115 const server = await startDesktopAppControlServer({
116 ...target,
117 userId,
118 handle: () => new Promise(() => undefined),
119 cancel: resolveCanceled,
120 });
121 openServers.push(server);
122 const socket = NodeNet.createConnection(target.address);
123 await new Promise<void>((resolve, reject) => {
124 socket.once("error", reject);
125 socket.once("connect", () => {
126 socket.write(`${JSON.stringify(request("request-canceled", platform))}\n`, () => {
127 socket.destroy();
128 resolve();
129 });
130 });
131 });
132
133 await expect(canceled).resolves.toBe("request-canceled");
134 await server.close();
135 openServers.splice(openServers.indexOf(server), 1);
136 await NodeFSP.rm(root, { recursive: true, force: true });
137 });
138 }),
139 );
140});
apps/desktop/src/app/DesktopAppActivation.ts created+306
......@@ -0,0 +1,306 @@
1// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat uid and an atomic stale-socket unlink at the Node adapter boundary.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeNet from "node:net";
4import * as NodeOS from "node:os";
5
6import {
7 DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
8 DesktopAppActivationRequest,
9 type DesktopAppActivationResponse,
10} from "@t3tools/contracts";
11import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
12import { HostProcessUserId } from "@t3tools/shared/hostProcess";
13import * as Context from "effect/Context";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import * as Option from "effect/Option";
17import * as Path from "effect/Path";
18import * as Schema from "effect/Schema";
19import * as Scope from "effect/Scope";
20
21import type * as Electron from "electron";
22
23import * as ElectronWindow from "../electron/ElectronWindow.ts";
24import { DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL } from "../ipc/channels.ts";
25import * as DesktopWindow from "../window/DesktopWindow.ts";
26import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts";
27import * as DesktopEnvironment from "./DesktopEnvironment.ts";
28import { makeComponentLogger } from "./DesktopObservability.ts";
29
30const MAX_REQUEST_BYTES = 64 * 1024;
31const REQUEST_TIMEOUT_MS = 15_000;
32const isDesktopAppActivationRequest = Schema.is(DesktopAppActivationRequest);
33
34export class DesktopAppActivationStartError extends Schema.TaggedErrorClass<DesktopAppActivationStartError>()(
35 "DesktopAppActivationStartError",
36 {
37 address: Schema.String,
38 cause: Schema.Defect(),
39 },
40) {
41 override get message(): string {
42 return `Could not start the desktop app control socket at ${this.address}.`;
43 }
44}
45
46interface RunningControlServer {
47 readonly close: () => Promise<void>;
48}
49
50function invalidResponse(requestId: string, message: string): DesktopAppActivationResponse {
51 return {
52 version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
53 requestId,
54 ok: false,
55 code: "invalid-request",
56 message,
57 };
58}
59
60function requestIdFromUnknown(value: unknown): string {
61 if (
62 typeof value === "object" &&
63 value !== null &&
64 "requestId" in value &&
65 typeof value.requestId === "string" &&
66 value.requestId.trim().length > 0
67 ) {
68 return value.requestId;
69 }
70 return "invalid-request";
71}
72
73async function prepareUnixSocket(input: {
74 readonly address: string;
75 readonly directory: string;
76 readonly userId: number | undefined;
77}): Promise<void> {
78 await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 });
79 const stat = await NodeFSP.lstat(input.directory);
80 if (!stat.isDirectory() || stat.isSymbolicLink()) {
81 throw new Error(`${input.directory} is not a directory.`);
82 }
83 if (input.userId !== undefined && stat.uid !== input.userId) {
84 throw new Error(`${input.directory} is owned by another user.`);
85 }
86 await NodeFSP.chmod(input.directory, 0o700);
87 await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => {
88 if (error.code !== "ENOENT") throw error;
89 });
90}
91
92export async function startDesktopAppControlServer(input: {
93 readonly address: string;
94 readonly directory: string | null;
95 readonly userId: number | undefined;
96 readonly handle: (request: DesktopAppActivationRequest) => Promise<DesktopAppActivationResponse>;
97 readonly cancel: (requestId: string) => void;
98}): Promise<RunningControlServer> {
99 if (input.directory !== null) {
100 await prepareUnixSocket({
101 address: input.address,
102 directory: input.directory,
103 userId: input.userId,
104 });
105 }
106
107 const sockets = new Set<NodeNet.Socket>();
108 const server = NodeNet.createServer((socket) => {
109 sockets.add(socket);
110 socket.setEncoding("utf8");
111 let buffer = "";
112 let handled = false;
113 let responseSent = false;
114 let activeRequestId: string | null = null;
115
116 socket.setTimeout(5_000, () => socket.destroy());
117
118 const finish = (response: DesktopAppActivationResponse) => {
119 responseSent = true;
120 if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`);
121 };
122
123 socket.on("data", (chunk) => {
124 if (handled) return;
125 buffer += chunk;
126 if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_BYTES) {
127 handled = true;
128 finish(invalidResponse("invalid-request", "The desktop app request is too large."));
129 return;
130 }
131
132 const newline = buffer.indexOf("\n");
133 if (newline === -1) return;
134 handled = true;
135 socket.setTimeout(0);
136 const line = buffer.slice(0, newline);
137 let parsed: unknown;
138 try {
139 parsed = JSON.parse(line);
140 } catch {
141 finish(invalidResponse("invalid-request", "The desktop app request is not valid JSON."));
142 return;
143 }
144
145 if (!isDesktopAppActivationRequest(parsed)) {
146 finish(
147 invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."),
148 );
149 return;
150 }
151 activeRequestId = parsed.requestId;
152 void input.handle(parsed).then(finish, () => {
153 finish(
154 invalidResponse(parsed.requestId, "T3 Code could not process the desktop app request."),
155 );
156 });
157 });
158 socket.on("error", () => socket.destroy());
159 socket.on("close", () => {
160 sockets.delete(socket);
161 if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId);
162 });
163 });
164
165 await new Promise<void>((resolve, reject) => {
166 const onError = (error: Error) => {
167 server.removeListener("listening", onListening);
168 reject(error);
169 };
170 const onListening = () => {
171 server.removeListener("error", onError);
172 resolve();
173 };
174 server.once("error", onError);
175 server.once("listening", onListening);
176 server.listen(input.address);
177 });
178
179 try {
180 if (input.directory !== null) {
181 await NodeFSP.chmod(input.address, 0o600);
182 }
183 } catch (error) {
184 await new Promise<void>((resolve) => server.close(() => resolve()));
185 throw error;
186 }
187
188 let closed = false;
189 return {
190 close: async () => {
191 if (closed) return;
192 closed = true;
193 for (const socket of sockets) socket.destroy();
194 await new Promise<void>((resolve) => server.close(() => resolve()));
195 server.removeAllListeners();
196 if (input.directory !== null) {
197 await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => {
198 if (error.code !== "ENOENT") throw error;
199 });
200 }
201 },
202 };
203}
204
205export class DesktopAppActivation extends Context.Service<
206 DesktopAppActivation,
207 {
208 readonly start: Effect.Effect<void, DesktopAppActivationStartError, Scope.Scope>;
209 readonly setRendererReady: (ready: boolean) => Effect.Effect<void>;
210 readonly complete: (response: DesktopAppActivationResponse) => Effect.Effect<void>;
211 }
212>()("@t3tools/desktop/app/DesktopAppActivation") {}
213
214const { logWarning } = makeComponentLogger("desktop-app-activation");
215
216export const make = Effect.gen(function* () {
217 const desktopEnvironment = yield* DesktopEnvironment.DesktopEnvironment;
218 const desktopWindow = yield* DesktopWindow.DesktopWindow;
219 const electronWindow = yield* ElectronWindow.ElectronWindow;
220 const path = yield* Path.Path;
221 const userId = yield* HostProcessUserId;
222 const runPromise = Effect.runPromiseWith(yield* Effect.context<never>());
223 const address = resolveDesktopAppControlAddress({
224 stateDir: path.resolve(desktopEnvironment.stateDir),
225 platform: desktopEnvironment.platform,
226 tempDir: NodeOS.tmpdir(),
227 userId,
228 joinPath: path.join,
229 });
230 let registeredWebContents: Electron.WebContents | null = null;
231 let detachRendererListeners: (() => void) | null = null;
232
233 const broker = new DesktopAppActivationBroker({
234 requestTimeoutMs: REQUEST_TIMEOUT_MS,
235 activate: () => {
236 void runPromise(
237 desktopWindow.activate.pipe(
238 Effect.catchCause((cause) => logWarning("failed to focus the desktop window", { cause })),
239 ),
240 );
241 },
242 });
243
244 const clearRegisteredRenderer = () => {
245 detachRendererListeners?.();
246 detachRendererListeners = null;
247 registeredWebContents = null;
248 broker.clearRenderer();
249 };
250
251 return DesktopAppActivation.of({
252 start: Effect.acquireRelease(
253 Effect.tryPromise({
254 try: () =>
255 startDesktopAppControlServer({
256 ...address,
257 userId,
258 handle: (request) => broker.request(request),
259 cancel: (requestId) => broker.cancel(requestId),
260 }),
261 catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }),
262 }),
263 (server) =>
264 Effect.promise(() => server.close()).pipe(
265 Effect.catchCause((cause) =>
266 logWarning("failed to close the desktop app control socket", { cause }),
267 ),
268 Effect.ensuring(Effect.sync(() => broker.close())),
269 ),
270 ).pipe(Effect.asVoid),
271 setRendererReady: Effect.fn("DesktopAppActivation.setRendererReady")(function* (ready) {
272 if (!ready) {
273 clearRegisteredRenderer();
274 return;
275 }
276 const main = yield* electronWindow.main;
277 if (Option.isNone(main)) return;
278 const webContents = main.value.webContents;
279 if (webContents.isDestroyed()) return;
280
281 if (registeredWebContents !== webContents) {
282 clearRegisteredRenderer();
283 registeredWebContents = webContents;
284 const onUnavailable = () => clearRegisteredRenderer();
285 const onNavigation = (
286 event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>,
287 ) => {
288 if (event.isMainFrame && !event.isSameDocument) clearRegisteredRenderer();
289 };
290 webContents.on("did-start-navigation", onNavigation);
291 webContents.once("destroyed", onUnavailable);
292 detachRendererListeners = () => {
293 webContents.removeListener("did-start-navigation", onNavigation);
294 webContents.removeListener("destroyed", onUnavailable);
295 };
296 }
297
298 broker.registerRenderer((request) => {
299 webContents.send(DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, request);
300 });
301 }),
302 complete: (response) => Effect.sync(() => broker.complete(response)),
303 });
304});
305
306export const layer = Layer.effect(DesktopAppActivation, make);
apps/desktop/src/app/DesktopAppActivationBroker.test.ts created+130
......@@ -0,0 +1,130 @@
1import { ProjectId, ThreadId, type DesktopAppActivationRequest } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts";
5
6const request: DesktopAppActivationRequest = {
7 version: 1,
8 requestId: "request-1",
9 type: "open-workspace",
10 workspaceRoot: "/workspace/project",
11 platform: "linux",
12};
13
14describe("DesktopAppActivationBroker", () => {
15 it("focuses immediately and waits for renderer readiness", async () => {
16 const activate = vi.fn();
17 const send = vi.fn();
18 const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate });
19
20 const response = broker.request(request);
21 expect(activate).toHaveBeenCalledOnce();
22 expect(send).not.toHaveBeenCalled();
23
24 broker.registerRenderer(send);
25 expect(send).toHaveBeenCalledWith(request);
26 broker.complete({
27 version: 1,
28 requestId: request.requestId,
29 ok: true,
30 projectId: ProjectId.make("project-1"),
31 threadId: ThreadId.make("thread-1"),
32 });
33
34 await expect(response).resolves.toMatchObject({ ok: true, projectId: "project-1" });
35 broker.close();
36 });
37
38 it("fails an in-flight request when the renderer goes away", async () => {
39 const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() });
40 broker.registerRenderer(vi.fn());
41
42 const response = broker.request(request);
43 broker.clearRenderer();
44
45 await expect(response).resolves.toMatchObject({
46 ok: false,
47 code: "renderer-unavailable",
48 });
49 broker.close();
50 });
51
52 it("queues requests after unsubscribe until a new renderer registers", async () => {
53 const previousSend = vi.fn();
54 const nextSend = vi.fn();
55 const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() });
56 broker.registerRenderer(previousSend);
57 broker.clearRenderer();
58
59 const response = broker.request(request);
60 expect(previousSend).not.toHaveBeenCalled();
61 expect(nextSend).not.toHaveBeenCalled();
62
63 broker.registerRenderer(nextSend);
64 expect(nextSend).toHaveBeenCalledWith(request);
65 broker.complete({
66 version: 1,
67 requestId: request.requestId,
68 ok: true,
69 projectId: ProjectId.make("project-1"),
70 threadId: ThreadId.make("thread-1"),
71 });
72
73 await expect(response).resolves.toMatchObject({ ok: true });
74 broker.close();
75 });
76
77 it("removes a queued request when its CLI connection closes", async () => {
78 const send = vi.fn();
79 const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() });
80
81 const response = broker.request(request);
82 broker.cancel(request.requestId);
83 broker.registerRenderer(send);
84
85 await expect(response).resolves.toMatchObject({ ok: false, code: "renderer-unavailable" });
86 expect(send).not.toHaveBeenCalled();
87 broker.close();
88 });
89
90 it("never sends a canceled request that was queued behind another request", async () => {
91 const send = vi.fn();
92 const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() });
93 broker.registerRenderer(send);
94 const secondRequest = { ...request, requestId: "request-2" };
95
96 const firstResponse = broker.request(request);
97 const secondResponse = broker.request(secondRequest);
98 expect(send).toHaveBeenCalledTimes(1);
99 expect(send).toHaveBeenLastCalledWith(request);
100
101 broker.cancel(secondRequest.requestId);
102 broker.complete({
103 version: 1,
104 requestId: request.requestId,
105 ok: true,
106 projectId: ProjectId.make("project-1"),
107 threadId: ThreadId.make("thread-1"),
108 });
109
110 await expect(firstResponse).resolves.toMatchObject({ ok: true });
111 await expect(secondResponse).resolves.toMatchObject({ ok: false });
112 expect(send).toHaveBeenCalledTimes(1);
113 broker.close();
114 });
115
116 it("times out a request without polling", async () => {
117 vi.useFakeTimers();
118 try {
119 const broker = new DesktopAppActivationBroker({ requestTimeoutMs: 1_000, activate: vi.fn() });
120 const response = broker.request(request);
121
122 await vi.advanceTimersByTimeAsync(1_000);
123
124 await expect(response).resolves.toMatchObject({ ok: false, code: "request-timeout" });
125 broker.close();
126 } finally {
127 vi.useRealTimers();
128 }
129 });
130});
apps/desktop/src/app/DesktopAppActivationBroker.ts created+146
......@@ -0,0 +1,146 @@
1// @effect-diagnostics globalTimers:off -- This protocol broker owns cancellable request deadlines outside the Effect runtime.
2import {
3 DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
4 type DesktopAppActivationFailure,
5 type DesktopAppActivationRequest,
6 type DesktopAppActivationResponse,
7} from "@t3tools/contracts";
8
9interface PendingActivation {
10 readonly request: DesktopAppActivationRequest;
11 readonly resolve: (response: DesktopAppActivationResponse) => void;
12 readonly timeout: ReturnType<typeof setTimeout>;
13 dispatched: boolean;
14}
15
16type RendererSender = (request: DesktopAppActivationRequest) => void;
17
18function failure(
19 requestId: string,
20 code: DesktopAppActivationFailure["code"],
21 message: string,
22): DesktopAppActivationFailure {
23 return {
24 version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
25 requestId,
26 ok: false,
27 code,
28 message,
29 };
30}
31
32/** Holds CLI requests until the real desktop renderer is ready to handle them. */
33export class DesktopAppActivationBroker {
34 readonly #pending = new Map<string, PendingActivation>();
35 readonly #requestTimeoutMs: number;
36 readonly #activate: () => void;
37 #renderer: RendererSender | null = null;
38 #closed = false;
39
40 constructor(input: { readonly requestTimeoutMs: number; readonly activate: () => void }) {
41 this.#requestTimeoutMs = input.requestTimeoutMs;
42 this.#activate = input.activate;
43 }
44
45 request(request: DesktopAppActivationRequest): Promise<DesktopAppActivationResponse> {
46 if (this.#closed) {
47 return Promise.resolve(
48 failure(request.requestId, "renderer-unavailable", "T3 Code is shutting down."),
49 );
50 }
51 if (this.#pending.has(request.requestId)) {
52 return Promise.resolve(
53 failure(request.requestId, "invalid-request", "The request id is already in use."),
54 );
55 }
56
57 const response = new Promise<DesktopAppActivationResponse>((resolve) => {
58 const timeout = setTimeout(() => {
59 this.#settle(
60 failure(
61 request.requestId,
62 "request-timeout",
63 "The desktop app did not finish opening the project in time.",
64 ),
65 );
66 }, this.#requestTimeoutMs);
67 this.#pending.set(request.requestId, {
68 request,
69 resolve,
70 timeout,
71 dispatched: false,
72 });
73 });
74
75 this.#activate();
76 this.#flush();
77 return response;
78 }
79
80 registerRenderer(send: RendererSender): void {
81 this.#renderer = send;
82 this.#flush();
83 }
84
85 clearRenderer(): void {
86 this.#renderer = null;
87 for (const pending of this.#pending.values()) {
88 if (pending.dispatched) {
89 this.#settle(
90 failure(
91 pending.request.requestId,
92 "renderer-unavailable",
93 "The T3 Code window closed before it opened the project.",
94 ),
95 );
96 }
97 }
98 }
99
100 complete(response: DesktopAppActivationResponse): void {
101 this.#settle(response);
102 }
103
104 cancel(requestId: string): void {
105 this.#settle(
106 failure(requestId, "renderer-unavailable", "The command closed before T3 Code was ready."),
107 );
108 }
109
110 close(): void {
111 this.#closed = true;
112 this.#renderer = null;
113 for (const pending of this.#pending.values()) {
114 this.#settle(
115 failure(pending.request.requestId, "renderer-unavailable", "T3 Code is shutting down."),
116 );
117 }
118 }
119
120 #flush(): void {
121 const renderer = this.#renderer;
122 if (renderer === null) return;
123 if ([...this.#pending.values()].some((pending) => pending.dispatched)) return;
124
125 for (const pending of this.#pending.values()) {
126 if (pending.dispatched) continue;
127 try {
128 pending.dispatched = true;
129 renderer(pending.request);
130 } catch {
131 pending.dispatched = false;
132 this.#renderer = null;
133 }
134 return;
135 }
136 }
137
138 #settle(response: DesktopAppActivationResponse): void {
139 const pending = this.#pending.get(response.requestId);
140 if (!pending) return;
141 clearTimeout(pending.timeout);
142 this.#pending.delete(response.requestId);
143 pending.resolve(response);
144 this.#flush();
145 }
146}
apps/desktop/src/ipc/DesktopIpcHandlers.ts+4
......@@ -45,12 +45,16 @@ import {
4545 showContextMenu,
4646} from "./methods/window.ts";
4747import * as PreviewIpc from "./methods/preview.ts";
48import * as AppActivationIpc from "./methods/appActivation.ts";
4849import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";
4950
5051export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
5152 const ipc = yield* DesktopIpc.DesktopIpc;
5253 yield* PreviewIpc.installPreviewEventForwarding();
5354
55 yield* ipc.handle(AppActivationIpc.setReady);
56 yield* ipc.handle(AppActivationIpc.complete);
57
5458 yield* ipc.handleSync(getAppBranding);
5559 yield* ipc.handleSync(getSystemLocale);
5660 yield* ipc.handleSync(getWindowFullscreenState);
apps/desktop/src/ipc/channels.ts+3
......@@ -9,6 +9,9 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action";
99export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
1010export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
1111export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
12export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready";
13export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete";
14export const DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL = "desktop:app-activation-request";
1215export const UPDATE_STATE_CHANNEL = "desktop:update-state";
1316export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
1417export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
apps/desktop/src/ipc/methods/appActivation.ts created+27
......@@ -0,0 +1,27 @@
1import { DesktopAppActivationResponse } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4
5import * as DesktopAppActivation from "../../app/DesktopAppActivation.ts";
6import * as IpcChannels from "../channels.ts";
7import * as DesktopIpc from "../DesktopIpc.ts";
8
9export const setReady = DesktopIpc.makeIpcMethod({
10 channel: IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL,
11 payload: Schema.Boolean,
12 result: Schema.Void,
13 handler: Effect.fn("desktop.ipc.appActivation.setReady")(function* (ready) {
14 const activation = yield* DesktopAppActivation.DesktopAppActivation;
15 yield* activation.setRendererReady(ready);
16 }),
17});
18
19export const complete = DesktopIpc.makeIpcMethod({
20 channel: IpcChannels.DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL,
21 payload: DesktopAppActivationResponse,
22 result: Schema.Void,
23 handler: Effect.fn("desktop.ipc.appActivation.complete")(function* (response) {
24 const activation = yield* DesktopAppActivation.DesktopAppActivation;
25 yield* activation.complete(response);
26 }),
27});
apps/desktop/src/main.ts+6
......@@ -32,6 +32,7 @@ import * as ElectronTheme from "./electron/ElectronTheme.ts";
3232import * as ElectronUpdater from "./electron/ElectronUpdater.ts";
3333import * as ElectronWindow from "./electron/ElectronWindow.ts";
3434import * as DesktopApp from "./app/DesktopApp.ts";
35import * as DesktopAppActivation from "./app/DesktopAppActivation.ts";
3536import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
3637import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
3738import * as DesktopClerk from "./app/DesktopClerk.ts";
......@@ -157,6 +158,10 @@ const desktopWindowLayer = DesktopWindow.layer.pipe(
157158 Layer.provideMerge(desktopPreviewLayer),
158159);
159160
161const desktopAppActivationLayer = DesktopAppActivation.layer.pipe(
162 Layer.provide(desktopWindowLayer),
163);
164
160165// Pool layer instantiates the backend factory once for the Windows
161166// primary instance and exposes it via pool.primary. Consumers go through
162167// the pool now; the legacy DesktopBackendManager service is gone. The
......@@ -184,6 +189,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe(
184189
185190const desktopApplicationLayer = Layer.mergeAll(
186191 DesktopLifecycle.layer,
192 desktopAppActivationLayer,
187193 DesktopApplicationMenu.layer,
188194 DesktopLinuxUrlHandler.layer,
189195 DesktopShellEnvironment.layer,
apps/desktop/src/preload.ts+19
......@@ -178,6 +178,25 @@ contextBridge.exposeInMainWorld("desktopBridge", {
178178 ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
179179 };
180180 },
181 appActivation: {
182 setReady: (ready) =>
183 ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_READY_CHANNEL, ready),
184 complete: (response) =>
185 ipcRenderer.invoke(IpcChannels.DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL, response),
186 onRequest: (listener) => {
187 const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => {
188 if (typeof request !== "object" || request === null) return;
189 listener(request as Parameters<typeof listener>[0]);
190 };
191 ipcRenderer.on(IpcChannels.DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL, wrappedListener);
192 return () => {
193 ipcRenderer.removeListener(
194 IpcChannels.DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL,
195 wrappedListener,
196 );
197 };
198 },
199 },
181200 preview: {
182201 createTab: (tabId, defaults) =>
183202 ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {
apps/server/src/bin.ts+2
......@@ -8,6 +8,7 @@ import * as CliError from "effect/unstable/cli/CliError";
88import * as NetService from "@t3tools/shared/Net";
99import packageJson from "../package.json" with { type: "json" };
1010import { authCommand } from "./cli/auth.ts";
11import { appCommand } from "./cli/app.ts";
1112import { connectCommand } from "./cli/connect.ts";
1213import { pairCommand } from "./cli/pair.ts";
1314import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
......@@ -53,6 +54,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
5354 Command.withSubcommands([
5455 startCommand,
5556 serveCommand,
57 appCommand,
5658 pairCommand,
5759 authCommand,
5860 projectCommand,
apps/server/src/cli/app.test.ts created+307
......@@ -0,0 +1,307 @@
1// @effect-diagnostics nodeBuiltinImport:off -- The integration fixture binds the same platform socket or named pipe as the CLI.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeNet from "node:net";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { it } from "@effect/vitest";
9import type { DesktopAppActivationRequest } from "@t3tools/contracts";
10import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
11import {
12 HostProcessPlatform,
13 HostProcessUserId,
14 HostProcessWorkingDirectory,
15} from "@t3tools/shared/hostProcess";
16import * as NetService from "@t3tools/shared/Net";
17import * as ConfigProvider from "effect/ConfigProvider";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import { Command } from "effect/unstable/cli";
21import { afterEach, describe, expect, vi } from "vite-plus/test";
22
23import { makeCli } from "../bin.ts";
24
25vi.mock("node:os", async (importOriginal) => {
26 const os = await importOriginal<typeof import("node:os")>();
27 return { ...os, homedir: vi.fn(os.homedir) };
28});
29
30afterEach(() => vi.mocked(NodeOS.homedir).mockReset());
31
32const runCli = (args: ReadonlyArray<string>, env: Record<string, string> = {}) =>
33 Command.runWith(makeCli(), { version: "0.0.0" })(args).pipe(
34 Effect.provide(
35 Layer.mergeAll(
36 NodeServices.layer,
37 NetService.layer,
38 ConfigProvider.layer(ConfigProvider.fromEnv({ env })),
39 ),
40 ),
41 );
42
43const pathExists = (path: string) =>
44 Effect.promise(() =>
45 NodeFSP.stat(path).then(
46 () => true,
47 () => false,
48 ),
49 );
50
51async function startFakeDesktop(input: {
52 readonly baseDir: string;
53 readonly stateSubdirectory?: "userdata" | "dev";
54 readonly platform: NodeJS.Platform;
55 readonly userId: number | undefined;
56 readonly reply?: (request: DesktopAppActivationRequest) => unknown;
57}) {
58 const target = resolveDesktopAppControlAddress({
59 stateDir: NodePath.join(input.baseDir, input.stateSubdirectory ?? "userdata"),
60 platform: input.platform,
61 tempDir: NodeOS.tmpdir(),
62 userId: input.userId,
63 joinPath: NodePath.join,
64 });
65 if (target.directory !== null) {
66 await NodeFSP.mkdir(target.directory, { recursive: true, mode: 0o700 });
67 await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => {
68 if (error.code !== "ENOENT") throw error;
69 });
70 }
71
72 const received: DesktopAppActivationRequest[] = [];
73 const server = NodeNet.createServer((socket) => {
74 socket.setEncoding("utf8");
75 let buffer = "";
76 socket.on("data", (chunk) => {
77 buffer += chunk;
78 const newline = buffer.indexOf("\n");
79 if (newline === -1) return;
80 const request = JSON.parse(buffer.slice(0, newline)) as DesktopAppActivationRequest;
81 received.push(request);
82 const response = input.reply
83 ? input.reply(request)
84 : {
85 version: 1,
86 requestId: request.requestId,
87 ok: true,
88 projectId: "project-1",
89 threadId: `thread-${received.length}`,
90 };
91 socket.end(`${JSON.stringify(response)}\n`);
92 });
93 });
94 await new Promise<void>((resolve, reject) => {
95 server.once("error", reject);
96 server.listen(target.address, resolve);
97 });
98
99 return {
100 received,
101 close: async () => {
102 await new Promise<void>((resolve) => server.close(() => resolve()));
103 if (target.directory !== null) {
104 await NodeFSP.unlink(target.address).catch((error: NodeJS.ErrnoException) => {
105 if (error.code !== "ENOENT") throw error;
106 });
107 }
108 },
109 };
110}
111
112const fakeDesktop = Effect.fn(function* (
113 input: Omit<Parameters<typeof startFakeDesktop>[0], "platform" | "userId">,
114) {
115 const platform = yield* HostProcessPlatform;
116 const userId = yield* HostProcessUserId;
117 return yield* Effect.acquireRelease(
118 Effect.promise(() => startFakeDesktop({ ...input, platform, userId })),
119 (server) => Effect.promise(() => server.close()),
120 );
121});
122
123const withTempDirectory = <A, E, R>(
124 prefix: string,
125 use: (root: string) => Effect.Effect<A, E, R>,
126) =>
127 Effect.acquireUseRelease(
128 Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), prefix))),
129 use,
130 (root) => Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })),
131 );
132
133describe("t3 app", () => {
134 it.effect("rejects SSH before it tries to reach a desktop app", () =>
135 withTempDirectory("t3-app-ssh-test-", (root) =>
136 Effect.gen(function* () {
137 const baseDir = NodePath.join(root, "missing-t3-home");
138 const error = yield* runCli(["app", "--base-dir", baseDir], {
139 SSH_CONNECTION: "client server",
140 }).pipe(Effect.flip);
141
142 expect(error).toMatchObject({
143 _tag: "DesktopAppSshUnsupportedError",
144 message:
145 "`t3 app` only controls a desktop app on the same machine. It cannot run over SSH.",
146 });
147 expect(yield* pathExists(baseDir)).toBe(false);
148 }),
149 ),
150 );
151
152 it.effect("rejects unsupported platforms without creating state", () =>
153 withTempDirectory("t3-app-platform-test-", (root) =>
154 Effect.gen(function* () {
155 const baseDir = NodePath.join(root, "missing-t3-home");
156 const error = yield* runCli(["app", "--base-dir", baseDir]).pipe(
157 Effect.provideService(HostProcessPlatform, "freebsd"),
158 Effect.flip,
159 );
160
161 expect(error).toMatchObject({
162 _tag: "DesktopAppPlatformUnsupportedError",
163 platform: "freebsd",
164 message: "`t3 app` is not supported on freebsd.",
165 });
166 expect(yield* pathExists(baseDir)).toBe(false);
167 }),
168 ),
169 );
170
171 it.effect("does not create state when only a server or no desktop app is running", () =>
172 withTempDirectory("t3-app-missing-test-", (root) =>
173 Effect.gen(function* () {
174 const baseDir = NodePath.join(root, "missing-t3-home");
175 const error = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip);
176
177 expect(error).toMatchObject({
178 _tag: "DesktopAppUnreachableError",
179 candidateAddresses: [expect.any(String)],
180 workspaceRoot: yield* HostProcessWorkingDirectory,
181 message: expect.stringContaining("Could not reach the T3 Code desktop app."),
182 cause: { code: "ENOENT" },
183 });
184 expect(yield* pathExists(baseDir)).toBe(false);
185 }),
186 ),
187 );
188
189 it.effect("uses T3CODE_HOME or --base-dir and sends the default or explicit path", () =>
190 withTempDirectory("t3-app-command-test-", (root) =>
191 Effect.gen(function* () {
192 const baseDir = NodePath.join(root, "t3-home");
193 const explicitPath = NodePath.join(root, "project");
194 const platform = yield* HostProcessPlatform;
195 const workingDirectory = yield* HostProcessWorkingDirectory;
196 const desktop = yield* fakeDesktop({ baseDir });
197
198 yield* runCli(["app"], { T3CODE_HOME: baseDir });
199 yield* runCli(["app", explicitPath, "--base-dir", baseDir]);
200
201 expect(desktop.received.map((request) => request.workspaceRoot)).toEqual([
202 workingDirectory,
203 explicitPath,
204 ]);
205 expect(desktop.received.every((request) => request.platform === platform)).toBe(true);
206 }).pipe(Effect.scoped),
207 ),
208 );
209
210 it.effect("prefers the installed desktop app when a dev desktop is also running", () =>
211 withTempDirectory("t3-app-preferred-test-", (root) =>
212 Effect.gen(function* () {
213 vi.mocked(NodeOS.homedir).mockReturnValue(root);
214 const baseDir = NodePath.join(root, ".t3");
215 const desktop = yield* fakeDesktop({ baseDir });
216 const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" });
217
218 yield* runCli(["app"]);
219
220 expect(desktop.received).toHaveLength(1);
221 expect(development.received).toHaveLength(0);
222 }).pipe(Effect.scoped),
223 ),
224 );
225
226 it.effect("finds the dev desktop when the default desktop socket is absent", () =>
227 withTempDirectory("t3-app-dev-test-", (root) =>
228 Effect.gen(function* () {
229 vi.mocked(NodeOS.homedir).mockReturnValue(root);
230 const baseDir = NodePath.join(root, ".t3");
231 const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" });
232
233 yield* runCli(["app"]);
234 yield* runCli(["app"], { T3CODE_HOME: " " });
235
236 expect(development.received).toHaveLength(2);
237 expect(yield* pathExists(baseDir)).toBe(false);
238 }).pipe(Effect.scoped),
239 ),
240 );
241
242 it.effect("never searches a dev state directory for an explicit T3 home", () =>
243 withTempDirectory("t3-app-explicit-test-", (root) =>
244 Effect.gen(function* () {
245 vi.mocked(NodeOS.homedir).mockReturnValue(root);
246 const baseDir = NodePath.join(root, ".t3");
247 const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" });
248
249 const flagError = yield* runCli(["app", "--base-dir", baseDir]).pipe(Effect.flip);
250 const envError = yield* runCli(["app"], { T3CODE_HOME: baseDir }).pipe(Effect.flip);
251
252 expect(flagError).toMatchObject({ _tag: "DesktopAppUnreachableError" });
253 expect(envError).toMatchObject({ _tag: "DesktopAppUnreachableError" });
254 expect(development.received).toHaveLength(0);
255 }).pipe(Effect.scoped),
256 ),
257 );
258
259 for (const responseKind of ["failure", "invalid"] as const) {
260 it.effect(`never falls back after the default desktop sends a ${responseKind} response`, () =>
261 withTempDirectory("t3-app-response-test-", (root) =>
262 Effect.gen(function* () {
263 vi.mocked(NodeOS.homedir).mockReturnValue(root);
264 const baseDir = NodePath.join(root, ".t3");
265 const desktop = yield* fakeDesktop({
266 baseDir,
267 reply: (request) =>
268 responseKind === "failure"
269 ? {
270 version: 1,
271 requestId: request.requestId,
272 ok: false,
273 code: "project-create-failed",
274 message: "The project path is not available.",
275 }
276 : { invalid: true },
277 });
278 const development = yield* fakeDesktop({ baseDir, stateSubdirectory: "dev" });
279
280 const error = yield* runCli(["app"]).pipe(Effect.flip);
281
282 expect(desktop.received).toHaveLength(1);
283 expect(development.received).toHaveLength(0);
284 if (responseKind === "failure") {
285 expect(error).toMatchObject({
286 _tag: "DesktopAppRequestFailedError",
287 code: "project-create-failed",
288 requestId: desktop.received[0]?.requestId,
289 workspaceRoot: yield* HostProcessWorkingDirectory,
290 message: expect.stringContaining("project-create-failed"),
291 cause: {
292 ok: false,
293 code: "project-create-failed",
294 message: "The project path is not available.",
295 },
296 });
297 } else {
298 expect(error).toMatchObject({
299 _tag: "DesktopAppUnreachableError",
300 cause: { message: "The desktop app response is invalid." },
301 });
302 }
303 }).pipe(Effect.scoped),
304 ),
305 );
306 }
307});
apps/server/src/cli/app.ts created+261
......@@ -0,0 +1,261 @@
1// @effect-diagnostics globalTimers:off -- The Node socket client owns its response deadline and clears it on every completion path.
2import * as NodeCrypto from "node:crypto";
3import * as NodeNet from "node:net";
4import * as NodeOS from "node:os";
5
6import {
7 DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
8 DesktopAppActivationErrorCode,
9 DesktopAppActivationResponse,
10 type DesktopAppActivationPlatform,
11 type DesktopAppActivationRequest,
12} from "@t3tools/contracts";
13import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
14import {
15 HostProcessPlatform,
16 HostProcessUserId,
17 HostProcessWorkingDirectory,
18} from "@t3tools/shared/hostProcess";
19import * as Config from "effect/Config";
20import * as Console from "effect/Console";
21import * as Effect from "effect/Effect";
22import * as Option from "effect/Option";
23import * as Path from "effect/Path";
24import * as Schema from "effect/Schema";
25import { Argument, Command } from "effect/unstable/cli";
26
27import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
28import { baseDirFlag } from "./config.ts";
29
30const CLI_RESPONSE_TIMEOUT_MS = 17_000;
31const MAX_RESPONSE_BYTES = 64 * 1024;
32const isDesktopAppActivationResponse = Schema.is(DesktopAppActivationResponse);
33
34export class DesktopAppSshUnsupportedError extends Schema.TaggedErrorClass<DesktopAppSshUnsupportedError>()(
35 "DesktopAppSshUnsupportedError",
36 {},
37) {
38 override get message(): string {
39 return "`t3 app` only controls a desktop app on the same machine. It cannot run over SSH.";
40 }
41}
42
43export class DesktopAppPlatformUnsupportedError extends Schema.TaggedErrorClass<DesktopAppPlatformUnsupportedError>()(
44 "DesktopAppPlatformUnsupportedError",
45 { platform: Schema.String },
46) {
47 override get message(): string {
48 return `\`t3 app\` is not supported on ${this.platform}.`;
49 }
50}
51
52export class DesktopAppUnreachableError extends Schema.TaggedErrorClass<DesktopAppUnreachableError>()(
53 "DesktopAppUnreachableError",
54 {
55 candidateAddresses: Schema.Array(Schema.String),
56 requestId: Schema.String,
57 workspaceRoot: Schema.String,
58 cause: Schema.Defect(),
59 },
60) {
61 override get message(): string {
62 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.";
63 }
64}
65
66export class DesktopAppRequestFailedError extends Schema.TaggedErrorClass<DesktopAppRequestFailedError>()(
67 "DesktopAppRequestFailedError",
68 {
69 code: DesktopAppActivationErrorCode,
70 requestId: Schema.String,
71 workspaceRoot: Schema.String,
72 cause: Schema.Defect(),
73 },
74) {
75 override get message(): string {
76 return `T3 Code could not open ${this.workspaceRoot} (${this.code}).`;
77 }
78}
79
80function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppActivationPlatform {
81 return platform === "darwin" || platform === "linux" || platform === "win32";
82}
83
84export function sendDesktopAppActivationRequest(input: {
85 readonly address: string;
86 readonly fallbackAddress?: string;
87 readonly request: DesktopAppActivationRequest;
88 readonly timeoutMs?: number;
89}): Promise<DesktopAppActivationResponse> {
90 return new Promise((resolve, reject) => {
91 const socket = NodeNet.createConnection(input.address);
92 socket.setEncoding("utf8");
93 let buffer = "";
94 let settled = false;
95 let connected = false;
96
97 const finish = (
98 result:
99 | { readonly type: "success"; readonly response: DesktopAppActivationResponse }
100 | { readonly type: "failure"; readonly error: Error },
101 ) => {
102 if (settled) return;
103 settled = true;
104 clearTimeout(timeout);
105 socket.destroy();
106 if (result.type === "success") resolve(result.response);
107 else reject(result.error);
108 };
109
110 const timeout = setTimeout(() => {
111 finish({
112 type: "failure",
113 error: new Error("The desktop app did not respond in time."),
114 });
115 }, input.timeoutMs ?? CLI_RESPONSE_TIMEOUT_MS);
116
117 socket.once("connect", () => {
118 connected = true;
119 socket.write(`${JSON.stringify(input.request)}\n`);
120 });
121 socket.on("data", (chunk) => {
122 buffer += chunk;
123 if (Buffer.byteLength(buffer, "utf8") > MAX_RESPONSE_BYTES) {
124 finish({ type: "failure", error: new Error("The desktop app response is too large.") });
125 return;
126 }
127 const newline = buffer.indexOf("\n");
128 if (newline === -1) return;
129
130 let parsed: unknown;
131 try {
132 parsed = JSON.parse(buffer.slice(0, newline));
133 } catch {
134 finish({
135 type: "failure",
136 error: new Error("The desktop app response is not valid JSON."),
137 });
138 return;
139 }
140 if (!isDesktopAppActivationResponse(parsed)) {
141 finish({ type: "failure", error: new Error("The desktop app response is invalid.") });
142 return;
143 }
144 if (parsed.requestId !== input.request.requestId) {
145 finish({
146 type: "failure",
147 error: new Error("The desktop app response did not match this request."),
148 });
149 return;
150 }
151 finish({ type: "success", response: parsed });
152 });
153 socket.once("error", (error: NodeJS.ErrnoException) => {
154 if (
155 !settled &&
156 !connected &&
157 input.fallbackAddress !== undefined &&
158 (error.code === "ENOENT" || error.code === "ECONNREFUSED")
159 ) {
160 settled = true;
161 clearTimeout(timeout);
162 socket.destroy();
163 resolve(
164 sendDesktopAppActivationRequest({
165 address: input.fallbackAddress,
166 request: input.request,
167 ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
168 }),
169 );
170 return;
171 }
172 finish({ type: "failure", error });
173 });
174 socket.once("end", () => {
175 finish({ type: "failure", error: new Error("The desktop app closed the connection.") });
176 });
177 });
178}
179
180const appEnvironment = Config.all({
181 t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
182 sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option),
183 sshTty: Config.string("SSH_TTY").pipe(Config.option),
184});
185
186const runAppCommand = Effect.fn("cli.app")(function* (flags: {
187 readonly baseDir: Option.Option<string>;
188 readonly workspaceRoot: Option.Option<string>;
189}) {
190 const environment = yield* appEnvironment;
191 const hostPlatform = yield* HostProcessPlatform;
192 if (Option.isSome(environment.sshConnection) || Option.isSome(environment.sshTty)) {
193 return yield* new DesktopAppSshUnsupportedError({});
194 }
195 if (!isDesktopPlatform(hostPlatform)) {
196 return yield* new DesktopAppPlatformUnsupportedError({ platform: hostPlatform });
197 }
198
199 const path = yield* Path.Path;
200 const configuredBaseDir = Option.getOrUndefined(flags.baseDir) ?? environment.t3Home;
201 const baseDir = yield* resolveBaseDir(configuredBaseDir);
202 const allowDevFallback = Option.isNone(flags.baseDir) && !environment.t3Home?.trim();
203 const rawWorkspaceRoot =
204 Option.getOrUndefined(flags.workspaceRoot) ?? (yield* HostProcessWorkingDirectory);
205 const workspaceRoot = path.resolve(yield* expandHomePath(rawWorkspaceRoot));
206 const userId = yield* HostProcessUserId;
207 const resolveAddress = (stateSubdirectory: "userdata" | "dev") =>
208 resolveDesktopAppControlAddress({
209 stateDir: path.join(baseDir, stateSubdirectory),
210 platform: hostPlatform,
211 tempDir: NodeOS.tmpdir(),
212 userId,
213 joinPath: path.join,
214 }).address;
215 const request: DesktopAppActivationRequest = {
216 version: DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
217 requestId: NodeCrypto.randomUUID(),
218 type: "open-workspace",
219 workspaceRoot,
220 platform: hostPlatform,
221 };
222 const address = resolveAddress("userdata");
223 const fallbackAddress = allowDevFallback ? resolveAddress("dev") : undefined;
224
225 const response = yield* Effect.tryPromise({
226 try: () =>
227 sendDesktopAppActivationRequest({
228 address,
229 ...(fallbackAddress === undefined ? {} : { fallbackAddress }),
230 request,
231 }),
232 catch: (cause) =>
233 new DesktopAppUnreachableError({
234 candidateAddresses: fallbackAddress === undefined ? [address] : [address, fallbackAddress],
235 requestId: request.requestId,
236 workspaceRoot,
237 cause,
238 }),
239 });
240 if (!response.ok) {
241 return yield* new DesktopAppRequestFailedError({
242 code: response.code,
243 requestId: response.requestId,
244 workspaceRoot,
245 cause: response,
246 });
247 }
248
249 yield* Console.log(`Opened ${workspaceRoot} in T3 Code.`);
250});
251
252export const appCommand = Command.make("app", {
253 baseDir: baseDirFlag,
254 workspaceRoot: Argument.string("path").pipe(
255 Argument.withDescription("Project directory. Default: current directory."),
256 Argument.optional,
257 ),
258}).pipe(
259 Command.withDescription("Open a project in the running T3 Code desktop app."),
260 Command.withHandler(runAppCommand),
261);
apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx created+105
......@@ -0,0 +1,105 @@
1import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2import type { DesktopAppActivationRequest } from "@t3tools/contracts";
3import { useEffect, useEffectEvent, useRef } from "react";
4
5import { handleDesktopAppActivationRequest } from "../../desktopAppActivation";
6import { useNewThreadHandler } from "../../hooks/useHandleNewThread";
7import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths";
8import { newProjectId } from "../../lib/utils";
9import { resolveDefaultProviderModelSelection } from "../../providerInstances";
10import { readProjects, waitForProject } from "../../state/entities";
11import { usePrimaryEnvironment } from "../../state/environments";
12import { projectEnvironment } from "../../state/projects";
13import { useEnvironmentQuery } from "../../state/query";
14import { environmentShell } from "../../state/shell";
15import { useAtomCommand } from "../../state/use-atom-command";
16
17export function DesktopAppActivationCoordinator() {
18 const primaryEnvironment = usePrimaryEnvironment();
19 const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false });
20 const openThread = useNewThreadHandler();
21 const queueRef = useRef(Promise.resolve());
22 const activation = window.desktopBridge?.appActivation;
23 const shell = useEnvironmentQuery(
24 primaryEnvironment === null
25 ? null
26 : environmentShell.stateAtom(primaryEnvironment.environmentId),
27 );
28 const ready =
29 activation !== undefined &&
30 primaryEnvironment?.connection.phase === "connected" &&
31 primaryEnvironment.serverConfig !== null &&
32 shell.data?.snapshot._tag === "Some";
33
34 const processRequest = useEffectEvent(async (request: DesktopAppActivationRequest) =>
35 handleDesktopAppActivationRequest(request, {
36 getTarget: () => {
37 if (
38 primaryEnvironment?.connection.phase !== "connected" ||
39 primaryEnvironment.serverConfig === null
40 ) {
41 return null;
42 }
43 return {
44 environmentId: primaryEnvironment.environmentId,
45 platform: primaryEnvironment.serverConfig.environment.platform.os,
46 };
47 },
48 findProject: (environmentId, workspaceRoot) =>
49 findProjectByPath(
50 readProjects().filter((project) => project.environmentId === environmentId),
51 workspaceRoot,
52 ) ?? null,
53 createProject: async (environmentId, workspaceRoot) => {
54 const projectId = newProjectId();
55 const providers =
56 primaryEnvironment?.environmentId === environmentId
57 ? (primaryEnvironment.serverConfig?.providers ?? [])
58 : [];
59 const result = await createProject({
60 environmentId,
61 input: {
62 projectId,
63 title: inferProjectTitleFromPath(workspaceRoot),
64 workspaceRoot,
65 createWorkspaceRootIfMissing: false,
66 defaultModelSelection: resolveDefaultProviderModelSelection(providers, null),
67 },
68 });
69 if (result._tag === "Failure") {
70 const error = squashAtomCommandFailure(result);
71 throw error instanceof Error ? error : new Error("T3 Code could not add the project.");
72 }
73 return projectId;
74 },
75 waitForProject: async (projectRef) => {
76 await waitForProject(projectRef);
77 },
78 openThread: (projectRef) => openThread(projectRef),
79 }),
80 );
81
82 useEffect(() => {
83 if (!ready || activation === undefined) return;
84
85 let subscribed = true;
86 const unsubscribe = activation.onRequest((request) => {
87 queueRef.current = queueRef.current.then(async () => {
88 const response = await processRequest(request);
89 await activation.complete(response);
90 });
91 queueRef.current = queueRef.current.catch(() => undefined);
92 });
93 // Skip readiness if React runs cleanup before this subscription can receive requests.
94 queueMicrotask(() => {
95 if (subscribed) void activation.setReady(true).catch(() => undefined);
96 });
97 return () => {
98 subscribed = false;
99 void activation.setReady(false).catch(() => undefined);
100 unsubscribe();
101 };
102 }, [activation, ready]);
103
104 return null;
105}
apps/web/src/desktopAppActivation.test.ts created+107
......@@ -0,0 +1,107 @@
1import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import {
5 handleDesktopAppActivationRequest,
6 type DesktopAppActivationDependencies,
7} from "./desktopAppActivation";
8
9const environmentId = EnvironmentId.make("primary");
10const existingProjectId = ProjectId.make("project-existing");
11const createdProjectId = ProjectId.make("project-created");
12const threadId = ThreadId.make("thread-1");
13const request = {
14 version: 1,
15 requestId: "request-1",
16 type: "open-workspace",
17 workspaceRoot: "/workspace/project",
18 platform: "linux",
19} as const;
20
21function dependencies(
22 overrides: Partial<DesktopAppActivationDependencies> = {},
23): DesktopAppActivationDependencies {
24 return {
25 getTarget: () => ({ environmentId, platform: "linux" }),
26 findProject: () => ({
27 id: existingProjectId,
28 environmentId,
29 workspaceRoot: request.workspaceRoot,
30 }),
31 createProject: vi.fn(async () => createdProjectId),
32 waitForProject: vi.fn(async () => undefined),
33 openThread: vi.fn(async () => ({ threadId })),
34 ...overrides,
35 };
36}
37
38describe("desktop app activation", () => {
39 it("reuses an existing project and opens a new thread", async () => {
40 const deps = dependencies();
41
42 const response = await handleDesktopAppActivationRequest(request, deps);
43
44 expect(deps.createProject).not.toHaveBeenCalled();
45 expect(deps.openThread).toHaveBeenCalledWith({ environmentId, projectId: existingProjectId });
46 expect(response).toEqual({
47 version: 1,
48 requestId: request.requestId,
49 ok: true,
50 projectId: existingProjectId,
51 threadId,
52 });
53 });
54
55 it("waits for a created project before it opens the thread", async () => {
56 const order: string[] = [];
57 const deps = dependencies({
58 findProject: () => null,
59 createProject: vi.fn(async () => {
60 order.push("create");
61 return createdProjectId;
62 }),
63 waitForProject: vi.fn(async () => {
64 order.push("project-event");
65 }),
66 openThread: vi.fn(async () => {
67 order.push("open-thread");
68 return { threadId };
69 }),
70 });
71
72 const response = await handleDesktopAppActivationRequest(request, deps);
73
74 expect(order).toEqual(["create", "project-event", "open-thread"]);
75 expect(response).toMatchObject({ ok: true, projectId: createdProjectId });
76 });
77
78 it("rejects a Windows path when the primary environment is WSL", async () => {
79 const response = await handleDesktopAppActivationRequest(
80 { ...request, platform: "win32" },
81 dependencies({ getTarget: () => ({ environmentId, platform: "linux" }) }),
82 );
83
84 expect(response).toMatchObject({ ok: false, code: "platform-mismatch" });
85 });
86
87 it("returns a project error without opening a thread", async () => {
88 const openThread = vi.fn(async () => ({ threadId }));
89 const response = await handleDesktopAppActivationRequest(
90 request,
91 dependencies({
92 findProject: () => null,
93 createProject: vi.fn(async () => {
94 throw new Error("Project path is not available.");
95 }),
96 openThread,
97 }),
98 );
99
100 expect(response).toMatchObject({
101 ok: false,
102 code: "project-create-failed",
103 message: "Project path is not available.",
104 });
105 expect(openThread).not.toHaveBeenCalled();
106 });
107});
apps/web/src/desktopAppActivation.ts created+119
......@@ -0,0 +1,119 @@
1import type {
2 DesktopAppActivationFailure,
3 DesktopAppActivationRequest,
4 DesktopAppActivationResponse,
5 EnvironmentId,
6 ExecutionEnvironmentPlatformOs,
7 ProjectId,
8 ScopedProjectRef,
9 ThreadId,
10} from "@t3tools/contracts";
11
12export interface DesktopAppActivationProject {
13 readonly id: ProjectId;
14 readonly environmentId: EnvironmentId;
15 readonly workspaceRoot: string;
16}
17
18export interface DesktopAppActivationTarget {
19 readonly environmentId: EnvironmentId;
20 readonly platform: ExecutionEnvironmentPlatformOs;
21}
22
23export interface DesktopAppActivationDependencies {
24 readonly getTarget: () => DesktopAppActivationTarget | null;
25 readonly findProject: (
26 environmentId: EnvironmentId,
27 workspaceRoot: string,
28 ) => DesktopAppActivationProject | null;
29 readonly createProject: (
30 environmentId: EnvironmentId,
31 workspaceRoot: string,
32 ) => Promise<ProjectId>;
33 readonly waitForProject: (projectRef: ScopedProjectRef) => Promise<void>;
34 readonly openThread: (
35 projectRef: ScopedProjectRef,
36 ) => Promise<{ readonly threadId: ThreadId } | null>;
37}
38
39function failure(
40 requestId: string,
41 code: DesktopAppActivationFailure["code"],
42 message: string,
43): DesktopAppActivationFailure {
44 return { version: 1, requestId, ok: false, code, message };
45}
46
47export function desktopPlatformToEnvironmentOs(
48 platform: DesktopAppActivationRequest["platform"],
49): ExecutionEnvironmentPlatformOs {
50 return platform === "win32" ? "windows" : platform;
51}
52
53function errorMessage(error: unknown, fallback: string): string {
54 return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback;
55}
56
57export async function handleDesktopAppActivationRequest(
58 request: DesktopAppActivationRequest,
59 dependencies: DesktopAppActivationDependencies,
60): Promise<DesktopAppActivationResponse> {
61 const target = dependencies.getTarget();
62 if (target === null) {
63 return failure(
64 request.requestId,
65 "environment-unavailable",
66 "The desktop app's primary local environment is not connected.",
67 );
68 }
69
70 const requestPlatform = desktopPlatformToEnvironmentOs(request.platform);
71 if (requestPlatform !== target.platform) {
72 return failure(
73 request.requestId,
74 "platform-mismatch",
75 `The command path is for ${requestPlatform}, but the desktop app's primary environment uses ${target.platform}. Cross-platform path mapping is not supported.`,
76 );
77 }
78
79 let projectId = dependencies.findProject(target.environmentId, request.workspaceRoot)?.id ?? null;
80 if (projectId === null) {
81 try {
82 projectId = await dependencies.createProject(target.environmentId, request.workspaceRoot);
83 await dependencies.waitForProject({ environmentId: target.environmentId, projectId });
84 } catch (error) {
85 return failure(
86 request.requestId,
87 "project-create-failed",
88 errorMessage(error, "T3 Code could not add the project."),
89 );
90 }
91 }
92
93 try {
94 const opened = await dependencies.openThread({
95 environmentId: target.environmentId,
96 projectId,
97 });
98 if (opened === null) {
99 return failure(
100 request.requestId,
101 "thread-open-failed",
102 "T3 Code could not open a new thread for the project.",
103 );
104 }
105 return {
106 version: 1,
107 requestId: request.requestId,
108 ok: true,
109 projectId,
110 threadId: opened.threadId,
111 };
112 } catch (error) {
113 return failure(
114 request.requestId,
115 "thread-open-failed",
116 errorMessage(error, "T3 Code could not open a new thread for the project."),
117 );
118 }
119}
apps/web/src/hooks/useHandleNewThread.ts+3-3
......@@ -23,7 +23,7 @@ import {
2323 selectProjectGroupingSettings,
2424} from "../logicalProject";
2525import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
26import { readThreadShell, useProjects, useThread } from "../state/entities";
26import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";
2727import {
2828 hasExplicitComposerModelSelection,
2929 resolveNewDraftStartFromOrigin,
......@@ -55,7 +55,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef
5555}
5656
5757export function useNewThreadHandler() {
58 const projects = useProjects();
5958 // New-thread defaults are a user preference, and the settings UI only ever
6059 // edits the primary environment's settings.json. Reading the target
6160 // environment's own settings here would silently reset remote projects to
......@@ -83,6 +82,7 @@ export function useNewThreadHandler() {
8382 // prepared checkout, a task to write — addresses that one rather than looking the project
8483 // up again and finding whichever draft it happens to hold.
8584 ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => {
85 const projects = readProjects();
8686 const {
8787 getComposerDraft,
8888 getDraftSessionByLogicalProjectKey,
......@@ -421,7 +421,7 @@ export function useNewThreadHandler() {
421421 return { draftId, threadId };
422422 })();
423423 },
424 [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, projects, router],
424 [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router],
425425 );
426426}
427427
apps/web/src/routes/__root.tsx+2
......@@ -18,6 +18,7 @@ import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
1818import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
1919import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
2020import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
21import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
2122import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
2223import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
2324import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
......@@ -139,6 +140,7 @@ function RootRouteView() {
139140 <GlassAppearanceSync />
140141 <FontAppearanceSync />
141142 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
143 {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null}
142144 <RelayClientInstallDialog />
143145 <ConnectOnboardingDialog />
144146 <SshPasswordPromptDialog />
apps/web/src/state/entities.ts+29
......@@ -218,6 +218,35 @@ export function readProject(ref: ScopedProjectRef): EnvironmentProject | null {
218218 return appAtomRegistry.get(environmentProjects.projectAtom(ref));
219219}
220220
221export function readProjects(): ReadonlyArray<EnvironmentProject> {
222 return appAtomRegistry.get(environmentProjects.projectsAtom);
223}
224
225/** Resolves when the project event reaches the live client store. */
226export function waitForProject(
227 ref: ScopedProjectRef,
228 timeoutMs = 10_000,
229): Promise<EnvironmentProject> {
230 const current = readProject(ref);
231 if (current !== null) return Promise.resolve(current);
232
233 return new Promise((resolve, reject) => {
234 let unsubscribe: (() => void) | null = null;
235 const timeout = setTimeout(() => {
236 unsubscribe?.();
237 reject(new Error("The project did not appear in the desktop app."));
238 }, timeoutMs);
239 const finish = (project: EnvironmentProject | null) => {
240 if (project === null) return;
241 clearTimeout(timeout);
242 unsubscribe?.();
243 resolve(project);
244 };
245 unsubscribe = appAtomRegistry.subscribe(environmentProjects.projectAtom(ref), finish);
246 finish(readProject(ref));
247 });
248}
249
221250export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null {
222251 return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));
223252}
docs/user/install.md+20
......@@ -17,6 +17,26 @@ npx t3@latest
1717This starts the T3 Code server on your machine and opens the local web app. Use
1818`npx t3@latest --help` for the full CLI reference.
1919
20## Open a project in the desktop app
21
22When the T3 Code desktop app is running on the same machine, open the current directory with:
23
24```bash
25npx t3 app
26```
27
28Pass a path to open another directory:
29
30```bash
31npx t3 app ../my-project
32```
33
34The command adds the directory as a project when needed, focuses the desktop app, and opens a new
35thread. It does not launch the desktop app, open a browser, or start a T3 Code server. A background
36server does not count as the desktop app. The command also rejects SSH sessions because a remote
37shell cannot focus a local desktop window. The CLI package and the running desktop app must both
38include `t3 app` support.
39
2040## Desktop App
2141
2242Download the latest release from
packages/contracts/src/desktopAppActivation.ts created+53
......@@ -0,0 +1,53 @@
1import * as Schema from "effect/Schema";
2
3import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4
5export const DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION = 1 as const;
6
7export const DesktopAppActivationPlatform = Schema.Literals(["darwin", "linux", "win32"]);
8export type DesktopAppActivationPlatform = typeof DesktopAppActivationPlatform.Type;
9
10export const DesktopAppActivationRequest = Schema.Struct({
11 version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION),
12 requestId: TrimmedNonEmptyString,
13 type: Schema.Literal("open-workspace"),
14 workspaceRoot: TrimmedNonEmptyString,
15 platform: DesktopAppActivationPlatform,
16});
17export type DesktopAppActivationRequest = typeof DesktopAppActivationRequest.Type;
18
19export const DesktopAppActivationErrorCode = Schema.Literals([
20 "invalid-request",
21 "renderer-unavailable",
22 "environment-unavailable",
23 "platform-mismatch",
24 "project-create-failed",
25 "thread-open-failed",
26 "request-timeout",
27 "internal-error",
28]);
29export type DesktopAppActivationErrorCode = typeof DesktopAppActivationErrorCode.Type;
30
31export const DesktopAppActivationSuccess = Schema.Struct({
32 version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION),
33 requestId: TrimmedNonEmptyString,
34 ok: Schema.Literal(true),
35 projectId: ProjectId,
36 threadId: ThreadId,
37});
38export type DesktopAppActivationSuccess = typeof DesktopAppActivationSuccess.Type;
39
40export const DesktopAppActivationFailure = Schema.Struct({
41 version: Schema.Literal(DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION),
42 requestId: TrimmedNonEmptyString,
43 ok: Schema.Literal(false),
44 code: DesktopAppActivationErrorCode,
45 message: TrimmedNonEmptyString,
46});
47export type DesktopAppActivationFailure = typeof DesktopAppActivationFailure.Type;
48
49export const DesktopAppActivationResponse = Schema.Union([
50 DesktopAppActivationSuccess,
51 DesktopAppActivationFailure,
52]);
53export type DesktopAppActivationResponse = typeof DesktopAppActivationResponse.Type;
packages/contracts/src/index.ts+1
......@@ -5,6 +5,7 @@ export * from "./environment.ts";
55export * from "./environmentHttp.ts";
66export * from "./relayClient.ts";
77export * from "./desktopBootstrap.ts";
8export * from "./desktopAppActivation.ts";
89export * from "./remoteAccess.ts";
910export * from "./ipc.ts";
1011export * from "./terminal.ts";
packages/contracts/src/ipc.ts+10
......@@ -101,6 +101,10 @@ import type {
101101 SourceControlRepositoryInfo,
102102 SourceControlRepositoryLookupInput,
103103} from "./sourceControl.ts";
104import type {
105 DesktopAppActivationRequest,
106 DesktopAppActivationResponse,
107} from "./desktopAppActivation.ts";
104108
105109export interface ContextMenuItem<T extends string = string> {
106110 id: T;
......@@ -1156,6 +1160,12 @@ export interface DesktopBridge {
11561160 downloadUpdate: () => Promise<DesktopUpdateActionResult>;
11571161 installUpdate: () => Promise<DesktopUpdateActionResult>;
11581162 onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;
1163 /** Present when the desktop shell accepts `t3 app` activation requests. */
1164 appActivation?: {
1165 setReady: (ready: boolean) => Promise<void>;
1166 complete: (response: DesktopAppActivationResponse) => Promise<void>;
1167 onRequest: (listener: (request: DesktopAppActivationRequest) => void) => () => void;
1168 };
11591169 /**
11601170 * Desktop-only preview surface. Present iff the renderer is hosted by the
11611171 * Electron desktop build; web builds have `preview === undefined`.
packages/shared/package.json+4
......@@ -235,6 +235,10 @@
235235 "types": "./src/usageFormat.ts",
236236 "import": "./src/usageFormat.ts"
237237 },
238 "./desktopAppControl": {
239 "types": "./src/desktopAppControl.ts",
240 "import": "./src/desktopAppControl.ts"
241 },
238242 "./claudeCompaction": {
239243 "types": "./src/claudeCompaction.ts",
240244 "import": "./src/claudeCompaction.ts"
packages/shared/src/desktopAppControl.test.ts created+39
......@@ -0,0 +1,39 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveDesktopAppControlAddress } from "./desktopAppControl.ts";
4
5describe("resolveDesktopAppControlAddress", () => {
6 it("keeps Unix socket paths short and separates desktop state directories", () => {
7 const first = resolveDesktopAppControlAddress({
8 stateDir: `/home/user/${"long/".repeat(40)}userdata`,
9 platform: "linux",
10 tempDir: "/tmp",
11 userId: 1000,
12 joinPath: (...segments) => segments.join("/"),
13 });
14 const second = resolveDesktopAppControlAddress({
15 stateDir: "/home/user/.t3/other/userdata",
16 platform: "linux",
17 tempDir: "/tmp",
18 userId: 1000,
19 joinPath: (...segments) => segments.join("/"),
20 });
21
22 expect(first.directory).toBe("/tmp/t3code-1000");
23 expect(first.address.length).toBeLessThan(108);
24 expect(first.address).not.toBe(second.address);
25 });
26
27 it("uses a Windows named pipe", () => {
28 const result = resolveDesktopAppControlAddress({
29 stateDir: "C:\\Users\\user\\.t3\\userdata",
30 platform: "win32",
31 tempDir: "C:\\Temp",
32 userId: undefined,
33 joinPath: (...segments) => segments.join("\\"),
34 });
35
36 expect(result.directory).toBeNull();
37 expect(result.address).toMatch(/^\\\\\.\\pipe\\t3code-app-[a-f0-9]{24}$/);
38 });
39});
packages/shared/src/desktopAppControl.ts created+41
......@@ -0,0 +1,41 @@
1import { sha256 } from "@noble/hashes/sha2";
2
3export interface DesktopAppControlAddress {
4 readonly address: string;
5 readonly directory: string | null;
6}
7
8function shortHash(value: string): string {
9 return Array.from(sha256(new TextEncoder().encode(value)).slice(0, 12), (byte) =>
10 byte.toString(16).padStart(2, "0"),
11 ).join("");
12}
13
14/**
15 * Returns the local-only socket address shared by the desktop shell and CLI.
16 * The state directory is hashed so custom T3 homes cannot exceed Unix socket
17 * path limits.
18 */
19export function resolveDesktopAppControlAddress(input: {
20 readonly stateDir: string;
21 readonly platform: NodeJS.Platform;
22 readonly tempDir: string;
23 readonly userId: number | undefined;
24 readonly joinPath: (...segments: readonly string[]) => string;
25}): DesktopAppControlAddress {
26 const stateHash = shortHash(input.stateDir);
27 if (input.platform === "win32") {
28 return {
29 address: `\\\\.\\pipe\\t3code-app-${stateHash}`,
30 directory: null,
31 };
32 }
33
34 const userKey =
35 input.userId === undefined ? shortHash(input.stateDir).slice(0, 12) : input.userId;
36 const directory = input.joinPath(input.tempDir, `t3code-${userKey}`);
37 return {
38 address: input.joinPath(directory, `${stateHash}.sock`),
39 directory,
40 };
41}