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