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