authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 22:59:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 22:59:47-07:00
logcba7dd77817df2a458d86977f1daf4606c67dfc8
tree31720c596d7da56d499043cab5f69f4dc949e86f
parente3792a53f7d7f9ebba37abfca99073f3f1ead2a7
signature Signed by PGP key B5690EEEBB952194

feat(desktop): allow disabling the local environment (#9194)

Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius@mac.lan>

41 files changed, 794 insertions(+), 139 deletions(-)

apps/desktop/src/app/DesktopApp.ts+34-23
......@@ -158,18 +158,43 @@ export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances"
158158);
159159
160160const bootstrap = Effect.gen(function* () {
161 const pool = yield* DesktopBackendPool.DesktopBackendPool;
162 const primaryBackend = yield* pool.primary;
163161 const state = yield* DesktopState.DesktopState;
164162 const environment = yield* DesktopEnvironment.DesktopEnvironment;
165163 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
166 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
167 const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
168164 const desktopWindow = yield* DesktopWindow.DesktopWindow;
169165 const snapShot = yield* DesktopSnapShot.DesktopSnapShot;
170166 const appActivation = yield* DesktopAppActivation.DesktopAppActivation;
171167 yield* logBootstrapInfo("bootstrap start");
172168
169 const settings = yield* desktopSettings.get;
170 // The renderer is served from the bundled client (or Vite in development)
171 // rather than through the local backend, so the window can open without one.
172 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
173 yield* electronProtocol.registerDesktopProtocol({
174 scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment),
175 ...(environment.isDevelopment
176 ? { targetOrigin: Option.getOrThrow(environment.devServerUrl) }
177 : { assetDirectory: environment.clientAssetsDir }),
178 clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
179 });
180 yield* installDesktopIpcHandlers();
181 yield* logBootstrapInfo("bootstrap ipc handlers registered");
182
183 yield* snapShot.initialize;
184
185 if (!settings.localEnvironmentEnabled) {
186 yield* logBootstrapInfo("bootstrap skipping local environment (disabled in settings)");
187 if (!(yield* Ref.get(state.quitting))) {
188 yield* desktopWindow.createMainIfBackendReady;
189 }
190 return;
191 }
192
193 const pool = yield* DesktopBackendPool.DesktopBackendPool;
194 const primaryBackend = yield* pool.primary;
195 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
196 const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
197
173198 if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
174199 return yield* new DesktopDevelopmentBackendPortRequiredError();
175200 }
......@@ -186,7 +211,6 @@ const bootstrap = Effect.gen(function* () {
186211 },
187212 );
188213
189 const settings = yield* desktopSettings.get;
190214 if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) {
191215 yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", {
192216 mode: settings.serverExposureMode,
......@@ -194,16 +218,6 @@ const bootstrap = Effect.gen(function* () {
194218 }
195219 const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort });
196220 const backendConfig = yield* serverExposure.backendConfig;
197 const electronProtocol = yield* ElectronProtocol.ElectronProtocol;
198 const rendererTarget = environment.isDevelopment
199 ? Option.getOrThrow(environment.devServerUrl)
200 : backendConfig.httpBaseUrl;
201 yield* electronProtocol.registerDesktopProtocol({
202 scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment),
203 targetOrigin: rendererTarget,
204 backendOrigin: backendConfig.httpBaseUrl,
205 clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname,
206 });
207221 yield* logBootstrapInfo("bootstrap resolved backend endpoint", {
208222 baseUrl: backendConfig.httpBaseUrl.href,
209223 });
......@@ -219,16 +233,13 @@ const bootstrap = Effect.gen(function* () {
219233 "bootstrap fell back to local-only because no advertised network host was available",
220234 );
221235 }
222 yield* snapShot.initialize;
223
224 yield* installDesktopIpcHandlers();
225 yield* logBootstrapInfo("bootstrap ipc handlers registered");
226236
227237 if (!(yield* Ref.get(state.quitting))) {
228 // In wsl-only mode the renderer is served by the WSL backend, which can be
229 // slow to cold-boot — show a "Connecting to WSL" splash immediately so the
230 // app feels responsive instead of presenting no window until WSL is ready.
231 // (Dual mode opens fast off the Windows primary, so no splash there.)
238 // The main window waits for the primary backend. In wsl-only mode that is
239 // the WSL backend, which can be slow to cold-boot — show a "Connecting to
240 // WSL" splash immediately so the app feels responsive instead of presenting
241 // no window until WSL is ready. (Dual mode opens fast off the Windows
242 // primary, so no splash there.)
232243 if (settings.wslOnly === true && settings.wslBackendEnabled === true) {
233244 yield* desktopWindow.showConnectingSplash;
234245 }
apps/desktop/src/app/DesktopEnvironment.test.ts+4
......@@ -120,6 +120,10 @@ describe("DesktopEnvironment", () => {
120120 environment.backendEntryPath,
121121 "/install/resources/server.asar/apps/server/dist/bin.mjs",
122122 );
123 assert.equal(
124 environment.clientAssetsDir,
125 "/install/resources/server.asar/apps/server/dist/client",
126 );
123127 }),
124128 );
125129
apps/desktop/src/app/DesktopEnvironment.ts+3
......@@ -61,6 +61,8 @@ export class DesktopEnvironment extends Context.Service<
6161 // extracts on demand (see DesktopWslServerTree).
6262 readonly serverRoot: string;
6363 readonly backendEntryPath: string;
64 // Built web client the packaged renderer is served from over t3code://app.
65 readonly clientAssetsDir: string;
6466 readonly backendCwd: string;
6567 readonly preloadPath: string;
6668 readonly appUpdateYmlPath: string;
......@@ -211,6 +213,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
211213 appRoot,
212214 serverRoot,
213215 backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"),
216 clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"),
214217 backendCwd: input.isPackaged ? homeDirectory : appRoot,
215218 preloadPath: path.join(input.dirname, "preload.cjs"),
216219 appUpdateYmlPath: input.isPackaged
apps/desktop/src/backend/DesktopServerExposure.test.ts+1
......@@ -257,6 +257,7 @@ describe("DesktopServerExposure", () => {
257257 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
258258 setWslDistro: () => Effect.die("unexpected WSL distro change"),
259259 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
260 setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"),
260261 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
261262 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
262263 } satisfies DesktopAppSettings.DesktopAppSettings["Service"]);
apps/desktop/src/electron/ElectronProtocol.test.ts+50-11
......@@ -1,6 +1,9 @@
11import { assert, describe, it } from "@effect/vitest";
22import * as Cause from "effect/Cause";
33import * as Effect from "effect/Effect";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
47import { beforeEach, vi } from "vite-plus/test";
58
69const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({
......@@ -16,6 +19,8 @@ vi.mock("electron", () => ({
1619
1720import * as ElectronProtocol from "./ElectronProtocol.ts";
1821
22const protocolLayer = ElectronProtocol.layer.pipe(Layer.provide(NodeServices.layer));
23
1924describe("ElectronProtocol", () => {
2025 beforeEach(() => {
2126 handleMock.mockReset();
......@@ -23,6 +28,46 @@ describe("ElectronProtocol", () => {
2328 unhandleMock.mockReset();
2429 });
2530
31 it.effect("serves the bundled client from disk without a backend", () =>
32 Effect.gen(function* () {
33 const fileSystem = yield* FileSystem.FileSystem;
34 const directory = yield* fileSystem.makeTempDirectoryScoped();
35 yield* fileSystem.writeFileString(`${directory}/index.html`, "<html>app</html>");
36 yield* fileSystem.writeFileString(`${directory}/app.js`, "export default 1;");
37 let handler: ((request: Request) => Promise<Response>) | undefined;
38 handleMock.mockImplementation((_scheme, nextHandler) => {
39 handler = nextHandler;
40 });
41 const protocol = yield* ElectronProtocol.ElectronProtocol;
42 yield* protocol.registerDesktopProtocol({
43 scheme: "t3code",
44 assetDirectory: directory,
45 clerkFrontendApiHostname: undefined,
46 });
47 const request = (pathname: string, init?: RequestInit) =>
48 Effect.promise(() => handler!(new Request(`t3code://app${pathname}`, init)));
49
50 // SPA routes fall back to index.html, including ones containing dots.
51 const page = yield* request("/settings/connections");
52 assert.equal(yield* Effect.promise(() => page.text()), "<html>app</html>");
53 assert.include(page.headers.get("content-security-policy") ?? "", "default-src 'self'");
54 const dottedRoute = yield* request("/environment/thread.with.dots", {
55 headers: { accept: "text/html" },
56 });
57 assert.equal(yield* Effect.promise(() => dottedRoute.text()), "<html>app</html>");
58
59 const script = yield* request("/app.js?v=1");
60 assert.equal(yield* Effect.promise(() => script.text()), "export default 1;");
61 assert.include(script.headers.get("content-type") ?? "", "javascript");
62
63 assert.equal((yield* request("/missing.js")).status, 404);
64 assert.equal((yield* request("/%2e%2e%2fsecret.txt")).status, 404);
65 assert.equal((yield* request("/%invalid")).status, 400);
66 assert.equal((yield* request("/", { method: "POST" })).status, 405);
67 assert.equal(netFetchMock.mock.calls.length, 0);
68 }).pipe(Effect.provide(Layer.merge(protocolLayer, NodeServices.layer)), Effect.scoped),
69 );
70
2671 it.effect("proxies the stable renderer origin to the current app server", () =>
2772 Effect.gen(function* () {
2873 let handler: ((request: Request) => Promise<Response>) | undefined;
......@@ -37,7 +82,6 @@ describe("ElectronProtocol", () => {
3782 yield* protocol.registerDesktopProtocol({
3883 scheme: "t3code-dev",
3984 targetOrigin: new URL("http://127.0.0.1:3773/"),
40 backendOrigin: new URL("http://127.0.0.1:3774/"),
4185 clerkFrontendApiHostname: "clerk.t3.codes",
4286 });
4387 assert.isDefined(handler);
......@@ -85,7 +129,7 @@ describe("ElectronProtocol", () => {
85129 assert.isNull(forwardedHeaders.get("referer"));
86130 assert.isNull(forwardedHeaders.get("sec-fetch-site"));
87131 assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]);
88 }).pipe(Effect.provide(ElectronProtocol.layer)),
132 }).pipe(Effect.provide(protocolLayer)),
89133 );
90134
91135 it.effect("rejects custom protocol requests for another host", () =>
......@@ -101,7 +145,6 @@ describe("ElectronProtocol", () => {
101145 yield* protocol.registerDesktopProtocol({
102146 scheme: "t3code",
103147 targetOrigin: new URL("http://127.0.0.1:3773/"),
104 backendOrigin: new URL("http://127.0.0.1:3773/"),
105148 clerkFrontendApiHostname: undefined,
106149 });
107150 return yield* Effect.promise(() => handler!(new Request("t3code://other/")));
......@@ -110,7 +153,7 @@ describe("ElectronProtocol", () => {
110153
111154 assert.equal(response.status, 404);
112155 assert.equal(netFetchMock.mock.calls.length, 0);
113 }).pipe(Effect.provide(ElectronProtocol.layer)),
156 }).pipe(Effect.provide(protocolLayer)),
114157 );
115158
116159 it.effect("retries transient renderer target failures", () =>
......@@ -129,7 +172,6 @@ describe("ElectronProtocol", () => {
129172 yield* protocol.registerDesktopProtocol({
130173 scheme: "t3code-dev",
131174 targetOrigin: new URL("http://127.0.0.1:5733/"),
132 backendOrigin: new URL("http://127.0.0.1:3773/"),
133175 clerkFrontendApiHostname: undefined,
134176 });
135177 return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/")));
......@@ -138,7 +180,7 @@ describe("ElectronProtocol", () => {
138180
139181 assert.equal(yield* Effect.promise(() => response.text()), "ready");
140182 assert.equal(netFetchMock.mock.calls.length, 2);
141 }).pipe(Effect.provide(ElectronProtocol.layer)),
183 }).pipe(Effect.provide(protocolLayer)),
142184 );
143185
144186 it.effect("preserves protocol registration failures", () =>
......@@ -153,7 +195,6 @@ describe("ElectronProtocol", () => {
153195 protocol.registerDesktopProtocol({
154196 scheme: "t3code-dev",
155197 targetOrigin: new URL("http://127.0.0.1:3773/"),
156 backendOrigin: new URL("http://127.0.0.1:3774/"),
157198 clerkFrontendApiHostname: undefined,
158199 }),
159200 ).pipe(Effect.flip);
......@@ -162,7 +203,7 @@ describe("ElectronProtocol", () => {
162203 assert.equal(error.scheme, "t3code-dev");
163204 assert.strictEqual(error.cause, cause);
164205 assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".');
165 }).pipe(Effect.provide(ElectronProtocol.layer)),
206 }).pipe(Effect.provide(protocolLayer)),
166207 );
167208
168209 it.effect("preserves protocol unregistration failures", () =>
......@@ -178,7 +219,6 @@ describe("ElectronProtocol", () => {
178219 protocol.registerDesktopProtocol({
179220 scheme: "t3code",
180221 targetOrigin: new URL("http://127.0.0.1:3773/"),
181 backendOrigin: new URL("http://127.0.0.1:3773/"),
182222 clerkFrontendApiHostname: undefined,
183223 }),
184224 ),
......@@ -192,14 +232,13 @@ describe("ElectronProtocol", () => {
192232 assert.strictEqual(error.cause, cause);
193233 assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".');
194234 }
195 }).pipe(Effect.provide(ElectronProtocol.layer)),
235 }).pipe(Effect.provide(protocolLayer)),
196236 );
197237
198238 it("keeps executable sources host-restricted while allowing runtime network resources", () => {
199239 const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({
200240 scheme: "t3code",
201241 targetOrigin: new URL("http://127.0.0.1:3773/"),
202 backendOrigin: new URL("http://127.0.0.1:3773/"),
203242 clerkFrontendApiHostname: "clerk.t3.codes",
204243 });
205244 const directives = Object.fromEntries(
apps/desktop/src/electron/ElectronProtocol.ts+57-7
......@@ -1,7 +1,10 @@
1import Mime from "@effect/platform-node/Mime";
12import * as Context from "effect/Context";
23import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
35import * as Layer from "effect/Layer";
46import * as NodeTimersPromises from "node:timers/promises";
7import * as Path from "effect/Path";
58import * as Ref from "effect/Ref";
69import * as Schema from "effect/Schema";
710import * as Scope from "effect/Scope";
......@@ -48,12 +51,12 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError<Elec
4851 }
4952}
5053
51export interface DesktopProtocolRegistrationInput {
54// The scheme either proxies to a dev server (`targetOrigin`) or serves the
55// built client from disk (`assetDirectory`).
56export type DesktopProtocolRegistrationInput = {
5257 readonly scheme: string;
53 readonly targetOrigin: URL;
54 readonly backendOrigin: URL;
5558 readonly clerkFrontendApiHostname: string | undefined;
56}
59} & ({ readonly targetOrigin: URL } | { readonly assetDirectory: string });
5760
5861export class ElectronProtocol extends Context.Service<
5962 ElectronProtocol,
......@@ -189,6 +192,45 @@ async function proxyRequest(
189192
190193const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const;
191194
195// Serves the packaged web client without a backend: files resolve within the
196// asset directory, and any other path falls back to index.html so the SPA
197// router handles it, except for asset-shaped misses (`/missing.js`) which 404.
198const serveDesktopAsset = Effect.fn("desktop.protocol.serveAsset")(function* (
199 request: Request,
200 assetDirectory: string,
201) {
202 const fileSystem = yield* FileSystem.FileSystem;
203 const path = yield* Path.Path;
204 const url = new URL(request.url);
205 if (url.host !== DESKTOP_HOST) return new Response(null, { status: 404 });
206 if (request.method !== "GET" && request.method !== "HEAD") {
207 return new Response(null, { status: 405 });
208 }
209 const pathname = yield* Effect.try(() => decodeURIComponent(url.pathname)).pipe(
210 Effect.orElseSucceed(() => null),
211 );
212 if (pathname === null || pathname.includes("\0")) return new Response(null, { status: 400 });
213 const root = path.resolve(assetDirectory);
214 const assetPath = path.resolve(root, `.${pathname}`);
215 if (assetPath !== root && !assetPath.startsWith(root + path.sep)) {
216 return new Response(null, { status: 404 });
217 }
218 const stat = yield* fileSystem.stat(assetPath).pipe(Effect.orElseSucceed(() => null));
219 let filePath = assetPath;
220 if (stat?.type !== "File") {
221 const wantsHtml = request.headers.get("accept")?.includes("text/html") ?? false;
222 if (path.extname(assetPath) !== "" && !wantsHtml) {
223 return new Response(null, { status: 404 });
224 }
225 filePath = path.join(root, "index.html");
226 }
227 const contents = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null));
228 if (contents === null) return new Response(null, { status: 404 });
229 return new Response(request.method === "HEAD" ? null : new Uint8Array(contents), {
230 headers: { "content-type": Mime.getType(filePath) ?? "application/octet-stream" },
231 });
232});
233
192234async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<Response> {
193235 let lastError: unknown;
194236
......@@ -210,6 +252,8 @@ async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<
210252/** @public Service construction is part of the canonical Effect module API. */
211253export const make = Effect.gen(function* () {
212254 const registered = yield* Ref.make(false);
255 const context = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
256 const runPromise = Effect.runPromiseWith(context);
213257
214258 const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")(
215259 function* (input: DesktopProtocolRegistrationInput) {
......@@ -220,9 +264,15 @@ export const make = Effect.gen(function* () {
220264 yield* Effect.acquireRelease(
221265 Effect.try({
222266 try: () => {
223 Electron.protocol.handle(input.scheme, (request) =>
224 proxyRequest(request, input.targetOrigin, contentSecurityPolicy),
225 );
267 Electron.protocol.handle(input.scheme, async (request) => {
268 if ("assetDirectory" in input) {
269 return withContentSecurityPolicy(
270 await runPromise(serveDesktopAsset(request, input.assetDirectory)),
271 contentSecurityPolicy,
272 );
273 }
274 return proxyRequest(request, input.targetOrigin, contentSecurityPolicy);
275 });
226276 },
227277 catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }),
228278 }).pipe(Effect.andThen(Ref.set(registered, true))),
apps/desktop/src/ipc/DesktopIpcHandlers.ts+6
......@@ -8,6 +8,10 @@ import {
88 getConnectionCatalog,
99 setConnectionCatalog,
1010} from "./methods/connectionCatalog.ts";
11import {
12 getLocalEnvironmentEnabled,
13 setLocalEnvironmentEnabled,
14} from "./methods/localEnvironment.ts";
1115import {
1216 getAdvertisedEndpoints,
1317 getServerExposureState,
......@@ -79,6 +83,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
7983 yield* ipc.handleSync(getSystemLocale);
8084 yield* ipc.handleSync(getWindowFullscreenState);
8185 yield* ipc.handleSync(getLocalEnvironmentBootstraps);
86 yield* ipc.handleSync(getLocalEnvironmentEnabled);
87 yield* ipc.handle(setLocalEnvironmentEnabled);
8288 yield* ipc.handle(getLocalEnvironmentBearerToken);
8389
8490 yield* ipc.handle(getClientSettings);
apps/desktop/src/ipc/channels.ts+2
......@@ -25,6 +25,8 @@ export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
2525export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
2626export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
2727export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";
28export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled";
29export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled";
2830export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =
2931 "desktop:get-local-environment-bearer-token";
3032export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
apps/desktop/src/ipc/methods/localEnvironment.test.ts created+63
......@@ -0,0 +1,63 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts";
6import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
7import * as DesktopShutdown from "../../app/DesktopShutdown.ts";
8import * as DesktopState from "../../app/DesktopState.ts";
9import * as ElectronApp from "../../electron/ElectronApp.ts";
10import * as ElectronTheme from "../../electron/ElectronTheme.ts";
11import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
12import * as DesktopWindow from "../../window/DesktopWindow.ts";
13import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts";
14
15// `relaunch` declares the lifecycle runtime services as requirements even
16// though the mocked relaunch never touches them.
17const unusedLifecycleRuntimeLayer = Layer.mergeAll(
18 DesktopShutdown.layer,
19 DesktopState.layer,
20 Layer.succeed(
21 DesktopEnvironment.DesktopEnvironment,
22 DesktopEnvironment.DesktopEnvironment.of(
23 {} as DesktopEnvironment.DesktopEnvironment["Service"],
24 ),
25 ),
26 Layer.mock(DesktopWindow.DesktopWindow, {}),
27 Layer.mock(ElectronApp.ElectronApp, {}),
28 Layer.mock(ElectronTheme.ElectronTheme, {}),
29);
30
31describe("local environment IPC", () => {
32 it.effect("relaunches only when the setting changes and keeps other settings", () => {
33 const relaunchReasons: Array<string> = [];
34 const layer = Layer.mergeAll(
35 DesktopAppSettings.layerTest({
36 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
37 wslBackendEnabled: true,
38 }),
39 Layer.mock(DesktopLifecycle.DesktopLifecycle, {
40 relaunch: (reason) =>
41 Effect.sync(() => {
42 relaunchReasons.push(reason);
43 }),
44 }),
45 unusedLifecycleRuntimeLayer,
46 );
47 return Effect.gen(function* () {
48 yield* setLocalEnvironmentEnabled.handler(false);
49 assert.isFalse(yield* getLocalEnvironmentEnabled.handler());
50 yield* setLocalEnvironmentEnabled.handler(false);
51 assert.deepEqual(relaunchReasons, ["localEnvironmentEnabled=false"]);
52
53 yield* setLocalEnvironmentEnabled.handler(true);
54 assert.isTrue(yield* getLocalEnvironmentEnabled.handler());
55 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
56 assert.isTrue((yield* appSettings.get).wslBackendEnabled);
57 assert.deepEqual(relaunchReasons, [
58 "localEnvironmentEnabled=false",
59 "localEnvironmentEnabled=true",
60 ]);
61 }).pipe(Effect.provide(layer));
62 });
63});
apps/desktop/src/ipc/methods/localEnvironment.ts created+30
......@@ -0,0 +1,30 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3
4import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts";
5import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
6import * as IpcChannels from "../channels.ts";
7import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts";
8
9export const getLocalEnvironmentEnabled = makeSyncIpcMethod({
10 channel: IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL,
11 result: Schema.Boolean,
12 handler: Effect.fn("desktop.ipc.localEnvironment.getEnabled")(function* () {
13 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
14 return (yield* appSettings.get).localEnvironmentEnabled;
15 }),
16});
17
18export const setLocalEnvironmentEnabled = makeIpcMethod({
19 channel: IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL,
20 payload: Schema.Boolean,
21 result: Schema.Void,
22 handler: Effect.fn("desktop.ipc.localEnvironment.setEnabled")(function* (enabled) {
23 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
24 const lifecycle = yield* DesktopLifecycle.DesktopLifecycle;
25 const change = yield* appSettings.setLocalEnvironmentEnabled(enabled);
26 if (change.changed) {
27 yield* lifecycle.relaunch(`localEnvironmentEnabled=${enabled}`);
28 }
29 }),
30});
apps/desktop/src/ipc/methods/window.test.ts+31-10
......@@ -19,6 +19,8 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
1919import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
2020import * as ElectronDialog from "../../electron/ElectronDialog.ts";
2121import * as ElectronWindow from "../../electron/ElectronWindow.ts";
22import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
23import type { DesktopSettings } from "../../settings/DesktopAppSettings.ts";
2224import {
2325 getLocalEnvironmentBootstraps,
2426 getWindowFullscreenState,
......@@ -208,19 +210,21 @@ describe("pasteAsText", () => {
208210});
209211
210212describe("pickProjectFavicon", () => {
213 const pickerLayer = (pickFiles: () => Effect.Effect<Array<string>>, settings?: DesktopSettings) =>
214 Layer.mergeAll(
215 Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }),
216 Layer.mock(ElectronWindow.ElectronWindow)({
217 focusedMainOrFirst: Effect.succeed(Option.none()),
218 }),
219 DesktopAppSettings.layerTest(settings),
220 );
221
211222 it.effect("opens a single-image picker from the project directory", () =>
212223 Effect.gen(function* () {
213224 const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"]));
214 const result = yield* pickProjectFavicon.handler("/project").pipe(
215 Effect.provide(
216 Layer.mergeAll(
217 Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }),
218 Layer.mock(ElectronWindow.ElectronWindow)({
219 focusedMainOrFirst: Effect.succeed(Option.none()),
220 }),
221 ),
222 ),
223 );
225 const result = yield* pickProjectFavicon
226 .handler("/project")
227 .pipe(Effect.provide(pickerLayer(pickFiles)));
224228
225229 assert.strictEqual(result, "/pictures/icon.png");
226230 assert.deepEqual(pickFiles.mock.calls, [
......@@ -240,4 +244,21 @@ describe("pickProjectFavicon", () => {
240244 ]);
241245 }),
242246 );
247
248 it.effect("does not open a picker while the local environment is off", () =>
249 Effect.gen(function* () {
250 const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"]));
251 const result = yield* pickProjectFavicon.handler("/project").pipe(
252 Effect.provide(
253 pickerLayer(pickFiles, {
254 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
255 localEnvironmentEnabled: false,
256 }),
257 ),
258 );
259
260 assert.strictEqual(result, null);
261 assert.strictEqual(pickFiles.mock.calls.length, 0);
262 }),
263 );
243264});
apps/desktop/src/ipc/methods/window.ts+9-1
......@@ -182,6 +182,11 @@ export const pickFolder = DesktopIpc.makeIpcMethod({
182182 const environment = yield* DesktopEnvironment.DesktopEnvironment;
183183 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
184184 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
185 const settings = yield* appSettings.get;
186 // A picked path only means something to a backend on this machine.
187 if (!settings.localEnvironmentEnabled) {
188 return null;
189 }
185190 // Three picker modes:
186191 // - targetEnvironmentId omitted: default to the primary picker. Keeps
187192 // the historical behavior unchanged for users who never enabled the
......@@ -200,7 +205,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({
200205 targetId !== undefined &&
201206 targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID &&
202207 targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX);
203 const settings = yield* appSettings.get;
204208 // Fall back to the persisted wslDistro when the id is the
205209 // "wsl:default" sentinel; the orchestrator uses the same fallback
206210 // for the actual backend.
......@@ -246,6 +250,10 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({
246250 handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) {
247251 const dialog = yield* ElectronDialog.ElectronDialog;
248252 const electronWindow = yield* ElectronWindow.ElectronWindow;
253 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
254 if (!(yield* appSettings.get).localEnvironmentEnabled) {
255 return null;
256 }
249257 const paths = yield* dialog.pickFiles({
250258 owner: yield* electronWindow.focusedMainOrFirst,
251259 defaultPath: Option.fromNullishOr(initialPath),
apps/desktop/src/ipc/methods/wsl.test.ts+37-1
......@@ -18,7 +18,7 @@ import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"
1818import * as DesktopWindow from "../../window/DesktopWindow.ts";
1919import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
2020import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
21import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts";
21import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts";
2222
2323const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema);
2424
......@@ -85,6 +85,42 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll(
8585);
8686
8787describe("WSL IPC", () => {
88 it.effect("does not probe WSL when local execution is disabled", () =>
89 Effect.gen(function* () {
90 const wsl = yield* DesktopWslEnvironment.DesktopWslEnvironment;
91 const state = yield* getWslState.handler(undefined).pipe(
92 Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, {
93 ...wsl,
94 isAvailable: Effect.die("must not probe WSL"),
95 listDistros: Effect.die("must not enumerate distros"),
96 }),
97 Effect.flatMap(decodeWslState),
98 );
99 assert.deepEqual(state, {
100 enabled: true,
101 distro: "Ubuntu",
102 available: false,
103 wslOnly: true,
104 distros: [],
105 preflightError: null,
106 });
107 }).pipe(
108 Effect.provide(
109 Layer.mergeAll(
110 DesktopAppSettings.layerTest({
111 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
112 localEnvironmentEnabled: false,
113 wslBackendEnabled: true,
114 wslDistro: "Ubuntu",
115 wslOnly: true,
116 }),
117 DesktopWslEnvironment.layerTest(),
118 makeWslBackendLayer(),
119 ),
120 ),
121 ),
122 );
123
88124 it.effect("stages dual-backend preferences before enabling without relaunching", () => {
89125 const relaunchReasons: Array<string> = [];
90126 const layer = Layer.mergeAll(
apps/desktop/src/ipc/methods/wsl.ts+1-1
......@@ -21,7 +21,7 @@ const readWslState: Effect.Effect<
2121 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
2222 const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
2323 const settings = yield* appSettings.get;
24 const available = yield* wslEnvironment.isAvailable;
24 const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable);
2525 // Only enumerate distros when WSL is actually available — listDistros on a
2626 // non-WSL host would spawn wsl.exe and hit the timeout for nothing.
2727 const distros = available ? yield* wslEnvironment.listDistros : [];
apps/desktop/src/preload.ts+4
......@@ -77,6 +77,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
7777 },
7878 getLocalEnvironmentBearerToken: () =>
7979 ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL),
80 getLocalEnvironmentEnabled: () =>
81 ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL) !== false,
82 setLocalEnvironmentEnabled: (enabled) =>
83 ipcRenderer.invoke(IpcChannels.SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL, enabled),
8084 getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL),
8185 setClientSettings: (settings) =>
8286 ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings),
apps/desktop/src/settings/DesktopAppSettings.test.ts+25
......@@ -91,6 +91,24 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) {
9191}
9292
9393describe("DesktopSettings", () => {
94 it.effect(
95 "persists disabling and re-enabling local execution without clearing backend settings",
96 () =>
97 withSettings(
98 Effect.gen(function* () {
99 const settings = yield* DesktopAppSettings.DesktopAppSettings;
100 yield* settings.setWslBackendEnabled(true);
101 yield* settings.setWslDistro("Ubuntu");
102 yield* settings.setServerExposureMode("network-accessible");
103 const before = yield* settings.get;
104 assert.isTrue((yield* settings.setLocalEnvironmentEnabled(false)).changed);
105 assert.deepEqual(yield* settings.load, { ...before, localEnvironmentEnabled: false });
106 assert.isFalse((yield* settings.setLocalEnvironmentEnabled(false)).changed);
107 yield* settings.setLocalEnvironmentEnabled(true);
108 assert.deepEqual(yield* settings.load, before);
109 }),
110 ),
111 );
94112 it.effect("loads defaults when no settings file exists", () =>
95113 withSettings(
96114 Effect.gen(function* () {
......@@ -106,6 +124,7 @@ describe("DesktopSettings", () => {
106124 DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"),
107125 {
108126 linuxPasswordStore: "auto",
127 localEnvironmentEnabled: true,
109128 mainWindowBounds: null,
110129 mainWindowMaximized: false,
111130 serverExposureMode: "local-only",
......@@ -135,6 +154,7 @@ describe("DesktopSettings", () => {
135154
136155 assert.deepEqual(yield* settings.load, {
137156 linuxPasswordStore: "gnome-libsecret",
157 localEnvironmentEnabled: true,
138158 mainWindowBounds: null,
139159 mainWindowMaximized: false,
140160 serverExposureMode: "network-accessible",
......@@ -242,6 +262,7 @@ describe("DesktopSettings", () => {
242262
243263 assert.deepEqual(yield* settings.load, {
244264 linuxPasswordStore: "auto",
265 localEnvironmentEnabled: true,
245266 mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 },
246267 mainWindowMaximized: false,
247268 serverExposureMode: "network-accessible",
......@@ -298,6 +319,7 @@ describe("DesktopSettings", () => {
298319
299320 assert.deepEqual(yield* settings.load, {
300321 linuxPasswordStore: "auto",
322 localEnvironmentEnabled: true,
301323 mainWindowBounds: null,
302324 mainWindowMaximized: false,
303325 serverExposureMode: "network-accessible",
......@@ -346,6 +368,7 @@ describe("DesktopSettings", () => {
346368
347369 assert.deepEqual(yield* settings.load, {
348370 linuxPasswordStore: "auto",
371 localEnvironmentEnabled: true,
349372 mainWindowBounds: null,
350373 mainWindowMaximized: false,
351374 serverExposureMode: "local-only",
......@@ -374,6 +397,7 @@ describe("DesktopSettings", () => {
374397
375398 assert.deepEqual(yield* settings.load, {
376399 linuxPasswordStore: "auto",
400 localEnvironmentEnabled: true,
377401 mainWindowBounds: null,
378402 mainWindowMaximized: false,
379403 serverExposureMode: "local-only",
......@@ -401,6 +425,7 @@ describe("DesktopSettings", () => {
401425
402426 assert.deepEqual(yield* settings.load, {
403427 linuxPasswordStore: "auto",
428 localEnvironmentEnabled: true,
404429 mainWindowBounds: null,
405430 mainWindowMaximized: false,
406431 serverExposureMode: "local-only",
apps/desktop/src/settings/DesktopAppSettings.ts+23
......@@ -25,6 +25,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts
2525import { isValidDistroName } from "../wsl/wslPathParsing.ts";
2626
2727export interface DesktopSettings {
28 readonly localEnvironmentEnabled: boolean;
2829 readonly linuxPasswordStore: LinuxPasswordStorePreference;
2930 readonly mainWindowBounds: DesktopWindowBounds | null;
3031 readonly mainWindowMaximized: boolean;
......@@ -73,6 +74,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = {
7374} as const;
7475
7576export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
77 localEnvironmentEnabled: true,
7678 linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE,
7779 mainWindowBounds: null,
7880 mainWindowMaximized: false,
......@@ -94,6 +96,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({
9496});
9597
9698const DesktopSettingsDocument = Schema.Struct({
99 localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean),
97100 linuxPasswordStore: Schema.optionalKey(Schema.Unknown),
98101 mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)),
99102 mainWindowMaximized: Schema.optionalKey(Schema.Boolean),
......@@ -152,6 +155,9 @@ export class DesktopAppSettings extends Context.Service<
152155 {
153156 readonly load: Effect.Effect<DesktopSettings>;
154157 readonly get: Effect.Effect<DesktopSettings>;
158 readonly setLocalEnvironmentEnabled: (
159 enabled: boolean,
160 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
155161 readonly setMainWindowBounds: (
156162 bounds: DesktopWindowBounds,
157163 isMaximized: boolean,
......@@ -224,6 +230,7 @@ function normalizeDesktopSettingsDocument(
224230 (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl");
225231
226232 return {
233 localEnvironmentEnabled: parsed.localEnvironmentEnabled !== false,
227234 linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore),
228235 mainWindowBounds,
229236 mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true,
......@@ -247,6 +254,10 @@ function toDesktopSettingsDocument(
247254): DesktopSettingsDocument {
248255 const document: Mutable<DesktopSettingsDocument> = {};
249256
257 if (settings.localEnvironmentEnabled !== defaults.localEnvironmentEnabled) {
258 document.localEnvironmentEnabled = settings.localEnvironmentEnabled;
259 }
260
250261 if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) {
251262 document.linuxPasswordStore = settings.linuxPasswordStore;
252263 }
......@@ -370,6 +381,12 @@ function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSetting
370381 };
371382}
372383
384function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings {
385 return settings.localEnvironmentEnabled === enabled
386 ? settings
387 : { ...settings, localEnvironmentEnabled: enabled };
388}
389
373390function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings {
374391 return setWslOnly(setWslBackendEnabled(settings, false), false);
375392}
......@@ -545,6 +562,10 @@ export const make = Effect.gen(function* () {
545562 persist((settings) => setWslOnly(settings, enabled)).pipe(
546563 Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }),
547564 ),
565 setLocalEnvironmentEnabled: (enabled) =>
566 persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe(
567 Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }),
568 ),
548569 applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe(
549570 Effect.withSpan("desktop.settings.applyWslWindowsFallback"),
550571 ),
......@@ -586,6 +607,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET
586607 update((settings) => setWslBackendEnabled(settings, enabled)),
587608 setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)),
588609 setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)),
610 setLocalEnvironmentEnabled: (enabled) =>
611 update((settings) => setLocalEnvironmentEnabled(settings, enabled)),
589612 applyWslWindowsFallback: update(applyWslWindowsFallback),
590613 applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback),
591614 });
apps/desktop/src/updates/updatesTestHarness.ts+1
......@@ -196,6 +196,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
196196 ),
197197 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
198198 setWslDistro: () => Effect.die("unexpected WSL distro change"),
199 setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"),
199200 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
200201 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
201202 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
apps/desktop/src/window/DesktopWindow.test.ts+32
......@@ -253,6 +253,7 @@ function makeTestLayer(input: {
253253 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
254254 setWslDistro: () => Effect.die("unexpected WSL distro change"),
255255 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
256 setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"),
256257 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
257258 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
258259 } satisfies DesktopAppSettings.DesktopAppSettings["Service"]);
......@@ -629,6 +630,37 @@ describe("DesktopWindow", () => {
629630 }),
630631 );
631632
633 it.effect(
634 "opens and reopens the window without backend readiness when local execution is disabled",
635 () =>
636 Effect.gen(function* () {
637 const fakeWindow = makeFakeBrowserWindow();
638 const createCount = yield* Ref.make(0);
639 const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
640 const layer = makeTestLayer({
641 window: fakeWindow.window,
642 createCount,
643 mainWindow,
644 createdWindowOptions: [],
645 desktopSettings: {
646 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
647 localEnvironmentEnabled: false,
648 },
649 });
650 yield* Effect.gen(function* () {
651 const desktopWindow = yield* DesktopWindow.DesktopWindow;
652 yield* desktopWindow.createMainIfBackendReady;
653 assert.equal(yield* Ref.get(createCount), 1);
654 yield* Ref.set(mainWindow, Option.none());
655 yield* desktopWindow.activate;
656 assert.equal(yield* Ref.get(createCount), 2);
657 yield* Ref.set(mainWindow, Option.none());
658 yield* desktopWindow.dispatchMenuAction("new-thread");
659 assert.equal(yield* Ref.get(createCount), 3);
660 }).pipe(Effect.provide(layer));
661 }),
662 );
663
632664 it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () =>
633665 Effect.gen(function* () {
634666 const fakeWindow = makeFakeBrowserWindow();
apps/desktop/src/window/DesktopWindow.ts+10-4
......@@ -84,7 +84,7 @@ export class DesktopWindow extends Context.Service<
8484 readonly activate: Effect.Effect<void, DesktopWindowError>;
8585 readonly createMainIfBackendReady: Effect.Effect<void, DesktopWindowError>;
8686 // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only
87 // mode), before the WSL backend that serves the renderer is ready. It is
87 // mode), before the WSL backend that acts as the primary is ready. It is
8888 // dismissed automatically once the real main window reveals.
8989 readonly showConnectingSplash: Effect.Effect<void>;
9090 // Marks the primary backend as ready so `createMainIfBackendReady` and the
......@@ -838,9 +838,15 @@ export const make = Effect.gen(function* () {
838838 return window;
839839 }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain"));
840840
841 // With the local environment disabled there is no backend to wait for: the
842 // renderer is served from bundled assets and only talks to remote environments.
843 const waitingForBackend = Effect.gen(function* () {
844 if (yield* Ref.get(backendReadyRef)) return false;
845 return (yield* desktopSettings.get).localEnvironmentEnabled;
846 });
847
841848 const createMainIfBackendReady = Effect.gen(function* () {
842 const backendReady = yield* Ref.get(backendReadyRef);
843 if (!backendReady) return;
849 if (yield* waitingForBackend) return;
844850 const existingWindow = yield* currentMainWindow;
845851 if (Option.isSome(existingWindow)) return;
846852 yield* createMain;
......@@ -898,7 +904,7 @@ export const make = Effect.gen(function* () {
898904 { reveal = true }: { readonly reveal?: boolean } = {},
899905 ) {
900906 const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main;
901 if (Option.isNone(existingWindow) && (!reveal || !(yield* Ref.get(backendReadyRef)))) return;
907 if (Option.isNone(existingWindow) && (!reveal || (yield* waitingForBackend))) return;
902908 const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain;
903909 if (targetWindow.isDestroyed()) return;
904910 const send = Effect.sync(() => {
apps/desktop/src/wsl/DesktopWslBackend.test.ts+23
......@@ -83,6 +83,29 @@ const netLayer = Layer.succeed(NetService.NetService, {
8383} satisfies NetService.NetService["Service"]);
8484
8585describe("DesktopWslBackend", () => {
86 it.effect("does not discover or start WSL when local execution is disabled", () =>
87 Effect.gen(function* () {
88 const backend = yield* DesktopWslBackend.DesktopWslBackend;
89 yield* backend.reconcile;
90 }).pipe(
91 Effect.provide(
92 DesktopWslBackend.layer.pipe(
93 Layer.provide(Layer.mock(DesktopBackendPool.DesktopBackendPool, {})),
94 Layer.provide(backendConfigurationLayer),
95 Layer.provide(serverExposureLayer),
96 Layer.provide(netLayer),
97 Layer.provide(Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {})),
98 Layer.provide(
99 DesktopAppSettings.layerTest({
100 ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS,
101 localEnvironmentEnabled: false,
102 wslBackendEnabled: true,
103 }),
104 ),
105 ),
106 ),
107 ),
108 );
86109 it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => {
87110 let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined;
88111 const primary = makeStubInstance({
apps/desktop/src/wsl/DesktopWslBackend.ts+1
......@@ -188,6 +188,7 @@ export const layer = Layer.effect(
188188
189189 const reconcileBody = Effect.gen(function* () {
190190 const settings = yield* appSettings.get;
191 if (!settings.localEnvironmentEnabled) return;
191192 const available = yield* wslEnvironment.isAvailable;
192193 const existing = yield* findExistingWslInstance;
193194 const existingId = Option.map(existing, (instance) => instance.id);
apps/web/src/components/CommandPalette.tsx+18-16
......@@ -953,8 +953,13 @@ function OpenCommandPaletteDialog(props: {
953953 )
954954 : "";
955955 const browsePath = useMemo(
956 () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep),
957 [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query],
956 () =>
957 getFilesystemBrowsePath(
958 query,
959 browseEnvironmentPlatform,
960 browseEnvironmentId !== null && !isRemoteProjectRepositoryStep,
961 ),
962 [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query],
958963 );
959964 const isBrowsing = browsePath.isBrowsing;
960965 const browseDirectoryPath = browsePath.directoryPath;
......@@ -1553,6 +1558,14 @@ function OpenCommandPaletteDialog(props: {
15531558 );
15541559
15551560 const openAddProjectFlow = useCallback(() => {
1561 // With no environment at all there is nothing to browse, so the only
1562 // useful next step is connecting one.
1563 if (addProjectEnvironmentOptions.length === 0) {
1564 setOpen(false);
1565 void navigate({ to: "/settings/connections" });
1566 return;
1567 }
1568
15561569 if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) {
15571570 pushPaletteView({
15581571 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
......@@ -1561,24 +1574,14 @@ function OpenCommandPaletteDialog(props: {
15611574 return;
15621575 }
15631576
1564 const environmentId = defaultAddProjectEnvironmentId;
1565 if (!environmentId) {
1566 toastManager.add(
1567 stackedThreadToast({
1568 type: "error",
1569 title: "Unable to browse projects",
1570 description: "No environment is available.",
1571 }),
1572 );
1573 return;
1574 }
1575
1576 void startAddProjectSourceSelection(environmentId);
1577 void startAddProjectSourceSelection(defaultAddProjectEnvironmentId);
15771578 }, [
15781579 addProjectEnvironmentGroups,
15791580 addProjectEnvironmentOptions.length,
15801581 defaultAddProjectEnvironmentId,
1582 navigate,
15811583 pushPaletteView,
1584 setOpen,
15821585 startAddProjectSourceSelection,
15831586 ]);
15841587
......@@ -1775,7 +1778,6 @@ function OpenCommandPaletteDialog(props: {
17751778 "environment",
17761779 ],
17771780 title: "Add project",
1778 disabled: defaultAddProjectEnvironmentId === null,
17791781 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
17801782 keepOpen: true,
17811783 run: async () => {
apps/web/src/components/onboarding/FirstRunGate.tsx+2
......@@ -9,6 +9,7 @@ import {
99 useClientSettings,
1010 useClientSettingsHydrationStatus,
1111} from "../../hooks/useSettings";
12import { isLocalEnvironmentDisabled } from "../../localEnvironment";
1213import { useCompleteOnboarding } from "../../onboarding/firstRun";
1314import {
1415 isFirstRunWorkspaceProvenanceAuthoritative,
......@@ -126,6 +127,7 @@ export function FirstRunGate({
126127
127128 const { decision: nextDecision, persistCompletion } = hostedStatic
128129 ? resolveHostedFirstRunDecision({
130 localEnvironmentDisabled: isLocalEnvironmentDisabled(),
129131 hydrated,
130132 completed: onboardingCompletedAt !== null,
131133 catalogReady: environmentCatalogReady,
apps/web/src/components/settings/ConnectionsSettings.tsx+61-46
......@@ -51,6 +51,7 @@ import * as Option from "effect/Option";
5151
5252import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
5353import { cn } from "../../lib/utils";
54import { isLocalEnvironmentDisabled } from "../../localEnvironment";
5455import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
5556import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
5657import {
......@@ -65,6 +66,7 @@ import {
6566 SettingsSection,
6667 useRelativeTimeTick,
6768} from "./settingsLayout";
69import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting";
6870import { searchableSetting } from "./settingsSearch";
6971import { EnvironmentIconMenu } from "./EnvironmentIconPicker";
7072import {
......@@ -1985,7 +1987,9 @@ export function ConnectionsSettings() {
19851987 const setDefaultAdvertisedEndpointKey = useUiStateStore(
19861988 (state) => state.setDefaultAdvertisedEndpointKey,
19871989 );
1988 const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false;
1990 const canManageLocalBackend =
1991 !isLocalEnvironmentDisabled() &&
1992 (currentSessionScopes?.includes(AuthAccessWriteScope) ?? false);
19891993 const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false;
19901994 const authAccessChanges = useEnvironmentQuery(
19911995 canManageLocalBackend && primaryEnvironmentId !== null
......@@ -3234,15 +3238,21 @@ export function ConnectionsSettings() {
32343238
32353239 const primarySettings = (
32363240 <>
3237 {canManageLocalBackend ? (
3241 {desktopBridge || canManageLocalBackend ? (
32383242 <>
32393243 <SettingsSection
32403244 {...searchableSetting("connections-environment")}
3241 title={primaryEnvironment?.label ?? "Primary environment"}
3245 title={
3246 primaryEnvironment?.label ?? (desktopBridge ? "This machine" : "Primary environment")
3247 }
32423248 icon={
32433249 <EnvironmentMachineIcon
32443250 aria-hidden
3245 kind={resolveEnvironmentMachineKind(primaryServerConfig)}
3251 kind={
3252 primaryServerConfig
3253 ? resolveEnvironmentMachineKind(primaryServerConfig)
3254 : "desktop"
3255 }
32463256 className="size-4"
32473257 />
32483258 }
......@@ -3272,46 +3282,51 @@ export function ConnectionsSettings() {
32723282 ) : null
32733283 }
32743284 >
3275 <SettingsRow
3276 title="Version"
3277 description={
3278 primaryServerUpdateState.status !== "idle" ? (
3279 <ServerUpdateProgress state={primaryServerUpdateState} />
3280 ) : (
3281 [
3282 primaryServerConfig?.environment.serverVersion ?? null,
3283 primaryEnvironment?.displayUrl ?? null,
3284 ]
3285 .filter((value): value is string => value !== null)
3286 .join(" · ") || "Loading…"
3287 )
3288 }
3289 control={
3290 primaryVersionMismatch &&
3291 primaryEnvironmentId !== null &&
3292 primaryServerUpdateState.status !== "running" ? (
3293 <ServerUpdateAction
3294 size="sm"
3295 environmentId={primaryEnvironmentId}
3296 serverLabel={
3297 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"
3298 }
3299 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3300 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}
3301 threadContinuation={supportsServerUpdateThreadContinuation(primaryServerConfig)}
3302 targetVersion={primaryVersionMismatch.clientVersion}
3303 label={
3304 primaryServerUpdateState.status === "failed"
3305 ? "Retry update"
3306 : `Update to ${primaryVersionMismatch.clientVersion}`
3307 }
3308 />
3309 ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? (
3310 <span className="text-xs text-muted-foreground">Up to date</span>
3311 ) : undefined
3312 }
3313 />
3314 {desktopBridge ? (
3285 <LocalEnvironmentSetting />
3286 {canManageLocalBackend ? (
3287 <SettingsRow
3288 title="Version"
3289 description={
3290 primaryServerUpdateState.status !== "idle" ? (
3291 <ServerUpdateProgress state={primaryServerUpdateState} />
3292 ) : (
3293 [
3294 primaryServerConfig?.environment.serverVersion ?? null,
3295 primaryEnvironment?.displayUrl ?? null,
3296 ]
3297 .filter((value): value is string => value !== null)
3298 .join(" · ") || "Loading…"
3299 )
3300 }
3301 control={
3302 primaryVersionMismatch &&
3303 primaryEnvironmentId !== null &&
3304 primaryServerUpdateState.status !== "running" ? (
3305 <ServerUpdateAction
3306 size="sm"
3307 environmentId={primaryEnvironmentId}
3308 serverLabel={
3309 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"
3310 }
3311 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3312 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}
3313 threadContinuation={supportsServerUpdateThreadContinuation(
3314 primaryServerConfig,
3315 )}
3316 targetVersion={primaryVersionMismatch.clientVersion}
3317 label={
3318 primaryServerUpdateState.status === "failed"
3319 ? "Retry update"
3320 : `Update to ${primaryVersionMismatch.clientVersion}`
3321 }
3322 />
3323 ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? (
3324 <span className="text-xs text-muted-foreground">Up to date</span>
3325 ) : undefined
3326 }
3327 />
3328 ) : null}
3329 {canManageLocalBackend && desktopBridge ? (
33153330 <>
33163331 {renderNetworkAccessRow()}
33173332 {renderEndpointRows("endpoint-rail")}
......@@ -3319,12 +3334,12 @@ export function ConnectionsSettings() {
33193334 {renderWslRow()}
33203335 <CloudLinkRow canManageRelay={canManageRelay} />
33213336 </>
3322 ) : (
3337 ) : canManageLocalBackend ? (
33233338 <>
33243339 {renderDisabledNetworkAccessRow()}
33253340 <CloudLinkRow canManageRelay={canManageRelay} />
33263341 </>
3327 )}
3342 ) : null}
33283343 </SettingsSection>
33293344
33303345 {isLocalBackendRemotelyReachable ? (
apps/web/src/components/settings/LocalEnvironmentSetting.tsx created+103
......@@ -0,0 +1,103 @@
1import { useState } from "react";
2
3import { isLocalEnvironmentDisabled } from "../../localEnvironment";
4import {
5 AlertDialog,
6 AlertDialogClose,
7 AlertDialogDescription,
8 AlertDialogFooter,
9 AlertDialogHeader,
10 AlertDialogPopup,
11 AlertDialogTitle,
12} from "../ui/alert-dialog";
13import { Button } from "../ui/button";
14import { Spinner } from "../ui/spinner";
15import { Switch } from "../ui/switch";
16import { SettingsRow } from "./settingsLayout";
17import { searchableSetting } from "./settingsSearch";
18
19// Toggling relaunches the desktop app, so the switch only reflects the value
20// this process started with; there is no live state to keep in sync.
21export function LocalEnvironmentSetting() {
22 const setEnabled = window.desktopBridge?.setLocalEnvironmentEnabled;
23 const [enabled] = useState(() => !isLocalEnvironmentDisabled());
24 const [confirmOpen, setConfirmOpen] = useState(false);
25 const [isUpdating, setIsUpdating] = useState(false);
26 const [error, setError] = useState<string | null>(null);
27 if (!setEnabled) return null;
28
29 const applyChange = async () => {
30 setIsUpdating(true);
31 setError(null);
32 try {
33 await setEnabled(!enabled);
34 } catch (cause) {
35 setError(cause instanceof Error ? cause.message : "Couldn't change this setting.");
36 setIsUpdating(false);
37 }
38 };
39
40 return (
41 <>
42 <SettingsRow
43 {...searchableSetting("local-environment")}
44 description={
45 enabled
46 ? "Run agents on this computer. Turn off to use T3 Code only with remote environments."
47 : "Turned off. Agents only run in remote environments."
48 }
49 control={
50 <Switch
51 checked={enabled}
52 disabled={isUpdating}
53 onCheckedChange={() => setConfirmOpen(true)}
54 aria-label="Local environment"
55 />
56 }
57 />
58 <AlertDialog
59 open={confirmOpen}
60 onOpenChange={(open) => {
61 if (isUpdating) return;
62 setConfirmOpen(open);
63 if (!open) setError(null);
64 }}
65 >
66 <AlertDialogPopup>
67 <AlertDialogHeader>
68 <AlertDialogTitle>
69 {enabled ? "Turn off local environment?" : "Turn on local environment?"}
70 </AlertDialogTitle>
71 <AlertDialogDescription>
72 {enabled
73 ? "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."
74 : "T3 Code will restart and start running a server on this computer again."}
75 </AlertDialogDescription>
76 </AlertDialogHeader>
77 {error ? <p className="px-6 pb-4 text-sm text-destructive">{error}</p> : null}
78 <AlertDialogFooter>
79 <AlertDialogClose disabled={isUpdating} render={<Button variant="outline" />}>
80 Cancel
81 </AlertDialogClose>
82 <Button
83 variant={enabled ? "destructive" : "default"}
84 disabled={isUpdating}
85 onClick={() => void applyChange()}
86 >
87 {isUpdating ? (
88 <>
89 <Spinner className="size-3.5" />
90 Restarting…
91 </>
92 ) : enabled ? (
93 "Restart and turn off"
94 ) : (
95 "Restart and turn on"
96 )}
97 </Button>
98 </AlertDialogFooter>
99 </AlertDialogPopup>
100 </AlertDialog>
101 </>
102 );
103}
apps/web/src/components/settings/settingsSearch.test.ts+22
......@@ -173,6 +173,28 @@ describe("searchSettings", () => {
173173 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);
174174 });
175175
176 it("keeps the local toggle searchable without offering hidden host publishing controls", () => {
177 const availability = {
178 hasCloudPublicConfig: true,
179 hasEnvironment: true,
180 hasProviderSettingsEnvironment: true,
181 canManageLocalBackend: false,
182 isWslSettingsRowVisible: false,
183 hasThreadAutoSettlement: false,
184 };
185 const remoteOnly = filterAvailableSettingsSearchItems({
186 ...availability,
187 localEnvironmentDisabled: true,
188 }).map((item) => item.id);
189 expect(remoteOnly).toContain("local-environment");
190 expect(remoteOnly).not.toContain("t3-connect");
191 expect(remoteOnly).not.toContain("publish-agent-activity");
192 expect(remoteOnly).not.toContain("wsl-backend");
193 // Browsers without access:write still render CloudLinkRow for their host.
194 const browser = filterAvailableSettingsSearchItems(availability).map((item) => item.id);
195 expect(browser).toContain("publish-agent-activity");
196 });
197
176198 it("shows automatic settlement settings when the server supports them", () => {
177199 const available = filterAvailableSettingsSearchItems({
178200 hasCloudPublicConfig: false,
apps/web/src/components/settings/settingsSearch.ts+13
......@@ -52,11 +52,13 @@ export interface SettingsSearchItem {
5252 readonly environmentOnly?: boolean;
5353 readonly providerSettingsOnly?: boolean;
5454 readonly localBackendManagementOnly?: boolean;
55 readonly localEnvironmentOnly?: boolean;
5556 readonly wslAvailableOnly?: boolean;
5657 readonly requiresThreadAutoSettlement?: boolean;
5758}
5859
5960export interface SettingsSearchAvailability {
61 readonly localEnvironmentDisabled?: boolean;
6062 readonly hasCloudPublicConfig: boolean;
6163 readonly hasEnvironment: boolean;
6264 readonly hasProviderSettingsEnvironment: boolean;
......@@ -626,6 +628,14 @@ export const SETTINGS_SEARCH_ITEMS = [
626628 searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"],
627629 localBackendManagementOnly: true,
628630 },
631 {
632 id: "local-environment",
633 title: "Local environment",
634 to: "/settings/connections",
635 targetId: "connections-environment",
636 searchTerms: ["turn off on disable enable local server agents remote only restart"],
637 desktopOnly: true,
638 },
629639 {
630640 id: "network-access",
631641 title: "Network access",
......@@ -657,6 +667,7 @@ export const SETTINGS_SEARCH_ITEMS = [
657667 },
658668 {
659669 id: "t3-connect",
670 localEnvironmentOnly: true,
660671 title: "T3 Connect",
661672 to: "/settings/connections",
662673 targetId: "connections-environment",
......@@ -666,6 +677,7 @@ export const SETTINGS_SEARCH_ITEMS = [
666677 },
667678 {
668679 id: "publish-agent-activity",
680 localEnvironmentOnly: true,
669681 title: "Publish agent activity",
670682 to: "/settings/connections",
671683 targetId: "connections-environment",
......@@ -840,6 +852,7 @@ export function filterAvailableSettingsSearchItems(
840852 (!item.environmentOnly || availability.hasEnvironment) &&
841853 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
842854 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
855 (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) &&
843856 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
844857 (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement),
845858 );
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+18-6
......@@ -3,6 +3,7 @@ import { AuthAccessWriteScope } from "@t3tools/contracts";
33
44import { hasCloudPublicConfig } from "~/cloud/publicConfig";
55import { isElectron } from "~/env";
6import { isLocalEnvironmentDisabled } from "~/localEnvironment";
67import { desktopWslStateAtom } from "~/state/desktopWslState";
78import { useEnvironments } from "~/state/environments";
89import { useEnvironmentQuery } from "~/state/query";
......@@ -17,16 +18,21 @@ import {
1718export function useAvailableSettingsSearchItems() {
1819 const { environments } = useEnvironments();
1920 const primarySessionState = usePrimarySessionState();
20 const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);
21 const localEnvironmentDisabled = isLocalEnvironmentDisabled();
22 const desktopWsl = useEnvironmentQuery(
23 isElectron && !localEnvironmentDisabled ? desktopWslStateAtom : null,
24 );
2125 const canManageLocalBackend =
22 isElectron ||
23 ((primarySessionState.data?.authenticated &&
24 primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ??
25 false);
26 !localEnvironmentDisabled &&
27 (isElectron ||
28 ((primarySessionState.data?.authenticated &&
29 primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ??
30 false));
2631
2732 return useMemo(
2833 () =>
2934 filterAvailableSettingsSearchItems({
35 localEnvironmentDisabled,
3036 hasCloudPublicConfig: hasCloudPublicConfig(),
3137 hasEnvironment: environments.some((environment) => environment.serverConfig !== null),
3238 hasProviderSettingsEnvironment: environments.some((environment) =>
......@@ -43,6 +49,12 @@ export function useAvailableSettingsSearchItems() {
4349 hasThreadAutoSettlement:
4450 getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0,
4551 }),
46 [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments],
52 [
53 canManageLocalBackend,
54 desktopWsl.data,
55 desktopWsl.error,
56 environments,
57 localEnvironmentDisabled,
58 ],
4759 );
4860}
apps/web/src/connection/platform.ts+2-1
......@@ -51,6 +51,7 @@ import {
5151} from "../environments/primary/target";
5252import { clearComposerDraftsEnvironment } from "../composerDraftStore";
5353import { isHostedStaticApp } from "../hostedPairing";
54import { isLocalEnvironmentDisabled } from "../localEnvironment";
5455import { appAtomRegistry } from "../rpc/atomRegistry";
5556import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState";
5657import {
......@@ -464,7 +465,7 @@ export function secondaryRegistrationsToRetainAfterTopologyRead(
464465const platformConnectionSourceLayer = Layer.effect(
465466 PlatformConnectionSource,
466467 Effect.gen(function* () {
467 if (isHostedStaticApp()) {
468 if (isHostedStaticApp() || isLocalEnvironmentDisabled()) {
468469 return PlatformConnectionSource.of({
469470 registrations: Stream.empty,
470471 });
apps/web/src/environments/primary/bootstrap.test.ts+18-2
......@@ -158,7 +158,7 @@ describe("environmentBootstrap", () => {
158158 it("keeps an uppercase wss scheme secure when deriving the http url", () => {
159159 vi.stubEnv("VITE_WS_URL", "WSS://remote.example.com");
160160
161 expect(readPrimaryEnvironmentTarget().target).toEqual({
161 expect(readPrimaryEnvironmentTarget()?.target).toEqual({
162162 httpBaseUrl: "https://remote.example.com/",
163163 wsBaseUrl: "wss://remote.example.com/",
164164 });
......@@ -167,7 +167,7 @@ describe("environmentBootstrap", () => {
167167 it("keeps an uppercase https scheme secure when deriving the websocket url", () => {
168168 vi.stubEnv("VITE_HTTP_URL", "HTTPS://remote.example.com");
169169
170 expect(readPrimaryEnvironmentTarget().target).toEqual({
170 expect(readPrimaryEnvironmentTarget()?.target).toEqual({
171171 httpBaseUrl: "https://remote.example.com/",
172172 wsBaseUrl: "wss://remote.example.com/",
173173 });
......@@ -257,6 +257,22 @@ describe("environmentBootstrap", () => {
257257 });
258258 });
259259
260 it("has no primary target when the desktop local environment is disabled", () => {
261 vi.stubGlobal("window", {
262 location: new URL("t3code://app/"),
263 desktopBridge: {
264 getLocalEnvironmentEnabled: () => false,
265 getLocalEnvironmentBootstraps: () => [],
266 },
267 });
268
269 expect(readPrimaryEnvironmentTarget()).toBeNull();
270 expect(getPrimaryKnownEnvironment()).toBeNull();
271 expect(() => resolvePrimaryEnvironmentHttpUrl("/api/auth/session")).toThrow(
272 "The local environment is disabled.",
273 );
274 });
275
260276 it("preserves an unsupported window-origin protocol", () => {
261277 vi.stubGlobal("window", {
262278 location: { origin: "file:///tmp/t3code/" },
apps/web/src/environments/primary/sessionState.ts+6-1
......@@ -5,10 +5,15 @@ import * as Option from "effect/Option";
55import { AsyncResult, Atom } from "effect/unstable/reactivity";
66import { useCallback } from "react";
77
8import { isLocalEnvironmentDisabled } from "../../localEnvironment";
89import { appAtomRegistry } from "../../rpc/atomRegistry";
910import { fetchSessionState } from "./auth";
1011
11const primarySessionStateAtom = Atom.make(Effect.promise(fetchSessionState)).pipe(
12const primarySessionStateAtom = Atom.make(
13 Effect.suspend(() =>
14 isLocalEnvironmentDisabled() ? Effect.succeed(null) : Effect.promise(fetchSessionState),
15 ),
16).pipe(
1217 Atom.swr({ staleTime: 5_000, revalidateOnMount: true }),
1318 Atom.setIdleTTL(5 * 60_000),
1419 Atom.withLabel("primary-environment:session"),
apps/web/src/environments/primary/target.ts+20-1
......@@ -1,6 +1,8 @@
11import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts";
22import * as Schema from "effect/Schema";
33
4import { isLocalEnvironmentDisabled } from "../../localEnvironment";
5
46const PrimaryEnvironmentTargetSource = Schema.Literals([
57 "configured",
68 "window-origin",
......@@ -57,6 +59,15 @@ export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedErr
5759 }
5860}
5961
62export class PrimaryEnvironmentDisabledError extends Schema.TaggedError<PrimaryEnvironmentDisabledError>()(
63 "PrimaryEnvironmentDisabledError",
64 {},
65) {
66 override get message(): string {
67 return "The local environment is disabled.";
68 }
69}
70
6071export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError);
6172export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is(
6273 PrimaryEnvironmentProtocolUnsupportedError,
......@@ -276,6 +287,9 @@ export function resolvePrimaryEnvironmentHttpUrl(
276287 searchParams?: Record<string, string>,
277288): string {
278289 const primaryTarget = readPrimaryEnvironmentTarget();
290 if (!primaryTarget) {
291 throw new PrimaryEnvironmentDisabledError();
292 }
279293
280294 const url = parseTargetUrl({
281295 rawValue: resolveHttpRequestBaseUrl(primaryTarget),
......@@ -289,7 +303,12 @@ export function resolvePrimaryEnvironmentHttpUrl(
289303 return url.toString();
290304}
291305
292export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget {
306// Null only when the desktop app runs with its local environment disabled;
307// every other host has a primary (falling back to the page origin).
308export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null {
309 if (isLocalEnvironmentDisabled()) {
310 return null;
311 }
293312 return (
294313 resolveDesktopPrimaryTarget() ??
295314 resolveConfiguredPrimaryTarget() ??
apps/web/src/localEnvironment.ts created+9
......@@ -0,0 +1,9 @@
1/**
2 * True when the desktop app runs without its local server. The renderer then
3 * has no primary environment: it skips primary auth and discovery and only
4 * connects to saved remote environments. Always false in browsers and on
5 * desktop builds predating the setting.
6 */
7export function isLocalEnvironmentDisabled(): boolean {
8 return window.desktopBridge?.getLocalEnvironmentEnabled?.() === false;
9}
apps/web/src/onboarding/firstRun.logic.test.ts+12
......@@ -316,6 +316,18 @@ describe("resolveHostedFirstRunDecision", () => {
316316 });
317317 });
318318
319 it("keeps Connections reachable for a remote-only desktop predating onboarding", () => {
320 expect(
321 resolveHostedFirstRunDecision({
322 hydrated: true,
323 completed: false,
324 catalogReady: true,
325 environmentCount: 0,
326 localEnvironmentDisabled: true,
327 }),
328 ).toEqual({ decision: "app", persistCompletion: true });
329 });
330
319331 it("backfills onboarding for a hosted install with saved environments", () => {
320332 expect(
321333 resolveHostedFirstRunDecision({
apps/web/src/onboarding/firstRun.logic.ts+4-1
......@@ -49,6 +49,7 @@ interface FirstRunDecisionInput {
4949}
5050
5151interface HostedFirstRunDecisionInput {
52 readonly localEnvironmentDisabled?: boolean;
5253 readonly hydrated: boolean;
5354 readonly completed: boolean;
5455 readonly catalogReady: boolean;
......@@ -178,7 +179,9 @@ export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput
178179 return { decision: "pending", persistCompletion: false };
179180 }
180181
181 return input.environmentCount === 0
182 // An existing desktop may have disabled its server before onboarding existed.
183 // Keep Connections accessible so it can turn local execution back on.
184 return input.environmentCount === 0 && !input.localEnvironmentDisabled
182185 ? { decision: "wizard", persistCompletion: false }
183186 : { decision: "app", persistCompletion: true };
184187}
apps/web/src/routes/__root.tsx+2-1
......@@ -54,6 +54,7 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme";
5454import { configureClientTracing } from "../observability/clientTracing";
5555import { resolveInitialServerAuthGateState } from "../environments/primary";
5656import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";
57import { isLocalEnvironmentDisabled } from "../localEnvironment";
5758import { shellEnvironment } from "../state/shell";
5859import { useAtomValue } from "@effect/atom-react";
5960import { useAtomCommand } from "../state/use-atom-command";
......@@ -83,7 +84,7 @@ export const Route = createRootRoute({
8384 };
8485 }
8586
86 if (isHostedStaticApp(new URL(window.location.href))) {
87 if (isLocalEnvironmentDisabled() || isHostedStaticApp(new URL(window.location.href))) {
8788 return {
8889 authGateState: {
8990 status: "hosted-static",
apps/web/src/routes/_chat.index.tsx+12-6
......@@ -4,6 +4,8 @@ import { createFileRoute, Link } from "@tanstack/react-router";
44import { LinkIcon, PlusIcon } from "lucide-react";
55import { useEffect, useMemo, useRef, useState } from "react";
66
7import { isLocalEnvironmentDisabled } from "../localEnvironment";
8import { isElectron } from "../env";
79import { NoProjectsHero } from "../components/NoProjectsHero";
810import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";
911import { Button } from "../components/ui/button";
......@@ -113,11 +115,17 @@ export const Route = createFileRoute("/_chat/")({
113115
114116function HostedStaticOnboardingState() {
115117 const cloudEnabled = hasCloudPublicConfig();
118 const localEnvironmentOff = isLocalEnvironmentDisabled();
119 const description = localEnvironmentOff
120 ? "The local environment is turned off. Connect a remote environment, or turn the local environment back on in Connections."
121 : cloudEnabled
122 ? "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."
123 : "Open Connections and add that machine using its pairing link. This app must be able to reach it.";
116124
117125 return (
118126 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
119127 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
120 <WorkspacePageHeader className="border-b border-border">
128 <WorkspacePageHeader electron={isElectron} className="border-b border-border">
121129 <div className="flex items-center gap-2">
122130 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
123131 {APP_DISPLAY_NAME}
......@@ -135,13 +143,11 @@ function HostedStaticOnboardingState() {
135143 Connect to a computer running T3 Code
136144 </EmptyTitle>
137145 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">
138 This browser connects to T3 Code running on your computer or a server. Start the T3
139 Code desktop app or command-line server on that machine and keep it running.
146 This app connects to T3 Code running on your computer or a server. Start the T3 Code
147 desktop app or command-line server on that machine and keep it running.
140148 </EmptyDescription>
141149 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">
142 {cloudEnabled
143 ? "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."
144 : "Open Connections and add that machine using its pairing link. This browser must be able to reach it."}
150 {description}
145151 </EmptyDescription>
146152 <div className="mt-6 flex justify-center">
147153 <Button render={<Link to="/settings/connections" />} size="sm">
docs/internals/remote.md+12
......@@ -57,3 +57,15 @@ capabilities and handle their absence, rather than assume their own version
5757describes the server. Process replacement belongs to the launcher's
5858[update protocol](./server-updates.md); the connection runtime handles the
5959resulting disconnect.
60
61### Desktop without a local environment
62
63Desktop normally launches its own primary server, but the desktop setting `localEnvironmentEnabled`
64(`apps/desktop/src/settings/DesktopAppSettings.ts`) turns that off. Changing it relaunches the app;
65no local state is deleted. On the next start the main process skips port selection, server exposure,
66and the primary and WSL backends, and opens the window right away. The renderer sees this through
67`desktopBridge.getLocalEnvironmentEnabled()`: `readPrimaryEnvironmentTarget` returns null, so primary
68auth and platform-managed discovery are skipped and only saved environments (pairing, relay, SSH)
69connect. This is possible because the desktop renderer is not served by the backend: the `t3code://`
70scheme serves the bundled client from disk (Vite in development) and API traffic always goes to the
71environment's own URL.
docs/user/remote-access.md+11
......@@ -187,3 +187,14 @@ Include the diagnostic message and trace ID when reporting a persistent failure.
187187
188188For a connection that still fails after linking, check the date and time on both
189189devices. For server version warnings, follow [Updating T3 Code](./updating.md).
190
191## Using the Desktop App as a Remote Only
192
193If a computer should only drive work running elsewhere, turn off its local environment. In the
194desktop app, open **Settings → Connections** and switch off **Local
195environment**. T3 Code restarts without a local server: no local agents or terminals run, WSL
196backends stay off, and other devices can no longer connect to this computer. Your projects,
197history, and saved connections are kept, and you keep working through pairing, T3 Connect, or SSH.
198
199Switch **Local environment** back on in the same place to restart with your previous local
200settings.
packages/contracts/src/ipc.ts+2
......@@ -1228,6 +1228,8 @@ export interface DesktopBridge {
12281228 // info (omits instances whose backend hasn't produced a config yet).
12291229 // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID.
12301230 getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[];
1231 getLocalEnvironmentEnabled?: () => boolean;
1232 setLocalEnvironmentEnabled?: (enabled: boolean) => Promise<void>;
12311233 getLocalEnvironmentBearerToken: () => Promise<string>;
12321234 getClientSettings: () => Promise<ClientSettings | null>;
12331235 setClientSettings: (settings: ClientSettings) => Promise<void>;