diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 4101840530f6..1d0cc22d0ed2 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -4,7 +4,6 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as NetService from "@t3tools/shared/Net"; import * as Crypto from "effect/Crypto"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronDialog from "../electron/ElectronDialog.ts"; @@ -16,6 +15,8 @@ import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; +import * as DesktopBackendPort from "../backend/DesktopBackendPort.ts"; +import * as DesktopExistingLocalBackendStartup from "../backend/DesktopExistingLocalBackendStartup.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLifecycle from "./DesktopLifecycle.ts"; import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; @@ -29,28 +30,11 @@ import * as DesktopState from "./DesktopState.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; -const DEFAULT_DESKTOP_BACKEND_PORT = 3773; -const MAX_TCP_PORT = 65_535; -const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; - const makeDesktopRunId = Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.randomUUIDv4), Effect.map((value) => value.replaceAll("-", "").slice(0, 12)), ); -export class DesktopBackendPortUnavailableError extends Schema.TaggedErrorClass()( - "DesktopBackendPortUnavailableError", - { - startPort: Schema.Int, - maxPort: Schema.Int, - hosts: Schema.Array(Schema.String), - }, -) { - override get message(): string { - return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; - } -} - export class DesktopDevelopmentBackendPortRequiredError extends Schema.TaggedErrorClass()( "DesktopDevelopmentBackendPortRequiredError", {}, @@ -66,42 +50,6 @@ const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = const { logInfo: logStartupInfo, logError: logStartupError } = DesktopObservability.makeComponentLogger("desktop-startup"); -const resolveDesktopBackendPort = Effect.fn("resolveDesktopBackendPort")(function* ( - configuredPort: Option.Option, -) { - if (Option.isSome(configuredPort)) { - return { - port: configuredPort.value, - selectedByScan: false, - } as const; - } - - const net = yield* NetService.NetService; - for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) { - let availableOnEveryHost = true; - - for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) { - if (!(yield* net.canListenOnHost(port, host))) { - availableOnEveryHost = false; - break; - } - } - - if (availableOnEveryHost) { - return { - port, - selectedByScan: true, - } as const; - } - } - - return yield* new DesktopBackendPortUnavailableError({ - startPort: DEFAULT_DESKTOP_BACKEND_PORT, - maxPort: MAX_TCP_PORT, - hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS, - }); -}); - const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupError")(function* ( stage: string, error: unknown, @@ -148,25 +96,47 @@ const bootstrap = Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const desktopWindow = yield* DesktopWindow.DesktopWindow; + const shutdown = yield* DesktopShutdown.DesktopShutdown; + const electronApp = yield* ElectronApp.ElectronApp; yield* logBootstrapInfo("bootstrap start"); if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); } - const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); - const backendPort = backendPortSelection.port; - yield* logBootstrapInfo( - backendPortSelection.selectedByScan - ? "selected backend port via sequential scan" - : "using configured backend port", - { - port: backendPort, - ...(backendPortSelection.selectedByScan ? { startPort: DEFAULT_DESKTOP_BACKEND_PORT } : {}), - }, - ); - const settings = yield* desktopSettings.get; + const existingLocalBackendSelection = + yield* DesktopExistingLocalBackendStartup.resolveExistingLocalBackendForStartup; + if (existingLocalBackendSelection._tag === "Quit") { + yield* Ref.set(state.quitting, true); + yield* shutdown.request; + yield* electronApp.quit; + return; + } + const existingLocalBackend = existingLocalBackendSelection.attachment; + const backendPortSelection = Option.isSome(existingLocalBackend) + ? ({ port: existingLocalBackend.value.backend.port, selectedByScan: false } as const) + : yield* DesktopBackendPort.resolveDesktopBackendPort(environment.configuredBackendPort); + const backendPort = backendPortSelection.port; + if (Option.isSome(existingLocalBackend)) { + yield* logBootstrapInfo("attaching to existing local backend", { + origin: existingLocalBackend.value.backend.origin, + port: existingLocalBackend.value.backend.port, + baseDir: existingLocalBackend.value.backend.baseDir, + }); + } else { + yield* logBootstrapInfo( + backendPortSelection.selectedByScan + ? "selected backend port via sequential scan" + : "using configured backend port", + { + port: backendPort, + ...(backendPortSelection.selectedByScan + ? { startPort: DesktopBackendPort.DEFAULT_DESKTOP_BACKEND_PORT } + : {}), + }, + ); + } if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", { mode: settings.serverExposureMode, @@ -175,17 +145,29 @@ const bootstrap = Effect.gen(function* () { const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const backendOrigin = Option.match(existingLocalBackend, { + onNone: () => backendConfig.httpBaseUrl, + onSome: ({ backend }) => new URL(backend.origin), + }); const rendererTarget = environment.isDevelopment ? Option.getOrThrow(environment.devServerUrl) - : backendConfig.httpBaseUrl; + : Option.match(existingLocalBackend, { + onNone: () => backendConfig.httpBaseUrl, + onSome: ({ backend }) => new URL(backend.origin), + }); yield* electronProtocol.registerDesktopProtocol({ scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), targetOrigin: rendererTarget, - backendOrigin: backendConfig.httpBaseUrl, + backendOrigin, + ...(environment.isDevelopment || Option.isNone(existingLocalBackend) + ? {} + : { + rendererRoot: environment.path.join(environment.serverRoot, "apps/server/dist/client"), + }), clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, }); yield* logBootstrapInfo("bootstrap resolved backend endpoint", { - baseUrl: backendConfig.httpBaseUrl.href, + baseUrl: backendOrigin.href, }); if (serverExposureState.endpointUrl) { yield* logBootstrapInfo("bootstrap enabled network access", { diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts index 666c36d391de..d96c15bdb73d 100644 --- a/apps/desktop/src/app/DesktopAppErrors.test.ts +++ b/apps/desktop/src/app/DesktopAppErrors.test.ts @@ -1,9 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; -import { - DesktopBackendPortUnavailableError, - DesktopDevelopmentBackendPortRequiredError, -} from "./DesktopApp.ts"; +import { DesktopBackendPortUnavailableError } from "../backend/DesktopBackendPort.ts"; +import { DesktopDevelopmentBackendPortRequiredError } from "./DesktopApp.ts"; describe("DesktopApp errors", () => { it("preserves unavailable backend port context", () => { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 2bbde73abaa2..36e9edc72dd8 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as NetService from "@t3tools/shared/Net"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -9,6 +11,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -94,6 +97,54 @@ const restoreEnv = (name: string, value: string | undefined) => { } }; +function existingBackendTestLayer(input: { + readonly baseDir: string; + readonly oauthResponses: ReadonlyArray; + readonly onHttpRequest?: (url: string) => void; + readonly net?: NetService.NetServiceShape; + readonly serverExposure?: DesktopServerExposure.DesktopServerExposure["Service"]; +}) { + let oauthResponseIndex = 0; + return Layer.effect( + DesktopBackendConfiguration.DesktopBackendConfiguration, + DesktopBackendConfiguration.make, + ).pipe( + Layer.provideMerge(Layer.succeed(NetService.NetService, input.net ?? NetService.make())), + Layer.provideMerge( + input.serverExposure === undefined + ? serverExposureLayer + : Layer.succeed(DesktopServerExposure.DesktopServerExposure, input.serverExposure), + ), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge(makeEnvironmentLayer(input.baseDir, { platform: "linux" })), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + input.onHttpRequest?.(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url.endsWith("/.well-known/t3/environment") + ? Response.json({ + environmentId: "existing-environment", + label: "Existing environment", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: true }, + }) + : (input.oauthResponses[oauthResponseIndex++] ?? + Response.json({ error: "unexpected_request" }, { status: 500 })), + ), + ); + }), + ), + ), + ); +} + const withHarness = ( effect: Effect.Effect< A, @@ -124,6 +175,205 @@ const withHarness = ( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); describe("DesktopBackendConfiguration", () => { + it.effect("never discovers a packaged or background backend from desktop development", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + const resolution = yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + return yield* configuration.resolveExistingLocalBackend; + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + devServerUrl: "http://127.0.0.1:5733", + isPackaged: false, + }), + ), + ), + ), + ); + + assert.deepEqual(resolution, { _tag: "Disabled", reason: "development" }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("keeps a detected backend retryable and never falls through after pairing fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-existing-backend-config-test-", + }); + yield* fileSystem.makeDirectory(`${baseDir}/userdata`, { recursive: true }); + yield* fileSystem.writeFileString( + `${baseDir}/userdata/server-runtime.json`, + `{"version":1,"pid":${String(process.pid)},"port":41773,"origin":"http://127.0.0.1:41773","desktopAttachToken":"attach-secret","startedAt":"2026-08-21T00:00:00.000Z"}`, + ); + let httpRequestCount = 0; + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const first = yield* configuration.resolveExistingLocalBackend; + assert.equal(first._tag, "PairingFailed"); + + const primaryError = yield* configuration.resolvePrimary.pipe(Effect.flip); + assert.equal(primaryError._tag, "ExistingLocalBackendPairingError"); + assert.equal(httpRequestCount, 4); + }).pipe( + Effect.provide( + existingBackendTestLayer({ + baseDir, + oauthResponses: [ + Response.json({ error: "invalid_grant" }, { status: 400 }), + Response.json({ error: "invalid_grant" }, { status: 400 }), + ], + onHttpRequest: () => { + httpRequestCount += 1; + }, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("caches discovery and a successful explicit pairing retry", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-existing-backend-config-test-", + }); + yield* fileSystem.makeDirectory(`${baseDir}/userdata`, { recursive: true }); + yield* fileSystem.writeFileString( + `${baseDir}/userdata/server-runtime.json`, + `{"version":1,"pid":${String(process.pid)},"port":41773,"origin":"http://127.0.0.1:41773","desktopAttachToken":"attach-secret","startedAt":"2026-08-21T00:00:00.000Z"}`, + ); + let httpRequestCount = 0; + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + assert.equal((yield* configuration.resolveExistingLocalBackend)._tag, "PairingFailed"); + const retry = yield* configuration.resolveExistingLocalBackend; + assert.equal(retry._tag, "ReadyToAttach"); + if (retry._tag === "ReadyToAttach") { + assert.equal(retry.attachment.bearerToken, "desktop-bearer-token"); + } + const attachedConfig = yield* configuration.resolvePrimary; + assert.equal(attachedConfig.manageProcess, false); + assert.equal(attachedConfig.attachedBearerToken, "desktop-bearer-token"); + assert.isString(attachedConfig.authSessionKey); + assert.isNotEmpty(attachedConfig.authSessionKey); + assert.equal( + Duration.toMillis(attachedConfig.readinessTimeout ?? Duration.zero), + Duration.toMillis(Duration.seconds(5)), + ); + assert.deepEqual(attachedConfig.args, []); + const cached = yield* configuration.resolveExistingLocalBackend; + assert.equal(cached._tag, "ReadyToAttach"); + assert.equal(httpRequestCount, 4); + }).pipe( + Effect.provide( + existingBackendTestLayer({ + baseDir, + oauthResponses: [ + Response.json({ error: "invalid_grant" }, { status: 400 }), + Response.json({ + access_token: "desktop-bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + ], + onHttpRequest: () => { + httpRequestCount += 1; + }, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("re-discovers an invalidated attachment and never silently falls back", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-existing-backend-config-test-", + }); + const runtimePath = `${baseDir}/userdata/server-runtime.json`; + yield* fileSystem.makeDirectory(`${baseDir}/userdata`, { recursive: true }); + yield* fileSystem.writeFileString( + runtimePath, + `{"version":1,"pid":${String(process.pid)},"port":41773,"origin":"http://127.0.0.1:41773","desktopAttachToken":"attach-one","startedAt":"2026-08-21T00:00:00.000Z"}`, + ); + const requestedUrls: string[] = []; + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const first = yield* configuration.resolveExistingLocalBackend; + assert.equal(first._tag, "ReadyToAttach"); + const firstConfig = yield* configuration.resolvePrimary; + + yield* fileSystem.writeFileString( + runtimePath, + `{"version":1,"pid":${String(process.pid)},"port":41774,"origin":"http://127.0.0.1:41774","desktopAttachToken":"attach-two","startedAt":"2026-08-21T00:01:00.000Z"}`, + ); + assert.isTrue(yield* configuration.invalidateExistingLocalBackendAttachment); + + const moved = yield* configuration.resolveExistingLocalBackend; + assert.equal(moved._tag, "ReadyToAttach"); + if (moved._tag === "ReadyToAttach") { + assert.equal(moved.attachment.backend.port, 41774); + assert.equal(moved.attachment.credential, "attach-two"); + assert.equal(moved.attachment.bearerToken, "bearer-two"); + } + const movedConfig = yield* configuration.resolvePrimary; + assert.notEqual(movedConfig.authSessionKey, firstConfig.authSessionKey); + + assert.isTrue(yield* configuration.invalidateExistingLocalBackendAttachment); + yield* fileSystem.remove(runtimePath); + const unavailable = yield* configuration.resolveExistingLocalBackend; + assert.equal(unavailable._tag, "PairingFailed"); + if (unavailable._tag === "PairingFailed") { + assert.equal(unavailable.error.reason, "server-unavailable"); + } + }).pipe( + Effect.provide( + existingBackendTestLayer({ + baseDir, + oauthResponses: [ + Response.json({ + access_token: "bearer-one", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + Response.json({ + access_token: "bearer-two", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + ], + onHttpRequest: (url) => requestedUrls.push(url), + }), + ), + ); + + assert.include(requestedUrls, "http://127.0.0.1:41773/oauth/token"); + assert.include(requestedUrls, "http://127.0.0.1:41774/oauth/token"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolvePrimary produces a stable scoped bootstrap token", () => withHarness( Effect.gen(function* () { @@ -155,6 +405,100 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect("uses isolated server state for an explicitly independent launch", () => + withHarness( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + + yield* configuration.useIndependentBackendForLaunch; + const config = yield* configuration.resolvePrimary; + + assert.equal( + config.bootstrap.t3Home, + environment.path.join(environment.baseDir, "desktop-independent"), + ); + }), + ), + ); + + it.effect("selects a free port when an attached launch becomes independent", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-independent-backend-config-test-", + }); + yield* fileSystem.makeDirectory(`${baseDir}/userdata`, { recursive: true }); + yield* fileSystem.writeFileString( + `${baseDir}/userdata/server-runtime.json`, + `{"version":1,"pid":${String(process.pid)},"port":41773,"origin":"http://127.0.0.1:41773","desktopAttachToken":"attach-secret","startedAt":"2026-08-21T00:00:00.000Z"}`, + ); + + let exposurePort = 41_773; + const configuredPorts: number[] = []; + const serverExposure = { + getState: Effect.die("unexpected getState"), + backendConfig: Effect.sync(() => ({ + port: exposurePort, + bindHost: "127.0.0.1", + httpBaseUrl: new URL(`http://127.0.0.1:${String(exposurePort)}`), + tailscaleServeEnabled: false, + tailscaleServePort: 8443, + })), + configureFromSettings: ({ port }: { readonly port: number }) => + Effect.sync(() => { + exposurePort = port; + configuredPorts.push(port); + return { + mode: "local-only" as const, + endpointUrl: null, + advertisedHost: null, + tailscaleServeEnabled: false, + tailscaleServePort: 8_443, + }; + }), + setMode: () => Effect.die("unexpected setMode"), + setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), + getAdvertisedEndpoints: Effect.succeed([]), + } satisfies DesktopServerExposure.DesktopServerExposure["Service"]; + const net = { + ...NetService.make(), + canListenOnHost: (port: number) => Effect.succeed(port === 3_774), + } satisfies NetService.NetServiceShape; + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + assert.equal((yield* configuration.resolveExistingLocalBackend)._tag, "ReadyToAttach"); + assert.equal((yield* configuration.resolvePrimary).bootstrap.port, 41_773); + + yield* configuration.useIndependentBackendForLaunch; + const independent = yield* configuration.resolvePrimary; + + assert.deepEqual(configuredPorts, [3_774]); + assert.equal(independent.bootstrap.port, 3_774); + assert.equal(independent.httpBaseUrl.href, "http://127.0.0.1:3774/"); + assert.equal(independent.bootstrap.t3Home, `${baseDir}/desktop-independent`); + }).pipe( + Effect.provide( + existingBackendTestLayer({ + baseDir, + oauthResponses: [ + Response.json({ + access_token: "desktop-bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + ], + net, + serverExposure, + }), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index bcce731a5953..fd878a609369 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -1,21 +1,27 @@ import * as NodeOS from "node:os"; +import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import * as SynchronizedRef from "effect/SynchronizedRef"; import serverPackageJson from "../../../server/package.json" with { type: "json" }; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopBackendPort from "./DesktopBackendPort.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopExistingLocalBackend from "./DesktopExistingLocalBackend.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; @@ -33,16 +39,44 @@ export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedE } } +export type ExistingLocalBackendResolution = + | { + readonly _tag: "Disabled"; + readonly reason: "development" | "setting-disabled" | "wsl-only" | "independent-launch"; + } + | { readonly _tag: "NotFound" } + | { + readonly _tag: "ReadyToAttach"; + readonly attachment: DesktopExistingLocalBackend.ExistingLocalBackendAttachment; + } + | { + readonly _tag: "PairingFailed"; + readonly backend: DesktopExistingLocalBackend.ExistingLocalBackend; + readonly error: DesktopExistingLocalBackend.ExistingLocalBackendPairingError; + }; + +type ExistingLocalBackendSelection = + | { readonly _tag: "Unresolved" } + | { + readonly _tag: "Required"; + readonly previousBackend: DesktopExistingLocalBackend.ExistingLocalBackend; + } + | ExistingLocalBackendResolution; + +export type DesktopBackendConfigurationError = + | PlatformError.PlatformError + | DesktopBackendPort.DesktopBackendPortUnavailableError + | DesktopExistingLocalBackend.ExistingLocalBackendPairingError; + export class DesktopBackendConfiguration extends Context.Service< DesktopBackendConfiguration, { // Build the Windows-native primary backend's start config. Reads the // primary's port/host/exposure from DesktopServerExposure. Can fail - // with PlatformError because bootstrap token generation now uses - // crypto.randomBytes under the hood (post Effect 4 migration). + // while generating a bootstrap token or pairing with a detected backend. readonly resolvePrimary: Effect.Effect< DesktopBackendManager.DesktopBackendStartConfig, - PlatformError.PlatformError + DesktopBackendConfigurationError >; // Build a WSL backend start config for the given distro on the given // port. The WSL backend is always loopback-only (the primary owns LAN @@ -61,6 +95,18 @@ export class DesktopBackendConfiguration extends Context.Service< // fall-back to Windows), so the env switcher can't show "WSL" for a // backend that actually resolved to Windows. readonly resolvePrimaryLabel: Effect.Effect; + // Resolve the external backend selected for this desktop launch. Detection + // and a successful attachment are cached, while PairingFailed remains + // retryable. A detected server never degrades into NotFound just because + // authenticated session creation failed. + readonly resolveExistingLocalBackend: Effect.Effect; + // Forget a live attachment after its health watcher fails. The next + // resolve re-discovers the service and refuses to silently fall back to a + // separate backend if it has moved or is temporarily unavailable. + readonly invalidateExistingLocalBackendAttachment: Effect.Effect; + // Explicit, launch-scoped escape hatch used only after the user confirms + // that Desktop may start a separate backend. + readonly useIndependentBackendForLaunch: Effect.Effect; } >()("@t3tools/desktop/backend/DesktopBackendConfiguration") {} @@ -367,6 +413,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv function* ( input: SharedBootstrapInput & { readonly resourceMonitorPath: Option.Option; + readonly t3Home: string; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -381,7 +428,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv mode: "desktop" as const, noBrowser: true, port: backendExposure.port, - t3Home: environment.baseDir, + t3Home: input.t3Home, host: backendExposure.bindHost, desktopBootstrapToken: input.bootstrapToken, tailscaleServeEnabled: backendExposure.tailscaleServeEnabled, @@ -415,6 +462,53 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv }, ); +const resolveAttachStartConfig = Effect.fn("desktop.backendConfiguration.resolveAttach")( + function* (input: { + readonly attachment: DesktopExistingLocalBackend.ExistingLocalBackendAttachment; + readonly authSessionKey: string; + }): Effect.fn.Return< + DesktopBackendManager.DesktopBackendStartConfig, + never, + DesktopEnvironment.DesktopEnvironment + > { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const origin = new URL(input.attachment.backend.origin); + + const bootstrap = { + mode: "desktop" as const, + noBrowser: true, + port: input.attachment.backend.port, + t3Home: input.attachment.backend.baseDir, + host: origin.hostname, + desktopBootstrapToken: input.attachment.credential, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + }; + + return { + executablePath: process.execPath, + args: [], + entryPath: environment.backendEntryPath, + cwd: environment.backendCwd, + env: backendChildEnvPatch(), + extendEnv: true, + bootstrap, + bootstrapDelivery: "fd3", + httpBaseUrl: origin, + captureOutput: false, + manageProcess: false, + attachedPid: input.attachment.backend.pid, + attachedBearerToken: input.attachment.bearerToken, + authSessionKey: input.authSessionKey, + // Attached servers are already known to be healthy when pairing + // succeeds. Keep subsequent health checks short so the desktop can + // begin reconnection while a service restart is still in progress. + readinessTimeout: Duration.seconds(5), + preflightFailure: Option.none(), + } satisfies DesktopBackendManager.DesktopBackendStartConfig; + }, +); + const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl")(function* ( input: SharedBootstrapInput & { readonly port: number; @@ -611,11 +705,14 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const settings = yield* DesktopAppSettings.DesktopAppSettings; const crypto = yield* Crypto.Crypto; + const net = yield* NetService.NetService; // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. // crypto.randomBytes is a yield point, and resolvePrimary + resolveWsl can // resolve concurrently; with a plain Ref both could observe None, generate @@ -637,11 +734,11 @@ export const make = Effect.gen(function* () { }), ); - // Both resolvers share the same bootstrap token: the renderer holds a - // single token and uses it against whichever backend it's currently - // talking to. Observability settings get re-read each resolve so a - // hot-swap of the server-settings file is picked up on the next - // restart cycle without having to bounce the desktop process. + // The managed primary and WSL resolvers share one bootstrap token: the + // renderer uses it against either Desktop-owned backend. An attached + // primary carries its own pairing credential instead. Observability + // settings get re-read each managed resolve so a hot-swap of the + // server-settings file is picked up on the next restart cycle. const sharedInputs = Effect.gen(function* () { const bootstrapToken = yield* getOrCreateBootstrapToken; const observabilitySettings = yield* readPersistedBackendObservabilitySettings.pipe( @@ -651,6 +748,126 @@ export const make = Effect.gen(function* () { return { bootstrapToken, observabilitySettings } satisfies SharedBootstrapInput; }); + const existingLocalBackendSelection = yield* SynchronizedRef.make({ + _tag: "Unresolved", + }); + + const resolveExistingLocalBackend = SynchronizedRef.modifyEffect( + existingLocalBackendSelection, + ( + selection, + ): Effect.Effect => { + if ( + selection._tag === "Disabled" || + selection._tag === "NotFound" || + selection._tag === "ReadyToAttach" + ) { + return Effect.succeed([selection, selection] as const); + } + + return Effect.gen(function* () { + const attachmentRequired = selection._tag === "Required"; + let detected = Option.none(); + + if (selection._tag === "Unresolved") { + // Development owns its worktree-local state and must never inspect or + // attach to a packaged/background environment in the user's real + // T3 home. + if (environment.isDevelopment) { + const disabled = { _tag: "Disabled", reason: "development" } as const; + return [disabled, disabled] as const; + } + + const persistedSettings = yield* settings.get; + if (!persistedSettings.attachExistingLocalBackend) { + const disabled = { _tag: "Disabled", reason: "setting-disabled" } as const; + return [disabled, disabled] as const; + } + + const wslRequested = persistedSettings.wslOnly && persistedSettings.wslBackendEnabled; + if (wslRequested && (yield* wslEnvironment.isAvailable)) { + const disabled = { _tag: "Disabled", reason: "wsl-only" } as const; + return [disabled, disabled] as const; + } + } + + detected = yield* DesktopExistingLocalBackend.discoverExistingLocalBackend({ + homeDirectory: environment.homeDirectory, + desktopBaseDir: environment.baseDir, + platform: environment.platform, + path, + fileSystem, + httpClient, + }); + if (Option.isNone(detected)) { + if (!attachmentRequired) { + const notFound = { _tag: "NotFound" } as const; + return [notFound, notFound] as const; + } + const previousBackend = selection.previousBackend; + const failed = { + _tag: "PairingFailed", + backend: previousBackend, + error: new DesktopExistingLocalBackend.ExistingLocalBackendPairingError({ + baseDir: previousBackend.baseDir, + origin: previousBackend.origin, + reason: "server-unavailable", + }), + } as const; + return [failed, selection] as const; + } + + const backend = Option.getOrThrow(detected); + const attachment = yield* DesktopExistingLocalBackend.pairExistingLocalBackend({ + backend, + httpClient, + }).pipe( + Effect.map((value) => ({ _tag: "Success", value }) as const), + Effect.catchTags({ + ExistingLocalBackendPairingError: (error) => + Effect.succeed({ _tag: "Failure", error } as const), + }), + ); + if (attachment._tag === "Failure") { + const failed = { _tag: "PairingFailed", backend, error: attachment.error } as const; + // Retry through discovery as well as authentication: the service may + // have restarted on a different pid or port while the dialog was open. + return [failed, { _tag: "Required", previousBackend: backend }] as const; + } + + const ready = { + _tag: "ReadyToAttach", + attachment: attachment.value, + } as const; + yield* Effect.logInfo("selected existing local T3 Code backend", { + origin: backend.origin, + baseDir: backend.baseDir, + pid: backend.pid, + }); + return [ready, ready] as const; + }); + }, + ); + + const useIndependentBackendForLaunch = SynchronizedRef.set(existingLocalBackendSelection, { + _tag: "Disabled", + reason: "independent-launch", + }); + + const invalidateExistingLocalBackendAttachment = SynchronizedRef.modify( + existingLocalBackendSelection, + (selection) => + selection._tag === "ReadyToAttach" + ? [ + true, + { + _tag: "Required", + previousBackend: selection.attachment.backend, + } as ExistingLocalBackendSelection, + ] + : [false, selection], + ); + const buildWslPrimaryConfig = Effect.gen(function* () { // wsl-only mode pipes the WSL backend through the same port the // Windows primary would normally take. That way the renderer @@ -675,12 +892,40 @@ export const make = Effect.gen(function* () { }); const buildWindowsPrimaryConfig = Effect.gen(function* () { + const existingResolution = yield* resolveExistingLocalBackend; + if (existingResolution._tag === "PairingFailed") { + return yield* existingResolution.error; + } + if (existingResolution._tag === "ReadyToAttach") { + const attachment = existingResolution.attachment; + return yield* resolveAttachStartConfig({ + attachment, + authSessionKey: yield* crypto.randomUUIDv4, + }).pipe(Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment)); + } const shared = yield* sharedInputs; const resourceMonitorPath = yield* resolveResourceMonitorPath().pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); - return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe( + const independentLaunch = + existingResolution._tag === "Disabled" && existingResolution.reason === "independent-launch"; + if (independentLaunch) { + const currentExposure = yield* serverExposure.backendConfig; + const currentPortAvailable = yield* DesktopBackendPort.isDesktopBackendPortAvailable( + currentExposure.port, + ).pipe(Effect.provideService(NetService.NetService, net)); + if (!currentPortAvailable) { + const nextPort = yield* DesktopBackendPort.resolveDesktopBackendPort(Option.none()).pipe( + Effect.provideService(NetService.NetService, net), + ); + yield* serverExposure.configureFromSettings({ port: nextPort.port }); + } + } + const t3Home = independentLaunch + ? path.join(environment.baseDir, "desktop-independent") + : environment.baseDir; + return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath, t3Home }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ); @@ -707,6 +952,9 @@ export const make = Effect.gen(function* () { }); return DesktopBackendConfiguration.of({ + resolveExistingLocalBackend, + invalidateExistingLocalBackendAttachment, + useIndependentBackendForLaunch, resolvePrimary: Effect.gen(function* () { const { useWsl, wslRequested } = yield* describePrimary; if (useWsl) { @@ -743,4 +991,6 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(DesktopBackendConfiguration, make); +export const layer = Layer.effect(DesktopBackendConfiguration, make).pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, NetService.layer)), +); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 3efc81ed5b64..0c3b25d9f2d3 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -57,6 +57,7 @@ const baseConfig: DesktopBackendManager.DesktopBackendStartConfig = { httpBaseUrl: new URL("http://127.0.0.1:3773"), captureOutput: true, preflightFailure: Option.none(), + manageProcess: true, }; const configWithObservability: DesktopBackendBootstrapValue = { @@ -120,14 +121,16 @@ interface MakeInstanceInput { readonly backendOutputLog?: Partial; readonly onReady?: Effect.Effect; readonly onShutdown?: Effect.Effect; + readonly onUnexpectedShutdown?: (reason: string) => Effect.Effect; + readonly onConfigurationFailure?: ( + error: Error, + restartAttempt: number, + ) => Effect.Effect; readonly onPreflightFailed?: ( failure: DesktopBackendManager.PreflightFailure, ) => Effect.Effect; readonly config?: DesktopBackendManager.DesktopBackendStartConfig; - readonly configResolve?: Effect.Effect< - DesktopBackendManager.DesktopBackendStartConfig, - PlatformError.PlatformError - >; + readonly configResolve?: Effect.Effect; readonly desktopTelemetryStream?: Stream.Stream; readonly desktopTelemetryPublisher?: Partial< DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"] @@ -175,6 +178,10 @@ function makeTestInstance(input: MakeInstanceInput) { configResolve: input.configResolve ?? Effect.succeed(input.config ?? baseConfig), ...(input.onReady ? { onReady: () => input.onReady! } : {}), ...(input.onShutdown ? { onShutdown: () => input.onShutdown! } : {}), + ...(input.onUnexpectedShutdown ? { onUnexpectedShutdown: input.onUnexpectedShutdown } : {}), + ...(input.onConfigurationFailure + ? { onConfigurationFailure: input.onConfigurationFailure } + : {}), ...(input.onPreflightFailed ? { onPreflightFailed: input.onPreflightFailed } : {}), }); @@ -1026,6 +1033,65 @@ describe("DesktopBackendManager", () => { ), ); + it.effect("lets a configuration failure callback stop the restart loop", () => + Effect.scoped( + Effect.gen(function* () { + const failure = new Error("attachment unavailable"); + const seenErrors: Error[] = []; + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ), + configResolve: Effect.fail(failure), + onConfigurationFailure: (error) => + Effect.sync(() => { + seenErrors.push(error); + return false; + }), + }); + + yield* instance.start; + + assert.deepEqual(seenErrors, [failure]); + assert.isFalse((yield* instance.snapshot).desiredRunning); + assert.isFalse((yield* instance.snapshot).restartScheduled); + }), + ), + ); + + it.effect("reports the backoff attempt to configuration failure callbacks", () => + Effect.scoped( + Effect.gen(function* () { + const failure = new Error("attachment unavailable"); + const seenAttempts: number[] = []; + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ), + configResolve: Effect.fail(failure), + onConfigurationFailure: (_error, restartAttempt) => + Effect.sync(() => { + seenAttempts.push(restartAttempt); + return restartAttempt < 4; + }), + }); + + yield* instance.start; + assert.deepEqual(seenAttempts, [0]); + + yield* TestClock.adjust(Duration.millis(500)); + yield* TestClock.adjust(Duration.seconds(1)); + yield* TestClock.adjust(Duration.seconds(2)); + yield* TestClock.adjust(Duration.seconds(4)); + + assert.deepEqual(seenAttempts, [0, 1, 2, 3, 4]); + assert.isFalse((yield* instance.snapshot).desiredRunning); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + it.effect("keeps a timed-out run active until its process exits", () => Effect.scoped( Effect.gen(function* () { @@ -1475,4 +1541,151 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("attaches to an existing backend without spawning a process", () => + Effect.scoped( + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + const instance = yield* makeTestInstance({ + spawnerLayer, + config: { + ...baseConfig, + manageProcess: false, + attachedPid: 473_417, + readinessTimeout: Duration.seconds(1), + }, + }); + + yield* instance.start; + while (!(yield* instance.snapshot).ready) { + yield* Effect.yieldNow; + } + const ready = yield* instance.waitForReady(Duration.seconds(1)); + const snapshot = yield* instance.snapshot; + + assert.isTrue(ready); + assert.isTrue(snapshot.ready); + assert.equal(Option.getOrThrow(snapshot.activePid), 473_417); + + yield* instance.stop(); + assert.isFalse((yield* instance.snapshot).desiredRunning); + }), + ), + ); + + it.effect("keeps an attached backend ready through a transient failed health check", () => + Effect.scoped( + Effect.gen(function* () { + let requestCount = 0; + const firstRequest = yield* Deferred.make(); + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ); + const instance = yield* makeTestInstance({ + spawnerLayer, + httpClientLayer: httpClientLayer((request) => + Effect.gen(function* () { + requestCount += 1; + if (requestCount === 1) { + yield* Deferred.succeed(firstRequest, void 0); + } + return responseForRequest(request, requestCount === 2 ? 503 : 200); + }), + ), + config: { + ...baseConfig, + manageProcess: false, + attachedPid: 473_417, + readinessTimeout: Duration.seconds(1), + }, + }); + + yield* instance.start; + yield* Deferred.await(firstRequest); + assert.equal(requestCount, 1); + + yield* TestClock.adjust(Duration.seconds(2)); + yield* TestClock.adjust(Duration.millis(100)); + yield* Effect.yieldNow; + + const snapshot = yield* instance.snapshot; + assert.equal(requestCount, 3); + assert.isTrue(snapshot.ready); + assert.isFalse(snapshot.restartScheduled); + + yield* instance.stop(); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("notifies attachment recovery after a ready backend becomes unreachable", () => + Effect.scoped( + Effect.gen(function* () { + let requestCount = 0; + const lost = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ), + httpClientLayer: httpClientLayer((request) => { + requestCount += 1; + return Effect.succeed(responseForRequest(request, requestCount === 1 ? 200 : 503)); + }), + config: { + ...baseConfig, + manageProcess: false, + attachedPid: 473_417, + readinessTimeout: Duration.seconds(1), + }, + onUnexpectedShutdown: (reason) => Deferred.succeed(lost, reason).pipe(Effect.asVoid), + }); + + yield* instance.start; + while (!(yield* instance.snapshot).ready) { + yield* Effect.yieldNow; + } + yield* TestClock.adjust(Duration.seconds(5)); + const reason = yield* Deferred.await(lost).pipe(Effect.timeout("1 second")); + + assert.include(reason, "became unreachable"); + assert.isTrue((yield* instance.snapshot).restartScheduled); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("notifies attachment recovery when the server disappears before readiness", () => + Effect.scoped( + Effect.gen(function* () { + const lost = yield* Deferred.make(); + const instance = yield* makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected backend spawn")), + ), + httpClientLayer: httpClientLayer((request) => + Effect.succeed(responseForRequest(request, 503)), + ), + config: { + ...baseConfig, + manageProcess: false, + attachedPid: 473_417, + readinessTimeout: Duration.seconds(1), + }, + onUnexpectedShutdown: (reason) => Deferred.succeed(lost, reason).pipe(Effect.asVoid), + }); + + yield* instance.start; + yield* TestClock.adjust(Duration.seconds(2)); + const reason = yield* Deferred.await(lost).pipe(Effect.timeout("1 second")); + + assert.include(reason, "Timed out waiting for attached backend readiness"); + assert.isTrue((yield* instance.snapshot).desiredRunning); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index fc1968180901..c649edb89c19 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -99,6 +99,20 @@ export interface DesktopBackendStartConfig extends BackendProcessContext { // Present for a WSL run after the configured/default distro has been // resolved to the concrete distro passed to wsl.exe. readonly runningDistro?: string; + // When false the desktop does not spawn or kill this backend: it attaches + // to a server that is already running on this machine (background service, + // `t3 serve`, etc). + readonly manageProcess?: boolean; + readonly attachedPid?: number; + readonly readinessTimeout?: Duration.Duration; + // A bearer session already validated during attachment. Keeping it on the + // resolved config lets the renderer authenticate immediately without + // deferring attachment failure until after the window opens. + readonly attachedBearerToken?: string; + // Opaque revision for renderer-side bearer caching. A successful re-pair + // gets a new key even when the server origin and attach credential stay the + // same, so the renderer asks main for the replacement bearer session. + readonly authSessionKey?: string; } // A preflight failure records whether it is fatal. Transient failures (WSL @@ -279,10 +293,10 @@ export interface DesktopBackendInstance { export interface BackendInstanceSpec { readonly id: BackendInstanceId; readonly label: Effect.Effect; - // configResolve can now fail with PlatformError because the - // bootstrap-token closure inside DesktopBackendConfiguration uses - // crypto.randomBytes (Effect 4 beta.73 migration). - readonly configResolve: Effect.Effect; + // Configuration resolves at the process boundary and may fail before a + // process is started (for example, random token generation or pairing with + // an already-running backend). + readonly configResolve: Effect.Effect; // Receives the *resolved* httpBaseUrl of the run that just became // ready. The window service uses this to decide what URL to load // (the WSL backend reports its distro IP, the Windows backend reports @@ -290,6 +304,16 @@ export interface BackendInstanceSpec { // between "fired onReady" and "currentConfig already advanced". readonly onReady?: (httpBaseUrl: URL) => Effect.Effect; readonly onShutdown?: () => Effect.Effect; + // Fired only when a ready run ends without an explicit stop. Attachment + // users use this to invalidate discovery before the restart resolves. + readonly onUnexpectedShutdown?: (reason: string) => Effect.Effect; + // Lets the primary surface a recoverable configuration failure and decide + // whether the manager should keep retrying. restartAttempt is the number of + // scheduled restarts since the last successful readiness check. + readonly onConfigurationFailure?: ( + error: Error, + restartAttempt: number, + ) => Effect.Effect; // Fired once when a fatal or bounded preflight failure has exhausted its // retries. Returns true when the callback changed configuration and the // manager should resolve once more; false stops the failed instance. @@ -349,7 +373,10 @@ const closeRun = ( ): Effect.Effect => { const waitForFiber = Option.match(run.fiber, { onNone: () => Effect.void, - onSome: (fiber) => Fiber.await(fiber).pipe(Effect.asVoid), + // Managed runs normally finish when closing their child-process scope. + // Attached runs have no owned process finalizer, so their health-watch + // fiber must be interrupted explicitly or Desktop shutdown waits forever. + onSome: (fiber) => Fiber.interrupt(fiber).pipe(Effect.asVoid), }); const close = Scope.close(run.scope, Exit.void).pipe(Effect.andThen(waitForFiber)); const timeout = options?.timeout; @@ -432,9 +459,63 @@ const decodeDesktopTelemetryControlLine = Schema.decodeUnknownEffect( Schema.fromJsonString(DesktopTelemetryControlMessage), ); +const ATTACHED_BACKEND_WATCH_INTERVAL = Duration.seconds(2); + +const runAttachedBackend = Effect.fn("runAttachedBackend")(function* ( + options: RunBackendProcessOptions, +): Effect.fn.Return { + yield* options.onStarted?.(options.attachedPid ?? 0) ?? Effect.void; + const becameReady = yield* waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }).pipe( + Effect.flatMap(() => options.onReady?.() ?? Effect.void), + Effect.as(true), + Effect.catchTags({ + BackendReadinessTimeoutError: (error) => + (options.onReadinessFailure?.(error) ?? Effect.void).pipe(Effect.as(false)), + }), + ); + if (!becameReady) { + return { + code: Option.none(), + reason: `Timed out waiting for attached backend readiness at ${options.httpBaseUrl.href}.`, + } satisfies BackendProcessExit; + } + + while (true) { + yield* Effect.sleep(ATTACHED_BACKEND_WATCH_INTERVAL); + const stillReady = yield* waitForHttpReady({ + executablePath: options.executablePath, + entryPath: options.entryPath, + cwd: options.cwd, + httpBaseUrl: options.httpBaseUrl, + timeout: options.readinessTimeout ?? DEFAULT_BACKEND_READINESS_TIMEOUT, + }).pipe( + Effect.as(true), + Effect.catchTags({ + BackendReadinessTimeoutError: (error) => + (options.onReadinessFailure?.(error) ?? Effect.void).pipe(Effect.as(false)), + }), + ); + if (!stillReady) { + return { + code: Option.none(), + reason: `attached backend at ${options.httpBaseUrl.href} became unreachable`, + } satisfies BackendProcessExit; + } + } +}); + export const runBackendProcess = Effect.fn("runBackendProcess")(function* ( options: RunBackendProcessOptions, ): Effect.fn.Return { + if (options.manageProcess === false) { + return yield* runAttachedBackend(options); + } const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const bootstrapJson = yield* encodeBootstrapJson(options.bootstrap).pipe( Effect.mapError( @@ -703,22 +784,42 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( latest.ready ? { ...latest, ready: false } : latest, ); } - const config = yield* spec.configResolve.pipe( - Effect.tapError((error) => + const configResult = yield* spec.configResolve.pipe( + Effect.map((config) => ({ _tag: "Success", config }) as const), + Effect.catch((error) => logInstanceError("failed to generate desktop backend configuration", { cause: error.message, - }), + }).pipe(Effect.as({ _tag: "Failure", error } as const)), ), - Effect.option, ); - if (Option.isNone(config)) { - if (current.desiredRunning) { + if (configResult._tag === "Failure") { + const shouldRestart = yield* ( + spec.onConfigurationFailure?.(configResult.error, current.restartAttempt) ?? + Effect.succeed(true) + ); + if (shouldRestart) { + // `start` expresses desired state even when configuration cannot + // be produced on the first attempt. This is especially important + // when the recovery dialog switches from a lost attachment to a + // separate managed backend: the next resolve must actually run. + yield* Ref.update(state, (latest) => ({ + ...latest, + desiredRunning: true, + ready: false, + })); yield* scheduleRestart("failed to generate desktop backend configuration"); + } else { + yield* Ref.update(state, (latest) => ({ + ...latest, + desiredRunning: false, + ready: false, + })); } return; } + const config = configResult.config; const entryExists = yield* fileSystem - .exists(config.value.entryPath) + .exists(config.entryPath) .pipe(Effect.orElseSucceed(() => false)); const resetFatalPreflightCounter = @@ -728,11 +829,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ...latest, desiredRunning: true, ready: false, - config: Option.some(config.value), + config: Option.some(config), preflightFailureAttempt: resetFatalPreflightCounter ? 0 : latest.preflightFailureAttempt, })); - const preflightFailure = config.value.preflightFailure; + const preflightFailure = config.preflightFailure; if (Option.isSome(preflightFailure)) { const { reason, fatal, retryLimit } = preflightFailure.value; if (!fatal && retryLimit === undefined) { @@ -799,8 +900,8 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( latest.preflightFailureAttempt === 0 ? latest : { ...latest, preflightFailureAttempt: 0 }, ); - if (!entryExists) { - yield* scheduleRestart(`missing server entry at ${config.value.entryPath}`); + if (!entryExists && config.manageProcess !== false) { + yield* scheduleRestart(`missing server entry at ${config.entryPath}`); return; } @@ -890,6 +991,13 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( if (wasReady) { yield* spec.onShutdown?.() ?? Effect.void; } + // An attached server can disappear after authentication but + // before the first readiness probe succeeds. Invalidate that + // cached attachment too; otherwise every restart keeps + // watching the same dead origin forever. + if (!stopRequested && (wasReady || config.manageProcess === false)) { + yield* spec.onUnexpectedShutdown?.(reason) ?? Effect.void; + } } if (isCurrentRun && nextState.desiredRunning) { @@ -900,7 +1008,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); const program = runBackendProcess({ - ...config.value, + ...config, desktopTelemetryStream: desktopTelemetryPublisher.encoded, onDesktopTelemetryControl: (message) => desktopTelemetryPublisher.handleControlForSource(spec.id, message), @@ -910,7 +1018,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( pid: Option.some(pid), })); yield* backendOutputLog.beginSession({ - details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, + details: `pid=${pid} port=${config.bootstrap.port} cwd=${config.cwd}`, }); }), onExitObserved: () => @@ -938,7 +1046,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( return; } - yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; + yield* spec.onReady?.(config.httpBaseUrl) ?? Effect.void; }), onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")( function* (error) { diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 98bd4065fbee..9ee1cebb9e71 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -16,6 +16,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; +import * as DesktopExistingLocalBackend from "./DesktopExistingLocalBackend.ts"; import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts"; function makeStubInstance( @@ -42,6 +43,12 @@ function makeStubInstance( function makePoolLayer( labelRef: Ref.Ref, + options?: { + readonly configuration?: Partial< + DesktopBackendConfiguration.DesktopBackendConfiguration["Service"] + >; + readonly dialogLayer?: Layer.Layer; + }, ): Layer.Layer { return DesktopBackendPool.layer.pipe( Layer.provideMerge( @@ -74,12 +81,16 @@ function makePoolLayer( removeControlSource: () => Effect.void, }), Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { + resolveExistingLocalBackend: Effect.succeed({ _tag: "NotFound" }), + invalidateExistingLocalBackendAttachment: Effect.succeed(false), + useIndependentBackendForLaunch: Effect.void, resolvePrimary: Effect.die("unexpected primary config resolve"), resolvePrimaryLabel: Ref.get(labelRef), resolveWsl: () => Effect.die("unexpected WSL config resolve"), + ...options?.configuration, } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), DesktopAppSettings.layerTest(), - ElectronDialog.layer, + options?.dialogLayer ?? ElectronDialog.layer, Layer.succeed(DesktopWindow.DesktopWindow, { createMain: Effect.die("unexpected window create"), ensureMain: Effect.die("unexpected window ensure"), @@ -128,9 +139,7 @@ describe("DesktopBackendPool", () => { it.effect("layerTest dies when no instances are supplied", () => Effect.exit( - Effect.gen(function* () { - yield* DesktopBackendPool.DesktopBackendPool; - }).pipe(Effect.provide(DesktopBackendPool.layerTest([]))), + DesktopBackendPool.DesktopBackendPool.pipe(Effect.provide(DesktopBackendPool.layerTest([]))), ).pipe(Effect.map((exit) => assert.equal(exit._tag, "Failure"))), ); @@ -149,4 +158,55 @@ describe("DesktopBackendPool", () => { }), ), ); + + it.effect("retries attachment before offering to start a separate backend", () => + Effect.gen(function* () { + const independentCount = yield* Ref.make(0); + const dialogCount = yield* Ref.make(0); + const backend: DesktopExistingLocalBackend.ExistingLocalBackend = { + baseDir: "/home/tester/.t3/service", + origin: "http://127.0.0.1:41773/", + port: 41773, + pid: 1234, + environmentId: "existing-environment", + label: "Existing environment", + desktopAttachToken: "attach-secret", + }; + const pairingError = new DesktopExistingLocalBackend.ExistingLocalBackendPairingError({ + baseDir: backend.baseDir, + origin: backend.origin, + reason: "token-exchange-rejected", + cause: new Error("server rejected attachment"), + }); + const showMessageBox: ElectronDialog.ElectronDialog["Service"]["showMessageBox"] = () => + Ref.updateAndGet(dialogCount, (count) => count + 1).pipe( + Effect.as({ response: 1, checkboxChecked: false }), + ); + const useIndependentBackendForLaunch = Ref.update(independentCount, (count) => count + 1); + + for (const restartAttempt of [1, 2, 3, 4]) { + assert.isTrue( + yield* DesktopBackendPool.handlePrimaryConfigurationFailure({ + error: pairingError, + restartAttempt, + showMessageBox, + useIndependentBackendForLaunch, + }), + ); + } + assert.equal(yield* Ref.get(dialogCount), 0); + assert.equal(yield* Ref.get(independentCount), 0); + + assert.isTrue( + yield* DesktopBackendPool.handlePrimaryConfigurationFailure({ + error: pairingError, + restartAttempt: 5, + showMessageBox, + useIndependentBackendForLaunch, + }), + ); + assert.equal(yield* Ref.get(dialogCount), 1); + assert.equal(yield* Ref.get(independentCount), 1); + }), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts index 9b85d1bb2430..7a1b1c3140b1 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.ts @@ -85,6 +85,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; @@ -95,6 +96,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopExistingLocalBackend from "./DesktopExistingLocalBackend.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; @@ -103,6 +105,58 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; const { logWarning: logBackendPoolWarning } = DesktopObservability.makeComponentLogger("desktop-backend-pool"); +const isExistingLocalBackendPairingError = Schema.is( + DesktopExistingLocalBackend.ExistingLocalBackendPairingError, +); +const AUTOMATIC_ATTACHMENT_RECONNECT_ATTEMPTS = 4; + +export const handlePrimaryConfigurationFailure = Effect.fn( + "desktop.backendPool.primaryConfigurationFailed", +)(function* (input: { + readonly error: Error; + readonly restartAttempt: number; + readonly showMessageBox: ElectronDialog.ElectronDialog["Service"]["showMessageBox"]; + readonly useIndependentBackendForLaunch: Effect.Effect; +}) { + if (!isExistingLocalBackendPairingError(input.error)) { + return true; + } + + if (input.restartAttempt <= AUTOMATIC_ATTACHMENT_RECONNECT_ATTEMPTS) { + return true; + } + + const result = yield* input + .showMessageBox({ + type: "warning", + title: "T3 Code server connection lost", + message: "Desktop could not reconnect securely to the local T3 Code server.", + detail: [ + `Server: ${input.error.origin}`, + "No additional backend has been started.", + "Try discovery again, explicitly start a separate backend for this launch, or stop reconnecting.", + "", + input.error.message, + ].join("\n"), + buttons: ["Try Again", "Start Separate Backend", "Stop Reconnecting"], + defaultId: 0, + cancelId: 2, + noLink: true, + }) + .pipe( + Effect.catch((dialogError) => + logBackendPoolWarning("failed to show backend reconnection dialog", { + error: dialogError.message, + }).pipe(Effect.as({ response: 2 })), + ), + ); + + if (result.response === 1) { + yield* input.useIndependentBackendForLaunch; + return true; + } + return result.response === 0; +}); export type BackendInstanceId = DesktopBackendManager.BackendInstanceId; export const BackendInstanceId = DesktopBackendManager.BackendInstanceId; @@ -226,6 +280,7 @@ export const layer = Layer.effect( // the same FileSystem, spawner, HTTP client and log factory the // primary instance uses. const factoryContext = yield* Effect.context(); + const reloadAttachedRendererOnReady = yield* Ref.make(false); // A WSL preflight failure on the primary only happens in wsl-only mode. // Fatal configuration failures persist the Windows fallback. Bounded @@ -290,7 +345,10 @@ export const layer = Layer.effect( // otherwise a post-readiness window-open failure vanishes silently and // is near-impossible to diagnose in production. onReady: (httpBaseUrl) => - desktopWindow.handleBackendReady(httpBaseUrl).pipe( + Effect.gen(function* () { + const reloadExisting = yield* Ref.getAndSet(reloadAttachedRendererOnReady, false); + yield* desktopWindow.handleBackendReady(httpBaseUrl, { reloadExisting }); + }).pipe( Effect.catch((error) => logBackendPoolWarning("failed to open main window after backend readiness", { error: error.message, @@ -298,6 +356,19 @@ export const layer = Layer.effect( ), ), onShutdown: () => desktopWindow.handleBackendNotReady, + onUnexpectedShutdown: () => + configuration.invalidateExistingLocalBackendAttachment.pipe( + Effect.flatMap((invalidated) => + invalidated ? Ref.set(reloadAttachedRendererOnReady, true) : Effect.void, + ), + ), + onConfigurationFailure: (error, restartAttempt) => + handlePrimaryConfigurationFailure({ + error, + restartAttempt, + showMessageBox: electronDialog.showMessageBox, + useIndependentBackendForLaunch: configuration.useIndependentBackendForLaunch, + }), onPreflightFailed: handlePrimaryPreflightFailure, }); diff --git a/apps/desktop/src/backend/DesktopBackendPort.ts b/apps/desktop/src/backend/DesktopBackendPort.ts new file mode 100644 index 000000000000..d2caa4219532 --- /dev/null +++ b/apps/desktop/src/backend/DesktopBackendPort.ts @@ -0,0 +1,59 @@ +import * as NetService from "@t3tools/shared/Net"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export const DEFAULT_DESKTOP_BACKEND_PORT = 3773; +const MAX_TCP_PORT = 65_535; +const DESKTOP_BACKEND_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::"] as const; + +export class DesktopBackendPortUnavailableError extends Schema.TaggedErrorClass()( + "DesktopBackendPortUnavailableError", + { + startPort: Schema.Int, + maxPort: Schema.Int, + hosts: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `No desktop backend port is available on hosts ${this.hosts.join(", ")} between ${this.startPort} and ${this.maxPort}.`; + } +} + +export const isDesktopBackendPortAvailable = Effect.fn("desktop.backendPort.isAvailable")( + function* (port: number) { + const net = yield* NetService.NetService; + for (const host of DESKTOP_BACKEND_PORT_PROBE_HOSTS) { + if (!(yield* net.canListenOnHost(port, host))) { + return false; + } + } + return true; + }, +); + +export const resolveDesktopBackendPort = Effect.fn("desktop.backendPort.resolve")(function* ( + configuredPort: Option.Option, +) { + if (Option.isSome(configuredPort)) { + return { + port: configuredPort.value, + selectedByScan: false, + } as const; + } + + for (let port = DEFAULT_DESKTOP_BACKEND_PORT; port <= MAX_TCP_PORT; port += 1) { + if (yield* isDesktopBackendPortAvailable(port)) { + return { + port, + selectedByScan: true, + } as const; + } + } + + return yield* new DesktopBackendPortUnavailableError({ + startPort: DEFAULT_DESKTOP_BACKEND_PORT, + maxPort: MAX_TCP_PORT, + hosts: DESKTOP_BACKEND_PORT_PROBE_HOSTS, + }); +}); diff --git a/apps/desktop/src/backend/DesktopExistingLocalBackend.test.ts b/apps/desktop/src/backend/DesktopExistingLocalBackend.test.ts new file mode 100644 index 000000000000..b542712c32e0 --- /dev/null +++ b/apps/desktop/src/backend/DesktopExistingLocalBackend.test.ts @@ -0,0 +1,325 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import { + collectSeedBaseDirs, + discoverExistingLocalBackend, + pairExistingLocalBackend, + parseLaunchdT3Home, + parseSystemdT3Home, +} from "./DesktopExistingLocalBackend.ts"; + +const runtimeFileSystem = (runtimeState: string) => + FileSystem.makeNoop({ + readDirectory: () => Effect.succeed([]), + readFileString: (path) => + Effect.succeed(path.endsWith("server-runtime.json") ? runtimeState : ""), + }); + +describe("parseSystemdT3Home", () => { + it("reads a simple Environment assignment", () => { + assert.equal( + parseSystemdT3Home("Environment=T3CODE_HOME=/home/pedro/.t3/codebox2\n"), + "/home/pedro/.t3/codebox2", + ); + }); + + it("reads quoted Environment assignments and drop-in overlays", () => { + assert.equal(parseSystemdT3Home('Environment="T3CODE_HOME=/data/t3 home"\n'), "/data/t3 home"); + assert.equal( + parseSystemdT3Home("Environment=T3CODE_HOST=0.0.0.0 T3CODE_HOME=/opt/t3 T3CODE_PORT=4100\n"), + "/opt/t3", + ); + }); + + it("decodes escaping emitted by the systemd service renderer", () => { + assert.equal(parseSystemdT3Home("Environment=T3CODE_HOME=/srv/t3%%data\n"), "/srv/t3%data"); + assert.equal( + parseSystemdT3Home('Environment=T3CODE_HOME="/srv/t3%% data\\\\slot\\"quoted"\n'), + '/srv/t3% data\\slot"quoted', + ); + }); + + it("matches only complete T3CODE_HOME assignment names", () => { + assert.equal( + parseSystemdT3Home("Environment=OLD_T3CODE_HOME=/wrong T3CODE_HOME=/srv/t3\n"), + "/srv/t3", + ); + assert.equal(parseSystemdT3Home("Environment=OLD_T3CODE_HOME=/wrong\n"), null); + }); + + it("stops at the end of a quoted assignment in an assignment list", () => { + assert.equal( + parseSystemdT3Home('Environment="T3CODE_HOME=/srv/t3" "OTHER=value"\n'), + "/srv/t3", + ); + }); + + it("uses the last assignment so systemd drop-ins override the base unit", () => { + assert.equal( + parseSystemdT3Home( + "Environment=T3CODE_HOME=/home/tester/.t3\nEnvironment=T3CODE_HOME=/srv/t3\n", + ), + "/srv/t3", + ); + }); + + it("returns null when T3CODE_HOME is absent", () => { + assert.equal(parseSystemdT3Home("Environment=T3CODE_PORT=4100\n"), null); + assert.equal(parseSystemdT3Home(""), null); + }); +}); + +describe("parseLaunchdT3Home", () => { + it("reads and decodes the service home from EnvironmentVariables", () => { + assert.equal( + parseLaunchdT3Home(` + + EnvironmentVariables + + T3CODE_HOME + /Users/tester/T3 & Code + + + `), + "/Users/tester/T3 & Code", + ); + }); + + it("does not confuse keys outside EnvironmentVariables for the service home", () => { + assert.equal( + parseLaunchdT3Home( + "T3CODE_HOME/tmp/wrong", + ), + null, + ); + }); +}); + +describe("collectSeedBaseDirs", () => { + it("prefers the installed service, then ~/.t3, then the desktop home", () => { + assert.deepEqual( + collectSeedBaseDirs({ + defaultBaseDir: "/home/pedro/.t3", + desktopBaseDir: "/home/pedro/.t3/desktop", + serviceT3Home: "/home/pedro/.t3/codebox2", + }), + ["/home/pedro/.t3/codebox2", "/home/pedro/.t3", "/home/pedro/.t3/desktop"], + ); + }); + + it("deduplicates identical paths", () => { + assert.deepEqual( + collectSeedBaseDirs({ + defaultBaseDir: "/home/pedro/.t3", + desktopBaseDir: "/home/pedro/.t3", + serviceT3Home: "/home/pedro/.t3", + }), + ["/home/pedro/.t3"], + ); + }); +}); + +describe("discoverExistingLocalBackend", () => { + it.effect("discovers a macOS background service with a custom T3 home first", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serviceHome = "/Volumes/Work/T3 service"; + const fileSystem = FileSystem.makeNoop({ + readDirectory: () => Effect.succeed([]), + readFileString: (filePath) => { + if (filePath.endsWith("com.t3tools.t3code.service.plist")) { + return Effect.succeed(` + EnvironmentVariables + T3CODE_HOME${serviceHome} + + `); + } + if (filePath === `${serviceHome}/userdata/server-runtime.json`) { + return Effect.succeed( + `{"version":1,"pid":${String(process.pid)},"port":41773,"origin":"http://127.0.0.1:41773","startedAt":"2026-08-21T00:00:00.000Z"}`, + ); + } + return Effect.succeed(""); + }, + }); + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + environmentId: "service-environment", + label: "Background service", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: true }, + }), + ), + ), + ); + + const found = yield* discoverExistingLocalBackend({ + homeDirectory: "/Users/tester", + desktopBaseDir: "/Users/tester/.t3", + platform: "darwin", + path, + fileSystem, + httpClient, + }); + + assert.equal(Option.getOrThrow(found).baseDir, serviceHome); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("ignores a malformed persisted origin without probing HTTP", () => + Effect.gen(function* () { + const path = yield* Path.Path; + let requestCount = 0; + const httpClient = HttpClient.make(() => + Effect.sync(() => { + requestCount += 1; + throw new Error("unexpected HTTP request"); + }), + ); + const found = yield* discoverExistingLocalBackend({ + homeDirectory: "/home/tester", + desktopBaseDir: "/home/tester/.t3/desktop", + platform: "linux", + path, + fileSystem: runtimeFileSystem( + `{"version":1,"pid":${String(process.pid)},"port":3773,"origin":"not a URL","startedAt":"2026-08-21T00:00:00.000Z"}`, + ), + httpClient, + }); + + assert.isTrue(Option.isNone(found)); + assert.equal(requestCount, 0); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("uses the persisted backend origin and ignores a development renderer URL", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const requestedUrls: string[] = []; + const httpClient = HttpClient.make((request) => + Effect.sync(() => { + requestedUrls.push(request.url); + return HttpClientResponse.fromWeb( + request, + Response.json( + { + environmentId: "existing-test-environment", + label: "Existing test backend", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: true }, + }, + { status: 200 }, + ), + ); + }), + ); + const found = yield* discoverExistingLocalBackend({ + homeDirectory: "/home/tester", + desktopBaseDir: "/home/tester/.t3/desktop", + platform: "linux", + path, + fileSystem: runtimeFileSystem( + `{"version":1,"pid":${String(process.pid)},"port":41773,"origin":"http://192.0.2.10:41773","devUrl":"http://localhost:5173","desktopAttachToken":"attach-secret","startedAt":"2026-08-21T00:00:00.000Z"}`, + ), + httpClient, + }); + + assert.isTrue(Option.isSome(found)); + assert.equal(Option.getOrThrow(found).origin, "http://192.0.2.10:41773/"); + assert.equal(Option.getOrThrow(found).desktopAttachToken, "attach-secret"); + assert.deepEqual(requestedUrls, ["http://192.0.2.10:41773/.well-known/t3/environment"]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + +describe("pairExistingLocalBackend", () => { + const backend = { + baseDir: "/home/tester/.t3/service", + origin: "http://127.0.0.1:41773/", + port: 41773, + pid: 1234, + environmentId: "existing-environment", + label: "Existing environment", + desktopAttachToken: "attach-secret", + } as const; + + it.effect("exchanges the running server's attachment credential for a bearer session", () => + Effect.gen(function* () { + const requestedUrls: string[] = []; + const attachment = yield* pairExistingLocalBackend({ + backend, + httpClient: HttpClient.make((request) => + Effect.sync(() => { + requestedUrls.push(request.url); + return HttpClientResponse.fromWeb( + request, + Response.json({ + access_token: "bearer-secret", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: "orchestration:read", + }), + ); + }), + ), + }); + + assert.equal(attachment.credential, "attach-secret"); + assert.equal(attachment.bearerToken, "bearer-secret"); + assert.deepEqual(requestedUrls, ["http://127.0.0.1:41773/oauth/token"]); + }), + ); + + it.effect("fails without touching the network when the running server is too old", () => + Effect.gen(function* () { + let requestCount = 0; + const error = yield* pairExistingLocalBackend({ + backend: { ...backend, desktopAttachToken: null }, + httpClient: HttpClient.make(() => + Effect.sync(() => { + requestCount += 1; + throw new Error("unexpected request"); + }), + ), + }).pipe(Effect.flip); + + assert.equal(error._tag, "ExistingLocalBackendPairingError"); + assert.equal(error.reason, "missing-credential"); + assert.isUndefined(error.cause); + assert.equal(requestCount, 0); + }), + ); + + it.effect("preserves a rejected server-side token exchange as a pairing failure", () => + Effect.gen(function* () { + const error = yield* pairExistingLocalBackend({ + backend, + httpClient: HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ error: "invalid_grant" }, { status: 400 }), + ), + ), + ), + }).pipe(Effect.flip); + + assert.equal(error._tag, "ExistingLocalBackendPairingError"); + assert.equal(error.reason, "token-exchange-rejected"); + assert.isDefined(error.cause); + assert.include(error.message, "Could not establish a secure Desktop session"); + }), + ); +}); diff --git a/apps/desktop/src/backend/DesktopExistingLocalBackend.ts b/apps/desktop/src/backend/DesktopExistingLocalBackend.ts new file mode 100644 index 000000000000..96f50c9d7ce1 --- /dev/null +++ b/apps/desktop/src/backend/DesktopExistingLocalBackend.ts @@ -0,0 +1,376 @@ +import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; +import { ExecutionEnvironmentDescriptor, PortSchema, PositiveInt } from "@t3tools/contracts"; +import { + BOOT_SERVICE_PLIST_FILE, + BOOT_SERVICE_UNIT_FILE, +} from "@t3tools/shared/bootServiceIdentity"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; + +const PersistedServerRuntimeState = Schema.Struct({ + version: Schema.Literal(1), + pid: PositiveInt, + host: Schema.optional(Schema.String), + port: PortSchema, + origin: Schema.URLFromString, + devUrl: Schema.optional(Schema.URLFromString), + desktopAttachToken: Schema.optional(Schema.NonEmptyString), + startedAt: Schema.String, +}); +export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; + +const decodePersistedServerRuntimeState = Schema.decodeUnknownEffect( + Schema.fromJsonString(PersistedServerRuntimeState), +); + +export interface ExistingLocalBackend { + readonly baseDir: string; + readonly origin: string; + readonly port: number; + readonly pid: number; + readonly environmentId: string | null; + readonly label: string | null; + readonly desktopAttachToken: string | null; +} + +export interface ExistingLocalBackendAttachment { + readonly backend: ExistingLocalBackend; + readonly credential: string; + readonly bearerToken: string; +} + +const ExistingLocalBackendPairingReason = Schema.Literals([ + "missing-credential", + "token-exchange-rejected", + "server-unavailable", +]); + +const pairingReasonMessage = { + "missing-credential": + "The running server does not advertise Desktop attachment credentials. Update and restart that server, then try again.", + "token-exchange-rejected": + "The server rejected or could not complete the Desktop session exchange.", + "server-unavailable": "The previously attached server is no longer available.", +} as const; + +export class ExistingLocalBackendPairingError extends Schema.TaggedErrorClass()( + "ExistingLocalBackendPairingError", + { + baseDir: Schema.String, + origin: Schema.String, + reason: ExistingLocalBackendPairingReason, + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not establish a secure Desktop session with the running T3 Code server at ${this.origin}: ${pairingReasonMessage[this.reason]}`; + } +} + +// signal 0 delivers nothing; it only reports whether the pid exists. EPERM +// means it exists but belongs to another user, which still counts as alive. +export const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + +/** + * Pull T3CODE_HOME out of a systemd unit or drop-in snippet. Handles the + * common `Environment=T3CODE_HOME=/path` form, quoted values, and multiple + * assignments on one line. + */ +const extractT3HomeAssignment = ( + envValue: string, + options?: { readonly remainderIsValue?: boolean }, +): string | null => { + const assignment = /(?:^|\s)T3CODE_HOME=/u.exec(envValue); + if (assignment === null) return null; + const rest = envValue.slice(assignment.index + assignment[0].length); + if (rest.startsWith('"')) { + let value = ""; + for (let index = 1; index < rest.length; index += 1) { + const character = rest[index]; + if (character === '"') return value.replaceAll("%%", "%"); + if (character === "\\" && index + 1 < rest.length) { + const escaped = rest[index + 1]; + if (escaped === "\\" || escaped === '"') { + value += escaped; + index += 1; + continue; + } + } + value += character; + } + return value.replaceAll("%%", "%"); + } + if (rest.startsWith("'")) { + const end = rest.indexOf("'", 1); + const value = end === -1 ? rest.slice(1) : rest.slice(1, end); + return value.replaceAll("%%", "%"); + } + if (options?.remainderIsValue === true) { + const quote = rest.indexOf('"'); + const value = quote === -1 ? rest : rest.slice(0, quote); + return value.length > 0 ? value.replaceAll("%%", "%") : null; + } + const space = rest.search(/\s/); + const home = space === -1 ? rest : rest.slice(0, space); + return home.length > 0 ? home.replaceAll("%%", "%") : null; +}; + +export const parseSystemdT3Home = (unitText: string): string | null => { + let resolvedHome: string | null = null; + for (const rawLine of unitText.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line.startsWith("Environment=")) continue; + const raw = line.slice("Environment=".length); + const quotedWhole = + (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) || + (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2); + const home = extractT3HomeAssignment(quotedWhole ? raw.slice(1, -1) : raw, { + remainderIsValue: quotedWhole, + }); + if (home !== null && home.length > 0) resolvedHome = home; + } + return resolvedHome; +}; + +const decodeXmlText = (value: string): string => + value + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("&", "&"); + +/** Read T3CODE_HOME from the launchd plist emitted by `t3 service install`. */ +export const parseLaunchdT3Home = (plistText: string): string | null => { + const environmentVariables = + /\s*EnvironmentVariables\s*<\/key>\s*([\s\S]*?)<\/dict>/.exec(plistText)?.[1]; + if (environmentVariables === undefined) return null; + const encodedHome = /\s*T3CODE_HOME\s*<\/key>\s*([\s\S]*?)<\/string>/.exec( + environmentVariables, + )?.[1]; + if (encodedHome === undefined) return null; + const home = decodeXmlText(encodedHome.trim()); + return home.length > 0 ? home : null; +}; + +const uniqueDirs = (dirs: ReadonlyArray): Array => { + const seen = new Set(); + const result: Array = []; + for (const dir of dirs) { + const trimmed = dir.trim(); + if (trimmed.length === 0 || seen.has(trimmed)) continue; + seen.add(trimmed); + result.push(trimmed); + } + return result; +}; + +export const collectSeedBaseDirs = (input: { + readonly defaultBaseDir: string; + readonly desktopBaseDir: string; + readonly serviceT3Home: string | null; +}): Array => + uniqueDirs([ + ...(input.serviceT3Home === null ? [] : [input.serviceT3Home]), + input.defaultBaseDir, + input.desktopBaseDir, + ]); + +const readOptionalFile = (fileSystem: FileSystem.FileSystem, path: string) => + fileSystem.readFileString(path).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.succeed(Option.none()), + onSuccess: (contents) => Effect.succeed(Option.some(contents)), + }), + ); + +const readSystemdT3Home = Effect.fn("desktop.existingLocalBackend.readSystemdT3Home")(function* ( + homeDirectory: string, + path: Path.Path, + fileSystem: FileSystem.FileSystem, +) { + const unitDir = path.join(homeDirectory, ".config", "systemd", "user"); + const unitFile = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); + const dropInDir = path.join(unitDir, `${BOOT_SERVICE_UNIT_FILE}.d`); + const pieces: Array = []; + const unitText = yield* readOptionalFile(fileSystem, unitFile); + if (Option.isSome(unitText)) pieces.push(unitText.value); + const dropInNames = yield* fileSystem + .readDirectory(dropInDir) + .pipe(Effect.orElseSucceed(() => [])); + for (const name of dropInNames.toSorted()) { + if (!name.endsWith(".conf")) continue; + const dropInText = yield* readOptionalFile(fileSystem, path.join(dropInDir, name)); + if (Option.isSome(dropInText)) pieces.push(dropInText.value); + } + return parseSystemdT3Home(pieces.join("\n")); +}); + +const readLaunchdT3Home = Effect.fn("desktop.existingLocalBackend.readLaunchdT3Home")(function* ( + homeDirectory: string, + path: Path.Path, + fileSystem: FileSystem.FileSystem, +) { + const plistPath = path.join(homeDirectory, "Library", "LaunchAgents", BOOT_SERVICE_PLIST_FILE); + const plistText = yield* readOptionalFile(fileSystem, plistPath); + return Option.match(plistText, { + onNone: () => null, + onSome: parseLaunchdT3Home, + }); +}); + +const readServiceT3Home = Effect.fn("desktop.existingLocalBackend.readServiceT3Home")(function* ( + platform: NodeJS.Platform, + homeDirectory: string, + path: Path.Path, + fileSystem: FileSystem.FileSystem, +) { + if (platform === "linux") { + return yield* readSystemdT3Home(homeDirectory, path, fileSystem); + } + if (platform === "darwin") { + return yield* readLaunchdT3Home(homeDirectory, path, fileSystem); + } + return null; +}); + +const probeExistingBackend = ( + origin: URL, + client: HttpClient.HttpClient, +): Effect.Effect> => { + const url = new URL(WELL_KNOWN_ENVIRONMENT_PATH, origin); + return client.execute(HttpClientRequest.get(url.toString())).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)), + Effect.timeout(Duration.millis(1_500)), + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); +}; + +const readRuntimeState = (fileSystem: FileSystem.FileSystem, statePath: string) => + Effect.gen(function* () { + const raw = yield* readOptionalFile(fileSystem, statePath); + if (Option.isNone(raw)) return Option.none(); + const trimmed = raw.value.trim(); + if (trimmed.length === 0) return Option.none(); + return yield* decodePersistedServerRuntimeState(trimmed).pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + }); + +const inspectBaseDir = Effect.fn("desktop.existingLocalBackend.inspectBaseDir")(function* (input: { + readonly baseDir: string; + readonly path: Path.Path; + readonly fileSystem: FileSystem.FileSystem; + readonly httpClient: HttpClient.HttpClient; +}) { + const statePath = input.path.join(input.baseDir, "userdata", "server-runtime.json"); + const state = yield* readRuntimeState(input.fileSystem, statePath); + if (Option.isNone(state)) return Option.none(); + if (!isProcessAlive(state.value.pid)) return Option.none(); + const descriptor = yield* probeExistingBackend(state.value.origin, input.httpClient); + if (Option.isNone(descriptor)) return Option.none(); + return Option.some({ + baseDir: input.baseDir, + origin: state.value.origin.href, + port: state.value.port, + pid: state.value.pid, + environmentId: descriptor.value.environmentId, + label: descriptor.value.label, + desktopAttachToken: state.value.desktopAttachToken ?? null, + } satisfies ExistingLocalBackend); +}); + +export const discoverExistingLocalBackend = Effect.fn("desktop.existingLocalBackend.discover")( + function* (input: { + readonly homeDirectory: string; + readonly desktopBaseDir: string; + readonly platform: NodeJS.Platform; + readonly path: Path.Path; + readonly fileSystem: FileSystem.FileSystem; + readonly httpClient: HttpClient.HttpClient; + }) { + const serviceT3Home = yield* readServiceT3Home( + input.platform, + input.homeDirectory, + input.path, + input.fileSystem, + ); + const seedDirs = collectSeedBaseDirs({ + defaultBaseDir: input.path.join(input.homeDirectory, ".t3"), + desktopBaseDir: input.desktopBaseDir, + serviceT3Home, + }); + + for (const baseDir of seedDirs) { + const found = yield* inspectBaseDir({ + baseDir, + path: input.path, + fileSystem: input.fileSystem, + httpClient: input.httpClient, + }); + if (Option.isSome(found)) return found; + } + return Option.none(); + }, +); + +export const pairExistingLocalBackend = Effect.fn("desktop.existingLocalBackend.pair")( + function* (input: { + readonly backend: ExistingLocalBackend; + readonly httpClient: HttpClient.HttpClient; + }) { + const credential = input.backend.desktopAttachToken; + if (credential === null) { + return yield* new ExistingLocalBackendPairingError({ + baseDir: input.backend.baseDir, + origin: input.backend.origin, + reason: "missing-credential", + }); + } + + const session = yield* bootstrapRemoteBearerSession({ + httpBaseUrl: input.backend.origin, + credential, + clientMetadata: { + label: "T3 Code Desktop", + deviceType: "desktop", + }, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.mapError( + (cause) => + new ExistingLocalBackendPairingError({ + baseDir: input.backend.baseDir, + origin: input.backend.origin, + reason: "token-exchange-rejected", + cause, + }), + ), + ); + return { + backend: input.backend, + credential, + bearerToken: session.access_token, + } satisfies ExistingLocalBackendAttachment; + }, +); diff --git a/apps/desktop/src/backend/DesktopExistingLocalBackendStartup.test.ts b/apps/desktop/src/backend/DesktopExistingLocalBackendStartup.test.ts new file mode 100644 index 000000000000..cec1b8e0fa6c --- /dev/null +++ b/apps/desktop/src/backend/DesktopExistingLocalBackendStartup.test.ts @@ -0,0 +1,178 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import * as DesktopExistingLocalBackend from "./DesktopExistingLocalBackend.ts"; +import { resolveExistingLocalBackendForStartup } from "./DesktopExistingLocalBackendStartup.ts"; + +const backend: DesktopExistingLocalBackend.ExistingLocalBackend = { + baseDir: "/home/tester/.t3/service", + origin: "http://127.0.0.1:41773/", + port: 41773, + pid: 1234, + environmentId: "existing-environment", + label: "Existing environment", + desktopAttachToken: "credential", +}; + +const pairingError = new DesktopExistingLocalBackend.ExistingLocalBackendPairingError({ + baseDir: backend.baseDir, + origin: backend.origin, + reason: "token-exchange-rejected", + cause: new Error("pairing failed"), +}); + +const pairingFailed: DesktopBackendConfiguration.ExistingLocalBackendResolution = { + _tag: "PairingFailed", + backend, + error: pairingError, +}; + +const ready: DesktopBackendConfiguration.ExistingLocalBackendResolution = { + _tag: "ReadyToAttach", + attachment: { backend, credential: "credential", bearerToken: "bearer" }, +}; + +function makeLayer(input: { + readonly resolutions: ReadonlyArray; + readonly dialogResponses?: ReadonlyArray; + readonly openExternalResult?: boolean; + readonly onResolve?: () => void; + readonly onIndependent?: () => void; + readonly onOpenExternal?: (url: unknown) => void; + readonly onDialog?: () => void; +}) { + let resolutionIndex = 0; + let dialogIndex = 0; + return Layer.mergeAll( + Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { + resolveExistingLocalBackend: Effect.sync(() => { + input.onResolve?.(); + const resolution = input.resolutions[resolutionIndex]; + resolutionIndex += 1; + if (resolution === undefined) throw new Error("unexpected resolution attempt"); + return resolution; + }), + useIndependentBackendForLaunch: Effect.sync(() => input.onIndependent?.()), + invalidateExistingLocalBackendAttachment: Effect.succeed(false), + resolvePrimary: Effect.die("unexpected primary resolution"), + resolvePrimaryLabel: Effect.die("unexpected primary label resolution"), + resolveWsl: () => Effect.die("unexpected WSL resolution"), + } satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]), + Layer.succeed( + ElectronDialog.ElectronDialog, + ElectronDialog.ElectronDialog.of({ + pickFolder: () => Effect.succeed(Option.none()), + pickFiles: () => Effect.succeed([]), + showMessageBox: () => + Effect.sync(() => { + input.onDialog?.(); + const response = input.dialogResponses?.[dialogIndex]; + dialogIndex += 1; + if (response === undefined) throw new Error("unexpected dialog"); + return { response, checkboxChecked: false }; + }), + showErrorBox: () => Effect.void, + }), + ), + Layer.succeed( + ElectronShell.ElectronShell, + ElectronShell.ElectronShell.of({ + openExternal: (url) => + Effect.sync(() => { + input.onOpenExternal?.(url); + return input.openExternalResult ?? true; + }), + copyText: () => Effect.void, + }), + ), + ); +} + +describe("resolveExistingLocalBackendForStartup", () => { + it.effect("retries pairing without treating the detected backend as absent", () => { + let resolveCount = 0; + let dialogCount = 0; + return Effect.gen(function* () { + const selection = yield* resolveExistingLocalBackendForStartup; + + assert.equal(selection._tag, "Continue"); + if (selection._tag !== "Continue") return; + assert.equal(Option.getOrThrow(selection.attachment).credential, "credential"); + assert.equal(resolveCount, 2); + assert.equal(dialogCount, 1); + }).pipe( + Effect.provide( + makeLayer({ + resolutions: [pairingFailed, ready], + dialogResponses: [0], + onResolve: () => { + resolveCount += 1; + }, + onDialog: () => { + dialogCount += 1; + }, + }), + ), + ); + }); + + it.effect("starts an independent backend only after explicit confirmation", () => { + let independentCount = 0; + return Effect.gen(function* () { + const selection = yield* resolveExistingLocalBackendForStartup; + + assert.equal(selection._tag, "Continue"); + if (selection._tag !== "Continue") return; + assert.isTrue(Option.isNone(selection.attachment)); + assert.equal(independentCount, 1); + }).pipe( + Effect.provide( + makeLayer({ + resolutions: [pairingFailed], + dialogResponses: [1], + onIndependent: () => { + independentCount += 1; + }, + }), + ), + ); + }); + + it.effect("opens the detected server in a browser and quits Desktop", () => { + const openedUrls: unknown[] = []; + return Effect.gen(function* () { + const selection = yield* resolveExistingLocalBackendForStartup; + + assert.equal(selection._tag, "Quit"); + assert.deepEqual(openedUrls, [backend.origin]); + }).pipe( + Effect.provide( + makeLayer({ + resolutions: [pairingFailed], + dialogResponses: [2], + onOpenExternal: (url) => openedUrls.push(url), + }), + ), + ); + }); + + it.effect("keeps the decision open if the browser cannot be launched", () => + Effect.gen(function* () { + const selection = yield* resolveExistingLocalBackendForStartup; + assert.equal(selection._tag, "Quit"); + }).pipe( + Effect.provide( + makeLayer({ + resolutions: [pairingFailed, pairingFailed], + dialogResponses: [2, 3], + openExternalResult: false, + }), + ), + ), + ); +}); diff --git a/apps/desktop/src/backend/DesktopExistingLocalBackendStartup.ts b/apps/desktop/src/backend/DesktopExistingLocalBackendStartup.ts new file mode 100644 index 000000000000..1cb30cdf8688 --- /dev/null +++ b/apps/desktop/src/backend/DesktopExistingLocalBackendStartup.ts @@ -0,0 +1,74 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as ElectronDialog from "../electron/ElectronDialog.ts"; +import * as ElectronShell from "../electron/ElectronShell.ts"; +import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts"; +import type { ExistingLocalBackendAttachment } from "./DesktopExistingLocalBackend.ts"; + +export type ExistingLocalBackendStartupSelection = + | { + readonly _tag: "Continue"; + readonly attachment: Option.Option; + } + | { readonly _tag: "Quit" }; + +const TRY_AGAIN_BUTTON = 0; +const START_SEPARATE_BUTTON = 1; +const OPEN_IN_BROWSER_BUTTON = 2; +const QUIT_BUTTON = 3; + +export const resolveExistingLocalBackendForStartup: Effect.Effect< + ExistingLocalBackendStartupSelection, + ElectronDialog.ElectronDialogShowMessageBoxError, + | DesktopBackendConfiguration.DesktopBackendConfiguration + | ElectronDialog.ElectronDialog + | ElectronShell.ElectronShell +> = Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const dialog = yield* ElectronDialog.ElectronDialog; + const shell = yield* ElectronShell.ElectronShell; + + while (true) { + const resolution = yield* configuration.resolveExistingLocalBackend; + if (resolution._tag === "ReadyToAttach") { + return { _tag: "Continue", attachment: Option.some(resolution.attachment) } as const; + } + if (resolution._tag === "Disabled" || resolution._tag === "NotFound") { + return { _tag: "Continue", attachment: Option.none() } as const; + } + + yield* Effect.logWarning("could not authenticate with existing local T3 Code backend", { + origin: resolution.backend.origin, + baseDir: resolution.backend.baseDir, + error: resolution.error, + }); + const result = yield* dialog.showMessageBox({ + type: "warning", + title: "Running T3 Code server detected", + message: "Desktop could not connect securely to the T3 Code server already running here.", + detail: [ + `Server: ${resolution.backend.origin}`, + "No additional backend has been started.", + "Try again, open the existing server in your browser, or explicitly start a separate backend for this Desktop launch.", + "", + resolution.error.message, + ].join("\n"), + buttons: ["Try Again", "Start Separate Backend", "Open in Browser", "Quit"], + defaultId: TRY_AGAIN_BUTTON, + cancelId: QUIT_BUTTON, + noLink: true, + }); + + if (result.response === TRY_AGAIN_BUTTON) continue; + if (result.response === START_SEPARATE_BUTTON) { + yield* configuration.useIndependentBackendForLaunch; + return { _tag: "Continue", attachment: Option.none() } as const; + } + if (result.response === OPEN_IN_BROWSER_BUTTON) { + const opened = yield* shell.openExternal(resolution.backend.origin); + if (!opened) continue; + } + return { _tag: "Quit" } as const; + } +}).pipe(Effect.withSpan("desktop.existingLocalBackend.resolveForStartup")); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index e7a58baef140..bf15cf18c9b4 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -78,4 +78,47 @@ describe("DesktopLocalEnvironmentAuth", () => { assert.strictEqual(yield* Ref.get(requestCount), 1); }), ); + + it.effect("uses and refreshes a bearer session validated during attachment", () => + Effect.gen(function* () { + const currentConfig = yield* Ref.make({ + ...config, + attachedBearerToken: "attached-bearer-one", + }); + const poolLayer = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([ + { + id: PRIMARY_LOCAL_ENVIRONMENT_ID, + label: Effect.succeed("Local environment"), + currentConfig: Ref.get(currentConfig).pipe(Effect.map(Option.some)), + }, + ]), + } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); + const testLayer = DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide( + Layer.mergeAll( + poolLayer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("validated attachment must not exchange again")), + ), + ), + ), + ); + + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( + Effect.provide(testLayer), + ); + const first = yield* auth.getBearerToken; + yield* Ref.set(currentConfig, { + ...config, + httpBaseUrl: new URL("http://127.0.0.1:41774"), + attachedBearerToken: "attached-bearer-two", + }); + const second = yield* auth.getBearerToken; + + assert.equal(first, "attached-bearer-one"); + assert.equal(second, "attached-bearer-two"); + }), + ); }); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 201492f0e4c1..27541910a705 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -45,17 +45,14 @@ export class DesktopLocalEnvironmentAuth extends Context.Service< export const make = Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; const httpClient = yield* HttpClient.HttpClient; - const tokenRef = yield* Ref.make(Option.none()); + const tokenRef = yield* Ref.make( + Option.none<{ readonly configKey: string; readonly token: string }>(), + ); const mutex = yield* Semaphore.make(1); const getBearerToken = mutex .withPermits(1)( Effect.gen(function* () { - const cached = yield* Ref.get(tokenRef); - if (Option.isSome(cached)) { - return cached.value; - } - const instances = yield* pool.list; const primary = instances.find((instance) => instance.id === PRIMARY_LOCAL_ENVIRONMENT_ID); const configOption = primary === undefined ? Option.none() : yield* primary.currentConfig; @@ -67,6 +64,19 @@ export const make = Effect.gen(function* () { if (!credential) { return yield* new DesktopLocalEnvironmentAuthBackendNotConfiguredError(); } + const configKey = [ + config.httpBaseUrl.href, + credential, + config.attachedBearerToken ?? "", + ].join("\u0000"); + const cached = yield* Ref.get(tokenRef); + if (Option.isSome(cached) && cached.value.configKey === configKey) { + return cached.value.token; + } + if (config.attachedBearerToken !== undefined) { + yield* Ref.set(tokenRef, Option.some({ configKey, token: config.attachedBearerToken })); + return config.attachedBearerToken; + } const session = yield* bootstrapRemoteBearerSession({ httpBaseUrl: config.httpBaseUrl.href, credential, @@ -83,7 +93,7 @@ export const make = Effect.gen(function* () { }), ), ); - yield* Ref.set(tokenRef, Option.some(session.access_token)); + yield* Ref.set(tokenRef, Option.some({ configKey, token: session.access_token })); return session.access_token; }), ) diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d1..9b7e5c67a3f4 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -257,6 +257,8 @@ describe("DesktopServerExposure", () => { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setAttachExistingLocalBackend: () => + Effect.die("unexpected attach-existing-local-backend toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 2db85dafc4da..a466a252bd7f 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -141,6 +141,64 @@ describe("ElectronProtocol", () => { }).pipe(Effect.provide(ElectronProtocol.layer)), ); + it.effect("serves the Desktop-owned renderer when attaching to another server", () => + Effect.acquireUseRelease( + Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-renderer-"))), + (rendererRoot) => + Effect.gen(function* () { + NodeFS.mkdirSync(NodePath.join(rendererRoot, "assets")); + NodeFS.writeFileSync(NodePath.join(rendererRoot, "index.html"), "desktop index"); + NodeFS.writeFileSync(NodePath.join(rendererRoot, "assets", "app.js"), "desktop js"); + NodeFS.writeFileSync(NodePath.join(rendererRoot, "assets", "terminal.wasm"), "wasm"); + + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + targetOrigin: new URL("http://127.0.0.1:41773/"), + backendOrigin: new URL("http://127.0.0.1:41773/"), + rendererRoot, + clerkFrontendApiHostname: undefined, + }); + + const route = yield* Effect.promise(() => + handler!(new Request("t3code://app/projects/local")), + ); + const asset = yield* Effect.promise(() => + handler!(new Request("t3code://app/assets/app.js")), + ); + const wasm = yield* Effect.promise(() => + handler!(new Request("t3code://app/assets/terminal.wasm")), + ); + const missingAsset = yield* Effect.promise(() => + handler!(new Request("t3code://app/assets/missing.js")), + ); + const traversal = yield* Effect.promise(() => + handler!(new Request("t3code://app/%2e%2e%2fsecret")), + ); + + assert.equal(yield* Effect.promise(() => route.text()), "desktop index"); + assert.equal(route.headers.get("content-type"), "text/html; charset=utf-8"); + assert.equal(yield* Effect.promise(() => asset.text()), "desktop js"); + assert.equal(asset.headers.get("content-type"), "text/javascript; charset=utf-8"); + assert.equal(wasm.headers.get("content-type"), "application/wasm"); + assert.equal(missingAsset.status, 404); + assert.equal(traversal.status, 404); + assert.equal(netFetchMock.mock.calls.length, 0); + }), + ); + }), + (rendererRoot) => + Effect.sync(() => NodeFS.rmSync(rendererRoot, { recursive: true, force: true })), + ).pipe(Effect.provide(ElectronProtocol.layer)), + ); + it.effect("preserves protocol registration failures", () => Effect.gen(function* () { const cause = new Error("protocol registration failed"); @@ -228,3 +286,7 @@ describe("ElectronProtocol", () => { assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); }); }); +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 11459c9ef7a8..433caf21f0ea 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,6 +1,9 @@ +// @effect-diagnostics nodeBuiltinImport:off import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; import * as NodeTimersPromises from "node:timers/promises"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -52,6 +55,9 @@ export interface DesktopProtocolRegistrationInput { readonly scheme: string; readonly targetOrigin: URL; readonly backendOrigin: URL; + // When present, serve the renderer bundled with this Desktop build instead + // of proxying static assets from an attached server of an arbitrary version. + readonly rendererRoot?: string; readonly clerkFrontendApiHostname: string | undefined; } @@ -182,6 +188,86 @@ async function proxyRequest( return withContentSecurityPolicy(response, contentSecurityPolicy); } +const CONTENT_TYPES: Readonly> = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".wasm": "application/wasm", + ".webmanifest": "application/manifest+json", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +const isWithinRoot = (root: string, candidate: string): boolean => + candidate === root || candidate.startsWith(`${root}${NodePath.sep}`); + +export async function serveLocalRendererRequest( + request: Request, + rendererRoot: string, + contentSecurityPolicy: string, +): Promise { + const requestUrl = new URL(request.url); + if (requestUrl.host !== DESKTOP_HOST) { + return new Response(null, { status: 404 }); + } + if (request.method !== "GET" && request.method !== "HEAD") { + return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } }); + } + + let pathname: string; + try { + pathname = decodeURIComponent(requestUrl.pathname); + } catch { + return new Response(null, { status: 400 }); + } + if (pathname.includes("\u0000")) { + return new Response(null, { status: 400 }); + } + + const root = NodePath.resolve(rendererRoot); + const relativePath = pathname.replace(/^\/+/, "") || "index.html"; + let candidate = NodePath.resolve(root, relativePath); + if (!isWithinRoot(root, candidate)) { + return new Response(null, { status: 404 }); + } + + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await NodeFSP.readFile(candidate)); + } catch { + // Client-side routes fall back to the app shell. Asset requests keep a + // real 404 so a missing versioned chunk cannot be mistaken for HTML. + if (NodePath.extname(relativePath) !== "") { + return new Response(null, { status: 404 }); + } + candidate = NodePath.join(root, "index.html"); + try { + bytes = new Uint8Array(await NodeFSP.readFile(candidate)); + } catch { + return new Response(null, { status: 404 }); + } + } + + const headers = new Headers({ + "Content-Length": String(bytes.byteLength), + "Content-Type": + CONTENT_TYPES[NodePath.extname(candidate).toLowerCase()] ?? "application/octet-stream", + }); + const body = + request.method === "HEAD" + ? null + : (bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer); + return withContentSecurityPolicy( + new Response(body, { status: 200, headers }), + contentSecurityPolicy, + ); +} + const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const; async function fetchWithTransientRetry(url: string, init: RequestInit): Promise { @@ -215,7 +301,9 @@ export const make = Effect.gen(function* () { Effect.try({ try: () => { Electron.protocol.handle(input.scheme, (request) => - proxyRequest(request, input.targetOrigin, contentSecurityPolicy), + input.rendererRoot === undefined + ? proxyRequest(request, input.targetOrigin, contentSecurityPolicy) + : serveLocalRendererRequest(request, input.rendererRoot, contentSecurityPolicy), ); }, catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8e8317db7971..fc2837cb6467 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -46,6 +46,10 @@ import { } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; +import { + getExistingLocalBackendState, + setAttachExistingLocalBackend, +} from "./methods/existingLocalBackend.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -81,6 +85,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslBackendEnabled); yield* ipc.handle(setWslDistro); yield* ipc.handle(setWslOnly); + yield* ipc.handle(getExistingLocalBackendState); + yield* ipc.handle(setAttachExistingLocalBackend); yield* ipc.handle(pickFolder); yield* ipc.handle(pickProjectFavicon); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index c4ef82ec8cb7..04459e2fbf5f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -42,6 +42,9 @@ export const GET_WSL_STATE_CHANNEL = "desktop:get-wsl-state"; export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"; export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; +export const GET_EXISTING_LOCAL_BACKEND_STATE_CHANNEL = "desktop:get-existing-local-backend-state"; +export const SET_ATTACH_EXISTING_LOCAL_BACKEND_CHANNEL = + "desktop:set-attach-existing-local-backend"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; diff --git a/apps/desktop/src/ipc/methods/existingLocalBackend.ts b/apps/desktop/src/ipc/methods/existingLocalBackend.ts new file mode 100644 index 000000000000..76047fb69d86 --- /dev/null +++ b/apps/desktop/src/ipc/methods/existingLocalBackend.ts @@ -0,0 +1,69 @@ +import { + DesktopExistingLocalBackendStateSchema, + type DesktopExistingLocalBackendState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +const readExistingLocalBackendState: Effect.Effect< + DesktopExistingLocalBackendState, + never, + | DesktopAppSettings.DesktopAppSettings + | DesktopBackendPool.DesktopBackendPool + | DesktopEnvironment.DesktopEnvironment +> = Effect.gen(function* () { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const settings = yield* appSettings.get; + const primary = yield* pool.primary; + const config = yield* primary.currentConfig; + const attached = Option.match(config, { + onNone: () => false, + onSome: (value) => value.manageProcess === false, + }); + return { + available: !environment.isDevelopment, + enabled: settings.attachExistingLocalBackend, + attached, + origin: attached + ? Option.getOrElse( + Option.map(config, (value) => value.httpBaseUrl.href.replace(/\/$/, "")), + () => null, + ) + : null, + }; +}); + +export const getExistingLocalBackendState = makeIpcMethod({ + channel: IpcChannels.GET_EXISTING_LOCAL_BACKEND_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopExistingLocalBackendStateSchema, + handler: Effect.fn("desktop.ipc.existingLocalBackend.getState")(function* () { + return yield* readExistingLocalBackendState; + }), +}); + +export const setAttachExistingLocalBackend = makeIpcMethod({ + channel: IpcChannels.SET_ATTACH_EXISTING_LOCAL_BACKEND_CHANNEL, + payload: Schema.Boolean, + result: DesktopExistingLocalBackendStateSchema, + handler: Effect.fn("desktop.ipc.existingLocalBackend.setEnabled")(function* (enabled) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const change = yield* appSettings.setAttachExistingLocalBackend(enabled); + const state = yield* readExistingLocalBackendState; + if (change.changed) { + yield* lifecycle.relaunch(`attachExistingLocalBackend=${enabled}`); + } + return state; + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 203151c2660e..34a349411ad4 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -73,6 +73,37 @@ describe("getLocalEnvironmentBootstraps", () => { }).pipe(Effect.provide(DesktopBackendPool.layerTest([defaultWslInstance]))), ); + it.effect("publishes a new auth session key for an attached primary", () => { + const { runningDistro: _runningDistro, ...readyPrimaryConfig } = readyWslConfig; + const attachedPrimary: DesktopBackendManager.DesktopBackendInstance = { + ...defaultWslInstance, + id: DesktopBackendManager.PRIMARY_INSTANCE_ID, + label: Effect.succeed("Local environment"), + currentConfig: Effect.succeed( + Option.some({ + ...readyPrimaryConfig, + manageProcess: false, + authSessionKey: "attached-session-two", + }), + ), + }; + + return Effect.gen(function* () { + const result = yield* getLocalEnvironmentBootstraps.handler(); + assert.deepEqual(result, [ + { + id: "primary", + label: "Local environment", + runningDistro: null, + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + bootstrapToken: "bootstrap-token", + authSessionKey: "attached-session-two", + }, + ]); + }).pipe(Effect.provide(DesktopBackendPool.layerTest([attachedPrimary]))); + }); + it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => { const retryingConfig: DesktopBackendManager.DesktopBackendStartConfig = { ...readyWslConfig, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index edae8394302c..f05c4246d65d 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -140,6 +140,7 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ ...(bootstrap.desktopBootstrapToken ? { bootstrapToken: bootstrap.desktopBootstrapToken } : {}), + ...(config.value.authSessionKey ? { authSessionKey: config.value.authSessionKey } : {}), }); } return bootstraps; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 407c7c3ef498..d027fa4cb6f7 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -100,6 +100,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.SET_WSL_BACKEND_ENABLED_CHANNEL, enabled), setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro), setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled), + getExistingLocalBackendState: () => + ipcRenderer.invoke(IpcChannels.GET_EXISTING_LOCAL_BACKEND_STATE_CHANNEL), + setAttachExistingLocalBackend: (enabled) => + ipcRenderer.invoke(IpcChannels.SET_ATTACH_EXISTING_LOCAL_BACKEND_CHANNEL, enabled), pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options), pickProjectFavicon: (initialPath) => ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe9..e72b806cdc4e 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -34,6 +34,7 @@ const DesktopSettingsPatch = Schema.Struct({ wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), wslOnly: Schema.optionalKey(Schema.Boolean), + attachExistingLocalBackend: Schema.optionalKey(Schema.Boolean), }); const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); @@ -116,6 +117,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + attachExistingLocalBackend: true, } satisfies DesktopAppSettings.DesktopSettings, ); }); @@ -145,6 +147,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + attachExistingLocalBackend: true, } satisfies DesktopAppSettings.DesktopSettings); const exposure = yield* settings.setServerExposureMode("local-only"); @@ -252,6 +255,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + attachExistingLocalBackend: true, } satisfies DesktopAppSettings.DesktopSettings); }), ), @@ -308,6 +312,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + attachExistingLocalBackend: true, } satisfies DesktopAppSettings.DesktopSettings); }), ), @@ -356,6 +361,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + attachExistingLocalBackend: true, } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -384,6 +390,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + attachExistingLocalBackend: true, } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -411,6 +418,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + attachExistingLocalBackend: true, } satisfies DesktopAppSettings.DesktopSettings); }), ), @@ -505,4 +513,23 @@ describe("DesktopSettings", () => { }), ), ); + + it.effect("persists attach-existing-local-backend and treats missing as enabled", () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + assert.equal((yield* settings.load).attachExistingLocalBackend, true); + + const disabled = yield* settings.setAttachExistingLocalBackend(false); + assert.isTrue(disabled.changed); + assert.equal(disabled.settings.attachExistingLocalBackend, false); + assert.equal((yield* settings.load).attachExistingLocalBackend, false); + + const enabled = yield* settings.setAttachExistingLocalBackend(true); + assert.isTrue(enabled.changed); + assert.equal(enabled.settings.attachExistingLocalBackend, true); + assert.equal((yield* settings.load).attachExistingLocalBackend, true); + }), + ), + ); }); diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index aefc67525531..3ea4927ea956 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -48,6 +48,10 @@ export interface DesktopSettings { // this requires a desktop restart because the pool's primary spec is // chosen once at layer init. readonly wslOnly: boolean; + // When true, the desktop attaches to a T3 Code server already running on + // this machine (background service or `t3 serve`) instead of spawning a + // second backend. Changing this requires a desktop restart. + readonly attachExistingLocalBackend: boolean; } export interface DesktopSettingsChange { @@ -84,6 +88,7 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { wslBackendEnabled: false, wslDistro: null, wslOnly: false, + attachExistingLocalBackend: true, }; const DesktopWindowBoundsDocument = Schema.Struct({ @@ -109,6 +114,7 @@ const DesktopSettingsDocument = Schema.Struct({ wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), wslOnly: Schema.optionalKey(Schema.Boolean), + attachExistingLocalBackend: Schema.optionalKey(Schema.Boolean), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; @@ -175,6 +181,9 @@ export class DesktopAppSettings extends Context.Service< readonly setWslOnly: ( enabled: boolean, ) => Effect.Effect; + readonly setAttachExistingLocalBackend: ( + enabled: boolean, + ) => Effect.Effect; readonly applyWslWindowsFallback: Effect.Effect< DesktopSettingsChange, DesktopSettingsWriteError @@ -238,6 +247,7 @@ function normalizeDesktopSettingsDocument( wslBackendEnabled, wslDistro: normalizeWslDistro(parsed.wslDistro), wslOnly: parsed.wslOnly === true, + attachExistingLocalBackend: parsed.attachExistingLocalBackend !== false, }; } @@ -280,6 +290,9 @@ function toDesktopSettingsDocument( if (settings.wslOnly !== defaults.wslOnly) { document.wslOnly = settings.wslOnly; } + if (settings.attachExistingLocalBackend !== defaults.attachExistingLocalBackend) { + document.attachExistingLocalBackend = settings.attachExistingLocalBackend; + } return document; } @@ -370,6 +383,18 @@ function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSetting }; } +function setAttachExistingLocalBackend( + settings: DesktopSettings, + enabled: boolean, +): DesktopSettings { + return settings.attachExistingLocalBackend === enabled + ? settings + : { + ...settings, + attachExistingLocalBackend: enabled, + }; +} + function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { return setWslOnly(setWslBackendEnabled(settings, false), false); } @@ -544,6 +569,12 @@ export const make = Effect.gen(function* () { persist((settings) => setWslOnly(settings, enabled)).pipe( Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), ), + setAttachExistingLocalBackend: (enabled) => + persist((settings) => setAttachExistingLocalBackend(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setAttachExistingLocalBackend", { + attributes: { enabled }, + }), + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -585,6 +616,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET update((settings) => setWslBackendEnabled(settings, enabled)), setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)), setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)), + setAttachExistingLocalBackend: (enabled) => + update((settings) => setAttachExistingLocalBackend(settings, enabled)), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index dd3cd1aaf5f5..9d6bac287a44 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -185,6 +185,8 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setAttachExistingLocalBackend: () => + Effect.die("unexpected attach-existing-local-backend toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 036eddd8db78..ff0691b730fc 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -236,6 +236,8 @@ function makeTestLayer(input: { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setAttachExistingLocalBackend: () => + Effect.die("unexpected attach-existing-local-backend toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); @@ -465,6 +467,33 @@ describe("DesktopWindow", () => { }), ); + it.effect("reloads an existing renderer after an attached backend is replaced", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + assert.equal(fakeWindow.reload.mock.calls.length, 0); + + yield* desktopWindow.handleBackendNotReady; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:41774"), { + reloadExisting: true, + }); + + assert.equal(fakeWindow.reload.mock.calls.length, 1); + assert.equal(yield* Ref.get(createCount), 1); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 56411711eb6c..fe98cff1e483 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -92,7 +92,10 @@ export class DesktopWindow extends Context.Service< // to the backend through the connection layer, so the reported httpBaseUrl is // no longer used to point the window at the backend — it is kept only for the // readiness log and to preserve the callback contract the backend pool drives. - readonly handleBackendReady: (httpBaseUrl: URL) => Effect.Effect; + readonly handleBackendReady: ( + httpBaseUrl: URL, + options?: { readonly reloadExisting?: boolean }, + ) => Effect.Effect; // Called when the backend transitions back to "not ready" (clean stop, // restart, crash). Clears the latch that lets `activate` auto-create a // window so a "macOS dock click" while the backend is down doesn't @@ -860,11 +863,19 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.activate")), createMainIfBackendReady, showConnectingSplash, - handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { - yield* Ref.set(backendReadyRef, true); - yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); - yield* createMainIfBackendReady; - }), + handleBackendReady: Effect.fn("desktop.window.handleBackendReady")( + function* (httpBaseUrl, options) { + yield* Ref.set(backendReadyRef, true); + yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); + if (options?.reloadExisting === true) { + const existingWindow = yield* currentMainWindow; + if (Option.isSome(existingWindow)) { + existingWindow.value.webContents.reload(); + } + } + yield* createMainIfBackendReady; + }, + ), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), ), diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index ed8911d40075..04b92d60f14b 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -68,6 +68,9 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp const backendConfigurationLayer = Layer.succeed( DesktopBackendConfiguration.DesktopBackendConfiguration, { + resolveExistingLocalBackend: Effect.succeed({ _tag: "NotFound" }), + invalidateExistingLocalBackendAttachment: Effect.succeed(false), + useIndependentBackendForLaunch: Effect.void, resolvePrimary: Effect.die("unexpected resolvePrimary"), resolvePrimaryLabel: Effect.succeed("Windows"), resolveWsl: () => Effect.die("unexpected resolveWsl"), diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts index fcd0345fc8d6..c999cbb23e43 100644 --- a/apps/server/src/atomicWrite.ts +++ b/apps/server/src/atomicWrite.ts @@ -5,6 +5,7 @@ import * as Path from "effect/Path"; export const writeFileStringAtomically = (input: { readonly filePath: string; readonly contents: string; + readonly mode?: number; }) => Effect.scoped( Effect.gen(function* () { @@ -20,6 +21,9 @@ export const writeFileStringAtomically = (input: { const tempPath = path.join(tempDirectory, "contents.tmp"); yield* fs.writeFileString(tempPath, input.contents); + if (input.mode !== undefined) { + yield* fs.chmod(tempPath, input.mode); + } yield* fs.rename(tempPath, input.filePath); }), ); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 5242dd738b89..377229b7cc36 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -13,7 +13,9 @@ import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; const makeServerConfigLayer = ( - overrides?: Partial>, + overrides?: Partial< + Pick + >, ) => Layer.effect( ServerConfig.ServerConfig, @@ -29,7 +31,9 @@ const makeServerConfigLayer = ( ); const makePairingGrantStoreLayer = ( - overrides?: Partial>, + overrides?: Partial< + Pick + >, ) => PairingGrantStore.layer.pipe( Layer.provide(SqlitePersistenceMemory), @@ -194,6 +198,35 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { ), ); + it.effect("seeds the per-process Desktop attachment credential as a reusable admin grant", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const first = yield* bootstrapCredentials.consume("desktop-attach-token"); + const second = yield* bootstrapCredentials.consume("desktop-attach-token"); + + expect(first.method).toBe("desktop-bootstrap"); + expect(first.subject).toBe("desktop-attach"); + expect(first.label).toBe("T3 Code Desktop"); + expect(first.scopes).toEqual([ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", + "access:read", + "access:write", + "relay:write", + ]); + expect(second.subject).toBe("desktop-attach"); + }).pipe( + Effect.provide( + makePairingGrantStoreLayer({ + desktopAttachToken: "desktop-attach-token", + }), + ), + ), + ); + it.effect("lists and revokes active pairing links", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 057a257ba664..c87ffaecfce7 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -248,6 +248,12 @@ const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); // window can still recover by re-bootstrapping rather than locking // the user out of the backend. const DESKTOP_BOOTSTRAP_TTL_HOURS = Duration.hours(24); +// This credential is scoped to one live server process: it is generated at +// startup, stored only in memory and in the owner-only runtime descriptor, +// and disappears when the process exits. Give it a deliberately long logical +// TTL so a long-running background service remains attachable without making +// the credential durable across restarts. +const DESKTOP_ATTACH_TTL_DAYS = Duration.days(365 * 100); // A dev server's startup token is read off a log by whoever (or whatever) is // driving the session, often minutes later — after a `node --watch` restart, a // detour into another task, or a hand-off to the person actually doing the @@ -329,6 +335,20 @@ export const make = Effect.gen(function* () { }); } + if (config.desktopAttachToken) { + const now = yield* DateTime.now; + yield* seedGrant(config.desktopAttachToken, { + method: "desktop-bootstrap", + scopes: AuthAdministrativeScopes, + subject: "desktop-attach", + label: "T3 Code Desktop", + expiresAt: DateTime.add(now, { + milliseconds: Duration.toMillis(DESKTOP_ATTACH_TTL_DAYS), + }), + remainingUses: "unbounded", + }); + } + const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn( "PairingGrantStore.listActive", )( diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index fcb662b9b780..e28871c6bdc1 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -7,6 +7,7 @@ import * as NodePath from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + AuthStandardClientScopes, CommandId, EnvironmentOrchestrationHttpApi, ProviderInstanceId, @@ -339,6 +340,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { const created = JSON.parse(createdOutput.output) as { readonly id: string; readonly credential: string; + readonly scopes: ReadonlyArray; }; const listedOutput = yield* captureStdout( runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), @@ -352,6 +354,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { assert.equal(typeof created.id, "string"); assert.equal(typeof created.credential, "string"); assert.equal(created.credential.length > 0, true); + assert.deepEqual(created.scopes, AuthStandardClientScopes); assert.equal(listed.length, 1); assert.equal(listed[0]?.id, created.id); assert.equal("credential" in (listed[0] ?? {}), false); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index def63b61fafe..20644ef84be1 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -128,6 +128,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, + desktopAttachToken: expect.stringMatching(/^[0-9a-f]{48}$/i), autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, tailscaleServeEnabled: false, @@ -198,6 +199,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, + desktopAttachToken: expect.stringMatching(/^[0-9a-f]{48}$/i), autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, tailscaleServeEnabled: true, @@ -271,6 +273,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: false, startupPresentation: "browser", desktopBootstrapToken: "desktop-bootstrap-token", + desktopAttachToken: expect.stringMatching(/^[0-9a-f]{48}$/i), autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, @@ -347,6 +350,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: "desktop-token", + desktopAttachToken: expect.stringMatching(/^[0-9a-f]{48}$/i), desktopTelemetryFd: 4, desktopTelemetryControlFd: 5, resourceMonitorPath: undefined, @@ -480,6 +484,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: "desktop-token", + desktopAttachToken: expect.stringMatching(/^[0-9a-f]{48}$/i), autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, tailscaleServeEnabled: false, @@ -549,6 +554,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "browser", desktopBootstrapToken: undefined, + desktopAttachToken: expect.stringMatching(/^[0-9a-f]{48}$/i), autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, @@ -612,6 +618,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { noBrowser: true, startupPresentation: "headless", desktopBootstrapToken: undefined, + desktopAttachToken: expect.stringMatching(/^[0-9a-f]{48}$/i), autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, tailscaleServeEnabled: false, diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 5b05b773b314..aefd6bee69a9 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -2,8 +2,10 @@ import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as LogLevel from "effect/LogLevel"; import * as Option from "effect/Option"; @@ -219,6 +221,7 @@ export const resolveServerConfig = ( const { findAvailablePort } = yield* NetService.NetService; const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; + const crypto = yield* Crypto.Crypto; const env = yield* EnvServerConfig; const normalizedFlags = { mode: flags.mode ?? Option.none(), @@ -303,6 +306,7 @@ export const resolveServerConfig = ( () => mode === "desktop", ); const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; + const desktopAttachToken = Encoding.encodeHex(yield* crypto.randomBytes(24)); const desktopTelemetryFd = bootstrap?.desktopTelemetryFd; const desktopTelemetryControlFd = bootstrap?.desktopTelemetryControlFd; const resourceMonitorPath = bootstrap?.resourceMonitorPath; @@ -379,6 +383,7 @@ export const resolveServerConfig = ( noBrowser, startupPresentation, desktopBootstrapToken, + desktopAttachToken, desktopTelemetryFd, desktopTelemetryControlFd, resourceMonitorPath, diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 795bf38e979d..4085d82499b4 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -3,6 +3,11 @@ import { HostProcessPlatform, HostProcessUserId, } from "@t3tools/shared/hostProcess"; +import { + BOOT_SERVICE_LAUNCHD_LABEL, + BOOT_SERVICE_PLIST_FILE, + BOOT_SERVICE_UNIT_FILE, +} from "@t3tools/shared/bootServiceIdentity"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -29,12 +34,6 @@ import { type ServiceState, } from "./serviceProtocol.ts"; -const BOOT_SERVICE_NAME = "t3code"; -export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; -// `.service` suffix keeps the label distinct from the desktop app's bundle id -// (com.t3tools.t3code), so launchd and TCC records never collide. -export const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; -export const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; export const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */ diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index bdff19572fdd..090612c0c141 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -79,6 +79,10 @@ export class ServerConfig extends Context.Service< readonly noBrowser: boolean; readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; + // Per-process credential advertised through the owner-only runtime state + // file so a local Desktop client can authenticate without opening the + // server's database from a second process. + readonly desktopAttachToken?: string | undefined; readonly desktopTelemetryFd?: number | undefined; readonly desktopTelemetryControlFd?: number | undefined; readonly resourceMonitorPath?: string | undefined; @@ -200,6 +204,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( port: 0, host: undefined, desktopBootstrapToken: undefined, + desktopAttachToken: undefined, desktopTelemetryFd: undefined, desktopTelemetryControlFd: undefined, resourceMonitorPath: undefined, diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index 4c2375b29a74..8f1740bedb85 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -34,28 +34,36 @@ describe("serverRuntimeState", () => { port: 4_971, origin: "http://127.0.0.1:4971", devUrl: "http://localhost:5733/", + desktopAttachToken: "attach-secret", startedAt: "2026-06-20T00:00:00.000Z", }; yield* ServerRuntimeState.persistServerRuntimeState({ path: statePath, state }); const restored = yield* ServerRuntimeState.readPersistedServerRuntimeState(statePath); + const info = yield* fileSystem.stat(statePath); assert.deepEqual(Option.getOrThrow(restored), state); + assert.equal(info.mode & 0o777, 0o600); }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("records the dev web URL when the server fronts a dev server", () => Effect.gen(function* () { const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: new URL("http://localhost:5733") }, + config: { + host: undefined, + devUrl: new URL("http://localhost:5733"), + desktopAttachToken: "attach-secret", + }, port: 13_773, }); assert.equal(state.devUrl, "http://localhost:5733/"); assert.equal(state.origin, "http://127.0.0.1:13773"); + assert.equal(state.desktopAttachToken, "attach-secret"); const withoutDev = yield* ServerRuntimeState.makePersistedServerRuntimeState({ - config: { host: undefined, devUrl: undefined }, + config: { host: undefined, devUrl: undefined, desktopAttachToken: undefined }, port: 13_773, }); assert.isFalse("devUrl" in withoutDev); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index b32f3814547c..9f8a38f4a932 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -17,6 +17,9 @@ export const PersistedServerRuntimeState = Schema.Struct({ // Present when the server fronts a dev web server (VITE_DEV_SERVER_URL). // Dev is single-origin: browsers must pair through this URL, not `origin`. devUrl: Schema.optional(Schema.String), + // Secret used only by another local T3 Code Desktop process to exchange for + // a normal bearer session through the running server. + desktopAttachToken: Schema.optional(Schema.NonEmptyString), startedAt: Schema.String, }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; @@ -48,7 +51,10 @@ const runtimeOriginForConfig = ( }; export const makePersistedServerRuntimeState = (input: { - readonly config: Pick; + readonly config: Pick< + ServerConfig.ServerConfig["Service"], + "host" | "devUrl" | "desktopAttachToken" + >; readonly port: number; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ @@ -58,6 +64,9 @@ export const makePersistedServerRuntimeState = (input: { port: input.port, origin: runtimeOriginForConfig(input.config, input.port), ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), + ...(input.config.desktopAttachToken + ? { desktopAttachToken: input.config.desktopAttachToken } + : {}), startedAt: DateTime.formatIso(now), })); @@ -68,6 +77,7 @@ export const persistServerRuntimeState = (input: { writeFileStringAtomically({ filePath: input.path, contents: `${JSON.stringify(input.state)}\n`, + mode: 0o600, }).pipe( Effect.mapError( (cause) => diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 290e2daa12b8..eed7ee40e60a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -3,9 +3,91 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { applyWslEnableSelection, isQrShareableEndpoint, + resolveDesktopBackendControlState, + resolveDesktopCurrentSessionScopes, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; +describe("resolveDesktopBackendControlState", () => { + it("keeps managed desktop controls enabled for legacy and managed backends", () => { + expect( + resolveDesktopBackendControlState({ + hasDesktopBridge: true, + supportsExistingBackendState: false, + existingBackendState: null, + existingBackendStateLoadFailed: false, + }), + ).toEqual({ isAttached: false, canManageDesktopBackend: true }); + expect( + resolveDesktopBackendControlState({ + hasDesktopBridge: true, + supportsExistingBackendState: true, + existingBackendState: { available: true, enabled: true, attached: false, origin: null }, + existingBackendStateLoadFailed: false, + }), + ).toEqual({ isAttached: false, canManageDesktopBackend: true }); + }); + + it("hides managed controls while attachment state loads and after attaching", () => { + expect( + resolveDesktopBackendControlState({ + hasDesktopBridge: true, + supportsExistingBackendState: true, + existingBackendState: null, + existingBackendStateLoadFailed: false, + }), + ).toEqual({ isAttached: false, canManageDesktopBackend: false }); + expect( + resolveDesktopBackendControlState({ + hasDesktopBridge: true, + supportsExistingBackendState: true, + existingBackendState: { + enabled: true, + available: true, + attached: true, + origin: "http://127.0.0.1:3773", + }, + existingBackendStateLoadFailed: false, + }), + ).toEqual({ isAttached: true, canManageDesktopBackend: false }); + }); + + it("keeps managed controls visible when attachment state fails to load", () => { + expect( + resolveDesktopBackendControlState({ + hasDesktopBridge: true, + supportsExistingBackendState: true, + existingBackendState: null, + existingBackendStateLoadFailed: true, + }), + ).toEqual({ isAttached: false, canManageDesktopBackend: true }); + }); +}); + +describe("resolveDesktopCurrentSessionScopes", () => { + it("keeps Desktop administrative scopes until an attached session resolves", () => { + expect( + resolveDesktopCurrentSessionScopes({ + isAttached: true, + hasDesktopBridge: true, + hasResolvedPrimarySession: false, + authenticatedSessionScopes: null, + }), + ).toContain("access:write"); + }); + + it("uses the attached server scopes after the session resolves", () => { + expect( + resolveDesktopCurrentSessionScopes({ + isAttached: true, + hasDesktopBridge: true, + hasResolvedPrimarySession: true, + authenticatedSessionScopes: ["orchestration:read"], + }), + ).toEqual(["orchestration:read"]); + }); +}); + const baseWslState: DesktopWslState = { enabled: false, distro: null, diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index faa0cb6c7543..93141ab94abd 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,7 +1,42 @@ -import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts"; +import type { + AdvertisedEndpoint, + DesktopBridge, + DesktopExistingLocalBackendState, + DesktopWslState, +} from "@t3tools/contracts"; +import { AuthAdministrativeScopes, type AuthEnvironmentScope } from "@t3tools/contracts"; type WslEnableBridge = Pick; +export function resolveDesktopBackendControlState(input: { + readonly hasDesktopBridge: boolean; + readonly supportsExistingBackendState: boolean; + readonly existingBackendState: DesktopExistingLocalBackendState | null; + readonly existingBackendStateLoadFailed: boolean; +}) { + const isAttached = input.existingBackendState?.attached === true; + return { + isAttached, + canManageDesktopBackend: + input.hasDesktopBridge && + (!input.supportsExistingBackendState || + input.existingBackendStateLoadFailed || + input.existingBackendState?.attached === false), + } as const; +} + +export function resolveDesktopCurrentSessionScopes(input: { + readonly isAttached: boolean; + readonly hasDesktopBridge: boolean; + readonly hasResolvedPrimarySession: boolean; + readonly authenticatedSessionScopes: ReadonlyArray | null; +}): ReadonlyArray | null { + if (input.isAttached && input.hasResolvedPrimarySession) { + return input.authenticatedSessionScopes; + } + return input.hasDesktopBridge ? AuthAdministrativeScopes : input.authenticatedSessionScopes; +} + /** * A QR code encoding a loopback URL makes the scanning device dial itself, so * loopback endpoints stay copyable from the endpoint menu but are never diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 18d1b0f1c924..adc0b50731ec 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -6,11 +6,10 @@ import { TerminalIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; +import { type ReactNode, memo, useCallback, useEffect, useId, useMemo, useState } from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, - AuthAdministrativeScopes, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -26,6 +25,7 @@ import { type DesktopSshEnvironmentTarget, type DesktopServerExposureState, type DesktopWslState, + type DesktopExistingLocalBackendState, type EnvironmentId, } from "@t3tools/contracts"; import { connectionStatusText } from "@t3tools/client-runtime/connection"; @@ -43,6 +43,8 @@ import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls import { applyWslEnableSelection, isQrShareableEndpoint, + resolveDesktopBackendControlState, + resolveDesktopCurrentSessionScopes, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; import { @@ -1745,6 +1747,12 @@ function CloudRemoteEnvironmentRows({ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; + const [existingLocalBackendState, setExistingLocalBackendState] = + useState(null); + const [existingLocalBackendStateLoadFailed, setExistingLocalBackendStateLoadFailed] = + useState(false); + const [isUpdatingAttachExistingLocalBackend, setIsUpdatingAttachExistingLocalBackend] = + useState(false); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); @@ -1755,12 +1763,23 @@ export function ConnectionsSettings() { const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const primarySessionState = usePrimarySessionState(); - const currentSessionScopes = desktopBridge - ? AuthAdministrativeScopes - : primarySessionState.data?.authenticated - ? (primarySessionState.data.scopes ?? null) - : null; - const currentAuthPolicy = desktopBridge ? null : (primarySessionState.data?.auth.policy ?? null); + const { isAttached: isAttachedToExistingLocalBackend, canManageDesktopBackend } = + resolveDesktopBackendControlState({ + hasDesktopBridge: Boolean(desktopBridge), + supportsExistingBackendState: Boolean(desktopBridge?.getExistingLocalBackendState), + existingBackendState: existingLocalBackendState, + existingBackendStateLoadFailed: existingLocalBackendStateLoadFailed, + }); + const authenticatedSessionScopes = primarySessionState.data?.authenticated + ? (primarySessionState.data.scopes ?? null) + : null; + const currentSessionScopes = resolveDesktopCurrentSessionScopes({ + isAttached: isAttachedToExistingLocalBackend, + hasDesktopBridge: Boolean(desktopBridge), + hasResolvedPrimarySession: primarySessionState.data !== null, + authenticatedSessionScopes, + }); + const currentAuthPolicy = primarySessionState.data?.auth.policy ?? null; const savedEnvironments = useMemo( () => environments @@ -1889,7 +1908,7 @@ export function ConnectionsSettings() { : null, ); const desktopNetworkAccess = useEnvironmentQuery( - canManageLocalBackend && desktopBridge ? desktopNetworkAccessStateAtom : null, + canManageLocalBackend && canManageDesktopBackend ? desktopNetworkAccessStateAtom : null, ); const desktopSshHosts = useEnvironmentQuery( desktopBridge && addBackendDialogOpen && savedBackendMode === "ssh" @@ -1945,7 +1964,7 @@ export function ConnectionsSettings() { ), ); }, [authAccessChanges.data]); - const isLocalBackendNetworkAccessible = desktopBridge + const isLocalBackendNetworkAccessible = canManageDesktopBackend ? desktopServerExposureState?.mode === "network-accessible" : currentAuthPolicy === "remote-reachable"; const trimmedTailscaleServePortInput = tailscaleServePortInput.trim(); @@ -1971,6 +1990,57 @@ export function ConnectionsSettings() { } }, [isTailscaleServePortValid, parsedTailscaleServePort, pendingTailscaleServeEndpoint]); + useEffect(() => { + if (!desktopBridge?.getExistingLocalBackendState) { + setExistingLocalBackendState(null); + setExistingLocalBackendStateLoadFailed(false); + return; + } + let cancelled = false; + setExistingLocalBackendStateLoadFailed(false); + void desktopBridge.getExistingLocalBackendState().then( + (state) => { + if (!cancelled) { + setExistingLocalBackendState(state); + setExistingLocalBackendStateLoadFailed(false); + } + }, + () => { + if (!cancelled) { + setExistingLocalBackendState(null); + setExistingLocalBackendStateLoadFailed(true); + } + }, + ); + return () => { + cancelled = true; + }; + }, [desktopBridge]); + + const handleAttachExistingLocalBackendChange = useCallback( + async (checked: boolean) => { + if (!desktopBridge?.setAttachExistingLocalBackend) return; + setIsUpdatingAttachExistingLocalBackend(true); + try { + const next = await desktopBridge.setAttachExistingLocalBackend(checked); + setExistingLocalBackendState(next); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to update local backend setting."; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not update local backend", + description: message, + }), + ); + } finally { + setIsUpdatingAttachExistingLocalBackend(false); + } + }, + [desktopBridge], + ); + const handleDesktopServerExposureChange = useCallback( async (checked: boolean) => { if (!desktopBridge) return; @@ -3062,9 +3132,42 @@ export function ConnectionsSettings() { ) : null} {desktopBridge ? ( <> - {renderNetworkAccessRow()} - {renderEndpointRows("endpoint-rail")} - {renderTailscaleRow()} + {desktopBridge.getExistingLocalBackendState && existingLocalBackendState ? ( + { + void handleAttachExistingLocalBackendChange(checked); + }} + aria-label="Attach to running server" + /> + } + /> + ) : null} + {canManageDesktopBackend ? ( + <> + {renderNetworkAccessRow()} + {renderEndpointRows("endpoint-rail")} + {renderTailscaleRow()} + + ) : isAttachedToExistingLocalBackend ? ( + renderDisabledNetworkAccessRow() + ) : null} {renderWslRow()} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5213cb55a503..7306fed0fc83 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -243,6 +243,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Source control", to: "/settings/source-control", }, + { + id: "attach-existing-local-backend", + title: "Attach to running server", + to: "/settings/connections", + desktopOnly: true, + }, { id: "remote-environments", title: "Remote environments", diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index f9381bcad714..08f62cff52ff 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -176,9 +176,9 @@ export function takePairingTokenFromUrl(): string | null { } function getDesktopBootstrapCredential(): string | null { - // Both backends share the same bootstrap token (DesktopBackendConfiguration - // mints one tokenRef and feeds it to both resolvers), so picking the - // primary entry is fine even when the WSL backend is also registered. + // Authentication for this gate is always scoped to the primary entry. A + // managed primary normally shares its bootstrap token with WSL; an attached + // primary instead carries the pairing credential minted for that server. const bootstraps = window.desktopBridge?.getLocalEnvironmentBootstraps() ?? []; const primary = bootstraps.find((entry) => entry.id === PRIMARY_LOCAL_ENVIRONMENT_ID); return typeof primary?.bootstrapToken === "string" && primary.bootstrapToken.length > 0 diff --git a/apps/web/src/environments/primary/desktopAuth.test.ts b/apps/web/src/environments/primary/desktopAuth.test.ts index d87a6a0c7f82..d2efd3d035a3 100644 --- a/apps/web/src/environments/primary/desktopAuth.test.ts +++ b/apps/web/src/environments/primary/desktopAuth.test.ts @@ -27,6 +27,32 @@ describe("desktop primary auth", () => { expect(getLocalEnvironmentBearerToken).toHaveBeenCalledTimes(1); }); + it("refreshes the bearer token when an attachment is reconfigured", async () => { + let authSessionKey = "session-one"; + const getLocalEnvironmentBearerToken = vi + .fn() + .mockResolvedValueOnce("desktop-bearer-one") + .mockResolvedValueOnce("desktop-bearer-two"); + window.desktopBridge = { + getLocalEnvironmentBootstraps: () => [ + { + id: "primary", + label: "Local environment", + httpBaseUrl: "http://127.0.0.1:41773", + wsBaseUrl: "ws://127.0.0.1:41773", + bootstrapToken: "stable-attach-credential", + authSessionKey, + }, + ], + getLocalEnvironmentBearerToken, + } as unknown as DesktopBridge; + + await expect(readDesktopPrimaryBearerToken()).resolves.toBe("desktop-bearer-one"); + authSessionKey = "session-two"; + await expect(readDesktopPrimaryBearerToken()).resolves.toBe("desktop-bearer-two"); + expect(getLocalEnvironmentBearerToken).toHaveBeenCalledTimes(2); + }); + it("does not require desktop auth in a browser", async () => { await expect(readDesktopPrimaryBearerToken()).resolves.toBeNull(); }); diff --git a/apps/web/src/environments/primary/desktopAuth.ts b/apps/web/src/environments/primary/desktopAuth.ts index 325773d910d7..7fe12a9aceac 100644 --- a/apps/web/src/environments/primary/desktopAuth.ts +++ b/apps/web/src/environments/primary/desktopAuth.ts @@ -1,4 +1,19 @@ +import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; + let desktopBearerTokenPromise: Promise | null = null; +let desktopBearerTokenConfigKey: string | null = null; +let desktopBearerTokenGeneration = 0; + +function readDesktopPrimaryConfigKey(): string { + const primary = window.desktopBridge + ?.getLocalEnvironmentBootstraps?.() + .find((entry) => entry.id === PRIMARY_LOCAL_ENVIRONMENT_ID); + return [ + primary?.httpBaseUrl ?? "", + primary?.bootstrapToken ?? "", + primary?.authSessionKey ?? "", + ].join("\u0000"); +} export function readDesktopPrimaryBearerToken(): Promise { if (typeof window === "undefined") { @@ -9,8 +24,19 @@ export function readDesktopPrimaryBearerToken(): Promise { return Promise.resolve(null); } - desktopBearerTokenPromise ??= bridge.getLocalEnvironmentBearerToken().catch((error) => { - desktopBearerTokenPromise = null; + const configKey = readDesktopPrimaryConfigKey(); + if (desktopBearerTokenPromise !== null && desktopBearerTokenConfigKey === configKey) { + return desktopBearerTokenPromise; + } + + const generation = desktopBearerTokenGeneration + 1; + desktopBearerTokenGeneration = generation; + desktopBearerTokenConfigKey = configKey; + desktopBearerTokenPromise = bridge.getLocalEnvironmentBearerToken().catch((error) => { + if (desktopBearerTokenGeneration === generation) { + desktopBearerTokenPromise = null; + desktopBearerTokenConfigKey = null; + } throw error; }); return desktopBearerTokenPromise; @@ -18,4 +44,6 @@ export function readDesktopPrimaryBearerToken(): Promise { export function __resetDesktopPrimaryAuthForTests(): void { desktopBearerTokenPromise = null; + desktopBearerTokenConfigKey = null; + desktopBearerTokenGeneration += 1; } diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 17de5777ba0b..f579321bfab7 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -64,6 +64,33 @@ A few more macOS notes: **Windows** is not supported yet. +## Using It with the Desktop App + +The installed desktop app attaches to a server that is already running on this machine instead +of starting a second backend. That keeps one environment and one T3 Connect device. Desktop +development remains isolated and always uses its development backend. + +This is on by default. Turn it off in **Settings → Connections → Attach to running server** +if you want the desktop app to spawn its own local backend. + +While attached, network exposure is controlled by the process that launched the server. The +desktop app shows that status but does not offer its own Network access or Tailscale controls. + +Discovery checks the installed background service first, including a custom `T3CODE_HOME` in +its systemd unit or launchd plist. It then checks the default T3 home and the configured desktop +home for a live server started with `t3 serve`. + +If Desktop finds a server but cannot create a secure local session, it does not silently start +another backend. You can try again, open the existing server in a browser, start a separate +backend for that Desktop launch, or quit. Starting a separate backend this way does not change +the saved attachment setting and uses an isolated local data directory instead of opening the +detected server's database. + +Desktop keeps using the interface bundled with the installed app while attached, so updating the +app does not depend on the background server's bundled web version. If the service restarts or +moves to another local port, Desktop discovers it again. If secure reconnection still fails, +Desktop asks whether to retry, start a separate backend, or stop reconnecting. + ## Using It with T3 Connect T3 Connect may offer to install the service during setup so the host stays reachable in the diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e753596f3d33..9c79b278145b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -283,6 +283,7 @@ export interface DesktopEnvironmentBootstrap { httpBaseUrl: string | null; wsBaseUrl: string | null; bootstrapToken?: string; + authSessionKey?: string; } export const DesktopEnvironmentBootstrapSchema = Schema.Struct({ @@ -292,6 +293,7 @@ export const DesktopEnvironmentBootstrapSchema = Schema.Struct({ httpBaseUrl: Schema.NullOr(Schema.String), wsBaseUrl: Schema.NullOr(Schema.String), bootstrapToken: Schema.optionalKey(Schema.String), + authSessionKey: Schema.optionalKey(Schema.String), }); export const DesktopSshEnvironmentTargetSchema = Schema.Struct({ @@ -504,6 +506,14 @@ export const DesktopWslStateSchema = Schema.Struct({ preflightError: Schema.NullOr(Schema.String), }); +export const DesktopExistingLocalBackendStateSchema = Schema.Struct({ + available: Schema.Boolean, + enabled: Schema.Boolean, + attached: Schema.Boolean, + origin: Schema.NullOr(Schema.String), +}); +export type DesktopExistingLocalBackendState = typeof DesktopExistingLocalBackendStateSchema.Type; + /** * Renderer-facing snapshot of a desktop preview tab. Mirrors the main-process * PreviewTabState shape but uses serialisable primitives only. @@ -1109,6 +1119,8 @@ export interface DesktopBridge { setWslBackendEnabled: (enabled: boolean) => Promise; setWslDistro: (distro: string | null) => Promise; setWslOnly: (enabled: boolean) => Promise; + getExistingLocalBackendState?: () => Promise; + setAttachExistingLocalBackend?: (enabled: boolean) => Promise; pickFolder: (options?: PickFolderOptions) => Promise; /** Optional while older desktop shells can host a newer web client. */ pickProjectFavicon?: (initialPath?: string) => Promise; diff --git a/packages/shared/package.json b/packages/shared/package.json index a797e97b6625..84a92343e266 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -211,6 +211,10 @@ "types": "./src/httpReadiness.ts", "import": "./src/httpReadiness.ts" }, + "./bootServiceIdentity": { + "types": "./src/bootServiceIdentity.ts", + "import": "./src/bootServiceIdentity.ts" + }, "./devHome": { "types": "./src/devHome.ts", "import": "./src/devHome.ts" diff --git a/packages/shared/src/bootServiceIdentity.ts b/packages/shared/src/bootServiceIdentity.ts new file mode 100644 index 000000000000..4719a42dc5df --- /dev/null +++ b/packages/shared/src/bootServiceIdentity.ts @@ -0,0 +1,5 @@ +export const BOOT_SERVICE_NAME = "t3code"; +export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; +// `.service` keeps the launchd label distinct from the desktop bundle id. +export const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; +export const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`;