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"...@@ -158,18 +158,43 @@ export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances"
158);158);
159159
160const bootstrap = Effect.gen(function* () {160const bootstrap = Effect.gen(function* () {
161 const pool = yield* DesktopBackendPool.DesktopBackendPool;
162 const primaryBackend = yield* pool.primary;
163 const state = yield* DesktopState.DesktopState;161 const state = yield* DesktopState.DesktopState;
164 const environment = yield* DesktopEnvironment.DesktopEnvironment;162 const environment = yield* DesktopEnvironment.DesktopEnvironment;
165 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;163 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
166 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
167 const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
168 const desktopWindow = yield* DesktopWindow.DesktopWindow;164 const desktopWindow = yield* DesktopWindow.DesktopWindow;
169 const snapShot = yield* DesktopSnapShot.DesktopSnapShot;165 const snapShot = yield* DesktopSnapShot.DesktopSnapShot;
170 const appActivation = yield* DesktopAppActivation.DesktopAppActivation;166 const appActivation = yield* DesktopAppActivation.DesktopAppActivation;
171 yield* logBootstrapInfo("bootstrap start");167 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
173 if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {198 if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) {
174 return yield* new DesktopDevelopmentBackendPortRequiredError();199 return yield* new DesktopDevelopmentBackendPortRequiredError();
175 }200 }
...@@ -186,7 +211,6 @@ const bootstrap = Effect.gen(function* () {...@@ -186,7 +211,6 @@ const bootstrap = Effect.gen(function* () {
186 },211 },
187 );212 );
188213
189 const settings = yield* desktopSettings.get;
190 if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) {214 if (settings.serverExposureMode !== environment.defaultDesktopSettings.serverExposureMode) {
191 yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", {215 yield* logBootstrapInfo("bootstrap restoring persisted server exposure mode", {
192 mode: settings.serverExposureMode,216 mode: settings.serverExposureMode,
...@@ -194,16 +218,6 @@ const bootstrap = Effect.gen(function* () {...@@ -194,16 +218,6 @@ const bootstrap = Effect.gen(function* () {
194 }218 }
195 const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort });219 const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort });
196 const backendConfig = yield* serverExposure.backendConfig;220 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 });
207 yield* logBootstrapInfo("bootstrap resolved backend endpoint", {221 yield* logBootstrapInfo("bootstrap resolved backend endpoint", {
208 baseUrl: backendConfig.httpBaseUrl.href,222 baseUrl: backendConfig.httpBaseUrl.href,
209 });223 });
...@@ -219,16 +233,13 @@ const bootstrap = Effect.gen(function* () {...@@ -219,16 +233,13 @@ const bootstrap = Effect.gen(function* () {
219 "bootstrap fell back to local-only because no advertised network host was available",233 "bootstrap fell back to local-only because no advertised network host was available",
220 );234 );
221 }235 }
222 yield* snapShot.initialize;
223
224 yield* installDesktopIpcHandlers();
225 yield* logBootstrapInfo("bootstrap ipc handlers registered");
226236
227 if (!(yield* Ref.get(state.quitting))) {237 if (!(yield* Ref.get(state.quitting))) {
228 // In wsl-only mode the renderer is served by the WSL backend, which can be238 // The main window waits for the primary backend. In wsl-only mode that is
229 // slow to cold-boot — show a "Connecting to WSL" splash immediately so the239 // the WSL backend, which can be slow to cold-boot — show a "Connecting to
230 // app feels responsive instead of presenting no window until WSL is ready.240 // WSL" splash immediately so the app feels responsive instead of presenting
231 // (Dual mode opens fast off the Windows primary, so no splash there.)241 // no window until WSL is ready. (Dual mode opens fast off the Windows
242 // primary, so no splash there.)
232 if (settings.wslOnly === true && settings.wslBackendEnabled === true) {243 if (settings.wslOnly === true && settings.wslBackendEnabled === true) {
233 yield* desktopWindow.showConnectingSplash;244 yield* desktopWindow.showConnectingSplash;
234 }245 }
apps/desktop/src/app/DesktopEnvironment.test.ts+4
...@@ -120,6 +120,10 @@ describe("DesktopEnvironment", () => {...@@ -120,6 +120,10 @@ describe("DesktopEnvironment", () => {
120 environment.backendEntryPath,120 environment.backendEntryPath,
121 "/install/resources/server.asar/apps/server/dist/bin.mjs",121 "/install/resources/server.asar/apps/server/dist/bin.mjs",
122 );122 );
123 assert.equal(
124 environment.clientAssetsDir,
125 "/install/resources/server.asar/apps/server/dist/client",
126 );
123 }),127 }),
124 );128 );
125129
apps/desktop/src/app/DesktopEnvironment.ts+3
...@@ -61,6 +61,8 @@ export class DesktopEnvironment extends Context.Service<...@@ -61,6 +61,8 @@ export class DesktopEnvironment extends Context.Service<
61 // extracts on demand (see DesktopWslServerTree).61 // extracts on demand (see DesktopWslServerTree).
62 readonly serverRoot: string;62 readonly serverRoot: string;
63 readonly backendEntryPath: string;63 readonly backendEntryPath: string;
64 // Built web client the packaged renderer is served from over t3code://app.
65 readonly clientAssetsDir: string;
64 readonly backendCwd: string;66 readonly backendCwd: string;
65 readonly preloadPath: string;67 readonly preloadPath: string;
66 readonly appUpdateYmlPath: string;68 readonly appUpdateYmlPath: string;
...@@ -211,6 +213,7 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -211,6 +213,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
211 appRoot,213 appRoot,
212 serverRoot,214 serverRoot,
213 backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"),215 backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"),
216 clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"),
214 backendCwd: input.isPackaged ? homeDirectory : appRoot,217 backendCwd: input.isPackaged ? homeDirectory : appRoot,
215 preloadPath: path.join(input.dirname, "preload.cjs"),218 preloadPath: path.join(input.dirname, "preload.cjs"),
216 appUpdateYmlPath: input.isPackaged219 appUpdateYmlPath: input.isPackaged
apps/desktop/src/backend/DesktopServerExposure.test.ts+1
...@@ -257,6 +257,7 @@ describe("DesktopServerExposure", () => {...@@ -257,6 +257,7 @@ describe("DesktopServerExposure", () => {
257 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),257 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
258 setWslDistro: () => Effect.die("unexpected WSL distro change"),258 setWslDistro: () => Effect.die("unexpected WSL distro change"),
259 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),259 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
260 setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"),
260 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),261 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
261 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),262 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
262 } satisfies DesktopAppSettings.DesktopAppSettings["Service"]);263 } satisfies DesktopAppSettings.DesktopAppSettings["Service"]);
apps/desktop/src/electron/ElectronProtocol.test.ts+50-11
...@@ -1,6 +1,9 @@...@@ -1,6 +1,9 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";3import * 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";
4import { beforeEach, vi } from "vite-plus/test";7import { beforeEach, vi } from "vite-plus/test";
58
6const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({9const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({
...@@ -16,6 +19,8 @@ vi.mock("electron", () => ({...@@ -16,6 +19,8 @@ vi.mock("electron", () => ({
1619
17import * as ElectronProtocol from "./ElectronProtocol.ts";20import * as ElectronProtocol from "./ElectronProtocol.ts";
1821
22const protocolLayer = ElectronProtocol.layer.pipe(Layer.provide(NodeServices.layer));
23
19describe("ElectronProtocol", () => {24describe("ElectronProtocol", () => {
20 beforeEach(() => {25 beforeEach(() => {
21 handleMock.mockReset();26 handleMock.mockReset();
...@@ -23,6 +28,46 @@ describe("ElectronProtocol", () => {...@@ -23,6 +28,46 @@ describe("ElectronProtocol", () => {
23 unhandleMock.mockReset();28 unhandleMock.mockReset();
24 });29 });
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
26 it.effect("proxies the stable renderer origin to the current app server", () =>71 it.effect("proxies the stable renderer origin to the current app server", () =>
27 Effect.gen(function* () {72 Effect.gen(function* () {
28 let handler: ((request: Request) => Promise<Response>) | undefined;73 let handler: ((request: Request) => Promise<Response>) | undefined;
...@@ -37,7 +82,6 @@ describe("ElectronProtocol", () => {...@@ -37,7 +82,6 @@ describe("ElectronProtocol", () => {
37 yield* protocol.registerDesktopProtocol({82 yield* protocol.registerDesktopProtocol({
38 scheme: "t3code-dev",83 scheme: "t3code-dev",
39 targetOrigin: new URL("http://127.0.0.1:3773/"),84 targetOrigin: new URL("http://127.0.0.1:3773/"),
40 backendOrigin: new URL("http://127.0.0.1:3774/"),
41 clerkFrontendApiHostname: "clerk.t3.codes",85 clerkFrontendApiHostname: "clerk.t3.codes",
42 });86 });
43 assert.isDefined(handler);87 assert.isDefined(handler);
...@@ -85,7 +129,7 @@ describe("ElectronProtocol", () => {...@@ -85,7 +129,7 @@ describe("ElectronProtocol", () => {
85 assert.isNull(forwardedHeaders.get("referer"));129 assert.isNull(forwardedHeaders.get("referer"));
86 assert.isNull(forwardedHeaders.get("sec-fetch-site"));130 assert.isNull(forwardedHeaders.get("sec-fetch-site"));
87 assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]);131 assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]);
88 }).pipe(Effect.provide(ElectronProtocol.layer)),132 }).pipe(Effect.provide(protocolLayer)),
89 );133 );
90134
91 it.effect("rejects custom protocol requests for another host", () =>135 it.effect("rejects custom protocol requests for another host", () =>
...@@ -101,7 +145,6 @@ describe("ElectronProtocol", () => {...@@ -101,7 +145,6 @@ describe("ElectronProtocol", () => {
101 yield* protocol.registerDesktopProtocol({145 yield* protocol.registerDesktopProtocol({
102 scheme: "t3code",146 scheme: "t3code",
103 targetOrigin: new URL("http://127.0.0.1:3773/"),147 targetOrigin: new URL("http://127.0.0.1:3773/"),
104 backendOrigin: new URL("http://127.0.0.1:3773/"),
105 clerkFrontendApiHostname: undefined,148 clerkFrontendApiHostname: undefined,
106 });149 });
107 return yield* Effect.promise(() => handler!(new Request("t3code://other/")));150 return yield* Effect.promise(() => handler!(new Request("t3code://other/")));
...@@ -110,7 +153,7 @@ describe("ElectronProtocol", () => {...@@ -110,7 +153,7 @@ describe("ElectronProtocol", () => {
110153
111 assert.equal(response.status, 404);154 assert.equal(response.status, 404);
112 assert.equal(netFetchMock.mock.calls.length, 0);155 assert.equal(netFetchMock.mock.calls.length, 0);
113 }).pipe(Effect.provide(ElectronProtocol.layer)),156 }).pipe(Effect.provide(protocolLayer)),
114 );157 );
115158
116 it.effect("retries transient renderer target failures", () =>159 it.effect("retries transient renderer target failures", () =>
...@@ -129,7 +172,6 @@ describe("ElectronProtocol", () => {...@@ -129,7 +172,6 @@ describe("ElectronProtocol", () => {
129 yield* protocol.registerDesktopProtocol({172 yield* protocol.registerDesktopProtocol({
130 scheme: "t3code-dev",173 scheme: "t3code-dev",
131 targetOrigin: new URL("http://127.0.0.1:5733/"),174 targetOrigin: new URL("http://127.0.0.1:5733/"),
132 backendOrigin: new URL("http://127.0.0.1:3773/"),
133 clerkFrontendApiHostname: undefined,175 clerkFrontendApiHostname: undefined,
134 });176 });
135 return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/")));177 return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/")));
...@@ -138,7 +180,7 @@ describe("ElectronProtocol", () => {...@@ -138,7 +180,7 @@ describe("ElectronProtocol", () => {
138180
139 assert.equal(yield* Effect.promise(() => response.text()), "ready");181 assert.equal(yield* Effect.promise(() => response.text()), "ready");
140 assert.equal(netFetchMock.mock.calls.length, 2);182 assert.equal(netFetchMock.mock.calls.length, 2);
141 }).pipe(Effect.provide(ElectronProtocol.layer)),183 }).pipe(Effect.provide(protocolLayer)),
142 );184 );
143185
144 it.effect("preserves protocol registration failures", () =>186 it.effect("preserves protocol registration failures", () =>
...@@ -153,7 +195,6 @@ describe("ElectronProtocol", () => {...@@ -153,7 +195,6 @@ describe("ElectronProtocol", () => {
153 protocol.registerDesktopProtocol({195 protocol.registerDesktopProtocol({
154 scheme: "t3code-dev",196 scheme: "t3code-dev",
155 targetOrigin: new URL("http://127.0.0.1:3773/"),197 targetOrigin: new URL("http://127.0.0.1:3773/"),
156 backendOrigin: new URL("http://127.0.0.1:3774/"),
157 clerkFrontendApiHostname: undefined,198 clerkFrontendApiHostname: undefined,
158 }),199 }),
159 ).pipe(Effect.flip);200 ).pipe(Effect.flip);
...@@ -162,7 +203,7 @@ describe("ElectronProtocol", () => {...@@ -162,7 +203,7 @@ describe("ElectronProtocol", () => {
162 assert.equal(error.scheme, "t3code-dev");203 assert.equal(error.scheme, "t3code-dev");
163 assert.strictEqual(error.cause, cause);204 assert.strictEqual(error.cause, cause);
164 assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".');205 assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".');
165 }).pipe(Effect.provide(ElectronProtocol.layer)),206 }).pipe(Effect.provide(protocolLayer)),
166 );207 );
167208
168 it.effect("preserves protocol unregistration failures", () =>209 it.effect("preserves protocol unregistration failures", () =>
...@@ -178,7 +219,6 @@ describe("ElectronProtocol", () => {...@@ -178,7 +219,6 @@ describe("ElectronProtocol", () => {
178 protocol.registerDesktopProtocol({219 protocol.registerDesktopProtocol({
179 scheme: "t3code",220 scheme: "t3code",
180 targetOrigin: new URL("http://127.0.0.1:3773/"),221 targetOrigin: new URL("http://127.0.0.1:3773/"),
181 backendOrigin: new URL("http://127.0.0.1:3773/"),
182 clerkFrontendApiHostname: undefined,222 clerkFrontendApiHostname: undefined,
183 }),223 }),
184 ),224 ),
...@@ -192,14 +232,13 @@ describe("ElectronProtocol", () => {...@@ -192,14 +232,13 @@ describe("ElectronProtocol", () => {
192 assert.strictEqual(error.cause, cause);232 assert.strictEqual(error.cause, cause);
193 assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".');233 assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".');
194 }234 }
195 }).pipe(Effect.provide(ElectronProtocol.layer)),235 }).pipe(Effect.provide(protocolLayer)),
196 );236 );
197237
198 it("keeps executable sources host-restricted while allowing runtime network resources", () => {238 it("keeps executable sources host-restricted while allowing runtime network resources", () => {
199 const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({239 const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({
200 scheme: "t3code",240 scheme: "t3code",
201 targetOrigin: new URL("http://127.0.0.1:3773/"),241 targetOrigin: new URL("http://127.0.0.1:3773/"),
202 backendOrigin: new URL("http://127.0.0.1:3773/"),
203 clerkFrontendApiHostname: "clerk.t3.codes",242 clerkFrontendApiHostname: "clerk.t3.codes",
204 });243 });
205 const directives = Object.fromEntries(244 const directives = Object.fromEntries(
apps/desktop/src/electron/ElectronProtocol.ts+57-7
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import Mime from "@effect/platform-node/Mime";
1import * as Context from "effect/Context";2import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
3import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
4import * as NodeTimersPromises from "node:timers/promises";6import * as NodeTimersPromises from "node:timers/promises";
7import * as Path from "effect/Path";
5import * as Ref from "effect/Ref";8import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
7import * as Scope from "effect/Scope";10import * as Scope from "effect/Scope";
...@@ -48,12 +51,12 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError<Elec...@@ -48,12 +51,12 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedError<Elec
48 }51 }
49}52}
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 = {
52 readonly scheme: string;57 readonly scheme: string;
53 readonly targetOrigin: URL;
54 readonly backendOrigin: URL;
55 readonly clerkFrontendApiHostname: string | undefined;58 readonly clerkFrontendApiHostname: string | undefined;
56}59} & ({ readonly targetOrigin: URL } | { readonly assetDirectory: string });
5760
58export class ElectronProtocol extends Context.Service<61export class ElectronProtocol extends Context.Service<
59 ElectronProtocol,62 ElectronProtocol,
...@@ -189,6 +192,45 @@ async function proxyRequest(...@@ -189,6 +192,45 @@ async function proxyRequest(
189192
190const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const;193const 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
192async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<Response> {234async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<Response> {
193 let lastError: unknown;235 let lastError: unknown;
194236
...@@ -210,6 +252,8 @@ async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<...@@ -210,6 +252,8 @@ async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<
210/** @public Service construction is part of the canonical Effect module API. */252/** @public Service construction is part of the canonical Effect module API. */
211export const make = Effect.gen(function* () {253export const make = Effect.gen(function* () {
212 const registered = yield* Ref.make(false);254 const registered = yield* Ref.make(false);
255 const context = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
256 const runPromise = Effect.runPromiseWith(context);
213257
214 const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")(258 const registerDesktopProtocol = Effect.fn("desktop.electron.protocol.registerDesktopProtocol")(
215 function* (input: DesktopProtocolRegistrationInput) {259 function* (input: DesktopProtocolRegistrationInput) {
...@@ -220,9 +264,15 @@ export const make = Effect.gen(function* () {...@@ -220,9 +264,15 @@ export const make = Effect.gen(function* () {
220 yield* Effect.acquireRelease(264 yield* Effect.acquireRelease(
221 Effect.try({265 Effect.try({
222 try: () => {266 try: () => {
223 Electron.protocol.handle(input.scheme, (request) =>267 Electron.protocol.handle(input.scheme, async (request) => {
224 proxyRequest(request, input.targetOrigin, contentSecurityPolicy),268 if ("assetDirectory" in input) {
225 );269 return withContentSecurityPolicy(
270 await runPromise(serveDesktopAsset(request, input.assetDirectory)),
271 contentSecurityPolicy,
272 );
273 }
274 return proxyRequest(request, input.targetOrigin, contentSecurityPolicy);
275 });
226 },276 },
227 catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }),277 catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }),
228 }).pipe(Effect.andThen(Ref.set(registered, true))),278 }).pipe(Effect.andThen(Ref.set(registered, true))),
apps/desktop/src/ipc/DesktopIpcHandlers.ts+6
...@@ -8,6 +8,10 @@ import {...@@ -8,6 +8,10 @@ import {
8 getConnectionCatalog,8 getConnectionCatalog,
9 setConnectionCatalog,9 setConnectionCatalog,
10} from "./methods/connectionCatalog.ts";10} from "./methods/connectionCatalog.ts";
11import {
12 getLocalEnvironmentEnabled,
13 setLocalEnvironmentEnabled,
14} from "./methods/localEnvironment.ts";
11import {15import {
12 getAdvertisedEndpoints,16 getAdvertisedEndpoints,
13 getServerExposureState,17 getServerExposureState,
...@@ -79,6 +83,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -79,6 +83,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
79 yield* ipc.handleSync(getSystemLocale);83 yield* ipc.handleSync(getSystemLocale);
80 yield* ipc.handleSync(getWindowFullscreenState);84 yield* ipc.handleSync(getWindowFullscreenState);
81 yield* ipc.handleSync(getLocalEnvironmentBootstraps);85 yield* ipc.handleSync(getLocalEnvironmentBootstraps);
86 yield* ipc.handleSync(getLocalEnvironmentEnabled);
87 yield* ipc.handle(setLocalEnvironmentEnabled);
82 yield* ipc.handle(getLocalEnvironmentBearerToken);88 yield* ipc.handle(getLocalEnvironmentBearerToken);
8389
84 yield* ipc.handle(getClientSettings);90 yield* ipc.handle(getClientSettings);
apps/desktop/src/ipc/channels.ts+2
...@@ -25,6 +25,8 @@ export const UPDATE_CHECK_CHANNEL = "desktop:update-check";...@@ -25,6 +25,8 @@ export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
25export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";25export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
26export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";26export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
27export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";27export 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";
28export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =30export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL =
29 "desktop:get-local-environment-bearer-token";31 "desktop:get-local-environment-bearer-token";
30export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";32export 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";...@@ -19,6 +19,8 @@ import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
19import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";19import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
20import * as ElectronDialog from "../../electron/ElectronDialog.ts";20import * as ElectronDialog from "../../electron/ElectronDialog.ts";
21import * as ElectronWindow from "../../electron/ElectronWindow.ts";21import * as ElectronWindow from "../../electron/ElectronWindow.ts";
22import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts";
23import type { DesktopSettings } from "../../settings/DesktopAppSettings.ts";
22import {24import {
23 getLocalEnvironmentBootstraps,25 getLocalEnvironmentBootstraps,
24 getWindowFullscreenState,26 getWindowFullscreenState,
...@@ -208,19 +210,21 @@ describe("pasteAsText", () => {...@@ -208,19 +210,21 @@ describe("pasteAsText", () => {
208});210});
209211
210describe("pickProjectFavicon", () => {212describe("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
211 it.effect("opens a single-image picker from the project directory", () =>222 it.effect("opens a single-image picker from the project directory", () =>
212 Effect.gen(function* () {223 Effect.gen(function* () {
213 const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"]));224 const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"]));
214 const result = yield* pickProjectFavicon.handler("/project").pipe(225 const result = yield* pickProjectFavicon
215 Effect.provide(226 .handler("/project")
216 Layer.mergeAll(227 .pipe(Effect.provide(pickerLayer(pickFiles)));
217 Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }),
218 Layer.mock(ElectronWindow.ElectronWindow)({
219 focusedMainOrFirst: Effect.succeed(Option.none()),
220 }),
221 ),
222 ),
223 );
224228
225 assert.strictEqual(result, "/pictures/icon.png");229 assert.strictEqual(result, "/pictures/icon.png");
226 assert.deepEqual(pickFiles.mock.calls, [230 assert.deepEqual(pickFiles.mock.calls, [
...@@ -240,4 +244,21 @@ describe("pickProjectFavicon", () => {...@@ -240,4 +244,21 @@ describe("pickProjectFavicon", () => {
240 ]);244 ]);
241 }),245 }),
242 );246 );
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 );
243});264});
apps/desktop/src/ipc/methods/window.ts+9-1
...@@ -182,6 +182,11 @@ export const pickFolder = DesktopIpc.makeIpcMethod({...@@ -182,6 +182,11 @@ export const pickFolder = DesktopIpc.makeIpcMethod({
182 const environment = yield* DesktopEnvironment.DesktopEnvironment;182 const environment = yield* DesktopEnvironment.DesktopEnvironment;
183 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;183 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
184 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;184 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 }
185 // Three picker modes:190 // Three picker modes:
186 // - targetEnvironmentId omitted: default to the primary picker. Keeps191 // - targetEnvironmentId omitted: default to the primary picker. Keeps
187 // the historical behavior unchanged for users who never enabled the192 // the historical behavior unchanged for users who never enabled the
...@@ -200,7 +205,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({...@@ -200,7 +205,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({
200 targetId !== undefined &&205 targetId !== undefined &&
201 targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID &&206 targetId !== PRIMARY_LOCAL_ENVIRONMENT_ID &&
202 targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX);207 targetId.startsWith(DesktopWslBackend.WSL_INSTANCE_ID_PREFIX);
203 const settings = yield* appSettings.get;
204 // Fall back to the persisted wslDistro when the id is the208 // Fall back to the persisted wslDistro when the id is the
205 // "wsl:default" sentinel; the orchestrator uses the same fallback209 // "wsl:default" sentinel; the orchestrator uses the same fallback
206 // for the actual backend.210 // for the actual backend.
...@@ -246,6 +250,10 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({...@@ -246,6 +250,10 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({
246 handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) {250 handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) {
247 const dialog = yield* ElectronDialog.ElectronDialog;251 const dialog = yield* ElectronDialog.ElectronDialog;
248 const electronWindow = yield* ElectronWindow.ElectronWindow;252 const electronWindow = yield* ElectronWindow.ElectronWindow;
253 const appSettings = yield* DesktopAppSettings.DesktopAppSettings;
254 if (!(yield* appSettings.get).localEnvironmentEnabled) {
255 return null;
256 }
249 const paths = yield* dialog.pickFiles({257 const paths = yield* dialog.pickFiles({
250 owner: yield* electronWindow.focusedMainOrFirst,258 owner: yield* electronWindow.focusedMainOrFirst,
251 defaultPath: Option.fromNullishOr(initialPath),259 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"...@@ -18,7 +18,7 @@ import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"
18import * as DesktopWindow from "../../window/DesktopWindow.ts";18import * as DesktopWindow from "../../window/DesktopWindow.ts";
19import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";19import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts";
20import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";20import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts";
21import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts";21import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts";
2222
23const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema);23const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema);
2424
...@@ -85,6 +85,42 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll(...@@ -85,6 +85,42 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll(
85);85);
8686
87describe("WSL IPC", () => {87describe("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
88 it.effect("stages dual-backend preferences before enabling without relaunching", () => {124 it.effect("stages dual-backend preferences before enabling without relaunching", () => {
89 const relaunchReasons: Array<string> = [];125 const relaunchReasons: Array<string> = [];
90 const layer = Layer.mergeAll(126 const layer = Layer.mergeAll(
apps/desktop/src/ipc/methods/wsl.ts+1-1
...@@ -21,7 +21,7 @@ const readWslState: Effect.Effect<...@@ -21,7 +21,7 @@ const readWslState: Effect.Effect<
21 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;21 const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
22 const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;22 const wslBackend = yield* DesktopWslBackend.DesktopWslBackend;
23 const settings = yield* appSettings.get;23 const settings = yield* appSettings.get;
24 const available = yield* wslEnvironment.isAvailable;24 const available = settings.localEnvironmentEnabled && (yield* wslEnvironment.isAvailable);
25 // Only enumerate distros when WSL is actually available — listDistros on a25 // Only enumerate distros when WSL is actually available — listDistros on a
26 // non-WSL host would spawn wsl.exe and hit the timeout for nothing.26 // non-WSL host would spawn wsl.exe and hit the timeout for nothing.
27 const distros = available ? yield* wslEnvironment.listDistros : [];27 const distros = available ? yield* wslEnvironment.listDistros : [];
apps/desktop/src/preload.ts+4
...@@ -77,6 +77,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -77,6 +77,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
77 },77 },
78 getLocalEnvironmentBearerToken: () =>78 getLocalEnvironmentBearerToken: () =>
79 ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL),79 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),
80 getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL),84 getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL),
81 setClientSettings: (settings) =>85 setClientSettings: (settings) =>
82 ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings),86 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) {...@@ -91,6 +91,24 @@ function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) {
91}91}
9292
93describe("DesktopSettings", () => {93describe("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 );
94 it.effect("loads defaults when no settings file exists", () =>112 it.effect("loads defaults when no settings file exists", () =>
95 withSettings(113 withSettings(
96 Effect.gen(function* () {114 Effect.gen(function* () {
...@@ -106,6 +124,7 @@ describe("DesktopSettings", () => {...@@ -106,6 +124,7 @@ describe("DesktopSettings", () => {
106 DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"),124 DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"),
107 {125 {
108 linuxPasswordStore: "auto",126 linuxPasswordStore: "auto",
127 localEnvironmentEnabled: true,
109 mainWindowBounds: null,128 mainWindowBounds: null,
110 mainWindowMaximized: false,129 mainWindowMaximized: false,
111 serverExposureMode: "local-only",130 serverExposureMode: "local-only",
...@@ -135,6 +154,7 @@ describe("DesktopSettings", () => {...@@ -135,6 +154,7 @@ describe("DesktopSettings", () => {
135154
136 assert.deepEqual(yield* settings.load, {155 assert.deepEqual(yield* settings.load, {
137 linuxPasswordStore: "gnome-libsecret",156 linuxPasswordStore: "gnome-libsecret",
157 localEnvironmentEnabled: true,
138 mainWindowBounds: null,158 mainWindowBounds: null,
139 mainWindowMaximized: false,159 mainWindowMaximized: false,
140 serverExposureMode: "network-accessible",160 serverExposureMode: "network-accessible",
...@@ -242,6 +262,7 @@ describe("DesktopSettings", () => {...@@ -242,6 +262,7 @@ describe("DesktopSettings", () => {
242262
243 assert.deepEqual(yield* settings.load, {263 assert.deepEqual(yield* settings.load, {
244 linuxPasswordStore: "auto",264 linuxPasswordStore: "auto",
265 localEnvironmentEnabled: true,
245 mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 },266 mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 },
246 mainWindowMaximized: false,267 mainWindowMaximized: false,
247 serverExposureMode: "network-accessible",268 serverExposureMode: "network-accessible",
...@@ -298,6 +319,7 @@ describe("DesktopSettings", () => {...@@ -298,6 +319,7 @@ describe("DesktopSettings", () => {
298319
299 assert.deepEqual(yield* settings.load, {320 assert.deepEqual(yield* settings.load, {
300 linuxPasswordStore: "auto",321 linuxPasswordStore: "auto",
322 localEnvironmentEnabled: true,
301 mainWindowBounds: null,323 mainWindowBounds: null,
302 mainWindowMaximized: false,324 mainWindowMaximized: false,
303 serverExposureMode: "network-accessible",325 serverExposureMode: "network-accessible",
...@@ -346,6 +368,7 @@ describe("DesktopSettings", () => {...@@ -346,6 +368,7 @@ describe("DesktopSettings", () => {
346368
347 assert.deepEqual(yield* settings.load, {369 assert.deepEqual(yield* settings.load, {
348 linuxPasswordStore: "auto",370 linuxPasswordStore: "auto",
371 localEnvironmentEnabled: true,
349 mainWindowBounds: null,372 mainWindowBounds: null,
350 mainWindowMaximized: false,373 mainWindowMaximized: false,
351 serverExposureMode: "local-only",374 serverExposureMode: "local-only",
...@@ -374,6 +397,7 @@ describe("DesktopSettings", () => {...@@ -374,6 +397,7 @@ describe("DesktopSettings", () => {
374397
375 assert.deepEqual(yield* settings.load, {398 assert.deepEqual(yield* settings.load, {
376 linuxPasswordStore: "auto",399 linuxPasswordStore: "auto",
400 localEnvironmentEnabled: true,
377 mainWindowBounds: null,401 mainWindowBounds: null,
378 mainWindowMaximized: false,402 mainWindowMaximized: false,
379 serverExposureMode: "local-only",403 serverExposureMode: "local-only",
...@@ -401,6 +425,7 @@ describe("DesktopSettings", () => {...@@ -401,6 +425,7 @@ describe("DesktopSettings", () => {
401425
402 assert.deepEqual(yield* settings.load, {426 assert.deepEqual(yield* settings.load, {
403 linuxPasswordStore: "auto",427 linuxPasswordStore: "auto",
428 localEnvironmentEnabled: true,
404 mainWindowBounds: null,429 mainWindowBounds: null,
405 mainWindowMaximized: false,430 mainWindowMaximized: false,
406 serverExposureMode: "local-only",431 serverExposureMode: "local-only",
apps/desktop/src/settings/DesktopAppSettings.ts+23
...@@ -25,6 +25,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts...@@ -25,6 +25,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts
25import { isValidDistroName } from "../wsl/wslPathParsing.ts";25import { isValidDistroName } from "../wsl/wslPathParsing.ts";
2626
27export interface DesktopSettings {27export interface DesktopSettings {
28 readonly localEnvironmentEnabled: boolean;
28 readonly linuxPasswordStore: LinuxPasswordStorePreference;29 readonly linuxPasswordStore: LinuxPasswordStorePreference;
29 readonly mainWindowBounds: DesktopWindowBounds | null;30 readonly mainWindowBounds: DesktopWindowBounds | null;
30 readonly mainWindowMaximized: boolean;31 readonly mainWindowMaximized: boolean;
...@@ -73,6 +74,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = {...@@ -73,6 +74,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = {
73} as const;74} as const;
7475
75export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {76export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
77 localEnvironmentEnabled: true,
76 linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE,78 linuxPasswordStore: DEFAULT_LINUX_PASSWORD_STORE,
77 mainWindowBounds: null,79 mainWindowBounds: null,
78 mainWindowMaximized: false,80 mainWindowMaximized: false,
...@@ -94,6 +96,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({...@@ -94,6 +96,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({
94});96});
9597
96const DesktopSettingsDocument = Schema.Struct({98const DesktopSettingsDocument = Schema.Struct({
99 localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean),
97 linuxPasswordStore: Schema.optionalKey(Schema.Unknown),100 linuxPasswordStore: Schema.optionalKey(Schema.Unknown),
98 mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)),101 mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)),
99 mainWindowMaximized: Schema.optionalKey(Schema.Boolean),102 mainWindowMaximized: Schema.optionalKey(Schema.Boolean),
...@@ -152,6 +155,9 @@ export class DesktopAppSettings extends Context.Service<...@@ -152,6 +155,9 @@ export class DesktopAppSettings extends Context.Service<
152 {155 {
153 readonly load: Effect.Effect<DesktopSettings>;156 readonly load: Effect.Effect<DesktopSettings>;
154 readonly get: Effect.Effect<DesktopSettings>;157 readonly get: Effect.Effect<DesktopSettings>;
158 readonly setLocalEnvironmentEnabled: (
159 enabled: boolean,
160 ) => Effect.Effect<DesktopSettingsChange, DesktopSettingsWriteError>;
155 readonly setMainWindowBounds: (161 readonly setMainWindowBounds: (
156 bounds: DesktopWindowBounds,162 bounds: DesktopWindowBounds,
157 isMaximized: boolean,163 isMaximized: boolean,
...@@ -224,6 +230,7 @@ function normalizeDesktopSettingsDocument(...@@ -224,6 +230,7 @@ function normalizeDesktopSettingsDocument(
224 (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl");230 (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl");
225231
226 return {232 return {
233 localEnvironmentEnabled: parsed.localEnvironmentEnabled !== false,
227 linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore),234 linuxPasswordStore: normalizeLinuxPasswordStorePreference(parsed.linuxPasswordStore),
228 mainWindowBounds,235 mainWindowBounds,
229 mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true,236 mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true,
...@@ -247,6 +254,10 @@ function toDesktopSettingsDocument(...@@ -247,6 +254,10 @@ function toDesktopSettingsDocument(
247): DesktopSettingsDocument {254): DesktopSettingsDocument {
248 const document: Mutable<DesktopSettingsDocument> = {};255 const document: Mutable<DesktopSettingsDocument> = {};
249256
257 if (settings.localEnvironmentEnabled !== defaults.localEnvironmentEnabled) {
258 document.localEnvironmentEnabled = settings.localEnvironmentEnabled;
259 }
260
250 if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) {261 if (settings.linuxPasswordStore !== defaults.linuxPasswordStore) {
251 document.linuxPasswordStore = settings.linuxPasswordStore;262 document.linuxPasswordStore = settings.linuxPasswordStore;
252 }263 }
...@@ -370,6 +381,12 @@ function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSetting...@@ -370,6 +381,12 @@ function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSetting
370 };381 };
371}382}
372383
384function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings {
385 return settings.localEnvironmentEnabled === enabled
386 ? settings
387 : { ...settings, localEnvironmentEnabled: enabled };
388}
389
373function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings {390function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings {
374 return setWslOnly(setWslBackendEnabled(settings, false), false);391 return setWslOnly(setWslBackendEnabled(settings, false), false);
375}392}
...@@ -545,6 +562,10 @@ export const make = Effect.gen(function* () {...@@ -545,6 +562,10 @@ export const make = Effect.gen(function* () {
545 persist((settings) => setWslOnly(settings, enabled)).pipe(562 persist((settings) => setWslOnly(settings, enabled)).pipe(
546 Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }),563 Effect.withSpan("desktop.settings.setWslOnly", { attributes: { enabled } }),
547 ),564 ),
565 setLocalEnvironmentEnabled: (enabled) =>
566 persist((settings) => setLocalEnvironmentEnabled(settings, enabled)).pipe(
567 Effect.withSpan("desktop.settings.setLocalEnvironmentEnabled", { attributes: { enabled } }),
568 ),
548 applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe(569 applyWslWindowsFallback: persist(applyWslWindowsFallback).pipe(
549 Effect.withSpan("desktop.settings.applyWslWindowsFallback"),570 Effect.withSpan("desktop.settings.applyWslWindowsFallback"),
550 ),571 ),
...@@ -586,6 +607,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET...@@ -586,6 +607,8 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET
586 update((settings) => setWslBackendEnabled(settings, enabled)),607 update((settings) => setWslBackendEnabled(settings, enabled)),
587 setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)),608 setWslDistro: (distro) => update((settings) => setWslDistro(settings, distro)),
588 setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)),609 setWslOnly: (enabled) => update((settings) => setWslOnly(settings, enabled)),
610 setLocalEnvironmentEnabled: (enabled) =>
611 update((settings) => setLocalEnvironmentEnabled(settings, enabled)),
589 applyWslWindowsFallback: update(applyWslWindowsFallback),612 applyWslWindowsFallback: update(applyWslWindowsFallback),
590 applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback),613 applyWslWindowsFallbackInMemory: update(applyWslWindowsFallback),
591 });614 });
apps/desktop/src/updates/updatesTestHarness.ts+1
...@@ -196,6 +196,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -196,6 +196,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) {
196 ),196 ),
197 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),197 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
198 setWslDistro: () => Effect.die("unexpected WSL distro change"),198 setWslDistro: () => Effect.die("unexpected WSL distro change"),
199 setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"),
199 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),200 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
200 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),201 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
201 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),202 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
apps/desktop/src/window/DesktopWindow.test.ts+32
...@@ -253,6 +253,7 @@ function makeTestLayer(input: {...@@ -253,6 +253,7 @@ function makeTestLayer(input: {
253 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),253 setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"),
254 setWslDistro: () => Effect.die("unexpected WSL distro change"),254 setWslDistro: () => Effect.die("unexpected WSL distro change"),
255 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),255 setWslOnly: () => Effect.die("unexpected WSL-only toggle"),
256 setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"),
256 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),257 applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"),
257 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),258 applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"),
258 } satisfies DesktopAppSettings.DesktopAppSettings["Service"]);259 } satisfies DesktopAppSettings.DesktopAppSettings["Service"]);
...@@ -629,6 +630,37 @@ describe("DesktopWindow", () => {...@@ -629,6 +630,37 @@ describe("DesktopWindow", () => {
629 }),630 }),
630 );631 );
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
632 it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () =>664 it.effect("blocks only repeated Cmd+W input before it reaches the native window menu", () =>
633 Effect.gen(function* () {665 Effect.gen(function* () {
634 const fakeWindow = makeFakeBrowserWindow();666 const fakeWindow = makeFakeBrowserWindow();
apps/desktop/src/window/DesktopWindow.ts+10-4
...@@ -84,7 +84,7 @@ export class DesktopWindow extends Context.Service<...@@ -84,7 +84,7 @@ export class DesktopWindow extends Context.Service<
84 readonly activate: Effect.Effect<void, DesktopWindowError>;84 readonly activate: Effect.Effect<void, DesktopWindowError>;
85 readonly createMainIfBackendReady: Effect.Effect<void, DesktopWindowError>;85 readonly createMainIfBackendReady: Effect.Effect<void, DesktopWindowError>;
86 // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only86 // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only
87 // mode), before the WSL backend that serves the renderer is ready. It is87 // mode), before the WSL backend that acts as the primary is ready. It is
88 // dismissed automatically once the real main window reveals.88 // dismissed automatically once the real main window reveals.
89 readonly showConnectingSplash: Effect.Effect<void>;89 readonly showConnectingSplash: Effect.Effect<void>;
90 // Marks the primary backend as ready so `createMainIfBackendReady` and the90 // Marks the primary backend as ready so `createMainIfBackendReady` and the
...@@ -838,9 +838,15 @@ export const make = Effect.gen(function* () {...@@ -838,9 +838,15 @@ export const make = Effect.gen(function* () {
838 return window;838 return window;
839 }).pipe(Effect.withSpan("desktop.window.revealOrCreateMain"));839 }).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
841 const createMainIfBackendReady = Effect.gen(function* () {848 const createMainIfBackendReady = Effect.gen(function* () {
842 const backendReady = yield* Ref.get(backendReadyRef);849 if (yield* waitingForBackend) return;
843 if (!backendReady) return;
844 const existingWindow = yield* currentMainWindow;850 const existingWindow = yield* currentMainWindow;
845 if (Option.isSome(existingWindow)) return;851 if (Option.isSome(existingWindow)) return;
846 yield* createMain;852 yield* createMain;
...@@ -898,7 +904,7 @@ export const make = Effect.gen(function* () {...@@ -898,7 +904,7 @@ export const make = Effect.gen(function* () {
898 { reveal = true }: { readonly reveal?: boolean } = {},904 { reveal = true }: { readonly reveal?: boolean } = {},
899 ) {905 ) {
900 const existingWindow = yield* reveal ? focusedMainWindow : electronWindow.main;906 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;
902 const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain;908 const targetWindow = Option.isSome(existingWindow) ? existingWindow.value : yield* ensureMain;
903 if (targetWindow.isDestroyed()) return;909 if (targetWindow.isDestroyed()) return;
904 const send = Effect.sync(() => {910 const send = Effect.sync(() => {
apps/desktop/src/wsl/DesktopWslBackend.test.ts+23
...@@ -83,6 +83,29 @@ const netLayer = Layer.succeed(NetService.NetService, {...@@ -83,6 +83,29 @@ const netLayer = Layer.succeed(NetService.NetService, {
83} satisfies NetService.NetService["Service"]);83} satisfies NetService.NetService["Service"]);
8484
85describe("DesktopWslBackend", () => {85describe("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 );
86 it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => {109 it.effect("clears the stored preflight error when a registered WSL backend becomes ready", () => {
87 let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined;110 let registeredSpec: DesktopBackendPool.BackendInstanceSpec | undefined;
88 const primary = makeStubInstance({111 const primary = makeStubInstance({
apps/desktop/src/wsl/DesktopWslBackend.ts+1
...@@ -188,6 +188,7 @@ export const layer = Layer.effect(...@@ -188,6 +188,7 @@ export const layer = Layer.effect(
188188
189 const reconcileBody = Effect.gen(function* () {189 const reconcileBody = Effect.gen(function* () {
190 const settings = yield* appSettings.get;190 const settings = yield* appSettings.get;
191 if (!settings.localEnvironmentEnabled) return;
191 const available = yield* wslEnvironment.isAvailable;192 const available = yield* wslEnvironment.isAvailable;
192 const existing = yield* findExistingWslInstance;193 const existing = yield* findExistingWslInstance;
193 const existingId = Option.map(existing, (instance) => instance.id);194 const existingId = Option.map(existing, (instance) => instance.id);
apps/web/src/components/CommandPalette.tsx+18-16
...@@ -953,8 +953,13 @@ function OpenCommandPaletteDialog(props: {...@@ -953,8 +953,13 @@ function OpenCommandPaletteDialog(props: {
953 )953 )
954 : "";954 : "";
955 const browsePath = useMemo(955 const browsePath = useMemo(
956 () => getFilesystemBrowsePath(query, browseEnvironmentPlatform, !isRemoteProjectRepositoryStep),956 () =>
957 [browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query],957 getFilesystemBrowsePath(
958 query,
959 browseEnvironmentPlatform,
960 browseEnvironmentId !== null && !isRemoteProjectRepositoryStep,
961 ),
962 [browseEnvironmentId, browseEnvironmentPlatform, isRemoteProjectRepositoryStep, query],
958 );963 );
959 const isBrowsing = browsePath.isBrowsing;964 const isBrowsing = browsePath.isBrowsing;
960 const browseDirectoryPath = browsePath.directoryPath;965 const browseDirectoryPath = browsePath.directoryPath;
...@@ -1553,6 +1558,14 @@ function OpenCommandPaletteDialog(props: {...@@ -1553,6 +1558,14 @@ function OpenCommandPaletteDialog(props: {
1553 );1558 );
15541559
1555 const openAddProjectFlow = useCallback(() => {1560 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
1556 if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) {1569 if (addProjectEnvironmentOptions.length > 1 || defaultAddProjectEnvironmentId === null) {
1557 pushPaletteView({1570 pushPaletteView({
1558 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,1571 addonIcon: <FolderPlusIcon className={ADDON_ICON_CLASS} />,
...@@ -1561,24 +1574,14 @@ function OpenCommandPaletteDialog(props: {...@@ -1561,24 +1574,14 @@ function OpenCommandPaletteDialog(props: {
1561 return;1574 return;
1562 }1575 }
15631576
1564 const environmentId = defaultAddProjectEnvironmentId;1577 void startAddProjectSourceSelection(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 }, [1578 }, [
1578 addProjectEnvironmentGroups,1579 addProjectEnvironmentGroups,
1579 addProjectEnvironmentOptions.length,1580 addProjectEnvironmentOptions.length,
1580 defaultAddProjectEnvironmentId,1581 defaultAddProjectEnvironmentId,
1582 navigate,
1581 pushPaletteView,1583 pushPaletteView,
1584 setOpen,
1582 startAddProjectSourceSelection,1585 startAddProjectSourceSelection,
1583 ]);1586 ]);
15841587
...@@ -1775,7 +1778,6 @@ function OpenCommandPaletteDialog(props: {...@@ -1775,7 +1778,6 @@ function OpenCommandPaletteDialog(props: {
1775 "environment",1778 "environment",
1776 ],1779 ],
1777 title: "Add project",1780 title: "Add project",
1778 disabled: defaultAddProjectEnvironmentId === null,
1779 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,1781 icon: <FolderPlusIcon className={ITEM_ICON_CLASS} />,
1780 keepOpen: true,1782 keepOpen: true,
1781 run: async () => {1783 run: async () => {
apps/web/src/components/onboarding/FirstRunGate.tsx+2
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 useClientSettings,9 useClientSettings,
10 useClientSettingsHydrationStatus,10 useClientSettingsHydrationStatus,
11} from "../../hooks/useSettings";11} from "../../hooks/useSettings";
12import { isLocalEnvironmentDisabled } from "../../localEnvironment";
12import { useCompleteOnboarding } from "../../onboarding/firstRun";13import { useCompleteOnboarding } from "../../onboarding/firstRun";
13import {14import {
14 isFirstRunWorkspaceProvenanceAuthoritative,15 isFirstRunWorkspaceProvenanceAuthoritative,
...@@ -126,6 +127,7 @@ export function FirstRunGate({...@@ -126,6 +127,7 @@ export function FirstRunGate({
126127
127 const { decision: nextDecision, persistCompletion } = hostedStatic128 const { decision: nextDecision, persistCompletion } = hostedStatic
128 ? resolveHostedFirstRunDecision({129 ? resolveHostedFirstRunDecision({
130 localEnvironmentDisabled: isLocalEnvironmentDisabled(),
129 hydrated,131 hydrated,
130 completed: onboardingCompletedAt !== null,132 completed: onboardingCompletedAt !== null,
131 catalogReady: environmentCatalogReady,133 catalogReady: environmentCatalogReady,
apps/web/src/components/settings/ConnectionsSettings.tsx+61-46
...@@ -51,6 +51,7 @@ import * as Option from "effect/Option";...@@ -51,6 +51,7 @@ import * as Option from "effect/Option";
5151
52import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";52import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
53import { cn } from "../../lib/utils";53import { cn } from "../../lib/utils";
54import { isLocalEnvironmentDisabled } from "../../localEnvironment";
54import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";55import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat";
55import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";56import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls";
56import {57import {
...@@ -65,6 +66,7 @@ import {...@@ -65,6 +66,7 @@ import {
65 SettingsSection,66 SettingsSection,
66 useRelativeTimeTick,67 useRelativeTimeTick,
67} from "./settingsLayout";68} from "./settingsLayout";
69import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting";
68import { searchableSetting } from "./settingsSearch";70import { searchableSetting } from "./settingsSearch";
69import { EnvironmentIconMenu } from "./EnvironmentIconPicker";71import { EnvironmentIconMenu } from "./EnvironmentIconPicker";
70import {72import {
...@@ -1985,7 +1987,9 @@ export function ConnectionsSettings() {...@@ -1985,7 +1987,9 @@ export function ConnectionsSettings() {
1985 const setDefaultAdvertisedEndpointKey = useUiStateStore(1987 const setDefaultAdvertisedEndpointKey = useUiStateStore(
1986 (state) => state.setDefaultAdvertisedEndpointKey,1988 (state) => state.setDefaultAdvertisedEndpointKey,
1987 );1989 );
1988 const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false;1990 const canManageLocalBackend =
1991 !isLocalEnvironmentDisabled() &&
1992 (currentSessionScopes?.includes(AuthAccessWriteScope) ?? false);
1989 const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false;1993 const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false;
1990 const authAccessChanges = useEnvironmentQuery(1994 const authAccessChanges = useEnvironmentQuery(
1991 canManageLocalBackend && primaryEnvironmentId !== null1995 canManageLocalBackend && primaryEnvironmentId !== null
...@@ -3234,15 +3238,21 @@ export function ConnectionsSettings() {...@@ -3234,15 +3238,21 @@ export function ConnectionsSettings() {
32343238
3235 const primarySettings = (3239 const primarySettings = (
3236 <>3240 <>
3237 {canManageLocalBackend ? (3241 {desktopBridge || canManageLocalBackend ? (
3238 <>3242 <>
3239 <SettingsSection3243 <SettingsSection
3240 {...searchableSetting("connections-environment")}3244 {...searchableSetting("connections-environment")}
3241 title={primaryEnvironment?.label ?? "Primary environment"}3245 title={
3246 primaryEnvironment?.label ?? (desktopBridge ? "This machine" : "Primary environment")
3247 }
3242 icon={3248 icon={
3243 <EnvironmentMachineIcon3249 <EnvironmentMachineIcon
3244 aria-hidden3250 aria-hidden
3245 kind={resolveEnvironmentMachineKind(primaryServerConfig)}3251 kind={
3252 primaryServerConfig
3253 ? resolveEnvironmentMachineKind(primaryServerConfig)
3254 : "desktop"
3255 }
3246 className="size-4"3256 className="size-4"
3247 />3257 />
3248 }3258 }
...@@ -3272,46 +3282,51 @@ export function ConnectionsSettings() {...@@ -3272,46 +3282,51 @@ export function ConnectionsSettings() {
3272 ) : null3282 ) : null
3273 }3283 }
3274 >3284 >
3275 <SettingsRow3285 <LocalEnvironmentSetting />
3276 title="Version"3286 {canManageLocalBackend ? (
3277 description={3287 <SettingsRow
3278 primaryServerUpdateState.status !== "idle" ? (3288 title="Version"
3279 <ServerUpdateProgress state={primaryServerUpdateState} />3289 description={
3280 ) : (3290 primaryServerUpdateState.status !== "idle" ? (
3281 [3291 <ServerUpdateProgress state={primaryServerUpdateState} />
3282 primaryServerConfig?.environment.serverVersion ?? null,3292 ) : (
3283 primaryEnvironment?.displayUrl ?? null,3293 [
3284 ]3294 primaryServerConfig?.environment.serverVersion ?? null,
3285 .filter((value): value is string => value !== null)3295 primaryEnvironment?.displayUrl ?? null,
3286 .join(" · ") || "Loading…"3296 ]
3287 )3297 .filter((value): value is string => value !== null)
3288 }3298 .join(" · ") || "Loading…"
3289 control={3299 )
3290 primaryVersionMismatch &&3300 }
3291 primaryEnvironmentId !== null &&3301 control={
3292 primaryServerUpdateState.status !== "running" ? (3302 primaryVersionMismatch &&
3293 <ServerUpdateAction3303 primaryEnvironmentId !== null &&
3294 size="sm"3304 primaryServerUpdateState.status !== "running" ? (
3295 environmentId={primaryEnvironmentId}3305 <ServerUpdateAction
3296 serverLabel={3306 size="sm"
3297 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"3307 environmentId={primaryEnvironmentId}
3298 }3308 serverLabel={
3299 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}3309 primaryEnvironment ? `${primaryEnvironment.label} server` : "server"
3300 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}3310 }
3301 threadContinuation={supportsServerUpdateThreadContinuation(primaryServerConfig)}3311 selfUpdate={resolveServerSelfUpdateCapability(primaryServerConfig)}
3302 targetVersion={primaryVersionMismatch.clientVersion}3312 desktopAppUpdate={supportsDesktopAppUpdate(primaryServerConfig)}
3303 label={3313 threadContinuation={supportsServerUpdateThreadContinuation(
3304 primaryServerUpdateState.status === "failed"3314 primaryServerConfig,
3305 ? "Retry update"3315 )}
3306 : `Update to ${primaryVersionMismatch.clientVersion}`3316 targetVersion={primaryVersionMismatch.clientVersion}
3307 }3317 label={
3308 />3318 primaryServerUpdateState.status === "failed"
3309 ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? (3319 ? "Retry update"
3310 <span className="text-xs text-muted-foreground">Up to date</span>3320 : `Update to ${primaryVersionMismatch.clientVersion}`
3311 ) : undefined3321 }
3312 }3322 />
3313 />3323 ) : primaryServerUpdateState.status === "idle" && primaryServerConfig ? (
3314 {desktopBridge ? (3324 <span className="text-xs text-muted-foreground">Up to date</span>
3325 ) : undefined
3326 }
3327 />
3328 ) : null}
3329 {canManageLocalBackend && desktopBridge ? (
3315 <>3330 <>
3316 {renderNetworkAccessRow()}3331 {renderNetworkAccessRow()}
3317 {renderEndpointRows("endpoint-rail")}3332 {renderEndpointRows("endpoint-rail")}
...@@ -3319,12 +3334,12 @@ export function ConnectionsSettings() {...@@ -3319,12 +3334,12 @@ export function ConnectionsSettings() {
3319 {renderWslRow()}3334 {renderWslRow()}
3320 <CloudLinkRow canManageRelay={canManageRelay} />3335 <CloudLinkRow canManageRelay={canManageRelay} />
3321 </>3336 </>
3322 ) : (3337 ) : canManageLocalBackend ? (
3323 <>3338 <>
3324 {renderDisabledNetworkAccessRow()}3339 {renderDisabledNetworkAccessRow()}
3325 <CloudLinkRow canManageRelay={canManageRelay} />3340 <CloudLinkRow canManageRelay={canManageRelay} />
3326 </>3341 </>
3327 )}3342 ) : null}
3328 </SettingsSection>3343 </SettingsSection>
33293344
3330 {isLocalBackendRemotelyReachable ? (3345 {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", () => {...@@ -173,6 +173,28 @@ describe("searchSettings", () => {
173 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);173 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);
174 });174 });
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
176 it("shows automatic settlement settings when the server supports them", () => {198 it("shows automatic settlement settings when the server supports them", () => {
177 const available = filterAvailableSettingsSearchItems({199 const available = filterAvailableSettingsSearchItems({
178 hasCloudPublicConfig: false,200 hasCloudPublicConfig: false,
apps/web/src/components/settings/settingsSearch.ts+13
...@@ -52,11 +52,13 @@ export interface SettingsSearchItem {...@@ -52,11 +52,13 @@ export interface SettingsSearchItem {
52 readonly environmentOnly?: boolean;52 readonly environmentOnly?: boolean;
53 readonly providerSettingsOnly?: boolean;53 readonly providerSettingsOnly?: boolean;
54 readonly localBackendManagementOnly?: boolean;54 readonly localBackendManagementOnly?: boolean;
55 readonly localEnvironmentOnly?: boolean;
55 readonly wslAvailableOnly?: boolean;56 readonly wslAvailableOnly?: boolean;
56 readonly requiresThreadAutoSettlement?: boolean;57 readonly requiresThreadAutoSettlement?: boolean;
57}58}
5859
59export interface SettingsSearchAvailability {60export interface SettingsSearchAvailability {
61 readonly localEnvironmentDisabled?: boolean;
60 readonly hasCloudPublicConfig: boolean;62 readonly hasCloudPublicConfig: boolean;
61 readonly hasEnvironment: boolean;63 readonly hasEnvironment: boolean;
62 readonly hasProviderSettingsEnvironment: boolean;64 readonly hasProviderSettingsEnvironment: boolean;
...@@ -626,6 +628,14 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -626,6 +628,14 @@ export const SETTINGS_SEARCH_ITEMS = [
626 searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"],628 searchTerms: ["machine glyph sidebar mac mini studio laptop desktop server cloud vm"],
627 localBackendManagementOnly: true,629 localBackendManagementOnly: true,
628 },630 },
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 },
629 {639 {
630 id: "network-access",640 id: "network-access",
631 title: "Network access",641 title: "Network access",
...@@ -657,6 +667,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -657,6 +667,7 @@ export const SETTINGS_SEARCH_ITEMS = [
657 },667 },
658 {668 {
659 id: "t3-connect",669 id: "t3-connect",
670 localEnvironmentOnly: true,
660 title: "T3 Connect",671 title: "T3 Connect",
661 to: "/settings/connections",672 to: "/settings/connections",
662 targetId: "connections-environment",673 targetId: "connections-environment",
...@@ -666,6 +677,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -666,6 +677,7 @@ export const SETTINGS_SEARCH_ITEMS = [
666 },677 },
667 {678 {
668 id: "publish-agent-activity",679 id: "publish-agent-activity",
680 localEnvironmentOnly: true,
669 title: "Publish agent activity",681 title: "Publish agent activity",
670 to: "/settings/connections",682 to: "/settings/connections",
671 targetId: "connections-environment",683 targetId: "connections-environment",
...@@ -840,6 +852,7 @@ export function filterAvailableSettingsSearchItems(...@@ -840,6 +852,7 @@ export function filterAvailableSettingsSearchItems(
840 (!item.environmentOnly || availability.hasEnvironment) &&852 (!item.environmentOnly || availability.hasEnvironment) &&
841 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&853 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
842 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&854 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
855 (!item.localEnvironmentOnly || !availability.localEnvironmentDisabled) &&
843 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&856 (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
844 (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement),857 (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement),
845 );858 );
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+18-6
...@@ -3,6 +3,7 @@ import { AuthAccessWriteScope } from "@t3tools/contracts";...@@ -3,6 +3,7 @@ import { AuthAccessWriteScope } from "@t3tools/contracts";
33
4import { hasCloudPublicConfig } from "~/cloud/publicConfig";4import { hasCloudPublicConfig } from "~/cloud/publicConfig";
5import { isElectron } from "~/env";5import { isElectron } from "~/env";
6import { isLocalEnvironmentDisabled } from "~/localEnvironment";
6import { desktopWslStateAtom } from "~/state/desktopWslState";7import { desktopWslStateAtom } from "~/state/desktopWslState";
7import { useEnvironments } from "~/state/environments";8import { useEnvironments } from "~/state/environments";
8import { useEnvironmentQuery } from "~/state/query";9import { useEnvironmentQuery } from "~/state/query";
...@@ -17,16 +18,21 @@ import {...@@ -17,16 +18,21 @@ import {
17export function useAvailableSettingsSearchItems() {18export function useAvailableSettingsSearchItems() {
18 const { environments } = useEnvironments();19 const { environments } = useEnvironments();
19 const primarySessionState = usePrimarySessionState();20 const primarySessionState = usePrimarySessionState();
20 const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);21 const localEnvironmentDisabled = isLocalEnvironmentDisabled();
22 const desktopWsl = useEnvironmentQuery(
23 isElectron && !localEnvironmentDisabled ? desktopWslStateAtom : null,
24 );
21 const canManageLocalBackend =25 const canManageLocalBackend =
22 isElectron ||26 !localEnvironmentDisabled &&
23 ((primarySessionState.data?.authenticated &&27 (isElectron ||
24 primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ??28 ((primarySessionState.data?.authenticated &&
25 false);29 primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ??
30 false));
2631
27 return useMemo(32 return useMemo(
28 () =>33 () =>
29 filterAvailableSettingsSearchItems({34 filterAvailableSettingsSearchItems({
35 localEnvironmentDisabled,
30 hasCloudPublicConfig: hasCloudPublicConfig(),36 hasCloudPublicConfig: hasCloudPublicConfig(),
31 hasEnvironment: environments.some((environment) => environment.serverConfig !== null),37 hasEnvironment: environments.some((environment) => environment.serverConfig !== null),
32 hasProviderSettingsEnvironment: environments.some((environment) =>38 hasProviderSettingsEnvironment: environments.some((environment) =>
...@@ -43,6 +49,12 @@ export function useAvailableSettingsSearchItems() {...@@ -43,6 +49,12 @@ export function useAvailableSettingsSearchItems() {
43 hasThreadAutoSettlement:49 hasThreadAutoSettlement:
44 getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0,50 getThreadAutoSettlementSearchAvailability(environments).eligibleEnvironmentIds.length > 0,
45 }),51 }),
46 [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments],52 [
53 canManageLocalBackend,
54 desktopWsl.data,
55 desktopWsl.error,
56 environments,
57 localEnvironmentDisabled,
58 ],
47 );59 );
48}60}
apps/web/src/connection/platform.ts+2-1
...@@ -51,6 +51,7 @@ import {...@@ -51,6 +51,7 @@ import {
51} from "../environments/primary/target";51} from "../environments/primary/target";
52import { clearComposerDraftsEnvironment } from "../composerDraftStore";52import { clearComposerDraftsEnvironment } from "../composerDraftStore";
53import { isHostedStaticApp } from "../hostedPairing";53import { isHostedStaticApp } from "../hostedPairing";
54import { isLocalEnvironmentDisabled } from "../localEnvironment";
54import { appAtomRegistry } from "../rpc/atomRegistry";55import { appAtomRegistry } from "../rpc/atomRegistry";
55import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState";56import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState";
56import {57import {
...@@ -464,7 +465,7 @@ export function secondaryRegistrationsToRetainAfterTopologyRead(...@@ -464,7 +465,7 @@ export function secondaryRegistrationsToRetainAfterTopologyRead(
464const platformConnectionSourceLayer = Layer.effect(465const platformConnectionSourceLayer = Layer.effect(
465 PlatformConnectionSource,466 PlatformConnectionSource,
466 Effect.gen(function* () {467 Effect.gen(function* () {
467 if (isHostedStaticApp()) {468 if (isHostedStaticApp() || isLocalEnvironmentDisabled()) {
468 return PlatformConnectionSource.of({469 return PlatformConnectionSource.of({
469 registrations: Stream.empty,470 registrations: Stream.empty,
470 });471 });
apps/web/src/environments/primary/bootstrap.test.ts+18-2
...@@ -158,7 +158,7 @@ describe("environmentBootstrap", () => {...@@ -158,7 +158,7 @@ describe("environmentBootstrap", () => {
158 it("keeps an uppercase wss scheme secure when deriving the http url", () => {158 it("keeps an uppercase wss scheme secure when deriving the http url", () => {
159 vi.stubEnv("VITE_WS_URL", "WSS://remote.example.com");159 vi.stubEnv("VITE_WS_URL", "WSS://remote.example.com");
160160
161 expect(readPrimaryEnvironmentTarget().target).toEqual({161 expect(readPrimaryEnvironmentTarget()?.target).toEqual({
162 httpBaseUrl: "https://remote.example.com/",162 httpBaseUrl: "https://remote.example.com/",
163 wsBaseUrl: "wss://remote.example.com/",163 wsBaseUrl: "wss://remote.example.com/",
164 });164 });
...@@ -167,7 +167,7 @@ describe("environmentBootstrap", () => {...@@ -167,7 +167,7 @@ describe("environmentBootstrap", () => {
167 it("keeps an uppercase https scheme secure when deriving the websocket url", () => {167 it("keeps an uppercase https scheme secure when deriving the websocket url", () => {
168 vi.stubEnv("VITE_HTTP_URL", "HTTPS://remote.example.com");168 vi.stubEnv("VITE_HTTP_URL", "HTTPS://remote.example.com");
169169
170 expect(readPrimaryEnvironmentTarget().target).toEqual({170 expect(readPrimaryEnvironmentTarget()?.target).toEqual({
171 httpBaseUrl: "https://remote.example.com/",171 httpBaseUrl: "https://remote.example.com/",
172 wsBaseUrl: "wss://remote.example.com/",172 wsBaseUrl: "wss://remote.example.com/",
173 });173 });
...@@ -257,6 +257,22 @@ describe("environmentBootstrap", () => {...@@ -257,6 +257,22 @@ describe("environmentBootstrap", () => {
257 });257 });
258 });258 });
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
260 it("preserves an unsupported window-origin protocol", () => {276 it("preserves an unsupported window-origin protocol", () => {
261 vi.stubGlobal("window", {277 vi.stubGlobal("window", {
262 location: { origin: "file:///tmp/t3code/" },278 location: { origin: "file:///tmp/t3code/" },
apps/web/src/environments/primary/sessionState.ts+6-1
...@@ -5,10 +5,15 @@ import * as Option from "effect/Option";...@@ -5,10 +5,15 @@ import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";5import { AsyncResult, Atom } from "effect/unstable/reactivity";
6import { useCallback } from "react";6import { useCallback } from "react";
77
8import { isLocalEnvironmentDisabled } from "../../localEnvironment";
8import { appAtomRegistry } from "../../rpc/atomRegistry";9import { appAtomRegistry } from "../../rpc/atomRegistry";
9import { fetchSessionState } from "./auth";10import { 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(
12 Atom.swr({ staleTime: 5_000, revalidateOnMount: true }),17 Atom.swr({ staleTime: 5_000, revalidateOnMount: true }),
13 Atom.setIdleTTL(5 * 60_000),18 Atom.setIdleTTL(5 * 60_000),
14 Atom.withLabel("primary-environment:session"),19 Atom.withLabel("primary-environment:session"),
apps/web/src/environments/primary/target.ts+20-1
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts";1import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
33
4import { isLocalEnvironmentDisabled } from "../../localEnvironment";
5
4const PrimaryEnvironmentTargetSource = Schema.Literals([6const PrimaryEnvironmentTargetSource = Schema.Literals([
5 "configured",7 "configured",
6 "window-origin",8 "window-origin",
...@@ -57,6 +59,15 @@ export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedErr...@@ -57,6 +59,15 @@ export class DesktopEnvironmentBootstrapIncompleteError extends Schema.TaggedErr
57 }59 }
58}60}
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
60export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError);71export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError);
61export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is(72export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is(
62 PrimaryEnvironmentProtocolUnsupportedError,73 PrimaryEnvironmentProtocolUnsupportedError,
...@@ -276,6 +287,9 @@ export function resolvePrimaryEnvironmentHttpUrl(...@@ -276,6 +287,9 @@ export function resolvePrimaryEnvironmentHttpUrl(
276 searchParams?: Record<string, string>,287 searchParams?: Record<string, string>,
277): string {288): string {
278 const primaryTarget = readPrimaryEnvironmentTarget();289 const primaryTarget = readPrimaryEnvironmentTarget();
290 if (!primaryTarget) {
291 throw new PrimaryEnvironmentDisabledError();
292 }
279293
280 const url = parseTargetUrl({294 const url = parseTargetUrl({
281 rawValue: resolveHttpRequestBaseUrl(primaryTarget),295 rawValue: resolveHttpRequestBaseUrl(primaryTarget),
...@@ -289,7 +303,12 @@ export function resolvePrimaryEnvironmentHttpUrl(...@@ -289,7 +303,12 @@ export function resolvePrimaryEnvironmentHttpUrl(
289 return url.toString();303 return url.toString();
290}304}
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 }
293 return (312 return (
294 resolveDesktopPrimaryTarget() ??313 resolveDesktopPrimaryTarget() ??
295 resolveConfiguredPrimaryTarget() ??314 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", () => {...@@ -316,6 +316,18 @@ describe("resolveHostedFirstRunDecision", () => {
316 });316 });
317 });317 });
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
319 it("backfills onboarding for a hosted install with saved environments", () => {331 it("backfills onboarding for a hosted install with saved environments", () => {
320 expect(332 expect(
321 resolveHostedFirstRunDecision({333 resolveHostedFirstRunDecision({
apps/web/src/onboarding/firstRun.logic.ts+4-1
...@@ -49,6 +49,7 @@ interface FirstRunDecisionInput {...@@ -49,6 +49,7 @@ interface FirstRunDecisionInput {
49}49}
5050
51interface HostedFirstRunDecisionInput {51interface HostedFirstRunDecisionInput {
52 readonly localEnvironmentDisabled?: boolean;
52 readonly hydrated: boolean;53 readonly hydrated: boolean;
53 readonly completed: boolean;54 readonly completed: boolean;
54 readonly catalogReady: boolean;55 readonly catalogReady: boolean;
...@@ -178,7 +179,9 @@ export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput...@@ -178,7 +179,9 @@ export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput
178 return { decision: "pending", persistCompletion: false };179 return { decision: "pending", persistCompletion: false };
179 }180 }
180181
181 return input.environmentCount === 0182 // 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
182 ? { decision: "wizard", persistCompletion: false }185 ? { decision: "wizard", persistCompletion: false }
183 : { decision: "app", persistCompletion: true };186 : { decision: "app", persistCompletion: true };
184}187}
apps/web/src/routes/__root.tsx+2-1
...@@ -54,6 +54,7 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme";...@@ -54,6 +54,7 @@ import { syncBrowserChromeTheme } from "../hooks/useTheme";
54import { configureClientTracing } from "../observability/clientTracing";54import { configureClientTracing } from "../observability/clientTracing";
55import { resolveInitialServerAuthGateState } from "../environments/primary";55import { resolveInitialServerAuthGateState } from "../environments/primary";
56import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";56import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing";
57import { isLocalEnvironmentDisabled } from "../localEnvironment";
57import { shellEnvironment } from "../state/shell";58import { shellEnvironment } from "../state/shell";
58import { useAtomValue } from "@effect/atom-react";59import { useAtomValue } from "@effect/atom-react";
59import { useAtomCommand } from "../state/use-atom-command";60import { useAtomCommand } from "../state/use-atom-command";
...@@ -83,7 +84,7 @@ export const Route = createRootRoute({...@@ -83,7 +84,7 @@ export const Route = createRootRoute({
83 };84 };
84 }85 }
8586
86 if (isHostedStaticApp(new URL(window.location.href))) {87 if (isLocalEnvironmentDisabled() || isHostedStaticApp(new URL(window.location.href))) {
87 return {88 return {
88 authGateState: {89 authGateState: {
89 status: "hosted-static",90 status: "hosted-static",
apps/web/src/routes/_chat.index.tsx+12-6
...@@ -4,6 +4,8 @@ import { createFileRoute, Link } from "@tanstack/react-router";...@@ -4,6 +4,8 @@ import { createFileRoute, Link } from "@tanstack/react-router";
4import { LinkIcon, PlusIcon } from "lucide-react";4import { LinkIcon, PlusIcon } from "lucide-react";
5import { useEffect, useMemo, useRef, useState } from "react";5import { useEffect, useMemo, useRef, useState } from "react";
66
7import { isLocalEnvironmentDisabled } from "../localEnvironment";
8import { isElectron } from "../env";
7import { NoProjectsHero } from "../components/NoProjectsHero";9import { NoProjectsHero } from "../components/NoProjectsHero";
8import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";10import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic";
9import { Button } from "../components/ui/button";11import { Button } from "../components/ui/button";
...@@ -113,11 +115,17 @@ export const Route = createFileRoute("/_chat/")({...@@ -113,11 +115,17 @@ export const Route = createFileRoute("/_chat/")({
113115
114function HostedStaticOnboardingState() {116function HostedStaticOnboardingState() {
115 const cloudEnabled = hasCloudPublicConfig();117 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
117 return (125 return (
118 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">126 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
119 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">127 <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">
121 <div className="flex items-center gap-2">129 <div className="flex items-center gap-2">
122 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">130 <span className="text-sm font-medium text-foreground md:text-muted-foreground/60">
123 {APP_DISPLAY_NAME}131 {APP_DISPLAY_NAME}
...@@ -135,13 +143,11 @@ function HostedStaticOnboardingState() {...@@ -135,13 +143,11 @@ function HostedStaticOnboardingState() {
135 Connect to a computer running T3 Code143 Connect to a computer running T3 Code
136 </EmptyTitle>144 </EmptyTitle>
137 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">145 <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 T3146 This app connects to T3 Code running on your computer or a server. Start the T3 Code
139 Code desktop app or command-line server on that machine and keep it running.147 desktop app or command-line server on that machine and keep it running.
140 </EmptyDescription>148 </EmptyDescription>
141 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">149 <EmptyDescription className="mt-2 text-sm leading-relaxed text-muted-foreground/78">
142 {cloudEnabled150 {description}
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."}
145 </EmptyDescription>151 </EmptyDescription>
146 <div className="mt-6 flex justify-center">152 <div className="mt-6 flex justify-center">
147 <Button render={<Link to="/settings/connections" />} size="sm">153 <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...@@ -57,3 +57,15 @@ capabilities and handle their absence, rather than assume their own version
57describes the server. Process replacement belongs to the launcher's57describes the server. Process replacement belongs to the launcher's
58[update protocol](./server-updates.md); the connection runtime handles the58[update protocol](./server-updates.md); the connection runtime handles the
59resulting disconnect.59resulting 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....@@ -187,3 +187,14 @@ Include the diagnostic message and trace ID when reporting a persistent failure.
187187
188For a connection that still fails after linking, check the date and time on both188For a connection that still fails after linking, check the date and time on both
189devices. For server version warnings, follow [Updating T3 Code](./updating.md).189devices. 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 {...@@ -1228,6 +1228,8 @@ export interface DesktopBridge {
1228 // info (omits instances whose backend hasn't produced a config yet).1228 // info (omits instances whose backend hasn't produced a config yet).
1229 // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID.1229 // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID.
1230 getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[];1230 getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[];
1231 getLocalEnvironmentEnabled?: () => boolean;
1232 setLocalEnvironmentEnabled?: (enabled: boolean) => Promise<void>;
1231 getLocalEnvironmentBearerToken: () => Promise<string>;1233 getLocalEnvironmentBearerToken: () => Promise<string>;
1232 getClientSettings: () => Promise<ClientSettings | null>;1234 getClientSettings: () => Promise<ClientSettings | null>;
1233 setClientSettings: (settings: ClientSettings) => Promise<void>;1235 setClientSettings: (settings: ClientSettings) => Promise<void>;