diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index e6abaab03251..365363881f5b 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 262097ca78ea..1ebd5dae56c2 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 9d7f00c3ee69..e604cb767f3f 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 eb0becee0981..0914167cffb0 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 0d204fb3ad42..508a5c296898 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 af0366f93f47..ed35bbc7952f 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 6f9bac7333f4..c97c602552f4 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 7106c45af8e8..226793657848 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 000000000000..e17c48948098 --- /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 000000000000..74cccd04a0c5 --- /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 6fcf5e813749..eca3db4ddf85 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 284b62ad31ac..81361db37303 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 38435e286fa7..bfd1a6e679d9 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 1d0dc262baea..37cd992bb641 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 4c9a8199de68..453879d37afe 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 64c59749abe9..f7db3c277810 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 3bd235018022..19fcf0e75962 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 cd1404a50464..fbcbb349f9e7 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 7bbb5c1da024..338a02b26a1f 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 0a966ec36e4d..e19a75962126 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 ed8911d40075..c2daf352837f 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 605f4e7a477f..3f20e58aa680 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 8af4419fca31..f33a9cce63b0 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 b7c378537fc9..0fda2ee2cecc 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 c450e849307b..f76e6ef10d64 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 000000000000..d32e28030074 --- /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 ef1c684118eb..ea9e1a88ec30 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 837aa7b47ce3..19268e2cdaed 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 b4a892f45270..a0601e41729f 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 7e88c4aae3c7..bcc2849dd041 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 b08717d7c413..c9da4dab7051 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 971f6811b8eb..5912a4865f9c 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 face3fa1e4fe..7e574bb7cd20 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 000000000000..e277d3ebd4ce --- /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 ca35f6322e3b..e8d74bfbd7ff 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 013dbb02d527..924c7b5b8f1f 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 982d81420445..89a1ff23cc83 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 e6bc867e9820..b32524bdde1e 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}