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";...@@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts";
11import * as ElectronProtocol from "../electron/ElectronProtocol.ts";11import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
12import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";12import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
13import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";13import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
14import * as DesktopAppActivation from "./DesktopAppActivation.ts";
14import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";15import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
15import * as DesktopClerk from "./DesktopClerk.ts";16import * as DesktopClerk from "./DesktopClerk.ts";
16import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";17import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
...@@ -148,6 +149,7 @@ const bootstrap = Effect.gen(function* () {...@@ -148,6 +149,7 @@ const bootstrap = Effect.gen(function* () {
148 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;149 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
149 const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;150 const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
150 const desktopWindow = yield* DesktopWindow.DesktopWindow;151 const desktopWindow = yield* DesktopWindow.DesktopWindow;
152 const appActivation = yield* DesktopAppActivation.DesktopAppActivation;
151 yield* logBootstrapInfo("bootstrap start");153 yield* logBootstrapInfo("bootstrap start");
152154
153 if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {155 if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
...@@ -210,6 +212,10 @@ const bootstrap = Effect.gen(function* () {...@@ -210,6 +212,10 @@ const bootstrap = Effect.gen(function* () {
210 }212 }
211 yield* primaryBackend.start;213 yield* primaryBackend.start;
212 yield* logBootstrapInfo("bootstrap backend start requested");214 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 );
213 // Bring up the WSL backend if the user previously enabled it. The219 // Bring up the WSL backend if the user previously enabled it. The
214 // primary is already starting; reconcile fires off the WSL register220 // primary is already starting; reconcile fires off the WSL register
215 // in parallel rather than blocking primary readiness on a possibly221 // 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 {...@@ -45,12 +45,16 @@ import {
45 showContextMenu,45 showContextMenu,
46} from "./methods/window.ts";46} from "./methods/window.ts";
47import * as PreviewIpc from "./methods/preview.ts";47import * as PreviewIpc from "./methods/preview.ts";
48import * as AppActivationIpc from "./methods/appActivation.ts";
48import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";49import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";
4950
50export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {51export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
51 const ipc = yield* DesktopIpc.DesktopIpc;52 const ipc = yield* DesktopIpc.DesktopIpc;
52 yield* PreviewIpc.installPreviewEventForwarding();53 yield* PreviewIpc.installPreviewEventForwarding();
5354
55 yield* ipc.handle(AppActivationIpc.setReady);
56 yield* ipc.handle(AppActivationIpc.complete);
57
54 yield* ipc.handleSync(getAppBranding);58 yield* ipc.handleSync(getAppBranding);
55 yield* ipc.handleSync(getSystemLocale);59 yield* ipc.handleSync(getSystemLocale);
56 yield* ipc.handleSync(getWindowFullscreenState);60 yield* ipc.handleSync(getWindowFullscreenState);
apps/desktop/src/ipc/channels.ts+3
...@@ -9,6 +9,9 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action";...@@ -9,6 +9,9 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action";
9export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";9export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
10export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";10export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
11export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";11export 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";
12export const UPDATE_STATE_CHANNEL = "desktop:update-state";15export const UPDATE_STATE_CHANNEL = "desktop:update-state";
13export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";16export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
14export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";17export 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";...@@ -32,6 +32,7 @@ import * as ElectronTheme from "./electron/ElectronTheme.ts";
32import * as ElectronUpdater from "./electron/ElectronUpdater.ts";32import * as ElectronUpdater from "./electron/ElectronUpdater.ts";
33import * as ElectronWindow from "./electron/ElectronWindow.ts";33import * as ElectronWindow from "./electron/ElectronWindow.ts";
34import * as DesktopApp from "./app/DesktopApp.ts";34import * as DesktopApp from "./app/DesktopApp.ts";
35import * as DesktopAppActivation from "./app/DesktopAppActivation.ts";
35import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";36import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
36import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";37import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
37import * as DesktopClerk from "./app/DesktopClerk.ts";38import * as DesktopClerk from "./app/DesktopClerk.ts";
...@@ -157,6 +158,10 @@ const desktopWindowLayer = DesktopWindow.layer.pipe(...@@ -157,6 +158,10 @@ const desktopWindowLayer = DesktopWindow.layer.pipe(
157 Layer.provideMerge(desktopPreviewLayer),158 Layer.provideMerge(desktopPreviewLayer),
158);159);
159160
161const desktopAppActivationLayer = DesktopAppActivation.layer.pipe(
162 Layer.provide(desktopWindowLayer),
163);
164
160// Pool layer instantiates the backend factory once for the Windows165// Pool layer instantiates the backend factory once for the Windows
161// primary instance and exposes it via pool.primary. Consumers go through166// primary instance and exposes it via pool.primary. Consumers go through
162// the pool now; the legacy DesktopBackendManager service is gone. The167// the pool now; the legacy DesktopBackendManager service is gone. The
...@@ -184,6 +189,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe(...@@ -184,6 +189,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe(
184189
185const desktopApplicationLayer = Layer.mergeAll(190const desktopApplicationLayer = Layer.mergeAll(
186 DesktopLifecycle.layer,191 DesktopLifecycle.layer,
192 desktopAppActivationLayer,
187 DesktopApplicationMenu.layer,193 DesktopApplicationMenu.layer,
188 DesktopLinuxUrlHandler.layer,194 DesktopLinuxUrlHandler.layer,
189 DesktopShellEnvironment.layer,195 DesktopShellEnvironment.layer,
apps/desktop/src/preload.ts+19
...@@ -178,6 +178,25 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -178,6 +178,25 @@ contextBridge.exposeInMainWorld("desktopBridge", {
178 ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);178 ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener);
179 };179 };
180 },180 },
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 },
181 preview: {200 preview: {
182 createTab: (tabId, defaults) =>201 createTab: (tabId, defaults) =>
183 ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {202 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";...@@ -8,6 +8,7 @@ import * as CliError from "effect/unstable/cli/CliError";
8import * as NetService from "@t3tools/shared/Net";8import * as NetService from "@t3tools/shared/Net";
9import packageJson from "../package.json" with { type: "json" };9import packageJson from "../package.json" with { type: "json" };
10import { authCommand } from "./cli/auth.ts";10import { authCommand } from "./cli/auth.ts";
11import { appCommand } from "./cli/app.ts";
11import { connectCommand } from "./cli/connect.ts";12import { connectCommand } from "./cli/connect.ts";
12import { pairCommand } from "./cli/pair.ts";13import { pairCommand } from "./cli/pair.ts";
13import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";14import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
...@@ -53,6 +54,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>...@@ -53,6 +54,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
53 Command.withSubcommands([54 Command.withSubcommands([
54 startCommand,55 startCommand,
55 serveCommand,56 serveCommand,
57 appCommand,
56 pairCommand,58 pairCommand,
57 authCommand,59 authCommand,
58 projectCommand,60 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 {...@@ -23,7 +23,7 @@ import {
23 selectProjectGroupingSettings,23 selectProjectGroupingSettings,
24} from "../logicalProject";24} from "../logicalProject";
25import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";25import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
26import { readThreadShell, useProjects, useThread } from "../state/entities";26import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";
27import {27import {
28 hasExplicitComposerModelSelection,28 hasExplicitComposerModelSelection,
29 resolveNewDraftStartFromOrigin,29 resolveNewDraftStartFromOrigin,
...@@ -55,7 +55,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef...@@ -55,7 +55,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef
55}55}
5656
57export function useNewThreadHandler() {57export function useNewThreadHandler() {
58 const projects = useProjects();
59 // New-thread defaults are a user preference, and the settings UI only ever58 // New-thread defaults are a user preference, and the settings UI only ever
60 // edits the primary environment's settings.json. Reading the target59 // edits the primary environment's settings.json. Reading the target
61 // environment's own settings here would silently reset remote projects to60 // environment's own settings here would silently reset remote projects to
...@@ -83,6 +82,7 @@ export function useNewThreadHandler() {...@@ -83,6 +82,7 @@ export function useNewThreadHandler() {
83 // prepared checkout, a task to write — addresses that one rather than looking the project82 // prepared checkout, a task to write — addresses that one rather than looking the project
84 // up again and finding whichever draft it happens to hold.83 // up again and finding whichever draft it happens to hold.
85 ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => {84 ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => {
85 const projects = readProjects();
86 const {86 const {
87 getComposerDraft,87 getComposerDraft,
88 getDraftSessionByLogicalProjectKey,88 getDraftSessionByLogicalProjectKey,
...@@ -421,7 +421,7 @@ export function useNewThreadHandler() {...@@ -421,7 +421,7 @@ export function useNewThreadHandler() {
421 return { draftId, threadId };421 return { draftId, threadId };
422 })();422 })();
423 },423 },
424 [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, projects, router],424 [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router],
425 );425 );
426}426}
427427
apps/web/src/routes/__root.tsx+2
...@@ -18,6 +18,7 @@ import { ConfirmDialogHost } from "../components/ConfirmDialogHost";...@@ -18,6 +18,7 @@ import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
18import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";18import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
19import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";19import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
20import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";20import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
21import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
21import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";22import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
22import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";23import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
23import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";24import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
...@@ -139,6 +140,7 @@ function RootRouteView() {...@@ -139,6 +140,7 @@ function RootRouteView() {
139 <GlassAppearanceSync />140 <GlassAppearanceSync />
140 <FontAppearanceSync />141 <FontAppearanceSync />
141 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}142 {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null}
143 {primaryEnvironmentAuthenticated ? <DesktopAppActivationCoordinator /> : null}
142 <RelayClientInstallDialog />144 <RelayClientInstallDialog />
143 <ConnectOnboardingDialog />145 <ConnectOnboardingDialog />
144 <SshPasswordPromptDialog />146 <SshPasswordPromptDialog />
apps/web/src/state/entities.ts+29
...@@ -218,6 +218,35 @@ export function readProject(ref: ScopedProjectRef): EnvironmentProject | null {...@@ -218,6 +218,35 @@ export function readProject(ref: ScopedProjectRef): EnvironmentProject | null {
218 return appAtomRegistry.get(environmentProjects.projectAtom(ref));218 return appAtomRegistry.get(environmentProjects.projectAtom(ref));
219}219}
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
221export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null {250export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null {
222 return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));251 return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));
223}252}
docs/user/install.md+20
...@@ -17,6 +17,26 @@ npx t3@latest...@@ -17,6 +17,26 @@ npx t3@latest
17This starts the T3 Code server on your machine and opens the local web app. Use17This starts the T3 Code server on your machine and opens the local web app. Use
18`npx t3@latest --help` for the full CLI reference.18`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
20## Desktop App40## Desktop App
2141
22Download the latest release from42Download 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";...@@ -5,6 +5,7 @@ export * from "./environment.ts";
5export * from "./environmentHttp.ts";5export * from "./environmentHttp.ts";
6export * from "./relayClient.ts";6export * from "./relayClient.ts";
7export * from "./desktopBootstrap.ts";7export * from "./desktopBootstrap.ts";
8export * from "./desktopAppActivation.ts";
8export * from "./remoteAccess.ts";9export * from "./remoteAccess.ts";
9export * from "./ipc.ts";10export * from "./ipc.ts";
10export * from "./terminal.ts";11export * from "./terminal.ts";
packages/contracts/src/ipc.ts+10
...@@ -101,6 +101,10 @@ import type {...@@ -101,6 +101,10 @@ import type {
101 SourceControlRepositoryInfo,101 SourceControlRepositoryInfo,
102 SourceControlRepositoryLookupInput,102 SourceControlRepositoryLookupInput,
103} from "./sourceControl.ts";103} from "./sourceControl.ts";
104import type {
105 DesktopAppActivationRequest,
106 DesktopAppActivationResponse,
107} from "./desktopAppActivation.ts";
104108
105export interface ContextMenuItem<T extends string = string> {109export interface ContextMenuItem<T extends string = string> {
106 id: T;110 id: T;
...@@ -1156,6 +1160,12 @@ export interface DesktopBridge {...@@ -1156,6 +1160,12 @@ export interface DesktopBridge {
1156 downloadUpdate: () => Promise<DesktopUpdateActionResult>;1160 downloadUpdate: () => Promise<DesktopUpdateActionResult>;
1157 installUpdate: () => Promise<DesktopUpdateActionResult>;1161 installUpdate: () => Promise<DesktopUpdateActionResult>;
1158 onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void;1162 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 };
1159 /**1169 /**
1160 * Desktop-only preview surface. Present iff the renderer is hosted by the1170 * Desktop-only preview surface. Present iff the renderer is hosted by the
1161 * Electron desktop build; web builds have `preview === undefined`.1171 * Electron desktop build; web builds have `preview === undefined`.
packages/shared/package.json+4
...@@ -235,6 +235,10 @@...@@ -235,6 +235,10 @@
235 "types": "./src/usageFormat.ts",235 "types": "./src/usageFormat.ts",
236 "import": "./src/usageFormat.ts"236 "import": "./src/usageFormat.ts"
237 },237 },
238 "./desktopAppControl": {
239 "types": "./src/desktopAppControl.ts",
240 "import": "./src/desktopAppControl.ts"
241 },
238 "./claudeCompaction": {242 "./claudeCompaction": {
239 "types": "./src/claudeCompaction.ts",243 "types": "./src/claudeCompaction.ts",
240 "import": "./src/claudeCompaction.ts"244 "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}