diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd86c5f05d2..5aec848e169 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,9 +1,12 @@ import * as Cause from "effect/Cause"; 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 Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import type { DesktopBackendMode as DesktopBackendModeValue } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as Crypto from "effect/Crypto"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -11,6 +14,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopBackendMode from "./DesktopBackendMode.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; @@ -57,6 +61,17 @@ export class DesktopDevelopmentBackendPortRequiredError extends Schema.TaggedErr } } +export class DesktopRendererAssetsUnavailableError extends Schema.TaggedErrorClass()( + "DesktopRendererAssetsUnavailableError", + { + candidates: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `The packaged desktop renderer was not found. Checked: ${this.candidates.join(", ")}.`; + } +} + const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = DesktopObservability.makeComponentLogger("desktop-bootstrap"); @@ -136,21 +151,88 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); +export const latchDesktopBackendModeForStartup = Effect.fn( + "desktop.startup.latchDesktopBackendMode", +)(function* (configuredMode: DesktopBackendModeValue) { + const backendMode = yield* DesktopBackendMode.DesktopBackendMode; + return yield* backendMode + .latch(configuredMode) + .pipe(Effect.catchCause((cause) => fatalStartupCause("backendMode", cause))); +}); + +export const handleClientOnlyRendererReady = ( + rendererReady: Effect.Effect, +): Effect.Effect => + rendererReady.pipe( + Effect.tapError((error) => + logBootstrapWarning("failed to open main window after renderer readiness", { + error: error.message, + }), + ), + ); + +const resolvePackagedClientRoot = Effect.fn("desktop.bootstrap.resolvePackagedClientRoot")( + function* (candidates: readonly string[]) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const candidate of candidates) { + if ( + yield* fileSystem + .exists(path.join(candidate, "index.html")) + .pipe(Effect.orElseSucceed(() => false)) + ) { + return candidate; + } + } + return yield* new DesktopRendererAssetsUnavailableError({ candidates: [...candidates] }); + }, +); + const bootstrap = Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primaryBackend = yield* pool.primary; + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; const state = yield* DesktopState.DesktopState; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const backendMode = (yield* launchMode.get).effectiveMode; yield* logBootstrapInfo("bootstrap start"); + if (backendMode === "client-only") { + if (environment.isDevelopment) { + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(true), + source: "proxy", + targetOrigin: Option.getOrThrow(environment.devServerUrl), + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + } else { + const staticRoot = yield* resolvePackagedClientRoot(environment.packagedClientRootCandidates); + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(false), + source: "static", + staticRoot, + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + yield* logBootstrapInfo("bootstrap resolved packaged renderer", { staticRoot }); + } + + yield* installDesktopIpcHandlers(); + yield* logBootstrapInfo("bootstrap ipc handlers registered"); + if (!(yield* Ref.get(state.quitting))) { + yield* handleClientOnlyRendererReady(desktopWindow.handleRendererReady); + } + return; + } + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); } + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primaryBackend = yield* pool.primary; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); const backendPort = backendPortSelection.port; yield* logBootstrapInfo( @@ -171,14 +253,13 @@ const bootstrap = Effect.gen(function* () { } const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; const rendererTarget = environment.isDevelopment ? Option.getOrThrow(environment.devServerUrl) : backendConfig.httpBaseUrl; yield* electronProtocol.registerDesktopProtocol({ scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), + source: "proxy", targetOrigin: rendererTarget, - backendOrigin: backendConfig.httpBaseUrl, clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, }); yield* logBootstrapInfo("bootstrap resolved backend endpoint", { @@ -230,7 +311,13 @@ const startup = Effect.gen(function* () { const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); - yield* desktopSettings.load; + const settings = yield* desktopSettings.load; + const launchMode = yield* latchDesktopBackendModeForStartup(settings.backendMode); + yield* logStartupInfo("desktop backend mode selected", { + effectiveMode: launchMode.effectiveMode, + configuredMode: launchMode.configuredMode, + ...(launchMode.cliOverride === null ? {} : { cliOverride: launchMode.cliOverride }), + }); if (environment.platform === "linux") { yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); @@ -261,6 +348,10 @@ const scopedProgram = Effect.scoped( yield* Effect.addFinalizer(() => Effect.gen(function* () { + const backendMode = yield* DesktopBackendMode.DesktopBackendMode; + if ((yield* backendMode.get).effectiveMode === "client-only") { + return; + } const pool = yield* DesktopBackendPool.DesktopBackendPool; // Stop every backend in the pool, not just the primary. The // electronApp.quit() path can race ahead of the layer-scope diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts index 666c36d391d..eba9723c703 100644 --- a/apps/desktop/src/app/DesktopAppErrors.test.ts +++ b/apps/desktop/src/app/DesktopAppErrors.test.ts @@ -1,9 +1,22 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +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 ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import { DesktopBackendPortUnavailableError, DesktopDevelopmentBackendPortRequiredError, + handleClientOnlyRendererReady, + latchDesktopBackendModeForStartup, } from "./DesktopApp.ts"; +import * as DesktopBackendMode from "./DesktopBackendMode.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopState from "./DesktopState.ts"; describe("DesktopApp errors", () => { it("preserves unavailable backend port context", () => { @@ -27,4 +40,59 @@ describe("DesktopApp errors", () => { assert.equal(error.message, "T3CODE_PORT is required in desktop development."); }); + + it.effect("propagates client-only window creation failures", () => + Effect.gen(function* () { + const error = new Error("window creation failed"); + + const exit = yield* Effect.exit(handleClientOnlyRendererReady(Effect.fail(error))); + assert(Exit.isFailure(exit)); + const failure = Cause.findErrorOption(exit.cause); + assert(Option.isSome(failure)); + assert.strictEqual(failure.value, error); + }), + ); + + it.effect("reports invalid backend-mode launch arguments as fatal startup errors", () => + Effect.gen(function* () { + const quitCount = yield* Ref.make(0); + const shownErrors = yield* Ref.make([]); + + const layer = Layer.mergeAll( + DesktopBackendMode.layerTest(["electron", "--backend-mode=invalid"]), + DesktopShutdown.layer, + DesktopState.layer, + Layer.mock(ElectronApp.ElectronApp)({ + quit: Ref.update(quitCount, (count) => count + 1), + }), + Layer.mock(ElectronDialog.ElectronDialog)({ + showErrorBox: (title, content) => + Ref.update(shownErrors, (errors) => [...errors, { title, content }]), + }), + ); + + yield* Effect.gen(function* () { + const exit = yield* Effect.exit(latchDesktopBackendModeForStartup("managed")); + assert(Exit.isFailure(exit)); + const failure = Cause.findErrorOption(exit.cause); + assert(Option.isSome(failure)); + assert( + DesktopBackendMode.isDesktopBackendModeArgumentError(failure.value), + "expected the original backend mode argument error", + ); + + const errors = yield* Ref.get(shownErrors); + assert.equal(errors.length, 1); + assert.equal(errors[0]?.title, "T3 Code failed to start"); + assert.include(errors[0]?.content ?? "", "Stage: backendMode"); + assert.include(errors[0]?.content ?? "", 'Invalid --backend-mode value "invalid"'); + assert.equal(yield* Ref.get(quitCount), 1); + + const state = yield* DesktopState.DesktopState; + assert.isTrue(yield* Ref.get(state.quitting)); + const shutdown = yield* DesktopShutdown.DesktopShutdown; + yield* shutdown.awaitRequest; + }).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopBackendMode.test.ts b/apps/desktop/src/app/DesktopBackendMode.test.ts new file mode 100644 index 00000000000..de9394da9f9 --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendMode.test.ts @@ -0,0 +1,70 @@ +import { assert, describe, it } from "@effect/vitest"; + +import * as DesktopBackendMode from "./DesktopBackendMode.ts"; + +describe("DesktopBackendMode", () => { + const captureThrown = (run: () => unknown): unknown => { + try { + run(); + } catch (error) { + return error; + } + throw new Error("Expected the operation to throw."); + }; + + it("uses the persisted mode when no CLI override is present", () => { + assert.deepEqual(DesktopBackendMode.resolveDesktopBackendModeState([], "client-only"), { + effectiveMode: "client-only", + configuredMode: "client-only", + cliOverride: null, + }); + }); + + it("gives the CLI override precedence without changing the configured mode", () => { + assert.deepEqual( + DesktopBackendMode.resolveDesktopBackendModeState( + ["electron", "main.cjs", "--backend-mode=client-only"], + "managed", + ), + { + effectiveMode: "client-only", + configuredMode: "managed", + cliOverride: "client-only", + }, + ); + }); + + it("accepts a separate flag value", () => { + assert.equal( + DesktopBackendMode.parseDesktopBackendModeOverride(["electron", "--backend-mode", "managed"]), + "managed", + ); + }); + + it.each([ + ["--backend-mode=other", "invalid-value"], + ["--backend-mode=", "missing-value"], + ["--backend-mode", "missing-value"], + ])("rejects invalid launch argument %s", (argument, reason) => { + const error = captureThrown(() => + DesktopBackendMode.parseDesktopBackendModeOverride(["electron", argument]), + ); + assert.isTrue(DesktopBackendMode.isDesktopBackendModeArgumentError(error)); + if (DesktopBackendMode.isDesktopBackendModeArgumentError(error)) { + assert.equal(error.reason, reason); + } + }); + + it("rejects repeated overrides", () => { + const error = captureThrown(() => + DesktopBackendMode.parseDesktopBackendModeOverride([ + "--backend-mode=managed", + "--backend-mode=client-only", + ]), + ); + assert.isTrue(DesktopBackendMode.isDesktopBackendModeArgumentError(error)); + if (DesktopBackendMode.isDesktopBackendModeArgumentError(error)) { + assert.equal(error.reason, "repeated"); + } + }); +}); diff --git a/apps/desktop/src/app/DesktopBackendMode.ts b/apps/desktop/src/app/DesktopBackendMode.ts new file mode 100644 index 00000000000..81819b41005 --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendMode.ts @@ -0,0 +1,128 @@ +import { + type DesktopBackendMode as DesktopBackendModeValue, + type DesktopBackendModeState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +const BACKEND_MODE_FLAG = "--backend-mode"; + +export class DesktopBackendModeArgumentError extends Schema.TaggedErrorClass()( + "DesktopBackendModeArgumentError", + { + value: Schema.NullOr(Schema.String), + reason: Schema.Literals(["missing-value", "invalid-value", "repeated"]), + }, +) { + override get message(): string { + if (this.reason === "missing-value") { + return `${BACKEND_MODE_FLAG} requires either "managed" or "client-only".`; + } + if (this.reason === "repeated") { + return `${BACKEND_MODE_FLAG} may only be specified once.`; + } + return `Invalid ${BACKEND_MODE_FLAG} value ${JSON.stringify(this.value)}. Expected "managed" or "client-only".`; + } +} + +export const isDesktopBackendModeArgumentError = Schema.is(DesktopBackendModeArgumentError); + +function parseBackendMode(value: string | undefined): DesktopBackendModeValue { + if (value === undefined || value.length === 0) { + throw new DesktopBackendModeArgumentError({ + value: value ?? null, + reason: "missing-value", + }); + } + if (value === "managed" || value === "client-only") { + return value; + } + throw new DesktopBackendModeArgumentError({ + value, + reason: "invalid-value", + }); +} + +export function parseDesktopBackendModeOverride( + argv: readonly string[], +): DesktopBackendModeValue | null { + let override: DesktopBackendModeValue | null = null; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === undefined) continue; + + let value: string | undefined; + if (argument === BACKEND_MODE_FLAG) { + value = argv[index + 1]; + index += 1; + } else if (argument.startsWith(`${BACKEND_MODE_FLAG}=`)) { + value = argument.slice(BACKEND_MODE_FLAG.length + 1); + } else { + continue; + } + + if (override !== null) { + throw new DesktopBackendModeArgumentError({ + value: value ?? null, + reason: "repeated", + }); + } + override = parseBackendMode(value); + } + + return override; +} + +export function resolveDesktopBackendModeState( + argv: readonly string[], + configuredMode: DesktopBackendModeValue, +): DesktopBackendModeState { + const cliOverride = parseDesktopBackendModeOverride(argv); + return { + effectiveMode: cliOverride ?? configuredMode, + configuredMode, + cliOverride, + }; +} + +export class DesktopBackendMode extends Context.Service< + DesktopBackendMode, + { + readonly latch: ( + configuredMode: DesktopBackendModeValue, + ) => Effect.Effect; + readonly get: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopBackendMode") {} + +export const make = Effect.fn("desktop.backendMode.make")(function* (argv: readonly string[]) { + const stateRef = yield* Ref.make({ + effectiveMode: "managed", + configuredMode: "managed", + cliOverride: null, + }); + + return DesktopBackendMode.of({ + latch: (configuredMode) => + Effect.try({ + try: () => resolveDesktopBackendModeState(argv, configuredMode), + catch: (cause) => + isDesktopBackendModeArgumentError(cause) + ? cause + : new DesktopBackendModeArgumentError({ + value: null, + reason: "invalid-value", + }), + }).pipe(Effect.tap((state) => Ref.set(stateRef, state))), + get: Ref.get(stateRef), + }); +}); + +export const layer = Layer.effect(DesktopBackendMode, make(process.argv)); + +export const layerTest = (argv: readonly string[] = []) => + Layer.effect(DesktopBackendMode, make(argv)); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 15d23f8e152..b1eeacfff81 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -67,6 +67,10 @@ describe("DesktopEnvironment", () => { assert.equal(environment.appRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); assert.equal(environment.backendCwd, "/repo"); + assert.deepEqual(environment.packagedClientRootCandidates, [ + "/Applications/T3 Code.app/Contents/Resources/app.asar/apps/server/dist/client", + "/Applications/T3 Code.app/Contents/Resources/app.asar.unpacked/apps/server/dist/client", + ]); assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); assert.equal(environment.linuxWmClass, "t3code-dev"); assert.deepEqual( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index c991f5b39d6..cb7afe59986 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -53,6 +53,7 @@ export class DesktopEnvironment extends Context.Service< readonly appRoot: string; readonly backendEntryPath: string; readonly backendCwd: string; + readonly packagedClientRootCandidates: readonly string[]; readonly preloadPath: string; readonly appUpdateYmlPath: string; readonly devServerUrl: Option.Option; @@ -188,6 +189,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( appRoot, backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), backendCwd: input.isPackaged ? homeDirectory : appRoot, + packagedClientRootCandidates: [ + path.join(input.appPath, "apps/server/dist/client"), + path.join(resourcesPath, "app.asar.unpacked/apps/server/dist/client"), + ], preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged ? path.join(resourcesPath, "app-update.yml") diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index f8e05915718..b087bc63209 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -43,7 +43,7 @@ export class DesktopLifecycle extends Context.Service< { readonly relaunch: ( reason: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly register: Effect.Effect; } >()("@t3tools/desktop/app/DesktopLifecycle") {} @@ -146,6 +146,18 @@ export const make = DesktopLifecycle.of({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const state = yield* DesktopState.DesktopState; yield* logLifecycleInfo("desktop relaunch requested", { reason }); + if (!environment.isDevelopment) { + yield* electronApp + .relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }) + .pipe( + Effect.catchCause((cause) => + Effect.fail(new DesktopLifecycleRelaunchError({ reason, cause })), + ), + ); + } yield* Effect.gen(function* () { yield* Effect.yieldNow; yield* Ref.set(state.quitting, true); @@ -154,10 +166,6 @@ export const make = DesktopLifecycle.of({ yield* electronApp.exit(75); return; } - yield* electronApp.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); yield* electronApp.exit(0); }).pipe( Effect.catchCause((cause) => { diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index e3a4de661ac..497d00a5a54 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -403,6 +403,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); + const startRequestedRef = yield* Ref.make(false); const mutex = yield* Semaphore.make(1); const { logWarning: logInstanceWarning, logError: logInstanceError } = @@ -442,6 +443,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const start: Effect.Effect = Effect.suspend(() => mutex.withPermits(1)( Effect.gen(function* () { + yield* Ref.set(startRequestedRef, true); const current = yield* Ref.get(state); if (Option.isSome(current.active)) { return; @@ -805,7 +807,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( Effect.map(Option.getOrElse(() => false)), ); - yield* Effect.addFinalizer(() => stop()); + yield* Effect.addFinalizer(() => + Ref.get(startRequestedRef).pipe( + Effect.flatMap((startRequested) => (startRequested ? stop() : Effect.void)), + ), + ); return { id: spec.id, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index fa0811d5df7..21a27602015 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -74,6 +74,7 @@ function makePoolLayer( activate: Effect.die("unexpected window activate"), createMainIfBackendReady: Effect.die("unexpected window create"), showConnectingSplash: Effect.void, + handleRendererReady: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d..3d7b0e6a2f1 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -251,6 +251,7 @@ describe("DesktopServerExposure", () => { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 92e30000427..2d35913e355 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -36,8 +36,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: "clerk.t3.codes", }); assert.isDefined(handler); @@ -100,8 +100,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); @@ -128,8 +128,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:5733/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/"))); @@ -152,8 +152,8 @@ describe("ElectronProtocol", () => { const error = yield* Effect.scoped( protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: undefined, }), ).pipe(Effect.flip); @@ -177,8 +177,8 @@ describe("ElectronProtocol", () => { Effect.scoped( protocol.registerDesktopProtocol({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }), ), @@ -198,8 +198,8 @@ describe("ElectronProtocol", () => { it("keeps executable sources host-restricted while allowing runtime network resources", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: "clerk.t3.codes", }); const directives = Object.fromEntries( @@ -226,4 +226,117 @@ describe("ElectronProtocol", () => { ]); assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); }); + + it.effect("serves packaged assets, SPA routes, HEAD requests, and CSP", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockImplementation(async (url: string) => { + if (url.endsWith("/index.html")) { + return new Response("
T3 Code
", { status: 200 }); + } + if (url.endsWith("/assets/app-123.js")) { + return new Response("export {};", { status: 200 }); + } + return new Response(null, { status: 404 }); + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + source: "static", + staticRoot: "/opt/t3/apps/server/dist/client", + clerkFrontendApiHostname: undefined, + }); + assert.isDefined(handler); + + const root = yield* Effect.promise(() => + handler!( + new Request("t3code://app/", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(root.status, 200); + assert.equal(root.headers.get("content-type"), "text/html; charset=utf-8"); + assert.include(root.headers.get("content-security-policy") ?? "", "default-src 'self'"); + assert.equal(yield* Effect.promise(() => root.text()), "
T3 Code
"); + + const asset = yield* Effect.promise(() => + handler!(new Request("t3code://app/assets/app-123.js")), + ); + assert.equal(asset.headers.get("content-type"), "text/javascript; charset=utf-8"); + assert.equal(yield* Effect.promise(() => asset.text()), "export {};"); + + const route = yield* Effect.promise(() => + handler!( + new Request("t3code://app/settings/connections", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(route.status, 200); + assert.equal(yield* Effect.promise(() => route.text()), "
T3 Code
"); + + const head = yield* Effect.promise(() => + handler!( + new Request("t3code://app/assets/app-123.js", { + method: "HEAD", + }), + ), + ); + assert.equal(head.status, 200); + assert.equal(yield* Effect.promise(() => head.text()), ""); + }), + ); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); + + it.effect("does not fall back missing assets and rejects unsafe static requests", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockResolvedValue(new Response(null, { status: 404 })); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + source: "static", + staticRoot: "/opt/t3/apps/server/dist/client", + clerkFrontendApiHostname: undefined, + }); + assert.isDefined(handler); + + const missingAsset = yield* Effect.promise(() => + handler!( + new Request("t3code://app/assets/missing.js", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(missingAsset.status, 404); + assert.equal(netFetchMock.mock.calls.length, 1); + + const traversal = yield* Effect.promise(() => + handler!(new Request("t3code://app/%2e%2e%2fsecret.txt")), + ); + assert.equal(traversal.status, 403); + + const unsupported = yield* Effect.promise(() => + handler!(new Request("t3code://app/", { method: "POST" })), + ); + assert.equal(unsupported.status, 405); + assert.equal(unsupported.headers.get("allow"), "GET, HEAD"); + }), + ); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 03c7ef64fd7..d65e563585d 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Electron static protocol handlers require synchronous platform path validation. import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -5,6 +6,8 @@ import * as NodeTimersPromises from "node:timers/promises"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import * as Electron from "electron"; @@ -48,13 +51,23 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedErrorClass } } -export interface DesktopProtocolRegistrationInput { +interface DesktopProtocolRegistrationBase { readonly scheme: string; - readonly targetOrigin: URL; - readonly backendOrigin: URL; readonly clerkFrontendApiHostname: string | undefined; } +export type DesktopProtocolRegistrationInput = DesktopProtocolRegistrationBase & + ( + | { + readonly source: "proxy"; + readonly targetOrigin: URL; + } + | { + readonly source: "static"; + readonly staticRoot: string; + } + ); + export class ElectronProtocol extends Context.Service< ElectronProtocol, { @@ -104,6 +117,143 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } +const STATIC_CONTENT_TYPES: Readonly> = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +type StaticPathResolution = + | { readonly _tag: "Invalid"; readonly status: 400 | 403 } + | { readonly _tag: "Resolved"; readonly path: string; readonly relativePath: string }; + +export function resolveDesktopStaticPath( + staticRoot: string, + encodedPathname: string, +): StaticPathResolution { + let decodedPathname: string; + try { + decodedPathname = decodeURIComponent(encodedPathname); + } catch { + return { _tag: "Invalid", status: 400 }; + } + + if ( + decodedPathname.includes("\0") || + decodedPathname.includes("\\") || + /^[a-zA-Z]:/u.test(decodedPathname.replace(/^\/+/u, "")) + ) { + return { _tag: "Invalid", status: 403 }; + } + + const segments = decodedPathname.split("/").filter((segment) => segment.length > 0); + if (segments.some((segment) => segment === "." || segment === "..")) { + return { _tag: "Invalid", status: 403 }; + } + + const relativePath = segments.length === 0 ? "index.html" : segments.join("/"); + const normalizedRoot = NodePath.resolve(staticRoot); + const resolvedPath = NodePath.resolve(normalizedRoot, relativePath); + const relativeToRoot = NodePath.relative(normalizedRoot, resolvedPath); + if ( + relativeToRoot === ".." || + relativeToRoot.startsWith(`..${NodePath.sep}`) || + NodePath.isAbsolute(relativeToRoot) + ) { + return { _tag: "Invalid", status: 403 }; + } + + return { + _tag: "Resolved", + path: resolvedPath, + relativePath, + }; +} + +function shouldUseSpaFallback(request: Request, relativePath: string): boolean { + if (NodePath.extname(relativePath) !== "") { + return false; + } + const accept = request.headers.get("accept") ?? ""; + const mode = request.headers.get("sec-fetch-mode") ?? ""; + return mode === "navigate" || accept.includes("text/html"); +} + +async function fetchStaticFile(path: string): Promise { + try { + return await Electron.net.fetch(NodeURL.pathToFileURL(path).href, { method: "GET" }); + } catch { + return new Response(null, { status: 404 }); + } +} + +function withStaticResponseHeaders(response: Response, path: string, headOnly: boolean): Response { + const headers = new Headers(response.headers); + const contentType = STATIC_CONTENT_TYPES[NodePath.extname(path).toLowerCase()]; + if (contentType !== undefined) { + headers.set("Content-Type", contentType); + } + return new Response(headOnly ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export async function serveDesktopStaticRequest( + request: Request, + staticRoot: string, + contentSecurityPolicy: string, +): Promise { + const requestUrl = new URL(request.url); + if (requestUrl.host !== DESKTOP_HOST) { + return withContentSecurityPolicy(new Response(null, { status: 404 }), contentSecurityPolicy); + } + if (request.method !== "GET" && request.method !== "HEAD") { + return withContentSecurityPolicy( + new Response(null, { + status: 405, + headers: { Allow: "GET, HEAD" }, + }), + contentSecurityPolicy, + ); + } + + const resolution = resolveDesktopStaticPath(staticRoot, requestUrl.pathname); + if (resolution._tag === "Invalid") { + return withContentSecurityPolicy( + new Response(null, { status: resolution.status }), + contentSecurityPolicy, + ); + } + + let response = await fetchStaticFile(resolution.path); + let responsePath = resolution.path; + if (response.status === 404 && shouldUseSpaFallback(request, resolution.relativePath)) { + // Resolve the shell through the same normalization as asset paths so a + // relative or non-normalized staticRoot still falls back correctly. + responsePath = NodePath.resolve(staticRoot, "index.html"); + response = await fetchStaticFile(responsePath); + } + + return withContentSecurityPolicy( + withStaticResponseHeaders(response, responsePath, request.method === "HEAD"), + contentSecurityPolicy, + ); +} + async function proxyRequest( request: Request, targetOrigin: URL, @@ -181,9 +331,12 @@ export const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ try: () => { - Electron.protocol.handle(input.scheme, (request) => - proxyRequest(request, input.targetOrigin, contentSecurityPolicy), - ); + Electron.protocol.handle(input.scheme, (request) => { + if (input.source === "static") { + return serveDesktopStaticRequest(request, input.staticRoot, contentSecurityPolicy); + } + return proxyRequest(request, input.targetOrigin, contentSecurityPolicy); + }); }, catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), }).pipe(Effect.andThen(Ref.set(registered, true))), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..d72a2f6226a 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; +import { getBackendModeState, setBackendMode } from "./methods/backendMode.ts"; import { clearConnectionCatalog, getConnectionCatalog, @@ -49,9 +50,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getBackendModeState); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handle(setBackendMode); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 95c725130e5..9557c9cd524 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -13,6 +13,8 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_BACKEND_MODE_STATE_CHANNEL = "desktop:get-backend-mode-state"; +export const SET_BACKEND_MODE_CHANNEL = "desktop:set-backend-mode"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; diff --git a/apps/desktop/src/ipc/methods/backendMode.test.ts b/apps/desktop/src/ipc/methods/backendMode.test.ts new file mode 100644 index 00000000000..32d875ade0f --- /dev/null +++ b/apps/desktop/src/ipc/methods/backendMode.test.ts @@ -0,0 +1,49 @@ +import { DesktopBackendModeStateSchema } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import { setBackendMode } from "./backendMode.ts"; + +const decodeBackendModeState = Schema.decodeUnknownEffect(DesktopBackendModeStateSchema); + +const unusedLifecycleRuntimeLayer = + Layer.empty as Layer.Layer; + +describe("backend mode IPC", () => { + it.effect("restores the configured mode when a packaged relaunch cannot be scheduled", () => { + const relaunchError = new DesktopLifecycle.DesktopLifecycleRelaunchError({ + reason: "backendMode=client-only", + cause: Cause.die(new Error("relaunch failed")), + }); + const layer = Layer.mergeAll( + DesktopBackendMode.layerTest(), + DesktopAppSettings.layerTest(), + Layer.succeed( + DesktopLifecycle.DesktopLifecycle, + DesktopLifecycle.DesktopLifecycle.of({ + relaunch: () => Effect.fail(relaunchError), + register: Effect.void, + }), + ), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* launchMode.latch("managed"); + const error = yield* setBackendMode + .handler("client-only") + .pipe(Effect.flatMap(decodeBackendModeState), Effect.flip); + + assert.strictEqual(error, relaunchError); + assert.equal((yield* settings.get).backendMode, "managed"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ipc/methods/backendMode.ts b/apps/desktop/src/ipc/methods/backendMode.ts new file mode 100644 index 00000000000..90f6b540de4 --- /dev/null +++ b/apps/desktop/src/ipc/methods/backendMode.ts @@ -0,0 +1,63 @@ +import { + DesktopBackendModeSchema, + DesktopBackendModeStateSchema, + type DesktopBackendModeState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as IpcChannels from "../channels.ts"; + +const readBackendModeState: Effect.Effect< + DesktopBackendModeState, + never, + DesktopBackendMode.DesktopBackendMode | DesktopAppSettings.DesktopAppSettings +> = Effect.gen(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const launchState = yield* launchMode.get; + const configuredMode = (yield* settings.get).backendMode; + return { + ...launchState, + configuredMode, + }; +}); + +export const getBackendModeState = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_BACKEND_MODE_STATE_CHANNEL, + result: DesktopBackendModeStateSchema, + handler: Effect.fn("desktop.ipc.backendMode.get")(function* () { + return yield* readBackendModeState; + }), +}); + +export const setBackendMode = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_BACKEND_MODE_CHANNEL, + payload: DesktopBackendModeSchema, + result: DesktopBackendModeStateSchema, + handler: Effect.fn("desktop.ipc.backendMode.set")(function* (mode) { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const previousMode = (yield* settings.get).backendMode; + const change = yield* settings.setBackendMode(mode); + const launchState = yield* launchMode.get; + const state = { + ...launchState, + configuredMode: change.settings.backendMode, + }; + if ( + change.changed && + launchState.cliOverride === null && + launchState.effectiveMode !== change.settings.backendMode + ) { + yield* lifecycle + .relaunch(`backendMode=${mode}`) + .pipe(Effect.tapError(() => settings.setBackendMode(previousMode))); + } + return state; + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 13e6e8d3956..3d0d23003ef 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -7,6 +7,7 @@ import type * as Electron from "electron"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; @@ -64,7 +65,14 @@ describe("getLocalEnvironmentBootstraps", () => { bootstrapToken: "bootstrap-token", }, ]); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([defaultWslInstance]))), + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([defaultWslInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ), ); it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => { @@ -99,7 +107,14 @@ describe("getLocalEnvironmentBootstraps", () => { wsBaseUrl: null, }, ]); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([retryingInstance]))); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([retryingInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ); }); it.effect("omits a bounded transient bootstrap after retries stop", () => { @@ -127,8 +142,30 @@ describe("getLocalEnvironmentBootstraps", () => { return Effect.gen(function* () { const result = yield* getLocalEnvironmentBootstraps.handler(); assert.deepEqual(result, []); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([stoppedInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ); }); + + it.effect("returns no local bootstraps in client-only mode", () => + Effect.gen(function* () { + const mode = yield* DesktopBackendMode.DesktopBackendMode; + yield* mode.latch("client-only"); + assert.deepEqual(yield* getLocalEnvironmentBootstraps.handler(), []); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([defaultWslInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ), + ); }); describe("getWindowFullscreenState", () => { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a4e98aaabad..784c5f6223c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; @@ -69,6 +70,10 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + if ((yield* launchMode.get).effectiveMode === "client-only") { + return []; + } const pool = yield* DesktopBackendPool.DesktopBackendPool; const instances = yield* pool.list; const bootstraps: DesktopEnvironmentBootstrap[] = []; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9795f04e8ae..282a69c4c0e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -31,6 +31,7 @@ import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; +import * as DesktopBackendMode from "./app/DesktopBackendMode.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; @@ -125,6 +126,7 @@ const electronLayer = Layer.mergeAll( const desktopFoundationLayer = Layer.mergeAll( DesktopState.layer, DesktopShutdown.layer, + DesktopBackendMode.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 228114fd1d1..d444914983f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -35,6 +35,21 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getBackendModeState: () => { + const result = ipcRenderer.sendSync(IpcChannels.GET_BACKEND_MODE_STATE_CHANNEL); + if (typeof result !== "object" || result === null) { + // An unavailable mode handler means the renderer cannot safely assume + // that this process owns a backend. Fail closed into the connection-only + // routing path instead of trying to resolve t3code:// as an HTTP backend. + return { + effectiveMode: "client-only", + configuredMode: "client-only", + cliOverride: null, + }; + } + return result as ReturnType; + }, + setBackendMode: (mode) => ipcRenderer.invoke(IpcChannels.SET_BACKEND_MODE_CHANNEL, mode), getLocalEnvironmentBootstraps: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); if (!Array.isArray(result)) { diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 3878b0e36ad..f7835e6069d 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + backendMode: Schema.optionalKey(Schema.Literals(["managed", "client-only"])), mainWindowBounds: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -102,6 +103,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -121,6 +123,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ + backendMode: "client-only", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -129,6 +132,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "client-only", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -156,6 +160,10 @@ describe("DesktopSettings", () => { assert.isTrue(updateChannel.changed); assert.equal(updateChannel.settings.updateChannel, "nightly"); assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); + + const backendMode = yield* settings.setBackendMode("managed"); + assert.isTrue(backendMode.changed); + assert.equal(backendMode.settings.backendMode, "managed"); }), ), ); @@ -235,6 +243,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -277,11 +286,13 @@ describe("DesktopSettings", () => { yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); + yield* settings.setBackendMode("client-only"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { + backendMode: "client-only", mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", @@ -300,6 +311,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -327,6 +339,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -353,6 +366,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 466c9a9b5f8..25befef96fb 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -1,6 +1,8 @@ import { DesktopServerExposureModeSchema, + DesktopBackendModeSchema, DesktopUpdateChannelSchema, + type DesktopBackendMode, type DesktopServerExposureMode, type DesktopUpdateChannel, } from "@t3tools/contracts"; @@ -20,6 +22,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly backendMode: DesktopBackendMode; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; @@ -67,6 +70,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -87,6 +91,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + backendMode: Schema.optionalKey(DesktopBackendModeSchema), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), @@ -148,6 +153,9 @@ export class DesktopAppSettings extends Context.Service< bounds: DesktopWindowBounds, isMaximized: boolean, ) => Effect.Effect; + readonly setBackendMode: ( + mode: DesktopBackendMode, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -216,6 +224,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + backendMode: parsed.backendMode === "client-only" ? "client-only" : "managed", mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: @@ -238,6 +247,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.backendMode !== defaults.backendMode) { + document.backendMode = settings.backendMode; + } if (settings.mainWindowBounds !== null) { document.mainWindowBounds = settings.mainWindowBounds; } @@ -284,6 +296,18 @@ function setServerExposureMode( }; } +function setBackendMode( + settings: DesktopSettings, + requestedMode: DesktopBackendMode, +): DesktopSettings { + return settings.backendMode === requestedMode + ? settings + : { + ...settings, + backendMode: requestedMode, + }; +} + function setMainWindowBounds( settings: DesktopSettings, bounds: DesktopWindowBounds, @@ -506,6 +530,10 @@ export const make = Effect.gen(function* () { }, }), ), + setBackendMode: (mode) => + persist((settings) => setBackendMode(settings, mode)).pipe( + Effect.withSpan("desktop.settings.setBackendMode", { attributes: { mode } }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -565,6 +593,7 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET load: SynchronizedRef.get(settingsRef), setMainWindowBounds: (bounds, isMaximized) => update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), + setBackendMode: (mode) => update((settings) => setBackendMode(settings, mode)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 32224c7a5ca..85757a468cf 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -159,6 +159,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.fail(setUpdateChannelError), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed..f8071f634ae 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -74,6 +74,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => activate: Effect.void, createMainIfBackendReady: Effect.void, showConnectingSplash: Effect.void, + handleRendererReady: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 587da8d4431..8cb74dffc05 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -211,6 +211,7 @@ function makeTestLayer(input: { } return { settings: desktopSettings, changed }; }), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.die("unexpected update channel change"), @@ -436,6 +437,26 @@ describe("DesktopWindow", () => { }), ); + it.effect("opens once the renderer is ready without a backend callback", () => + 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.handleRendererReady; + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index db4b698434d..baaf1580765 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -66,6 +66,9 @@ export class DesktopWindow extends Context.Service< // mode), before the WSL backend that serves the renderer is ready. It is // dismissed automatically once the real main window reveals. readonly showConnectingSplash: Effect.Effect; + // Marks the packaged/Vite renderer as loadable independently of whether + // this desktop process owns a backend. + readonly handleRendererReady: Effect.Effect; // Marks the primary backend as ready so `createMainIfBackendReady` and the // macOS "activate without windows" path may open the real main window. The // renderer now always loads the local client URL (getDesktopUrl) and connects @@ -727,6 +730,11 @@ export const make = Effect.gen(function* () { Effect.withSpan("desktop.window.showConnectingSplash"), ); + const handleRendererReady = Ref.set(backendReadyRef, true).pipe( + Effect.andThen(createMainIfBackendReady), + Effect.withSpan("desktop.window.handleRendererReady"), + ); + return DesktopWindow.of({ createMain, ensureMain, @@ -754,10 +762,10 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.activate")), createMainIfBackendReady, showConnectingSplash, + handleRendererReady, handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { - yield* Ref.set(backendReadyRef, true); yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); - yield* createMainIfBackendReady; + yield* handleRendererReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..b0f21b281f6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -56,7 +56,11 @@ import { useEnvironmentQuery } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; -import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { + useAppOwnsLocalEnvironment, + useEnvironments, + usePrimaryEnvironmentId, +} from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { @@ -492,6 +496,7 @@ function OpenCommandPaletteDialog(props: { const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); @@ -540,12 +545,14 @@ function OpenCommandPaletteDialog(props: { projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }), [ clientSettings.sidebarProjectSortOrder, environmentLabelById, orderedProjects, + ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projects, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa734..b814edce7fa 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -71,6 +71,7 @@ import { type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { isRemoteEnvironmentId } from "../environmentPresence"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; import { useOpenPrLink } from "../lib/openPullRequestLink"; @@ -114,7 +115,13 @@ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; -import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { + useAppOwnsLocalEnvironment, + useEnvironment, + useEnvironmentPresenceScope, + useEnvironments, + usePrimaryEnvironmentId, +} from "../state/environments"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, @@ -382,9 +389,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr reportFailure: false, }); const environment = useEnvironment(thread.environmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const presenceScope = useEnvironmentPresenceScope(); + const isRemoteThread = isRemoteEnvironmentId(thread.environmentId, presenceScope); const remoteEnvLabel = environment?.label ?? null; // A desktop-local secondary backend (e.g. the WSL backend) shows up as a // bearer environment whose connection id is prefixed "local:". It runs on the @@ -3038,6 +3044,7 @@ export default function Sidebar() { const shortcutModifiers = useShortcutModifierState(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const environmentLabelById = useMemo( () => new Map( @@ -3092,6 +3099,7 @@ export default function Sidebar() { projects: orderedProjects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), }); @@ -3099,6 +3107,7 @@ export default function Sidebar() { environmentLabelById, desktopLocalEnvironmentIds, orderedProjects, + ownsLocalEnvironment, projectGroupingSettings, primaryEnvironmentId, ]); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e..c312a9533fa 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -88,7 +88,8 @@ import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; -import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useEnvironmentPresenceScope, useEnvironments } from "../state/environments"; +import { isRemoteEnvironmentId, type EnvironmentPresenceScope } from "../environmentPresence"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; @@ -370,7 +371,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { wokeAt: string | null; isActive: boolean; jumpLabel: string | null; - currentEnvironmentId: string | null; + presenceScope: EnvironmentPresenceScope; environmentLabel: string | null; projectCwd: string | null; projectTitle: string | null; @@ -523,8 +524,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; - const isRemote = - props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + const isRemote = isRemoteEnvironmentId(thread.environmentId, props.presenceScope); const detailsTooltip = ( s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); @@ -1092,11 +1093,13 @@ export default function SidebarV2() { projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }), [ environmentLabelById, orderedProjects, + ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projects, @@ -2421,7 +2424,7 @@ export default function SidebarV2() { wokeAt={threadWokeAt(thread, { now: snoozeNow })} isActive={routeThreadKey === threadKey} jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} - currentEnvironmentId={primaryEnvironmentId} + presenceScope={presenceScope} environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} projectCwd={ projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0d3291a9e28..ecbb6d6c4a0 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -6,7 +6,8 @@ import { import type { VcsStatusResult } from "@t3tools/contracts"; import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; -import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; +import { isRemoteEnvironmentId } from "../environmentPresence"; +import { useEnvironment, useEnvironmentPresenceScope } from "../state/environments"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; @@ -307,9 +308,8 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma threadId: thread.id, }); const environment = useEnvironment(thread.environmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const presenceScope = useEnvironmentPresenceScope(); + const isRemoteThread = isRemoteEnvironmentId(thread.environmentId, presenceScope); const remoteEnvLabel = environment?.label ?? null; const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 98091f9aab6..6f1dd2a7be4 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -12,7 +12,11 @@ import { buildSidebarProjectSnapshots, } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; -import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { + useAppOwnsLocalEnvironment, + useEnvironments, + usePrimaryEnvironmentId, +} from "~/state/environments"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { Menu, @@ -37,6 +41,7 @@ export function DraftHeroHeadline({ const threads = useThreadShells(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); const handleNewThread = useNewThreadHandler(); @@ -56,6 +61,7 @@ export function DraftHeroHeadline({ projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }), @@ -64,6 +70,7 @@ export function DraftHeroHeadline({ ), [ environmentLabelById, + ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projectSortOrder, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 52b89530127..03f95707581 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -24,6 +24,8 @@ import { type AuthPairingLink, type AdvertisedEndpoint, type DesktopDiscoveredSshHost, + type DesktopBackendMode, + type DesktopBackendModeState, type DesktopSshEnvironmentTarget, type DesktopServerExposureState, type DesktopWslState, @@ -1711,6 +1713,10 @@ function CloudRemoteEnvironmentRows({ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; + const [desktopBackendModeState, setDesktopBackendModeState] = + useState(() => desktopBridge?.getBackendModeState?.() ?? null); + const [desktopBackendModeError, setDesktopBackendModeError] = useState(null); + const [isUpdatingDesktopBackendMode, setIsUpdatingDesktopBackendMode] = useState(false); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); @@ -1734,6 +1740,10 @@ export function ConnectionsSettings() { .toSorted((left, right) => left.label.localeCompare(right.label)), [environments], ); + const hasUsableClientOnlyEnvironment = savedEnvironments.some( + (environment) => !isDesktopLocalConnectionTarget(environment.entry.target), + ); + const isClientOnlyDesktop = desktopBackendModeState?.effectiveMode === "client-only"; const savedDesktopSshEnvironmentsByAlias = useMemo( () => savedEnvironments.reduce>( @@ -1841,7 +1851,8 @@ export function ConnectionsSettings() { const setDefaultAdvertisedEndpointKey = useUiStateStore( (state) => state.setDefaultAdvertisedEndpointKey, ); - const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; + const canManageLocalBackend = + !isClientOnlyDesktop && (currentSessionScopes?.includes(AuthAccessWriteScope) ?? false); const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; const authAccessChanges = useEnvironmentQuery( canManageLocalBackend && primaryEnvironmentId !== null @@ -2975,9 +2986,88 @@ export function ConnectionsSettings() { /> ); + const handleDesktopBackendModeChange = async (mode: DesktopBackendMode) => { + if (!desktopBridge || !desktopBackendModeState) return; + if (mode === desktopBackendModeState.configuredMode) return; + if (mode === "client-only" && !hasUsableClientOnlyEnvironment) { + setDesktopBackendModeError( + "Pair and save an environment before switching to client-only mode.", + ); + setAddBackendDialogOpen(true); + return; + } + + setIsUpdatingDesktopBackendMode(true); + setDesktopBackendModeError(null); + try { + const next = await desktopBridge.setBackendMode(mode); + setDesktopBackendModeState(next); + setIsUpdatingDesktopBackendMode(false); + } catch (error) { + setDesktopBackendModeError( + error instanceof Error ? error.message : "Could not update the desktop backend mode.", + ); + setIsUpdatingDesktopBackendMode(false); + } + }; + return ( - {canManageLocalBackend ? ( + {desktopBridge && desktopBackendModeState ? ( + + {desktopBackendModeError} + ) : desktopBackendModeState.cliOverride !== null ? ( + + This launch is overridden by --backend-mode= + {desktopBackendModeState.cliOverride}. The saved preference applies when launched + without that flag. + + ) : null + } + control={ + + } + /> + + ) : null} + + {isClientOnlyDesktop ? null : canManageLocalBackend ? ( <> {primaryVersionMismatch ? ( diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 56d25fa142e..699711ffcc3 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -45,7 +45,8 @@ import { FetchHttpClient } from "effect/unstable/http"; import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { - readPrimaryEnvironmentTarget, + readOptionalPrimaryEnvironmentTarget, + isDesktopClientOnlyMode, type PrimaryEnvironmentTarget, } from "../environments/primary/target"; import { clearComposerDraftsEnvironment } from "../composerDraftStore"; @@ -399,7 +400,7 @@ export type PrimaryEnvironmentTargetRead = }; export function readPrimaryEnvironmentTargetResult( - readTarget: () => PrimaryEnvironmentTarget | null = readPrimaryEnvironmentTarget, + readTarget: () => PrimaryEnvironmentTarget | null = readOptionalPrimaryEnvironmentTarget, ): PrimaryEnvironmentTargetRead { try { return { _tag: "Success", target: readTarget() }; @@ -456,7 +457,7 @@ export function secondaryRegistrationsToRetainAfterTopologyRead( const platformConnectionSourceLayer = Layer.effect( PlatformConnectionSource, Effect.gen(function* () { - if (isHostedStaticApp()) { + if (isHostedStaticApp() || isDesktopClientOnlyMode()) { return PlatformConnectionSource.of({ registrations: Stream.empty, }); diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 17d86ca0912..c7968ee0eee 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -81,6 +81,33 @@ describe("environment grouping", () => { expect(projectGroupCount).toBe(1); }); + it("marks every project remote for an app with no local backend of its own", () => { + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + }); + + const [clientOnly] = buildSidebarProjectSnapshots({ + projects: [remote], + settings: defaultGroupingSettings, + primaryEnvironmentId: null, + ownsLocalEnvironment: false, + resolveEnvironmentLabel: () => "remote", + }); + + expect(clientOnly?.environmentPresence).toBe("remote-only"); + expect(clientOnly?.remoteEnvironmentLabels).toEqual(["remote"]); + + const [managed] = buildSidebarProjectSnapshots({ + projects: [remote], + settings: defaultGroupingSettings, + primaryEnvironmentId: null, + resolveEnvironmentLabel: () => "remote", + }); + + expect(managed?.environmentPresence).toBe("local-only"); + }); + it("keeps projects without repository identity physically scoped", () => { const primary = makeProject(); const remote = makeProject({ diff --git a/apps/web/src/environmentPresence.test.ts b/apps/web/src/environmentPresence.test.ts new file mode 100644 index 00000000000..7e6e9daf47a --- /dev/null +++ b/apps/web/src/environmentPresence.test.ts @@ -0,0 +1,29 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { isRemoteEnvironmentId } from "./environmentPresence"; + +const primaryEnvironmentId = EnvironmentId.make("env-primary"); +const remoteEnvironmentId = EnvironmentId.make("env-remote"); + +describe("isRemoteEnvironmentId", () => { + it("treats the primary environment as local and everything else as remote", () => { + const scope = { primaryEnvironmentId, ownsLocalEnvironment: true }; + + expect(isRemoteEnvironmentId(primaryEnvironmentId, scope)).toBe(false); + expect(isRemoteEnvironmentId(remoteEnvironmentId, scope)).toBe(true); + }); + + it("treats every environment as remote when the app owns no local backend", () => { + const scope = { primaryEnvironmentId: null, ownsLocalEnvironment: false }; + + expect(isRemoteEnvironmentId(remoteEnvironmentId, scope)).toBe(true); + expect(isRemoteEnvironmentId(primaryEnvironmentId, scope)).toBe(true); + }); + + it("treats nothing as remote while a managed app's primary is still registering", () => { + const scope = { primaryEnvironmentId: null, ownsLocalEnvironment: true }; + + expect(isRemoteEnvironmentId(remoteEnvironmentId, scope)).toBe(false); + }); +}); diff --git a/apps/web/src/environmentPresence.ts b/apps/web/src/environmentPresence.ts new file mode 100644 index 00000000000..109f0cd8a8c --- /dev/null +++ b/apps/web/src/environmentPresence.ts @@ -0,0 +1,28 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +/** + * The comparison the UI uses to decide whether an environment is "remote" — + * i.e. somewhere other than the machine this app runs its own backend on. + */ +export interface EnvironmentPresenceScope { + /** The environment served by the app's own backend, when it has one. */ + readonly primaryEnvironmentId: EnvironmentId | null; + /** + * False when this app can never own a local backend: a client-only desktop + * and the hosted static web app only ever talk to backends they paired with, + * so every environment they reach is remote. This is deliberately separate + * from a null `primaryEnvironmentId`, which a managed app also reports while + * its local backend is still registering — there, nothing is remote yet. + */ + readonly ownsLocalEnvironment: boolean; +} + +export function isRemoteEnvironmentId( + environmentId: EnvironmentId, + scope: EnvironmentPresenceScope, +): boolean { + if (!scope.ownsLocalEnvironment) { + return true; + } + return scope.primaryEnvironmentId !== null && environmentId !== scope.primaryEnvironmentId; +} diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index e200ecf2ff8..6ee612f54c1 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -8,6 +8,7 @@ import { isPrimaryEnvironmentProtocolUnsupportedError, isPrimaryEnvironmentUrlInvalidError, readPrimaryEnvironmentTarget, + readOptionalPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, resolveInitialPrimaryEnvironmentDescriptor, resetPrimaryEnvironmentDescriptorForTests, @@ -257,4 +258,21 @@ describe("environmentBootstrap", () => { message: "The window-origin primary environment target uses unsupported protocol file:.", }); }); + + it("returns no primary target for a client-only desktop without using the custom origin", () => { + vi.stubGlobal("window", { + location: new URL("t3code://app/"), + history: { replaceState: vi.fn() }, + desktopBridge: { + getBackendModeState: () => ({ + effectiveMode: "client-only", + configuredMode: "client-only", + cliOverride: null, + }), + getLocalEnvironmentBootstraps: () => [], + }, + }); + + expect(readOptionalPrimaryEnvironmentTarget()).toBeNull(); + }); }); diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index f85c91c1536..ed01813c67d 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -48,6 +48,8 @@ export { PrimaryEnvironmentProtocolUnsupportedError, PrimaryEnvironmentUrlInvalidError, readPrimaryEnvironmentTarget, + readOptionalPrimaryEnvironmentTarget, + isDesktopClientOnlyMode, resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname, type PrimaryEnvironmentTarget, diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index cc002419fe7..f3fed57afeb 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -73,6 +73,11 @@ export interface PrimaryEnvironmentTarget { }; } +export function isDesktopClientOnlyMode(): boolean { + const bridge = window.desktopBridge; + return bridge?.getBackendModeState?.().effectiveMode === "client-only"; +} + const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | null { @@ -292,3 +297,7 @@ export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { resolveWindowOriginPrimaryTarget() ); } + +export function readOptionalPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { + return isDesktopClientOnlyMode() ? null : readPrimaryEnvironmentTarget(); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114d..ed943e7e59c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -36,7 +36,10 @@ import { import { useUiStateStore } from "../uiStateStore"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { configureClientTracing } from "../observability/clientTracing"; -import { resolveInitialServerAuthGateState } from "../environments/primary"; +import { + isDesktopClientOnlyMode, + resolveInitialServerAuthGateState, +} from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; import { shellEnvironment } from "../state/shell"; import { useAtomValue } from "@effect/atom-react"; @@ -63,7 +66,7 @@ export const Route = createRootRoute({ }; } - if (isHostedStaticApp(new URL(window.location.href))) { + if (isHostedStaticApp(new URL(window.location.href)) || isDesktopClientOnlyMode()) { return { authGateState: { status: "hosted-static", diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d3cf003d99c..d38085bdbd8 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -6,7 +6,7 @@ import { isCommandPaletteOpen } from "../commandPaletteBus"; import { useClientSettings } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; -import { usePrimaryEnvironmentId } from "../state/environments"; +import { useAppOwnsLocalEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; @@ -32,15 +32,17 @@ function ChatRouteGlobalShortcuts() { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const projectGroupCount = useMemo( () => buildSidebarProjectSnapshots({ projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: () => null, }).length, - [primaryEnvironmentId, projectGroupingSettings, projects], + [ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projects], ); const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 32299b565f4..36859f37eea 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,5 +1,6 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; +import { isRemoteEnvironmentId, type EnvironmentPresenceScope } from "./environmentPresence"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, @@ -116,6 +117,10 @@ export function buildSidebarProjectSnapshots(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings; primaryEnvironmentId: EnvironmentId | null; + // False when the app has no backend of its own (a client-only desktop or the + // hosted static web app), which makes every member of a group remote. + // Defaults to true so callers that always own a local backend are unchanged. + ownsLocalEnvironment?: boolean; resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; // Returns true when an env id maps to a desktopLocal saved-env // record (today: the WSL backend). Defaults to "false for every @@ -159,6 +164,10 @@ export function buildSidebarProjectSnapshots(input: { } } + const presenceScope: EnvironmentPresenceScope = { + primaryEnvironmentId: input.primaryEnvironmentId, + ownsLocalEnvironment: input.ownsLocalEnvironment ?? true, + }; const result: SidebarProjectSnapshot[] = []; const seen = new Set(); for (const project of input.projects) { @@ -177,17 +186,11 @@ export function buildSidebarProjectSnapshots(input: { continue; } - const hasLocal = - input.primaryEnvironmentId !== null && - members.some((member) => member.environmentId === input.primaryEnvironmentId); - const hasRemote = - input.primaryEnvironmentId !== null - ? members.some((member) => member.environmentId !== input.primaryEnvironmentId) - : false; - const remoteMembers = members.filter( - (member) => - input.primaryEnvironmentId !== null && member.environmentId !== input.primaryEnvironmentId, + const remoteMembers = members.filter((member) => + isRemoteEnvironmentId(member.environmentId, presenceScope), ); + const hasRemote = remoteMembers.length > 0; + const hasLocal = remoteMembers.length < members.length; const remoteEnvironmentLabels = remoteMembers .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) .filter((label, index, labels) => labels.indexOf(label) === index); diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 443e99b84cd..b761dfd37a8 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -9,6 +9,9 @@ import * as Option from "effect/Option"; import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; +import type { EnvironmentPresenceScope } from "../environmentPresence"; +import { isDesktopClientOnlyMode } from "../environments/primary"; +import { isHostedStaticApp } from "../hostedPairing"; import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { useEnvironmentQuery } from "./query"; @@ -60,6 +63,30 @@ export function usePrimaryEnvironmentId(): EnvironmentId | null { return useAtomValue(primaryEnvironmentIdAtom); } +/** + * Whether this app can ever serve an environment from its own backend. A + * client-only desktop and the hosted static web app cannot, so they have no + * local environment for threads and projects to belong to. + */ +export function appOwnsLocalEnvironment(): boolean { + return !isHostedStaticApp() && !isDesktopClientOnlyMode(); +} + +export function useAppOwnsLocalEnvironment(): boolean { + // The answer is fixed for the app's lifetime: the hosted-static check is + // origin/build derived, and changing the desktop backend mode relaunches. + return useMemo(appOwnsLocalEnvironment, []); +} + +export function useEnvironmentPresenceScope(): EnvironmentPresenceScope { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); + return useMemo( + () => ({ primaryEnvironmentId, ownsLocalEnvironment }), + [primaryEnvironmentId, ownsLocalEnvironment], + ); +} + export function useEnvironment( environmentId: EnvironmentId | null, ): EnvironmentPresentation | null { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4b1676d7926..b8323be6814 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -163,6 +163,7 @@ export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; +export type DesktopBackendMode = "managed" | "client-only"; export const DesktopUpdateStatusSchema = Schema.Literals([ "disabled", @@ -178,6 +179,19 @@ export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other" export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); +export const DesktopBackendModeSchema = Schema.Literals(["managed", "client-only"]); + +export interface DesktopBackendModeState { + effectiveMode: DesktopBackendMode; + configuredMode: DesktopBackendMode; + cliOverride: DesktopBackendMode | null; +} + +export const DesktopBackendModeStateSchema = Schema.Struct({ + effectiveMode: DesktopBackendModeSchema, + configuredMode: DesktopBackendModeSchema, + cliOverride: Schema.NullOr(DesktopBackendModeSchema), +}); export interface DesktopAppBranding { baseName: string; @@ -974,6 +988,8 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + getBackendModeState: () => DesktopBackendModeState; + setBackendMode: (mode: DesktopBackendMode) => Promise; // One bootstrap per pool instance currently registered with bootstrap // info (omits instances whose backend hasn't produced a config yet). // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID.