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