| author | |
| committer | |
| log | cba7dd77817df2a458d86977f1daf4606c67dfc8 |
| tree | 31720c596d7da56d499043cab5f69f4dc949e86f |
| parent | e3792a53f7d7f9ebba37abfca99073f3f1ead2a7 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 159 | 159 | ||
| 160 | const bootstrap = Effect.gen(function* () { | 160 | const 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"); |
| 172 | 168 | ||
| 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 | ); |
| 188 | 213 | ||
| 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"); | ||
| 226 | 236 | ||
| 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 be | 238 | // 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 the | 239 | // 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 | ); |
| 125 | 129 |
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.isPackaged | 219 | 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 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Cause from "effect/Cause"; | 2 | import * as Cause from "effect/Cause"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Layer from "effect/Layer"; | ||
| 4 | import { beforeEach, vi } from "vite-plus/test"; | 7 | import { beforeEach, vi } from "vite-plus/test"; |
| 5 | 8 | ||
| 6 | const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ | 9 | const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ |
| ... | @@ -16,6 +19,8 @@ vi.mock("electron", () => ({ | ... | @@ -16,6 +19,8 @@ vi.mock("electron", () => ({ |
| 16 | 19 | ||
| 17 | import * as ElectronProtocol from "./ElectronProtocol.ts"; | 20 | import * as ElectronProtocol from "./ElectronProtocol.ts"; |
| 18 | 21 | ||
| 22 | const protocolLayer = ElectronProtocol.layer.pipe(Layer.provide(NodeServices.layer)); | ||
| 23 | |||
| 19 | describe("ElectronProtocol", () => { | 24 | describe("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 | }); |
| 25 | 30 | ||
| 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 | ); |
| 90 | 134 | ||
| 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", () => { |
| 110 | 153 | ||
| 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 | ); |
| 115 | 158 | ||
| 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", () => { |
| 138 | 180 | ||
| 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 | ); |
| 143 | 185 | ||
| 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 | ); |
| 167 | 208 | ||
| 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 | ); |
| 197 | 237 | ||
| 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 @@ |
| 1 | import Mime from "@effect/platform-node/Mime"; | ||
| 1 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 3 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 4 | import * as NodeTimersPromises from "node:timers/promises"; | 6 | import * as NodeTimersPromises from "node:timers/promises"; |
| 7 | import * as Path from "effect/Path"; | ||
| 5 | import * as Ref from "effect/Ref"; | 8 | import * as Ref from "effect/Ref"; |
| 6 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 7 | import * as Scope from "effect/Scope"; | 10 | import * 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 | } |
| 50 | 53 | ||
| 51 | export interface DesktopProtocolRegistrationInput { | 54 | // The scheme either proxies to a dev server (`targetOrigin`) or serves the |
| 55 | // built client from disk (`assetDirectory`). | ||
| 56 | export 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 }); |
| 57 | 60 | ||
| 58 | export class ElectronProtocol extends Context.Service< | 61 | export class ElectronProtocol extends Context.Service< |
| 59 | ElectronProtocol, | 62 | ElectronProtocol, |
| ... | @@ -189,6 +192,45 @@ async function proxyRequest( | ... | @@ -189,6 +192,45 @@ async function proxyRequest( |
| 189 | 192 | ||
| 190 | const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const; | 193 | const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const; |
| 191 | 194 | ||
| 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. | ||
| 198 | const 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 | |||
| 192 | async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<Response> { | 234 | async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<Response> { |
| 193 | let lastError: unknown; | 235 | let lastError: unknown; |
| 194 | 236 | ||
| ... | @@ -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. */ |
| 211 | export const make = Effect.gen(function* () { | 253 | export 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); | ||
| 213 | 257 | ||
| 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"; |
| 11 | import { | ||
| 12 | getLocalEnvironmentEnabled, | ||
| 13 | setLocalEnvironmentEnabled, | ||
| 14 | } from "./methods/localEnvironment.ts"; | ||
| 11 | import { | 15 | import { |
| 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); |
| 83 | 89 | ||
| 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"; |
| 25 | export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; | 25 | export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; |
| 26 | export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; | 26 | export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; |
| 27 | export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; | 27 | export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; |
| 28 | export const GET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:get-local-environment-enabled"; | ||
| 29 | export const SET_LOCAL_ENVIRONMENT_ENABLED_CHANNEL = "desktop:set-local-environment-enabled"; | ||
| 28 | export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = | 30 | export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = |
| 29 | "desktop:get-local-environment-bearer-token"; | 31 | "desktop:get-local-environment-bearer-token"; |
| 30 | export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; | 32 | export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; |
apps/desktop/src/ipc/methods/localEnvironment.test.ts created+63| ... | @@ -0,0 +1,63 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | |||
| 5 | import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; | ||
| 6 | import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; | ||
| 7 | import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; | ||
| 8 | import * as DesktopState from "../../app/DesktopState.ts"; | ||
| 9 | import * as ElectronApp from "../../electron/ElectronApp.ts"; | ||
| 10 | import * as ElectronTheme from "../../electron/ElectronTheme.ts"; | ||
| 11 | import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; | ||
| 12 | import * as DesktopWindow from "../../window/DesktopWindow.ts"; | ||
| 13 | import { getLocalEnvironmentEnabled, setLocalEnvironmentEnabled } from "./localEnvironment.ts"; | ||
| 14 | |||
| 15 | // `relaunch` declares the lifecycle runtime services as requirements even | ||
| 16 | // though the mocked relaunch never touches them. | ||
| 17 | const 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 | |||
| 31 | describe("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 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; | ||
| 5 | import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; | ||
| 6 | import * as IpcChannels from "../channels.ts"; | ||
| 7 | import { makeIpcMethod, makeSyncIpcMethod } from "../DesktopIpc.ts"; | ||
| 8 | |||
| 9 | export 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 | |||
| 18 | export 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"; |
| 19 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; | 19 | import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; |
| 20 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; | 20 | import * as ElectronDialog from "../../electron/ElectronDialog.ts"; |
| 21 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | 21 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; |
| 22 | import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; | ||
| 23 | import type { DesktopSettings } from "../../settings/DesktopAppSettings.ts"; | ||
| 22 | import { | 24 | import { |
| 23 | getLocalEnvironmentBootstraps, | 25 | getLocalEnvironmentBootstraps, |
| 24 | getWindowFullscreenState, | 26 | getWindowFullscreenState, |
| ... | @@ -208,19 +210,21 @@ describe("pasteAsText", () => { | ... | @@ -208,19 +210,21 @@ describe("pasteAsText", () => { |
| 208 | }); | 210 | }); |
| 209 | 211 | ||
| 210 | describe("pickProjectFavicon", () => { | 212 | describe("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 | ); | ||
| 224 | 228 | ||
| 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. Keeps | 191 | // - targetEnvironmentId omitted: default to the primary picker. Keeps |
| 187 | // the historical behavior unchanged for users who never enabled the | 192 | // 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 the | 208 | // Fall back to the persisted wslDistro when the id is the |
| 205 | // "wsl:default" sentinel; the orchestrator uses the same fallback | 209 | // "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" |
| 18 | import * as DesktopWindow from "../../window/DesktopWindow.ts"; | 18 | import * as DesktopWindow from "../../window/DesktopWindow.ts"; |
| 19 | import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; | 19 | import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; |
| 20 | import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; | 20 | import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; |
| 21 | import { setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; | 21 | import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./wsl.ts"; |
| 22 | 22 | ||
| 23 | const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); | 23 | const decodeWslState = Schema.decodeUnknownEffect(DesktopWslStateSchema); |
| 24 | 24 | ||
| ... | @@ -85,6 +85,42 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( | ... | @@ -85,6 +85,42 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( |
| 85 | ); | 85 | ); |
| 86 | 86 | ||
| 87 | describe("WSL IPC", () => { | 87 | describe("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 a | 25 | // 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 | } |
| 92 | 92 | ||
| 93 | describe("DesktopSettings", () => { | 93 | describe("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", () => { |
| 135 | 154 | ||
| 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", () => { |
| 242 | 262 | ||
| 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", () => { |
| 298 | 319 | ||
| 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", () => { |
| 346 | 368 | ||
| 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", () => { |
| 374 | 397 | ||
| 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", () => { |
| 401 | 425 | ||
| 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 |
| 25 | import { isValidDistroName } from "../wsl/wslPathParsing.ts"; | 25 | import { isValidDistroName } from "../wsl/wslPathParsing.ts"; |
| 26 | 26 | ||
| 27 | export interface DesktopSettings { | 27 | export 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; |
| 74 | 75 | ||
| 75 | export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { | 76 | export 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 | }); |
| 95 | 97 | ||
| 96 | const DesktopSettingsDocument = Schema.Struct({ | 98 | const 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"); |
| 225 | 231 | ||
| 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> = {}; |
| 249 | 256 | ||
| 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 | } |
| 372 | 383 | ||
| 384 | function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { | ||
| 385 | return settings.localEnvironmentEnabled === enabled | ||
| 386 | ? settings | ||
| 387 | : { ...settings, localEnvironmentEnabled: enabled }; | ||
| 388 | } | ||
| 389 | |||
| 373 | function applyWslWindowsFallback(settings: DesktopSettings): DesktopSettings { | 390 | function 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 | ); |
| 631 | 632 | ||
| 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-only | 86 | // Show a lightweight "Connecting to WSL" splash window immediately (wsl-only |
| 87 | // mode), before the WSL backend that serves the renderer is ready. It is | 87 | // mode), before the WSL backend that acts as the primary is ready. It is |
| 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 the | 90 | // 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")); |
| 840 | 840 | ||
| 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"]); |
| 84 | 84 | ||
| 85 | describe("DesktopWslBackend", () => { | 85 | describe("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( |
| 188 | 188 | ||
| 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 | ); |
| 1554 | 1559 | ||
| 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 | } |
| 1563 | 1576 | ||
| 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 | ]); |
| 1584 | 1587 | ||
| ... | @@ -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"; |
| 12 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; | ||
| 12 | import { useCompleteOnboarding } from "../../onboarding/firstRun"; | 13 | import { useCompleteOnboarding } from "../../onboarding/firstRun"; |
| 13 | import { | 14 | import { |
| 14 | isFirstRunWorkspaceProvenanceAuthoritative, | 15 | isFirstRunWorkspaceProvenanceAuthoritative, |
| ... | @@ -126,6 +127,7 @@ export function FirstRunGate({ | ... | @@ -126,6 +127,7 @@ export function FirstRunGate({ |
| 126 | 127 | ||
| 127 | const { decision: nextDecision, persistCompletion } = hostedStatic | 128 | 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"; |
| 51 | 51 | ||
| 52 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | 52 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 53 | import { cn } from "../../lib/utils"; | 53 | import { cn } from "../../lib/utils"; |
| 54 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; | ||
| 54 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; | 55 | import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; |
| 55 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; | 56 | import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; |
| 56 | import { | 57 | import { |
| ... | @@ -65,6 +66,7 @@ import { | ... | @@ -65,6 +66,7 @@ import { |
| 65 | SettingsSection, | 66 | SettingsSection, |
| 66 | useRelativeTimeTick, | 67 | useRelativeTimeTick, |
| 67 | } from "./settingsLayout"; | 68 | } from "./settingsLayout"; |
| 69 | import { LocalEnvironmentSetting } from "./LocalEnvironmentSetting"; | ||
| 68 | import { searchableSetting } from "./settingsSearch"; | 70 | import { searchableSetting } from "./settingsSearch"; |
| 69 | import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; | 71 | import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; |
| 70 | import { | 72 | import { |
| ... | @@ -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 !== null | 1995 | canManageLocalBackend && primaryEnvironmentId !== null |
| ... | @@ -3234,15 +3238,21 @@ export function ConnectionsSettings() { | ... | @@ -3234,15 +3238,21 @@ export function ConnectionsSettings() { |
| 3234 | 3238 | ||
| 3235 | const primarySettings = ( | 3239 | const primarySettings = ( |
| 3236 | <> | 3240 | <> |
| 3237 | {canManageLocalBackend ? ( | 3241 | {desktopBridge || canManageLocalBackend ? ( |
| 3238 | <> | 3242 | <> |
| 3239 | <SettingsSection | 3243 | <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 | <EnvironmentMachineIcon | 3249 | <EnvironmentMachineIcon |
| 3244 | aria-hidden | 3250 | 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 | ) : null | 3282 | ) : null |
| 3273 | } | 3283 | } |
| 3274 | > | 3284 | > |
| 3275 | <SettingsRow | 3285 | <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 | <ServerUpdateAction | 3303 | 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 | ) : undefined | 3321 | } |
| 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> |
| 3329 | 3344 | ||
| 3330 | {isLocalBackendRemotelyReachable ? ( | 3345 | {isLocalBackendRemotelyReachable ? ( |
apps/web/src/components/settings/LocalEnvironmentSetting.tsx created+103| ... | @@ -0,0 +1,103 @@ | ||
| 1 | import { useState } from "react"; | ||
| 2 | |||
| 3 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; | ||
| 4 | import { | ||
| 5 | AlertDialog, | ||
| 6 | AlertDialogClose, | ||
| 7 | AlertDialogDescription, | ||
| 8 | AlertDialogFooter, | ||
| 9 | AlertDialogHeader, | ||
| 10 | AlertDialogPopup, | ||
| 11 | AlertDialogTitle, | ||
| 12 | } from "../ui/alert-dialog"; | ||
| 13 | import { Button } from "../ui/button"; | ||
| 14 | import { Spinner } from "../ui/spinner"; | ||
| 15 | import { Switch } from "../ui/switch"; | ||
| 16 | import { SettingsRow } from "./settingsLayout"; | ||
| 17 | import { 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. | ||
| 21 | export 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 | }); |
| 175 | 175 | ||
| 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 | } |
| 58 | 59 | ||
| 59 | export interface SettingsSearchAvailability { | 60 | export 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"; |
| 3 | 3 | ||
| 4 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | 4 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| 5 | import { isElectron } from "~/env"; | 5 | import { isElectron } from "~/env"; |
| 6 | import { isLocalEnvironmentDisabled } from "~/localEnvironment"; | ||
| 6 | import { desktopWslStateAtom } from "~/state/desktopWslState"; | 7 | import { desktopWslStateAtom } from "~/state/desktopWslState"; |
| 7 | import { useEnvironments } from "~/state/environments"; | 8 | import { useEnvironments } from "~/state/environments"; |
| 8 | import { useEnvironmentQuery } from "~/state/query"; | 9 | import { useEnvironmentQuery } from "~/state/query"; |
| ... | @@ -17,16 +18,21 @@ import { | ... | @@ -17,16 +18,21 @@ import { |
| 17 | export function useAvailableSettingsSearchItems() { | 18 | export 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)); | ||
| 26 | 31 | ||
| 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"; |
| 52 | import { clearComposerDraftsEnvironment } from "../composerDraftStore"; | 52 | import { clearComposerDraftsEnvironment } from "../composerDraftStore"; |
| 53 | import { isHostedStaticApp } from "../hostedPairing"; | 53 | import { isHostedStaticApp } from "../hostedPairing"; |
| 54 | import { isLocalEnvironmentDisabled } from "../localEnvironment"; | ||
| 54 | import { appAtomRegistry } from "../rpc/atomRegistry"; | 55 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 55 | import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; | 56 | import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; |
| 56 | import { | 57 | import { |
| ... | @@ -464,7 +465,7 @@ export function secondaryRegistrationsToRetainAfterTopologyRead( | ... | @@ -464,7 +465,7 @@ export function secondaryRegistrationsToRetainAfterTopologyRead( |
| 464 | const platformConnectionSourceLayer = Layer.effect( | 465 | const 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"); |
| 160 | 160 | ||
| 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"); |
| 169 | 169 | ||
| 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 | }); |
| 259 | 259 | ||
| 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"; |
| 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 6 | import { useCallback } from "react"; | 6 | import { useCallback } from "react"; |
| 7 | 7 | ||
| 8 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; | ||
| 8 | import { appAtomRegistry } from "../../rpc/atomRegistry"; | 9 | import { appAtomRegistry } from "../../rpc/atomRegistry"; |
| 9 | import { fetchSessionState } from "./auth"; | 10 | import { fetchSessionState } from "./auth"; |
| 10 | 11 | ||
| 11 | const primarySessionStateAtom = Atom.make(Effect.promise(fetchSessionState)).pipe( | 12 | const 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 @@ |
| 1 | import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts"; | 1 | import { PRIMARY_LOCAL_ENVIRONMENT_ID, type DesktopEnvironmentBootstrap } from "@t3tools/contracts"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | import { isLocalEnvironmentDisabled } from "../../localEnvironment"; | ||
| 5 | |||
| 4 | const PrimaryEnvironmentTargetSource = Schema.Literals([ | 6 | const 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 | } |
| 59 | 61 | ||
| 62 | export 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 | |||
| 60 | export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError); | 71 | export const isPrimaryEnvironmentUrlInvalidError = Schema.is(PrimaryEnvironmentUrlInvalidError); |
| 61 | export const isPrimaryEnvironmentProtocolUnsupportedError = Schema.is( | 72 | export 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 | } | ||
| 279 | 293 | ||
| 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 | } |
| 291 | 305 | ||
| 292 | export 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). | ||
| 308 | export 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 | */ | ||
| 7 | export 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 | }); |
| 318 | 318 | ||
| 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 | } |
| 50 | 50 | ||
| 51 | interface HostedFirstRunDecisionInput { | 51 | interface 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 | } |
| 180 | 181 | ||
| 181 | return input.environmentCount === 0 | 182 | // An existing desktop may have disabled its server before onboarding existed. |
| 183 | // Keep Connections accessible so it can turn local execution back on. | ||
| 184 | return input.environmentCount === 0 && !input.localEnvironmentDisabled | ||
| 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"; |
| 54 | import { configureClientTracing } from "../observability/clientTracing"; | 54 | import { configureClientTracing } from "../observability/clientTracing"; |
| 55 | import { resolveInitialServerAuthGateState } from "../environments/primary"; | 55 | import { resolveInitialServerAuthGateState } from "../environments/primary"; |
| 56 | import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; | 56 | import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; |
| 57 | import { isLocalEnvironmentDisabled } from "../localEnvironment"; | ||
| 57 | import { shellEnvironment } from "../state/shell"; | 58 | import { shellEnvironment } from "../state/shell"; |
| 58 | import { useAtomValue } from "@effect/atom-react"; | 59 | import { useAtomValue } from "@effect/atom-react"; |
| 59 | import { useAtomCommand } from "../state/use-atom-command"; | 60 | import { 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 | } |
| 85 | 86 | ||
| 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"; |
| 4 | import { LinkIcon, PlusIcon } from "lucide-react"; | 4 | import { LinkIcon, PlusIcon } from "lucide-react"; |
| 5 | import { useEffect, useMemo, useRef, useState } from "react"; | 5 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 6 | 6 | ||
| 7 | import { isLocalEnvironmentDisabled } from "../localEnvironment"; | ||
| 8 | import { isElectron } from "../env"; | ||
| 7 | import { NoProjectsHero } from "../components/NoProjectsHero"; | 9 | import { NoProjectsHero } from "../components/NoProjectsHero"; |
| 8 | import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; | 10 | import { sortScopedProjectsForSidebar } from "../components/Sidebar.logic"; |
| 9 | import { Button } from "../components/ui/button"; | 11 | import { Button } from "../components/ui/button"; |
| ... | @@ -113,11 +115,17 @@ export const Route = createFileRoute("/_chat/")({ | ... | @@ -113,11 +115,17 @@ export const Route = createFileRoute("/_chat/")({ |
| 113 | 115 | ||
| 114 | function HostedStaticOnboardingState() { | 116 | function 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."; | ||
| 116 | 124 | ||
| 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 Code | 143 | 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 T3 | 146 | 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 | {cloudEnabled | 150 | {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 |
| 57 | describes the server. Process replacement belongs to the launcher's | 57 | describes the server. Process replacement belongs to the launcher's |
| 58 | [update protocol](./server-updates.md); the connection runtime handles the | 58 | [update protocol](./server-updates.md); the connection runtime handles the |
| 59 | resulting disconnect. | 59 | resulting disconnect. |
| 60 | |||
| 61 | ### Desktop without a local environment | ||
| 62 | |||
| 63 | Desktop 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; | ||
| 65 | no local state is deleted. On the next start the main process skips port selection, server exposure, | ||
| 66 | and the primary and WSL backends, and opens the window right away. The renderer sees this through | ||
| 67 | `desktopBridge.getLocalEnvironmentEnabled()`: `readPrimaryEnvironmentTarget` returns null, so primary | ||
| 68 | auth and platform-managed discovery are skipped and only saved environments (pairing, relay, SSH) | ||
| 69 | connect. This is possible because the desktop renderer is not served by the backend: the `t3code://` | ||
| 70 | scheme serves the bundled client from disk (Vite in development) and API traffic always goes to the | ||
| 71 | environment'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. |
| 187 | 187 | ||
| 188 | For a connection that still fails after linking, check the date and time on both | 188 | For a connection that still fails after linking, check the date and time on both |
| 189 | devices. For server version warnings, follow [Updating T3 Code](./updating.md). | 189 | devices. For server version warnings, follow [Updating T3 Code](./updating.md). |
| 190 | |||
| 191 | ## Using the Desktop App as a Remote Only | ||
| 192 | |||
| 193 | If a computer should only drive work running elsewhere, turn off its local environment. In the | ||
| 194 | desktop app, open **Settings → Connections** and switch off **Local | ||
| 195 | environment**. T3 Code restarts without a local server: no local agents or terminals run, WSL | ||
| 196 | backends stay off, and other devices can no longer connect to this computer. Your projects, | ||
| 197 | history, and saved connections are kept, and you keep working through pairing, T3 Connect, or SSH. | ||
| 198 | |||
| 199 | Switch **Local environment** back on in the same place to restart with your previous local | ||
| 200 | settings. |
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>; |