From cba7dd77817df2a458d86977f1daf4606c67dfc8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 13 Sep 2026 22:59:47 -0700 Subject: [PATCH] feat(desktop): allow disabling the local environment (#9194) Co-authored-by: Claude Code Co-authored-by: Julius Marminge --- apps/desktop/src/app/DesktopApp.ts | 57 ++++++---- .../src/app/DesktopEnvironment.test.ts | 4 + apps/desktop/src/app/DesktopEnvironment.ts | 3 + .../src/backend/DesktopServerExposure.test.ts | 1 + .../src/electron/ElectronProtocol.test.ts | 61 ++++++++-- apps/desktop/src/electron/ElectronProtocol.ts | 64 +++++++++-- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 6 + apps/desktop/src/ipc/channels.ts | 2 + .../src/ipc/methods/localEnvironment.test.ts | 63 +++++++++++ .../src/ipc/methods/localEnvironment.ts | 30 +++++ apps/desktop/src/ipc/methods/window.test.ts | 41 +++++-- apps/desktop/src/ipc/methods/window.ts | 10 +- apps/desktop/src/ipc/methods/wsl.test.ts | 38 ++++++- apps/desktop/src/ipc/methods/wsl.ts | 2 +- apps/desktop/src/preload.ts | 4 + .../src/settings/DesktopAppSettings.test.ts | 25 ++++ .../src/settings/DesktopAppSettings.ts | 23 ++++ .../desktop/src/updates/updatesTestHarness.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 32 ++++++ apps/desktop/src/window/DesktopWindow.ts | 14 ++- .../desktop/src/wsl/DesktopWslBackend.test.ts | 23 ++++ apps/desktop/src/wsl/DesktopWslBackend.ts | 1 + apps/web/src/components/CommandPalette.tsx | 34 +++--- .../components/onboarding/FirstRunGate.tsx | 2 + .../settings/ConnectionsSettings.tsx | 107 ++++++++++-------- .../settings/LocalEnvironmentSetting.tsx | 103 +++++++++++++++++ .../settings/settingsSearch.test.ts | 22 ++++ .../src/components/settings/settingsSearch.ts | 13 +++ .../useAvailableSettingsSearchItems.ts | 24 +++- apps/web/src/connection/platform.ts | 3 +- .../environments/primary/bootstrap.test.ts | 20 +++- .../src/environments/primary/sessionState.ts | 7 +- apps/web/src/environments/primary/target.ts | 21 +++- apps/web/src/localEnvironment.ts | 9 ++ .../web/src/onboarding/firstRun.logic.test.ts | 12 ++ apps/web/src/onboarding/firstRun.logic.ts | 5 +- apps/web/src/routes/__root.tsx | 3 +- apps/web/src/routes/_chat.index.tsx | 18 ++- docs/internals/remote.md | 12 ++ docs/user/remote-access.md | 11 ++ packages/contracts/src/ipc.ts | 2 + 41 files changed, 794 insertions(+), 139 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/localEnvironment.test.ts create mode 100644 apps/desktop/src/ipc/methods/localEnvironment.ts create mode 100644 apps/web/src/components/settings/LocalEnvironmentSetting.tsx create mode 100644 apps/web/src/localEnvironment.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e6abaab03251cadb8018919eaf635b3ee1e63eb8..365363881f5b8f0fb15e64a94b81f80c2eac3be6 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -158,18 +158,43 @@ export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances" ); const bootstrap = Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primaryBackend = yield* pool.primary; 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 snapShot = yield* DesktopSnapShot.DesktopSnapShot; const appActivation = yield* DesktopAppActivation.DesktopAppActivation; yield* logBootstrapInfo("bootstrap start"); + const settings = yield* desktopSettings.get; + // The renderer is served from the bundled client (or Vite in development) + // rather than through the local backend, so the window can open without one. + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), + ...(environment.isDevelopment + ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) } + : { assetDirectory: environment.clientAssetsDir }), + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + yield* installDesktopIpcHandlers(); + yield* logBootstrapInfo("bootstrap ipc handlers registered"); + + yield* snapShot.initialize; + + if (!settings.localEnvironmentEnabled) { + yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)"); + if (!(yield* Ref.get(state.quitting))) { + yield* desktopWindow.createMainIfBackendReady; + } + return; + } + + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primaryBackend = yield* pool.primary; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); } @@ -186,7 +211,6 @@ const bootstrap = Effect.gen(function* () { }, ); - const settings = yield* desktopSettings.get; if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) { yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", { mode: settings.serverExposureMode, @@ -194,16 +218,6 @@ 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), - targetOrigin: rendererTarget, - backendOrigin: backendConfig.httpBaseUrl, - clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, - }); yield* logBootstrapInfo("bootstrap resolved backend endpoint", { baseUrl: backendConfig.httpBaseUrl.href, }); @@ -219,16 +233,13 @@ const bootstrap = Effect.gen(function* () { "bootstrap fell back to local-only because no advertised network host was available", ); } - yield* snapShot.initialize; - - yield* installDesktopIpcHandlers(); - yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { - // In wsl-only mode the renderer is served by the WSL backend, which can be - // slow to cold-boot — show a "Connecting to WSL" splash immediately so the - // app feels responsive instead of presenting no window until WSL is ready. - // (Dual mode opens fast off the Windows primary, so no splash there.) + // The main window waits for the primary backend. In wsl-only mode that is + // the WSL backend, which can be slow to cold-boot — show a "Connecting to + // WSL" splash immediately so the app feels responsive instead of presenting + // no window until WSL is ready. (Dual mode opens fast off the Windows + // primary, so no splash there.) if (settings.wslOnly === true && settings.wslBackendEnabled === true) { yield* desktopWindow.showConnectingSplash; } diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 262097ca78ea67be03613b0effb45f1a2193f606..1ebd5dae56c2aa886df17155064ae163bb61de8b 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -120,6 +120,10 @@ describe("DesktopEnvironment", () => { environment.backendEntryPath, "/install/resources/server.asar/apps/server/dist/bin.mjs", ); + assert.equal( + environment.clientAssetsDir, + "/install/resources/server.asar/apps/server/dist/client", + ); }), ); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 9d7f00c3ee69f0f04f8e3f7cd0b19ebef38af410..e604cb767f3fc0bb30eb17137f5628eafc52074e 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -61,6 +61,8 @@ export class DesktopEnvironment extends Context.Service< // extracts on demand (see DesktopWslServerTree). readonly serverRoot: string; readonly backendEntryPath: string; + // Built web client the packaged renderer is served from over t3code://app. + readonly clientAssetsDir: string; readonly backendCwd: string; readonly preloadPath: string; readonly appUpdateYmlPath: string; @@ -211,6 +213,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( appRoot, serverRoot, backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"), + clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"), backendCwd: input.isPackaged ? homeDirectory : appRoot, preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index eb0becee098106e149c014e6b6cea8c2e3d68851..0914167cffb09079ac6aed6fe74207143d275b85 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -257,6 +257,7 @@ describe("DesktopServerExposure", () => { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment 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 0d204fb3ad4238d636c73bb99e15787b6fb422c2..508a5c296898b19eead692654d7492fa6ee65d74 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -1,6 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import { beforeEach, vi } from "vite-plus/test"; const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ @@ -16,6 +19,8 @@ vi.mock("electron", () => ({ import * as ElectronProtocol from "./ElectronProtocol.ts"; +const protocolLayer = ElectronProtocol.layer.pipe(Layer.provide(NodeServices.layer)); + describe("ElectronProtocol", () => { beforeEach(() => { handleMock.mockReset(); @@ -23,6 +28,46 @@ describe("ElectronProtocol", () => { unhandleMock.mockReset(); }); + it.effect("serves the bundled client from disk without a backend", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + yield* fileSystem.writeFileString(`${directory}/index.html`, "app"); + yield* fileSystem.writeFileString(`${directory}/app.js`, "export default 1;"); + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + assetDirectory: directory, + clerkFrontendApiHostname: undefined, + }); + const request = (pathname: string, init?: RequestInit) => + Effect.promise(() => handler!(new Request(`t3code://app${pathname}`, init))); + + // SPA routes fall back to index.html, including ones containing dots. + const page = yield* request("/settings/connections"); + assert.equal(yield* Effect.promise(() => page.text()), "app"); + assert.include(page.headers.get("content-security-policy") ?? "", "default-src 'self'"); + const dottedRoute = yield* request("/environment/thread.with.dots", { + headers: { accept: "text/html" }, + }); + assert.equal(yield* Effect.promise(() => dottedRoute.text()), "app"); + + const script = yield* request("/app.js?v=1"); + assert.equal(yield* Effect.promise(() => script.text()), "export default 1;"); + assert.include(script.headers.get("content-type") ?? "", "javascript"); + + assert.equal((yield* request("/missing.js")).status, 404); + assert.equal((yield* request("/%2e%2e%2fsecret.txt")).status, 404); + assert.equal((yield* request("/%invalid")).status, 400); + assert.equal((yield* request("/", { method: "POST" })).status, 405); + assert.equal(netFetchMock.mock.calls.length, 0); + }).pipe(Effect.provide(Layer.merge(protocolLayer, NodeServices.layer)), Effect.scoped), + ); + it.effect("proxies the stable renderer origin to the current app server", () => Effect.gen(function* () { let handler: ((request: Request) => Promise) | undefined; @@ -37,7 +82,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", 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); @@ -85,7 +129,7 @@ describe("ElectronProtocol", () => { assert.isNull(forwardedHeaders.get("referer")); assert.isNull(forwardedHeaders.get("sec-fetch-site")); assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("rejects custom protocol requests for another host", () => @@ -101,7 +145,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code", 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/"))); @@ -110,7 +153,7 @@ describe("ElectronProtocol", () => { assert.equal(response.status, 404); assert.equal(netFetchMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("retries transient renderer target failures", () => @@ -129,7 +172,6 @@ describe("ElectronProtocol", () => { yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", 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/"))); @@ -138,7 +180,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ready"); assert.equal(netFetchMock.mock.calls.length, 2); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("preserves protocol registration failures", () => @@ -153,7 +195,6 @@ describe("ElectronProtocol", () => { protocol.registerDesktopProtocol({ scheme: "t3code-dev", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: undefined, }), ).pipe(Effect.flip); @@ -162,7 +203,7 @@ describe("ElectronProtocol", () => { assert.equal(error.scheme, "t3code-dev"); assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it.effect("preserves protocol unregistration failures", () => @@ -178,7 +219,6 @@ describe("ElectronProtocol", () => { protocol.registerDesktopProtocol({ scheme: "t3code", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }), ), @@ -192,14 +232,13 @@ describe("ElectronProtocol", () => { assert.strictEqual(error.cause, cause); assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); } - }).pipe(Effect.provide(ElectronProtocol.layer)), + }).pipe(Effect.provide(protocolLayer)), ); it("keeps executable sources host-restricted while allowing runtime network resources", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ scheme: "t3code", 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( diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index af0366f93f4742cf3de5087d1ee7626260448627..ed35bbc7952fa73e1b14d4d522abb72018c53786 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,7 +1,10 @@ +import Mime from "@effect/platform-node/Mime"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as NodeTimersPromises from "node:timers/promises"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -48,12 +51,12 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError decodeURIComponent(url.pathname)).pipe( + Effect.orElseSucceed(() => null), + ); + if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 }); + const root = path.resolve(assetDirectory); + const assetPath = path.resolve(root, `.${pathname}`); + if (assetPath !== root && !assetPath.startsWith(root + path.sep)) { + return new Response(null, { status: 404 }); + } + const stat = yield* fileSystem.stat(assetPath).pipe(Effect.orElseSucceed(() => null)); + let filePath = assetPath; + if (stat?.type !== "File") { + const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false; + if (path.extname(assetPath) !== "" && !wantsHtml) { + return new Response(null, { status: 404 }); + } + filePath = path.join(root, "index.html"); + } + const contents = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null)); + if (contents === null) return new Response(null, { status: 404 }); + return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), { + headers: { "content-type": Mime.getType(filePath) ?? "application/octet-stream" }, + }); +}); + async function fetchWithTransientRetry(url: string, init: RequestInit): Promise { let lastError: unknown; @@ -210,6 +252,8 @@ async function fetchWithTransientRetry(url: string, init: RequestInit): Promise< /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registered = yield* Ref.make(false); + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")( function* (input: DesktopProtocolRegistrationInput) { @@ -220,9 +264,15 @@ 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, async (request) => { + if ("assetDirectory" in input) { + return withContentSecurityPolicy( + await runPromise(serveDesktopAsset(request, input.assetDirectory)), + 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 6f9bac7333f4b988632f6e8a7099ec357b36c636..c97c602552f41f87bcd57b49ddfce18af4f91d86 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -8,6 +8,10 @@ import { getConnectionCatalog, setConnectionCatalog, } from "./methods/connectionCatalog.ts"; +import { + getLocalEnvironmentEnabled, + setLocalEnvironmentEnabled, +} from "./methods/localEnvironment.ts"; import { getAdvertisedEndpoints, getServerExposureState, @@ -79,6 +83,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); + yield* ipc.handleSync(getLocalEnvironmentEnabled); + yield* ipc.handle(setLocalEnvironmentEnabled); yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(getClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 7106c45af8e80c5e68754fcc9910d467de494da3..226793657848940d45ffea840547c9d7283075fb 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -25,6 +25,8 @@ export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; +export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; +export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; diff --git a/apps/desktop/src/ipc/methods/localEnvironment.test.ts b/apps/desktop/src/ipc/methods/localEnvironment.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e17c4894809805a4231cf19f9a834e76c6d3b70e --- /dev/null +++ b/apps/desktop/src/ipc/methods/localEnvironment.test.ts @@ -0,0 +1,63 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; +import * as DesktopState from "../../app/DesktopState.ts"; +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; +import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; + +// `relaunch` declares the lifecycle runtime services as requirements even +// though the mocked relaunch never touches them. +const unusedLifecycleRuntimeLayer = Layer.mergeAll( + DesktopShutdown.layer, + DesktopState.layer, + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of( + {} as DesktopEnvironment.DesktopEnvironment["Service"], + ), + ), + Layer.mock(DesktopWindow.DesktopWindow, {}), + Layer.mock(ElectronApp.ElectronApp, {}), + Layer.mock(ElectronTheme.ElectronTheme, {}), +); + +describe("local environment IPC", () => { + it.effect("relaunches only when the setting changes and keeps other settings", () => { + const relaunchReasons: Array = []; + const layer = Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + wslBackendEnabled: true, + }), + Layer.mock(DesktopLifecycle.DesktopLifecycle, { + relaunch: (reason) => + Effect.sync(() => { + relaunchReasons.push(reason); + }), + }), + unusedLifecycleRuntimeLayer, + ); + return Effect.gen(function* () { + yield* setLocalEnvironmentEnabled.handler(false); + assert.isFalse(yield* getLocalEnvironmentEnabled.handler()); + yield* setLocalEnvironmentEnabled.handler(false); + assert.deepEqual(relaunchReasons, ["localEnvironmentEnabled=false"]); + + yield* setLocalEnvironmentEnabled.handler(true); + assert.isTrue(yield* getLocalEnvironmentEnabled.handler()); + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + assert.isTrue((yield* appSettings.get).wslBackendEnabled); + assert.deepEqual(relaunchReasons, [ + "localEnvironmentEnabled=false", + "localEnvironmentEnabled=true", + ]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/ipc/methods/localEnvironment.ts b/apps/desktop/src/ipc/methods/localEnvironment.ts new file mode 100644 index 0000000000000000000000000000000000000000..74cccd04a0c58a4502d2d5474db0a74f7359531c --- /dev/null +++ b/apps/desktop/src/ipc/methods/localEnvironment.ts @@ -0,0 +1,30 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; + +export const getLocalEnvironmentEnabled = makeSyncIpcMethod({ + channel: IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.localEnvironment.getEnabled")(function* () { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + return (yield* appSettings.get).localEnvironmentEnabled; + }), +}); + +export const setLocalEnvironmentEnabled = makeIpcMethod({ + channel: IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.localEnvironment.setEnabled")(function* (enabled) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const change = yield* appSettings.setLocalEnvironmentEnabled(enabled); + if (change.changed) { + yield* lifecycle.relaunch(`localEnvironmentEnabled=${enabled}`); + } + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 6fcf5e8137492aa7e1a6004a5fe69ab1435b46e4..eca3db4ddf85af51bb0a579ce5fcc6147949824e 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -19,6 +19,8 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import type { DesktopSettings } from "../../settings/DesktopAppSettings.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, @@ -208,19 +210,21 @@ describe("pasteAsText", () => { }); describe("pickProjectFavicon", () => { + const pickerLayer = (pickFiles: () => Effect.Effect>, settings?: DesktopSettings) => + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + }), + DesktopAppSettings.layerTest(settings), + ); + it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); - const result = yield* pickProjectFavicon.handler("/project").pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), - Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.none()), - }), - ), - ), - ); + const result = yield* pickProjectFavicon + .handler("/project") + .pipe(Effect.provide(pickerLayer(pickFiles))); assert.strictEqual(result, "/pictures/icon.png"); assert.deepEqual(pickFiles.mock.calls, [ @@ -240,4 +244,21 @@ describe("pickProjectFavicon", () => { ]); }), ); + + it.effect("does not open a picker while the local environment is off", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon.handler("/project").pipe( + Effect.provide( + pickerLayer(pickFiles, { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }), + ), + ); + + assert.strictEqual(result, null); + assert.strictEqual(pickFiles.mock.calls.length, 0); + }), + ); }); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 284b62ad31ac9c4f896df3257ac61c2bf8ad617c..81361db37303d7749a808003b4109428bd916ec0 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -182,6 +182,11 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const appSettings = yield* DesktopAppSettings.DesktopAppSettings; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const settings = yield* appSettings.get; + // A picked path only means something to a backend on this machine. + if (!settings.localEnvironmentEnabled) { + return null; + } // Three picker modes: // - targetEnvironmentId omitted: default to the primary picker. Keeps // the historical behavior unchanged for users who never enabled the @@ -200,7 +205,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ targetId !== undefined && targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID && targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX); - const settings = yield* appSettings.get; // Fall back to the persisted wslDistro when the id is the // "wsl:default" sentinel; the orchestrator uses the same fallback // for the actual backend. @@ -246,6 +250,10 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) { const dialog = yield* ElectronDialog.ElectronDialog; const electronWindow = yield* ElectronWindow.ElectronWindow; + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + if (!(yield* appSettings.get).localEnvironmentEnabled) { + return null; + } const paths = yield* dialog.pickFiles({ owner: yield* electronWindow.focusedMainOrFirst, defaultPath: Option.fromNullishOr(initialPath), diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 38435e286fa7d9fd72a39b036ac9f512ef0b794c..bfd1a6e679d942fcefe1a3c24f3ef2c65789eaf1 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -18,7 +18,7 @@ import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts" import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; -import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; +import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); @@ -85,6 +85,42 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( ); describe("WSL IPC", () => { + it.effect("does not probe WSL when local execution is disabled", () => + Effect.gen(function* () { + const wsl = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const state = yield* getWslState.handler(undefined).pipe( + Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, { + ...wsl, + isAvailable: Effect.die("must not probe WSL"), + listDistros: Effect.die("must not enumerate distros"), + }), + Effect.flatMap(decodeWslState), + ); + assert.deepEqual(state, { + enabled: true, + distro: "Ubuntu", + available: false, + wslOnly: true, + distros: [], + preflightError: null, + }); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + wslBackendEnabled: true, + wslDistro: "Ubuntu", + wslOnly: true, + }), + DesktopWslEnvironment.layerTest(), + makeWslBackendLayer(), + ), + ), + ), + ); + it.effect("stages dual-backend preferences before enabling without relaunching", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( diff --git a/apps/desktop/src/ipc/methods/wsl.ts b/apps/desktop/src/ipc/methods/wsl.ts index 1d0dc262baea2cf582080643978cea338c2f4c98..37cd992bb64115417b647187b6fbe213455d7099 100644 --- a/apps/desktop/src/ipc/methods/wsl.ts +++ b/apps/desktop/src/ipc/methods/wsl.ts @@ -21,7 +21,7 @@ const readWslState: Effect.Effect< const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const settings = yield* appSettings.get; - const available = yield* wslEnvironment.isAvailable; + const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable); // Only enumerate distros when WSL is actually available — listDistros on a // non-WSL host would spawn wsl.exe and hit the timeout for nothing. const distros = available ? yield* wslEnvironment.listDistros : []; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 4c9a8199de68909620e654dd78eb135e0697d45b..453879d37afe4a42673564f2259ebec591ed5726 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -77,6 +77,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), + getLocalEnvironmentEnabled: () => + ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false, + setLocalEnvironmentEnabled: (enabled) => + ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 64c59749abe9b9c81e17bdf4adf9d79a8cc4dc1e..f7db3c27781022ec498b4e0de22ca23e21d90ba0 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -91,6 +91,24 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) { } describe("DesktopSettings", () => { + it.effect( + "persists disabling and re-enabling local execution without clearing backend settings", + () => + withSettings( + Effect.gen(function* () { + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setWslBackendEnabled(true); + yield* settings.setWslDistro("Ubuntu"); + yield* settings.setServerExposureMode("network-accessible"); + const before = yield* settings.get; + assert.isTrue((yield* settings.setLocalEnvironmentEnabled(false)).changed); + assert.deepEqual(yield* settings.load, { ...before, localEnvironmentEnabled: false }); + assert.isFalse((yield* settings.setLocalEnvironmentEnabled(false)).changed); + yield* settings.setLocalEnvironmentEnabled(true); + assert.deepEqual(yield* settings.load, before); + }), + ), + ); it.effect("loads defaults when no settings file exists", () => withSettings( Effect.gen(function* () { @@ -106,6 +124,7 @@ describe("DesktopSettings", () => { DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -135,6 +154,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "gnome-libsecret", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -242,6 +262,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -298,6 +319,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -346,6 +368,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -374,6 +397,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -401,6 +425,7 @@ describe("DesktopSettings", () => { assert.deepEqual(yield* settings.load, { linuxPasswordStore: "auto", + localEnvironmentEnabled: true, mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 3bd235018022eac0ef2949a4052afc50e0b47bd9..19fcf0e75962e40a071718939b0d1b6c0e3c6492 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -25,6 +25,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly localEnvironmentEnabled: boolean; readonly linuxPasswordStore: LinuxPasswordStorePreference; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; @@ -73,6 +74,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + localEnvironmentEnabled: true, linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE, mainWindowBounds: null, mainWindowMaximized: false, @@ -94,6 +96,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), linuxPasswordStore: Schema.optionalKey(Schema.Unknown), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), @@ -152,6 +155,9 @@ export class DesktopAppSettings extends Context.Service< { readonly load: Effect.Effect; readonly get: Effect.Effect; + readonly setLocalEnvironmentEnabled: ( + enabled: boolean, + ) => Effect.Effect; readonly setMainWindowBounds: ( bounds: DesktopWindowBounds, isMaximized: boolean, @@ -224,6 +230,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + localEnvironmentEnabled: parsed.localEnvironmentEnabled !== false, linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore), mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, @@ -247,6 +254,10 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.localEnvironmentEnabled !== defaults.localEnvironmentEnabled) { + document.localEnvironmentEnabled = settings.localEnvironmentEnabled; + } + if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) { document.linuxPasswordStore = settings.linuxPasswordStore; } @@ -370,6 +381,12 @@ function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSetting }; } +function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { + return settings.localEnvironmentEnabled === enabled + ? settings + : { ...settings, localEnvironmentEnabled: enabled }; +} + function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { return setWslOnly(setWslBackendEnabled(settings, false), false); } @@ -545,6 +562,10 @@ export const make = Effect.gen(function* () { persist((settings) => setWslOnly(settings, enabled)).pipe( Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }), ), + setLocalEnvironmentEnabled: (enabled) => + persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe( + Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }), + ), applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe( Effect.withSpan("desktop.settings.applyWslWindowsFallback"), ), @@ -586,6 +607,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)), + setLocalEnvironmentEnabled: (enabled) => + update((settings) => setLocalEnvironmentEnabled(settings, enabled)), applyWslWindowsFallback: update(applyWslWindowsFallback), applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback), }); diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index cd1404a50464792644b8dbd3f90b280c60d1a06e..fbcbb349f9e76339db929a878872f65672eff8dc 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -196,6 +196,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { ), setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 7bbb5c1da024d953fbd6320b07e177e2b1d1d469..338a02b26a1fbe30a794b353769579e562ba99a8 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -253,6 +253,7 @@ function makeTestLayer(input: { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); @@ -629,6 +630,37 @@ describe("DesktopWindow", () => { }), ); + it.effect( + "opens and reopens the window without backend readiness when local execution is disabled", + () => + 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, + createdWindowOptions: [], + desktopSettings: { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + }, + }); + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.createMainIfBackendReady; + assert.equal(yield* Ref.get(createCount), 1); + yield* Ref.set(mainWindow, Option.none()); + yield* desktopWindow.activate; + assert.equal(yield* Ref.get(createCount), 2); + yield* Ref.set(mainWindow, Option.none()); + yield* desktopWindow.dispatchMenuAction("new-thread"); + assert.equal(yield* Ref.get(createCount), 3); + }).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 0a966ec36e4d341f98fdd8047a7996d1c0e9d110..e19a759621266259d8fe73213fc639c1b950911b 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -84,7 +84,7 @@ export class DesktopWindow extends Context.Service< readonly activate: Effect.Effect; readonly createMainIfBackendReady: Effect.Effect; // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only - // mode), before the WSL backend that serves the renderer is ready. It is + // mode), before the WSL backend that acts as the primary is ready. It is // dismissed automatically once the real main window reveals. readonly showConnectingSplash: Effect.Effect; // Marks the primary backend as ready so `createMainIfBackendReady` and the @@ -838,9 +838,15 @@ export const make = Effect.gen(function* () { return window; }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain")); + // With the local environment disabled there is no backend to wait for: the + // renderer is served from bundled assets and only talks to remote environments. + const waitingForBackend = Effect.gen(function* () { + if (yield* Ref.get(backendReadyRef)) return false; + return (yield* desktopSettings.get).localEnvironmentEnabled; + }); + const createMainIfBackendReady = Effect.gen(function* () { - const backendReady = yield* Ref.get(backendReadyRef); - if (!backendReady) return; + if (yield* waitingForBackend) return; const existingWindow = yield* currentMainWindow; if (Option.isSome(existingWindow)) return; yield* createMain; @@ -898,7 +904,7 @@ export const make = Effect.gen(function* () { { reveal = true }: { readonly reveal?: boolean } = {}, ) { const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main; - if (Option.isNone(existingWindow) && (!reveal || !(yield* Ref.get(backendReadyRef)))) return; + if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return; const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain; if (targetWindow.isDestroyed()) return; const send = Effect.sync(() => { diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index ed8911d40075d186e9da593866e1296443de747e..c2daf352837f50bfd1de12243054ba245b741539 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -83,6 +83,29 @@ const netLayer = Layer.succeed(NetService.NetService, { } satisfies NetService.NetService["Service"]); describe("DesktopWslBackend", () => { + it.effect("does not discover or start WSL when local execution is disabled", () => + Effect.gen(function* () { + const backend = yield* DesktopWslBackend.DesktopWslBackend; + yield* backend.reconcile; + }).pipe( + Effect.provide( + DesktopWslBackend.layer.pipe( + Layer.provide(Layer.mock(DesktopBackendPool.DesktopBackendPool, {})), + Layer.provide(backendConfigurationLayer), + Layer.provide(serverExposureLayer), + Layer.provide(netLayer), + Layer.provide(Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {})), + Layer.provide( + DesktopAppSettings.layerTest({ + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + localEnvironmentEnabled: false, + wslBackendEnabled: true, + }), + ), + ), + ), + ), + ); it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => { let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined; const primary = makeStubInstance({ diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index 605f4e7a477f20dc146bbd3686f84f0a3c922406..3f20e58aa6809caf7750c72ca80b526b440af003 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -188,6 +188,7 @@ export const layer = Layer.effect( const reconcileBody = Effect.gen(function* () { const settings = yield* appSettings.get; + if (!settings.localEnvironmentEnabled) return; const available = yield* wslEnvironment.isAvailable; const existing = yield* findExistingWslInstance; const existingId = Option.map(existing, (instance) => instance.id); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8af4419fca317fe6bb08e6041bd7a98bb6aa8aa3..f33a9cce63b05f788a6df91beab76a00ba1b6546 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -953,8 +953,13 @@ function OpenCommandPaletteDialog(props: { ) : ""; const browsePath = useMemo( - () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep), - [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], + () => + getFilesystemBrowsePath( + query, + browseEnvironmentPlatform, + browseEnvironmentId !== null && !isRemoteProjectRepositoryStep, + ), + [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query], ); const isBrowsing = browsePath.isBrowsing; const browseDirectoryPath = browsePath.directoryPath; @@ -1553,6 +1558,14 @@ function OpenCommandPaletteDialog(props: { ); const openAddProjectFlow = useCallback(() => { + // With no environment at all there is nothing to browse, so the only + // useful next step is connecting one. + if (addProjectEnvironmentOptions.length === 0) { + setOpen(false); + void navigate({ to: "/settings/connections" }); + return; + } + if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) { pushPaletteView({ addonIcon: , @@ -1561,24 +1574,14 @@ function OpenCommandPaletteDialog(props: { return; } - const environmentId = defaultAddProjectEnvironmentId; - if (!environmentId) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to browse projects", - description: "No environment is available.", - }), - ); - return; - } - - void startAddProjectSourceSelection(environmentId); + void startAddProjectSourceSelection(defaultAddProjectEnvironmentId); }, [ addProjectEnvironmentGroups, addProjectEnvironmentOptions.length, defaultAddProjectEnvironmentId, + navigate, pushPaletteView, + setOpen, startAddProjectSourceSelection, ]); @@ -1775,7 +1778,6 @@ function OpenCommandPaletteDialog(props: { "environment", ], title: "Add project", - disabled: defaultAddProjectEnvironmentId === null, icon: , keepOpen: true, run: async () => { diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx index b7c378537fc93248830064b5df3e169da44e7ea4..0fda2ee2cecc70894ada667143e1690a80f303dd 100644 --- a/apps/web/src/components/onboarding/FirstRunGate.tsx +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -9,6 +9,7 @@ import { useClientSettings, useClientSettingsHydrationStatus, } from "../../hooks/useSettings"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { useCompleteOnboarding } from "../../onboarding/firstRun"; import { isFirstRunWorkspaceProvenanceAuthoritative, @@ -126,6 +127,7 @@ export function FirstRunGate({ const { decision: nextDecision, persistCompletion } = hostedStatic ? resolveHostedFirstRunDecision({ + localEnvironmentDisabled: isLocalEnvironmentDisabled(), hydrated, completed: onboardingCompletedAt !== null, catalogReady: environmentCatalogReady, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index c450e849307bf72d149d820f8c88a9774282fc40..f76e6ef10d64eb7021b187c219029760cec6f30b 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -51,6 +51,7 @@ import * as Option from "effect/Option"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; import { @@ -65,6 +66,7 @@ import { SettingsSection, useRelativeTimeTick, } from "./settingsLayout"; +import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; import { @@ -1985,7 +1987,9 @@ export function ConnectionsSettings() { const setDefaultAdvertisedEndpointKey = useUiStateStore( (state) => state.setDefaultAdvertisedEndpointKey, ); - const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; + const canManageLocalBackend = + !isLocalEnvironmentDisabled() && + (currentSessionScopes?.includes(AuthAccessWriteScope) ?? false); const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; const authAccessChanges = useEnvironmentQuery( canManageLocalBackend && primaryEnvironmentId !== null @@ -3234,15 +3238,21 @@ export function ConnectionsSettings() { const primarySettings = ( <> - {canManageLocalBackend ? ( + {desktopBridge || canManageLocalBackend ? ( <> } @@ -3272,46 +3282,51 @@ export function ConnectionsSettings() { ) : null } > - - ) : ( - [ - primaryServerConfig?.environment.serverVersion ?? null, - primaryEnvironment?.displayUrl ?? null, - ] - .filter((value): value is string => value !== null) - .join(" · ") || "Loading…" - ) - } - control={ - primaryVersionMismatch && - primaryEnvironmentId !== null && - primaryServerUpdateState.status !== "running" ? ( - - ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? ( - Up to date - ) : undefined - } - /> - {desktopBridge ? ( + + {canManageLocalBackend ? ( + + ) : ( + [ + primaryServerConfig?.environment.serverVersion ?? null, + primaryEnvironment?.displayUrl ?? null, + ] + .filter((value): value is string => value !== null) + .join(" · ") || "Loading…" + ) + } + control={ + primaryVersionMismatch && + primaryEnvironmentId !== null && + primaryServerUpdateState.status !== "running" ? ( + + ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? ( + Up to date + ) : undefined + } + /> + ) : null} + {canManageLocalBackend && desktopBridge ? ( <> {renderNetworkAccessRow()} {renderEndpointRows("endpoint-rail")} @@ -3319,12 +3334,12 @@ export function ConnectionsSettings() { {renderWslRow()} - ) : ( + ) : canManageLocalBackend ? ( <> {renderDisabledNetworkAccessRow()} - )} + ) : null} {isLocalBackendRemotelyReachable ? ( diff --git a/apps/web/src/components/settings/LocalEnvironmentSetting.tsx b/apps/web/src/components/settings/LocalEnvironmentSetting.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d32e28030074e8937b1b7bed0062bbf5e48610f4 --- /dev/null +++ b/apps/web/src/components/settings/LocalEnvironmentSetting.tsx @@ -0,0 +1,103 @@ +import { useState } from "react"; + +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { Spinner } from "../ui/spinner"; +import { Switch } from "../ui/switch"; +import { SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +// Toggling relaunches the desktop app, so the switch only reflects the value +// this process started with; there is no live state to keep in sync. +export function LocalEnvironmentSetting() { + const setEnabled = window.desktopBridge?.setLocalEnvironmentEnabled; + const [enabled] = useState(() => !isLocalEnvironmentDisabled()); + const [confirmOpen, setConfirmOpen] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + if (!setEnabled) return null; + + const applyChange = async () => { + setIsUpdating(true); + setError(null); + try { + await setEnabled(!enabled); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Couldn't change this setting."); + setIsUpdating(false); + } + }; + + return ( + <> + setConfirmOpen(true)} + aria-label="Local environment" + /> + } + /> + { + if (isUpdating) return; + setConfirmOpen(open); + if (!open) setError(null); + }} + > + + + + {enabled ? "Turn off local environment?" : "Turn on local environment?"} + + + {enabled + ? "T3 Code will restart without running a server on this computer. Any agents and terminals running here will stop, and other devices will no longer be able to connect to this computer. Your projects, history, and remote environments are unaffected." + : "T3 Code will restart and start running a server on this computer again."} + + + {error ?

{error}

: null} + + }> + Cancel + + + +
+
+ + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index ef1c684118eb4a4f7f456027d829f5e0adc68154..ea9e1a88ec3065110a419d7c7051aa97ebbfc482 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -173,6 +173,28 @@ describe("searchSettings", () => { expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); }); + it("keeps the local toggle searchable without offering hidden host publishing controls", () => { + const availability = { + hasCloudPublicConfig: true, + hasEnvironment: true, + hasProviderSettingsEnvironment: true, + canManageLocalBackend: false, + isWslSettingsRowVisible: false, + hasThreadAutoSettlement: false, + }; + const remoteOnly = filterAvailableSettingsSearchItems({ + ...availability, + localEnvironmentDisabled: true, + }).map((item) => item.id); + expect(remoteOnly).toContain("local-environment"); + expect(remoteOnly).not.toContain("t3-connect"); + expect(remoteOnly).not.toContain("publish-agent-activity"); + expect(remoteOnly).not.toContain("wsl-backend"); + // Browsers without access:write still render CloudLinkRow for their host. + const browser = filterAvailableSettingsSearchItems(availability).map((item) => item.id); + expect(browser).toContain("publish-agent-activity"); + }); + it("shows automatic settlement settings when the server supports them", () => { const available = filterAvailableSettingsSearchItems({ hasCloudPublicConfig: false, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 837aa7b47ce3f98c2c118fe14b94a1c0e4ef81bd..19268e2cdaed9df0dd1eeddd3f1cd5ccc50ad56c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -52,11 +52,13 @@ export interface SettingsSearchItem { readonly environmentOnly?: boolean; readonly providerSettingsOnly?: boolean; readonly localBackendManagementOnly?: boolean; + readonly localEnvironmentOnly?: boolean; readonly wslAvailableOnly?: boolean; readonly requiresThreadAutoSettlement?: boolean; } export interface SettingsSearchAvailability { + readonly localEnvironmentDisabled?: boolean; readonly hasCloudPublicConfig: boolean; readonly hasEnvironment: boolean; readonly hasProviderSettingsEnvironment: boolean; @@ -626,6 +628,14 @@ export const SETTINGS_SEARCH_ITEMS = [ searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"], localBackendManagementOnly: true, }, + { + id: "local-environment", + title: "Local environment", + to: "/settings/connections", + targetId: "connections-environment", + searchTerms: ["turn off on disable enable local server agents remote only restart"], + desktopOnly: true, + }, { id: "network-access", title: "Network access", @@ -657,6 +667,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "t3-connect", + localEnvironmentOnly: true, title: "T3 Connect", to: "/settings/connections", targetId: "connections-environment", @@ -666,6 +677,7 @@ export const SETTINGS_SEARCH_ITEMS = [ }, { id: "publish-agent-activity", + localEnvironmentOnly: true, title: "Publish agent activity", to: "/settings/connections", targetId: "connections-environment", @@ -840,6 +852,7 @@ export function filterAvailableSettingsSearchItems( (!item.environmentOnly || availability.hasEnvironment) && (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && (!item.localBackendManagementOnly || availability.canManageLocalBackend) && + (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) && (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement), ); diff --git a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts index b4a892f452703fd50d25d064318c429f3c5e54ba..a0601e41729f2d7bf55cae308fc2fc9c27372215 100644 --- a/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +++ b/apps/web/src/components/settings/useAvailableSettingsSearchItems.ts @@ -3,6 +3,7 @@ import { AuthAccessWriteScope } from "@t3tools/contracts"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "~/env"; +import { isLocalEnvironmentDisabled } from "~/localEnvironment"; import { desktopWslStateAtom } from "~/state/desktopWslState"; import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; @@ -17,16 +18,21 @@ import { export function useAvailableSettingsSearchItems() { const { environments } = useEnvironments(); const primarySessionState = usePrimarySessionState(); - const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); + const localEnvironmentDisabled = isLocalEnvironmentDisabled(); + const desktopWsl = useEnvironmentQuery( + isElectron && !localEnvironmentDisabled ? desktopWslStateAtom : null, + ); const canManageLocalBackend = - isElectron || - ((primarySessionState.data?.authenticated && - primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ?? - false); + !localEnvironmentDisabled && + (isElectron || + ((primarySessionState.data?.authenticated && + primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ?? + false)); return useMemo( () => filterAvailableSettingsSearchItems({ + localEnvironmentDisabled, hasCloudPublicConfig: hasCloudPublicConfig(), hasEnvironment: environments.some((environment) => environment.serverConfig !== null), hasProviderSettingsEnvironment: environments.some((environment) => @@ -43,6 +49,12 @@ export function useAvailableSettingsSearchItems() { hasThreadAutoSettlement: getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0, }), - [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments], + [ + canManageLocalBackend, + desktopWsl.data, + desktopWsl.error, + environments, + localEnvironmentDisabled, + ], ); } diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 7e88c4aae3c7576c44b9795532d1b181ef568abe..bcc2849dd04140bfe2b86d213b976bda4eb84da4 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -51,6 +51,7 @@ import { } from "../environments/primary/target"; import { clearComposerDraftsEnvironment } from "../composerDraftStore"; import { isHostedStaticApp } from "../hostedPairing"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; import { @@ -464,7 +465,7 @@ export function secondaryRegistrationsToRetainAfterTopologyRead( const platformConnectionSourceLayer = Layer.effect( PlatformConnectionSource, Effect.gen(function* () { - if (isHostedStaticApp()) { + if (isHostedStaticApp() || isLocalEnvironmentDisabled()) { return PlatformConnectionSource.of({ registrations: Stream.empty, }); diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index b08717d7c413abfb2c2944f26b5f0a3dcb260724..c9da4dab7051279cad852ee5c2ab46fd2233ea22 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -158,7 +158,7 @@ describe("environmentBootstrap", () => { it("keeps an uppercase wss scheme secure when deriving the http url", () => { vi.stubEnv("VITE_WS_URL", "WSS://remote.example.com"); - expect(readPrimaryEnvironmentTarget().target).toEqual({ + expect(readPrimaryEnvironmentTarget()?.target).toEqual({ httpBaseUrl: "https://remote.example.com/", wsBaseUrl: "wss://remote.example.com/", }); @@ -167,7 +167,7 @@ describe("environmentBootstrap", () => { it("keeps an uppercase https scheme secure when deriving the websocket url", () => { vi.stubEnv("VITE_HTTP_URL", "HTTPS://remote.example.com"); - expect(readPrimaryEnvironmentTarget().target).toEqual({ + expect(readPrimaryEnvironmentTarget()?.target).toEqual({ httpBaseUrl: "https://remote.example.com/", wsBaseUrl: "wss://remote.example.com/", }); @@ -257,6 +257,22 @@ describe("environmentBootstrap", () => { }); }); + it("has no primary target when the desktop local environment is disabled", () => { + vi.stubGlobal("window", { + location: new URL("t3code://app/"), + desktopBridge: { + getLocalEnvironmentEnabled: () => false, + getLocalEnvironmentBootstraps: () => [], + }, + }); + + expect(readPrimaryEnvironmentTarget()).toBeNull(); + expect(getPrimaryKnownEnvironment()).toBeNull(); + expect(() => resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toThrow( + "The local environment is disabled.", + ); + }); + it("preserves an unsupported window-origin protocol", () => { vi.stubGlobal("window", { location: { origin: "file:///tmp/t3code/" }, diff --git a/apps/web/src/environments/primary/sessionState.ts b/apps/web/src/environments/primary/sessionState.ts index 971f6811b8eb44b35511f991790508b132f92b5e..5912a4865f9c39b1f39df06dc6d3e7f91399942d 100644 --- a/apps/web/src/environments/primary/sessionState.ts +++ b/apps/web/src/environments/primary/sessionState.ts @@ -5,10 +5,15 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback } from "react"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { fetchSessionState } from "./auth"; -const primarySessionStateAtom = Atom.make(Effect.promise(fetchSessionState)).pipe( +const primarySessionStateAtom = Atom.make( + Effect.suspend(() => + isLocalEnvironmentDisabled() ? Effect.succeed(null) : Effect.promise(fetchSessionState), + ), +).pipe( Atom.swr({ staleTime: 5_000, revalidateOnMount: true }), Atom.setIdleTTL(5 * 60_000), Atom.withLabel("primary-environment:session"), diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index face3fa1e4fe314a516a5a179e64723d293ff33f..7e574bb7cd2098c82bf77ba7e15463d2784492ae 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -1,6 +1,8 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { isLocalEnvironmentDisabled } from "../../localEnvironment"; + const PrimaryEnvironmentTargetSource = Schema.Literals([ "configured", "window-origin", @@ -57,6 +59,15 @@ export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedErr } } +export class PrimaryEnvironmentDisabledError extends Schema.TaggedError()( + "PrimaryEnvironmentDisabledError", + {}, +) { + override get message(): string { + return "The local environment is disabled."; + } +} + export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError); export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is( PrimaryEnvironmentProtocolUnsupportedError, @@ -276,6 +287,9 @@ export function resolvePrimaryEnvironmentHttpUrl( searchParams?: Record, ): string { const primaryTarget = readPrimaryEnvironmentTarget(); + if (!primaryTarget) { + throw new PrimaryEnvironmentDisabledError(); + } const url = parseTargetUrl({ rawValue: resolveHttpRequestBaseUrl(primaryTarget), @@ -289,7 +303,12 @@ export function resolvePrimaryEnvironmentHttpUrl( return url.toString(); } -export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { +// Null only when the desktop app runs with its local environment disabled; +// every other host has a primary (falling back to the page origin). +export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { + if (isLocalEnvironmentDisabled()) { + return null; + } return ( resolveDesktopPrimaryTarget() ?? resolveConfiguredPrimaryTarget() ?? diff --git a/apps/web/src/localEnvironment.ts b/apps/web/src/localEnvironment.ts new file mode 100644 index 0000000000000000000000000000000000000000..e277d3ebd4cea457e1f560b85ab0eae89ca97e48 --- /dev/null +++ b/apps/web/src/localEnvironment.ts @@ -0,0 +1,9 @@ +/** + * True when the desktop app runs without its local server. The renderer then + * has no primary environment: it skips primary auth and discovery and only + * connects to saved remote environments. Always false in browsers and on + * desktop builds predating the setting. + */ +export function isLocalEnvironmentDisabled(): boolean { + return window.desktopBridge?.getLocalEnvironmentEnabled?.() === false; +} diff --git a/apps/web/src/onboarding/firstRun.logic.test.ts b/apps/web/src/onboarding/firstRun.logic.test.ts index ca35f6322e3b0a9872efdc1b705e02d9a2421e61..e8d74bfbd7ff1b1cd08292c47881f5202adab222 100644 --- a/apps/web/src/onboarding/firstRun.logic.test.ts +++ b/apps/web/src/onboarding/firstRun.logic.test.ts @@ -316,6 +316,18 @@ describe("resolveHostedFirstRunDecision", () => { }); }); + it("keeps Connections reachable for a remote-only desktop predating onboarding", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: true, + environmentCount: 0, + localEnvironmentDisabled: true, + }), + ).toEqual({ decision: "app", persistCompletion: true }); + }); + it("backfills onboarding for a hosted install with saved environments", () => { expect( resolveHostedFirstRunDecision({ diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts index 013dbb02d5270481bcdd76127aed6cc7df0eec03..924c7b5b8f1ff8af25db8e72ec88353198ae9b90 100644 --- a/apps/web/src/onboarding/firstRun.logic.ts +++ b/apps/web/src/onboarding/firstRun.logic.ts @@ -49,6 +49,7 @@ interface FirstRunDecisionInput { } interface HostedFirstRunDecisionInput { + readonly localEnvironmentDisabled?: boolean; readonly hydrated: boolean; readonly completed: boolean; readonly catalogReady: boolean; @@ -178,7 +179,9 @@ export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput return { decision: "pending", persistCompletion: false }; } - return input.environmentCount === 0 + // An existing desktop may have disabled its server before onboarding existed. + // Keep Connections accessible so it can turn local execution back on. + return input.environmentCount === 0 && !input.localEnvironmentDisabled ? { decision: "wizard", persistCompletion: false } : { decision: "app", persistCompletion: true }; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 982d81420445be9c899857a5cd5904dc19974985..89a1ff23cc833dff5e2b908406fc3c8745c5c319 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -54,6 +54,7 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { configureClientTracing } from "../observability/clientTracing"; import { resolveInitialServerAuthGateState } from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; import { shellEnvironment } from "../state/shell"; import { useAtomValue } from "@effect/atom-react"; import { useAtomCommand } from "../state/use-atom-command"; @@ -83,7 +84,7 @@ export const Route = createRootRoute({ }; } - if (isHostedStaticApp(new URL(window.location.href))) { + if (isLocalEnvironmentDisabled() || isHostedStaticApp(new URL(window.location.href))) { return { authGateState: { status: "hosted-static", diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index e6bc867e98202a5b2bad96f804b8330732bcee1c..b32524bdde1e1fb8fd4dbfc0b08be2b0e3d29c70 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -4,6 +4,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { LinkIcon, PlusIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { isLocalEnvironmentDisabled } from "../localEnvironment"; +import { isElectron } from "../env"; import { NoProjectsHero } from "../components/NoProjectsHero"; import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; import { Button } from "../components/ui/button"; @@ -113,11 +115,17 @@ export const Route = createFileRoute("/_chat/")({ function HostedStaticOnboardingState() { const cloudEnabled = hasCloudPublicConfig(); + const localEnvironmentOff = isLocalEnvironmentDisabled(); + const description = localEnvironmentOff + ? "The local environment is turned off. Connect a remote environment, or turn the local environment back on in Connections." + : cloudEnabled + ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." + : "Open Connections and add that machine using its pairing link. This app must be able to reach it."; return (
- +
{APP_DISPLAY_NAME} @@ -135,13 +143,11 @@ function HostedStaticOnboardingState() { Connect to a computer running T3 Code - This browser connects to T3 Code running on your computer or a server. Start the T3 - Code desktop app or command-line server on that machine and keep it running. + This app connects to T3 Code running on your computer or a server. Start the T3 Code + desktop app or command-line server on that machine and keep it running. - {cloudEnabled - ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." - : "Open Connections and add that machine using its pairing link. This browser must be able to reach it."} + {description}