History

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

Theo Browne committed 2 months ago

Commit 04efa791 parent beae21426 files · +1890 −3Browse files

Unified · Split

26 files
  1. apps/desktop/src/app/DesktopApp.ts +6 −0
  2. apps/desktop/src/app/DesktopAppActivation.test.ts +140 −0
  3. apps/desktop/src/app/DesktopAppActivation.ts +306 −0
  4. apps/desktop/src/app/DesktopAppActivationBroker.test.ts +130 −0
  5. apps/desktop/src/app/DesktopAppActivationBroker.ts +146 −0
  6. apps/desktop/src/ipc/DesktopIpcHandlers.ts +4 −0
  7. apps/desktop/src/ipc/channels.ts +3 −0
  8. apps/desktop/src/ipc/methods/appActivation.ts +27 −0
  9. apps/desktop/src/main.ts +6 −0
  10. apps/desktop/src/preload.ts +19 −0
  11. apps/server/src/bin.ts +2 −0
  12. apps/server/src/cli/app.test.ts +307 −0
  13. apps/server/src/cli/app.ts +261 −0
  14. apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx +105 −0
  15. apps/web/src/desktopAppActivation.test.ts +107 −0
  16. apps/web/src/desktopAppActivation.ts +119 −0
  17. apps/web/src/hooks/useHandleNewThread.ts +3 −3
  18. apps/web/src/routes/__root.tsx +2 −0
  19. apps/web/src/state/entities.ts +29 −0
  20. docs/user/install.md +20 −0
  21. packages/contracts/src/desktopAppActivation.ts +53 −0
  22. packages/contracts/src/index.ts +1 −0
  23. packages/contracts/src/ipc.ts +10 −0
  24. packages/shared/package.json +4 −0
  25. packages/shared/src/desktopAppControl.test.ts +39 −0
  26. packages/shared/src/desktopAppControl.ts +41 −0
apps/desktop/src/app/DesktopApp.ts+6−0
@@ -11,6 +11,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; · 10 unmodified lines
1111 import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
1212 import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts";
1313 import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts";
14+import * as DesktopAppActivation from "./DesktopAppActivation.ts";
1415 import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
1516 import * as DesktopClerk from "./DesktopClerk.ts";
1617 import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
@@ -148,6 +149,7 @@ const bootstrap = Effect.gen(function* () { · 131 unmodified lines
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* () { · 56 unmodified lines
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+140−0
@@ -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.
2+import * as NodeFSP from "node:fs/promises";
3+import * as NodeNet from "node:net";
4+import * as NodeOS from "node:os";
5+import * as NodePath from "node:path";
6+
7+import {
8+ ProjectId,
9+ ThreadId,
10+ type DesktopAppActivationRequest,
11+ type DesktopAppActivationResponse,
12+} from "@t3tools/contracts";
13+import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
14+import { HostProcessPlatform, HostProcessUserId } from "@t3tools/shared/hostProcess";
15+import { it } from "@effect/vitest";
16+import * as Effect from "effect/Effect";
17+import { afterEach, describe, expect } from "vite-plus/test";
18+
19+import { startDesktopAppControlServer } from "./DesktopAppActivation.ts";
20+
21+const openServers: Array<{ close: () => Promise<void> }> = [];
22+
23+afterEach(async () => {
24+ await Promise.all(openServers.splice(0).map((server) => server.close()));
25+});
26+
27+function 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+
37+function 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+
47+function 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+
64+describe("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+306−0
@@ -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.
2+import * as NodeFSP from "node:fs/promises";
3+import * as NodeNet from "node:net";
4+import * as NodeOS from "node:os";
5+
6+import {
7+ DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
8+ DesktopAppActivationRequest,
9+ type DesktopAppActivationResponse,
10+} from "@t3tools/contracts";
11+import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
12+import { HostProcessUserId } from "@t3tools/shared/hostProcess";
13+import * as Context from "effect/Context";
14+import * as Effect from "effect/Effect";
15+import * as Layer from "effect/Layer";
16+import * as Option from "effect/Option";
17+import * as Path from "effect/Path";
18+import * as Schema from "effect/Schema";
19+import * as Scope from "effect/Scope";
20+
21+import type * as Electron from "electron";
22+
23+import * as ElectronWindow from "../electron/ElectronWindow.ts";
24+import { DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL } from "../ipc/channels.ts";
25+import * as DesktopWindow from "../window/DesktopWindow.ts";
26+import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts";
27+import * as DesktopEnvironment from "./DesktopEnvironment.ts";
28+import { makeComponentLogger } from "./DesktopObservability.ts";
29+
30+const MAX_REQUEST_BYTES = 64 * 1024;
31+const REQUEST_TIMEOUT_MS = 15_000;
32+const isDesktopAppActivationRequest = Schema.is(DesktopAppActivationRequest);
33+
34+export 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+
46+interface RunningControlServer {
47+ readonly close: () => Promise<void>;
48+}
49+
50+function 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+
60+function 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+
73+async 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+
92+export 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+
205+export 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+
214+const { logWarning } = makeComponentLogger("desktop-app-activation");
215+
216+export 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+
306+export const layer = Layer.effect(DesktopAppActivation, make);
apps/desktop/src/app/DesktopAppActivationBroker.test.ts+130−0
@@ -0,0 +1,130 @@
1+import { ProjectId, ThreadId, type DesktopAppActivationRequest } from "@t3tools/contracts";
2+import { describe, expect, it, vi } from "vite-plus/test";
3+
4+import { DesktopAppActivationBroker } from "./DesktopAppActivationBroker.ts";
5+
6+const request: DesktopAppActivationRequest = {
7+ version: 1,
8+ requestId: "request-1",
9+ type: "open-workspace",
10+ workspaceRoot: "/workspace/project",
11+ platform: "linux",
12+};
13+
14+describe("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+146−0
@@ -0,0 +1,146 @@
1+// @effect-diagnostics globalTimers:off -- This protocol broker owns cancellable request deadlines outside the Effect runtime.
2+import {
3+ DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
4+ type DesktopAppActivationFailure,
5+ type DesktopAppActivationRequest,
6+ type DesktopAppActivationResponse,
7+} from "@t3tools/contracts";
8+
9+interface PendingActivation {
10+ readonly request: DesktopAppActivationRequest;
11+ readonly resolve: (response: DesktopAppActivationResponse) => void;
12+ readonly timeout: ReturnType<typeof setTimeout>;
13+ dispatched: boolean;
14+}
15+
16+type RendererSender = (request: DesktopAppActivationRequest) => void;
17+
18+function 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. */
33+export 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−0
@@ -45,12 +45,16 @@ import { · 44 unmodified lines
4545 showContextMenu,
4646 } from "./methods/window.ts";
4747 import * as PreviewIpc from "./methods/preview.ts";
48+import * as AppActivationIpc from "./methods/appActivation.ts";
4849 import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts";
4950
5051 export 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−0
@@ -9,6 +9,9 @@ export const MENU_ACTION_CHANNEL = "desktop:menu-action"; · 8 unmodified lines
99 export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
1010 export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
1111 export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state";
12+export const DESKTOP_APP_ACTIVATION_READY_CHANNEL = "desktop:app-activation-ready";
13+export const DESKTOP_APP_ACTIVATION_COMPLETE_CHANNEL = "desktop:app-activation-complete";
14+export const DESKTOP_APP_ACTIVATION_REQUEST_CHANNEL = "desktop:app-activation-request";
1215 export const UPDATE_STATE_CHANNEL = "desktop:update-state";
1316 export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
1417 export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
apps/desktop/src/ipc/methods/appActivation.ts+27−0
@@ -0,0 +1,27 @@
1+import { DesktopAppActivationResponse } from "@t3tools/contracts";
2+import * as Effect from "effect/Effect";
3+import * as Schema from "effect/Schema";
4+
5+import * as DesktopAppActivation from "../../app/DesktopAppActivation.ts";
6+import * as IpcChannels from "../channels.ts";
7+import * as DesktopIpc from "../DesktopIpc.ts";
8+
9+export 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+
19+export 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−0
@@ -32,6 +32,7 @@ import * as ElectronTheme from "./electron/ElectronTheme.ts"; · 31 unmodified lines
3232 import * as ElectronUpdater from "./electron/ElectronUpdater.ts";
3333 import * as ElectronWindow from "./electron/ElectronWindow.ts";
3434 import * as DesktopApp from "./app/DesktopApp.ts";
35+import * as DesktopAppActivation from "./app/DesktopAppActivation.ts";
3536 import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts";
3637 import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts";
3738 import * as DesktopClerk from "./app/DesktopClerk.ts";
@@ -157,6 +158,10 @@ const desktopWindowLayer = DesktopWindow.layer.pipe( · 119 unmodified lines
157158 Layer.provideMerge(desktopPreviewLayer),
158159 );
159160
161+const 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( · 21 unmodified lines
184189
185190 const desktopApplicationLayer = Layer.mergeAll(
186191 DesktopLifecycle.layer,
192+ desktopAppActivationLayer,
187193 DesktopApplicationMenu.layer,
188194 DesktopLinuxUrlHandler.layer,
189195 DesktopShellEnvironment.layer,
apps/desktop/src/preload.ts+19−0
@@ -178,6 +178,25 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 177 unmodified lines
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−0
@@ -8,6 +8,7 @@ import * as CliError from "effect/unstable/cli/CliError"; · 7 unmodified lines
88 import * as NetService from "@t3tools/shared/Net";
99 import packageJson from "../package.json" with { type: "json" };
1010 import { authCommand } from "./cli/auth.ts";
11+import { appCommand } from "./cli/app.ts";
1112 import { connectCommand } from "./cli/connect.ts";
1213 import { pairCommand } from "./cli/pair.ts";
1314 import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
@@ -53,6 +54,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => · 39 unmodified lines
5354 Command.withSubcommands([
5455 startCommand,
5556 serveCommand,
57+ appCommand,
5658 pairCommand,
5759 authCommand,
5860 projectCommand,
apps/server/src/cli/app.test.ts+307−0
@@ -0,0 +1,307 @@
1+// @effect-diagnostics nodeBuiltinImport:off -- The integration fixture binds the same platform socket or named pipe as the CLI.
2+import * as NodeFSP from "node:fs/promises";
3+import * as NodeNet from "node:net";
4+import * as NodeOS from "node:os";
5+import * as NodePath from "node:path";
6+
7+import * as NodeServices from "@effect/platform-node/NodeServices";
8+import { it } from "@effect/vitest";
9+import type { DesktopAppActivationRequest } from "@t3tools/contracts";
10+import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
11+import {
12+ HostProcessPlatform,
13+ HostProcessUserId,
14+ HostProcessWorkingDirectory,
15+} from "@t3tools/shared/hostProcess";
16+import * as NetService from "@t3tools/shared/Net";
17+import * as ConfigProvider from "effect/ConfigProvider";
18+import * as Effect from "effect/Effect";
19+import * as Layer from "effect/Layer";
20+import { Command } from "effect/unstable/cli";
21+import { afterEach, describe, expect, vi } from "vite-plus/test";
22+
23+import { makeCli } from "../bin.ts";
24+
25+vi.mock("node:os", async (importOriginal) => {
26+ const os = await importOriginal<typeof import("node:os")>();
27+ return { ...os, homedir: vi.fn(os.homedir) };
28+});
29+
30+afterEach(() => vi.mocked(NodeOS.homedir).mockReset());
31+
32+const 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+
43+const pathExists = (path: string) =>
44+ Effect.promise(() =>
45+ NodeFSP.stat(path).then(
46+ () => true,
47+ () => false,
48+ ),
49+ );
50+
51+async 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+
112+const 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+
123+const 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+
133+describe("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+261−0
@@ -0,0 +1,261 @@
1+// @effect-diagnostics globalTimers:off -- The Node socket client owns its response deadline and clears it on every completion path.
2+import * as NodeCrypto from "node:crypto";
3+import * as NodeNet from "node:net";
4+import * as NodeOS from "node:os";
5+
6+import {
7+ DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
8+ DesktopAppActivationErrorCode,
9+ DesktopAppActivationResponse,
10+ type DesktopAppActivationPlatform,
11+ type DesktopAppActivationRequest,
12+} from "@t3tools/contracts";
13+import { resolveDesktopAppControlAddress } from "@t3tools/shared/desktopAppControl";
14+import {
15+ HostProcessPlatform,
16+ HostProcessUserId,
17+ HostProcessWorkingDirectory,
18+} from "@t3tools/shared/hostProcess";
19+import * as Config from "effect/Config";
20+import * as Console from "effect/Console";
21+import * as Effect from "effect/Effect";
22+import * as Option from "effect/Option";
23+import * as Path from "effect/Path";
24+import * as Schema from "effect/Schema";
25+import { Argument, Command } from "effect/unstable/cli";
26+
27+import { expandHomePath, resolveBaseDir } from "../os-jank.ts";
28+import { baseDirFlag } from "./config.ts";
29+
30+const CLI_RESPONSE_TIMEOUT_MS = 17_000;
31+const MAX_RESPONSE_BYTES = 64 * 1024;
32+const isDesktopAppActivationResponse = Schema.is(DesktopAppActivationResponse);
33+
34+export 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+
43+export 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+
52+export 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+
66+export 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+
80+function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppActivationPlatform {
81+ return platform === "darwin" || platform === "linux" || platform === "win32";
82+}
83+
84+export 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+
180+const 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+
186+const 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+
252+export 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+105−0
@@ -0,0 +1,105 @@
1+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
2+import type { DesktopAppActivationRequest } from "@t3tools/contracts";
3+import { useEffect, useEffectEvent, useRef } from "react";
4+
5+import { handleDesktopAppActivationRequest } from "../../desktopAppActivation";
6+import { useNewThreadHandler } from "../../hooks/useHandleNewThread";
7+import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths";
8+import { newProjectId } from "../../lib/utils";
9+import { resolveDefaultProviderModelSelection } from "../../providerInstances";
10+import { readProjects, waitForProject } from "../../state/entities";
11+import { usePrimaryEnvironment } from "../../state/environments";
12+import { projectEnvironment } from "../../state/projects";
13+import { useEnvironmentQuery } from "../../state/query";
14+import { environmentShell } from "../../state/shell";
15+import { useAtomCommand } from "../../state/use-atom-command";
16+
17+export 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+107−0
@@ -0,0 +1,107 @@
1+import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
2+import { describe, expect, it, vi } from "vite-plus/test";
3+
4+import {
5+ handleDesktopAppActivationRequest,
6+ type DesktopAppActivationDependencies,
7+} from "./desktopAppActivation";
8+
9+const environmentId = EnvironmentId.make("primary");
10+const existingProjectId = ProjectId.make("project-existing");
11+const createdProjectId = ProjectId.make("project-created");
12+const threadId = ThreadId.make("thread-1");
13+const request = {
14+ version: 1,
15+ requestId: "request-1",
16+ type: "open-workspace",
17+ workspaceRoot: "/workspace/project",
18+ platform: "linux",
19+} as const;
20+
21+function 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+
38+describe("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+119−0
@@ -0,0 +1,119 @@
1+import type {
2+ DesktopAppActivationFailure,
3+ DesktopAppActivationRequest,
4+ DesktopAppActivationResponse,
5+ EnvironmentId,
6+ ExecutionEnvironmentPlatformOs,
7+ ProjectId,
8+ ScopedProjectRef,
9+ ThreadId,
10+} from "@t3tools/contracts";
11+
12+export interface DesktopAppActivationProject {
13+ readonly id: ProjectId;
14+ readonly environmentId: EnvironmentId;
15+ readonly workspaceRoot: string;
16+}
17+
18+export interface DesktopAppActivationTarget {
19+ readonly environmentId: EnvironmentId;
20+ readonly platform: ExecutionEnvironmentPlatformOs;
21+}
22+
23+export 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+
39+function failure(
40+ requestId: string,
41+ code: DesktopAppActivationFailure["code"],
42+ message: string,
43+): DesktopAppActivationFailure {
44+ return { version: 1, requestId, ok: false, code, message };
45+}
46+
47+export function desktopPlatformToEnvironmentOs(
48+ platform: DesktopAppActivationRequest["platform"],
49+): ExecutionEnvironmentPlatformOs {
50+ return platform === "win32" ? "windows" : platform;
51+}
52+
53+function errorMessage(error: unknown, fallback: string): string {
54+ return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback;
55+}
56+
57+export 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 { · 22 unmodified lines
2323 selectProjectGroupingSettings,
2424 } from "../logicalProject";
2525 import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
26−import { readThreadShell, useProjects, useThread } from "../state/entities";
26+import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";
2727 import {
2828 hasExplicitComposerModelSelection,
2929 resolveNewDraftStartFromOrigin,
@@ -55,7 +55,6 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef · 25 unmodified lines
5555 }
5656
5757 export 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() { · 21 unmodified lines
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() { · 332 unmodified lines
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−0
@@ -18,6 +18,7 @@ import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; · 17 unmodified lines
1818 import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
1919 import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
2020 import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
21+import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
2122 import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
2223 import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
2324 import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
@@ -139,6 +140,7 @@ function RootRouteView() { · 115 unmodified lines
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−0
@@ -218,6 +218,35 @@ export function readProject(ref: ScopedProjectRef): EnvironmentProject | null { · 217 unmodified lines
218218 return appAtomRegistry.get(environmentProjects.projectAtom(ref));
219219 }
220220
221+export function readProjects(): ReadonlyArray<EnvironmentProject> {
222+ return appAtomRegistry.get(environmentProjects.projectsAtom);
223+}
224+
225+/** Resolves when the project event reaches the live client store. */
226+export 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+
221250 export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null {
222251 return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref));
223252 }
docs/user/install.md+20−0
@@ -17,6 +17,26 @@ npx t3@latest · 16 unmodified lines
1717 This 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+
22+When the T3 Code desktop app is running on the same machine, open the current directory with:
23+
24+```bash
25+npx t3 app
26+```
27+
28+Pass a path to open another directory:
29+
30+```bash
31+npx t3 app ../my-project
32+```
33+
34+The command adds the directory as a project when needed, focuses the desktop app, and opens a new
35+thread. It does not launch the desktop app, open a browser, or start a T3 Code server. A background
36+server does not count as the desktop app. The command also rejects SSH sessions because a remote
37+shell cannot focus a local desktop window. The CLI package and the running desktop app must both
38+include `t3 app` support.
39+
2040 ## Desktop App
2141
2242 Download the latest release from
packages/contracts/src/desktopAppActivation.ts+53−0
@@ -0,0 +1,53 @@
1+import * as Schema from "effect/Schema";
2+
3+import { ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4+
5+export const DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION = 1 as const;
6+
7+export const DesktopAppActivationPlatform = Schema.Literals(["darwin", "linux", "win32"]);
8+export type DesktopAppActivationPlatform = typeof DesktopAppActivationPlatform.Type;
9+
10+export 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+});
17+export type DesktopAppActivationRequest = typeof DesktopAppActivationRequest.Type;
18+
19+export 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+]);
29+export type DesktopAppActivationErrorCode = typeof DesktopAppActivationErrorCode.Type;
30+
31+export 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+});
38+export type DesktopAppActivationSuccess = typeof DesktopAppActivationSuccess.Type;
39+
40+export 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+});
47+export type DesktopAppActivationFailure = typeof DesktopAppActivationFailure.Type;
48+
49+export const DesktopAppActivationResponse = Schema.Union([
50+ DesktopAppActivationSuccess,
51+ DesktopAppActivationFailure,
52+]);
53+export type DesktopAppActivationResponse = typeof DesktopAppActivationResponse.Type;
packages/contracts/src/index.ts+1−0
@@ -5,6 +5,7 @@ export * from "./environment.ts"; · 4 unmodified lines
55 export * from "./environmentHttp.ts";
66 export * from "./relayClient.ts";
77 export * from "./desktopBootstrap.ts";
8+export * from "./desktopAppActivation.ts";
89 export * from "./remoteAccess.ts";
910 export * from "./ipc.ts";
1011 export * from "./terminal.ts";
packages/contracts/src/ipc.ts+10−0
@@ -101,6 +101,10 @@ import type { · 100 unmodified lines
101101 SourceControlRepositoryInfo,
102102 SourceControlRepositoryLookupInput,
103103 } from "./sourceControl.ts";
104+import type {
105+ DesktopAppActivationRequest,
106+ DesktopAppActivationResponse,
107+} from "./desktopAppActivation.ts";
104108
105109 export interface ContextMenuItem<T extends string = string> {
106110 id: T;
@@ -1156,6 +1160,12 @@ export interface DesktopBridge { · 1049 unmodified lines
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−0
@@ -235,6 +235,10 @@ · 234 unmodified lines
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+39−0
@@ -0,0 +1,39 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { resolveDesktopAppControlAddress } from "./desktopAppControl.ts";
4+
5+describe("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+41−0
@@ -0,0 +1,41 @@
1+import { sha256 } from "@noble/hashes/sha2";
2+
3+export interface DesktopAppControlAddress {
4+ readonly address: string;
5+ readonly directory: string | null;
6+}
7+
8+function 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+ */
19+export 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+}