Skip to content

Add client-only desktop backend mode#4444

Open
colonelpanic8 wants to merge 4 commits into
pingdotgg:mainfrom
colonelpanic8:desktop-client-only-discovery
Open

Add client-only desktop backend mode#4444
colonelpanic8 wants to merge 4 commits into
pingdotgg:mainfrom
colonelpanic8:desktop-client-only-discovery

Conversation

@colonelpanic8

@colonelpanic8 colonelpanic8 commented Jul 24, 2026

Copy link
Copy Markdown

What Changed

Let the desktop app run as a pure client for remote or independently managed backends, instead of always owning a local backend process.

  • Add a persisted desktop backend mode with managed and client-only options, plus a launch-only --backend-mode override, exposed over IPC.
  • In client-only, bootstrap skips the backend pool entirely and serves the renderer from packaged static assets (or the dev-server proxy), opens the window on renderer readiness rather than backend readiness, and skips backend shutdown on quit.
  • ElectronProtocol now takes an explicit source: "proxy" | "static" (dropping the separate backendOrigin). Static serving adds path-safe file resolution with SPA fallback, MIME types, CSP, and HEAD support.
  • Web routing treats client-only like hosted static, and local environment bootstraps are empty, so a client-only desktop is never stranded on a dead primary-backend state — it is routed to Connections instead.

Why

The desktop app should be usable as a pure client without stranding users who have no saved connections.

Scope note

This PR previously also contained Linux local t3 serve discovery. That half has been split into a separate PR so the two can be reviewed independently — the discovery work carries a distinct security surface (a live pairing credential on disk) and is Linux-only, whereas this change is cross-platform.

UI Changes

  • Connections gains a Desktop applicationBackend mode control.
  • Client-only users with no connections are routed to Connections rather than a dead primary-backend state.

Packaged desktop evidence

Final privacy-reviewed evidence for this PR is pinned to immutable asset commit
d23321ea7d7200ef4d5e14f20dd2367997731706.

Capture context:

  • PR head: bb2c567e6879cf762910a02a8d20943edc2689ce
  • PR base: ece05087a70e94efcd57441337fa1249559362ba
  • Synthetic combined-stack integration: 5ba3b7bd0995f401993706689105f41f51c16b02
  • Exact GUI-tested package: /nix/store/86n9kxipsprgw6nbzyw7qhrw0wl4lfwx-t3code-0.0.29-patched-main-20260724

The packaged Electron app started with --backend-mode=client-only without
starting or owning a local backend, resolved its installed renderer assets,
and paired successfully to an independently started packaged t3 serve
backend. The saved environment then appeared online.

Client-only startup with no saved environment

Client-only startup with no saved environment

Connections settings and active CLI override

Connections settings showing client-only semantics and the active CLI override

Independent backend paired and online

Independently managed backend paired and online

Connected home state ·
capture manifest

After this GUI capture, the maintained stack advanced its upstream base to
41a430a88e8dde9c428f59d54dd328aa6a66a8fd. The intervening upstream delta
was isolated to #4472 model registration and did not overlap this desktop
change. The resulting final installed package
/nix/store/brp90sqhjxnnpczsnw9nmp1rlzi0qjgk-t3code-0.0.29-patched-main-20260724
built and activated successfully. The screenshots above remain specifically
attributable to the exact earlier package and synthetic integration listed
above; they are not represented as a GUI recapture of the later package.

Checklist

  • corepack pnpm exec vp check (0 errors; 11 pre-existing unrelated warnings)
  • corepack pnpm exec vp run --filter @t3tools/contracts --filter @t3tools/shared --filter t3 --filter @t3tools/desktop --filter @t3tools/web typecheck — clean
  • CI=true vitest run apps/desktop/src apps/web/src/components/settings apps/web/src/environments apps/server/src/startupAccess.test.ts — 63 files, 437 tests
  • Manually verified in an isolated Electron desktop: mode switch relaunches, client-only opens without a backend, packaged static renderer serves deep links, saved connections reconnect after restart

Note

Add client-only desktop backend mode that serves static renderer assets without starting a backend

  • Introduces a client-only backend mode alongside the existing managed mode, controlled via a new DesktopBackendMode service in DesktopBackendMode.ts. The mode can be set via CLI flag (--backend-mode) or persisted in desktop settings.
  • In client-only mode, the Electron app serves static renderer assets from a packaged root (or proxies to a dev server) via the custom protocol handler in ElectronProtocol.ts, and skips starting or stopping any backends.
  • registerDesktopProtocol now uses a discriminated union input (source: "proxy" or source: "static"); the static handler includes path traversal protection, SPA fallback, MIME types, CSP, and proper HEAD/405 responses.
  • Adds getBackendModeState and setBackendMode IPC endpoints (exposed via desktopBridge) so the renderer can read and change the backend mode; switching modes triggers a relaunch and reverts the setting if relaunch fails.
  • Web-side changes propagate client-only awareness: sidebar, command palette, and connection platform skip local-environment logic when isDesktopClientOnlyMode() is true, and a new isRemoteEnvironmentId utility centralizes remote-environment detection.
  • Risk: registerDesktopProtocol callers must now supply a source discriminator; backendOrigin is no longer accepted.

Macroscope summarized 4e25680.


Note

High Risk
Touches Electron custom-protocol static serving (path traversal defenses), startup/shutdown paths, and auth/routing when no local primary exists; mode changes require relaunch.

Overview
Introduces client-only desktop mode alongside the existing managed mode: the app can run without starting or owning a local backend, using paired/saved environments instead.

Desktop startup & protocol: Mode is persisted in settings, overridable via --backend-mode, and latched at startup. In client-only, bootstrap skips the backend pool, registers IPC, and opens the window on renderer readiness (not backend readiness); shutdown skips stopping backend instances. Production uses packaged client roots (packagedClientRootCandidates); dev still proxies the Vite server. ElectronProtocol now takes source: "proxy" | "static" (replacing separate backendOrigin); static mode adds path-safe resolution, SPA fallback, MIME types, CSP, and GET/HEAD handling.

IPC & lifecycle: getBackendModeState / setBackendMode on the bridge; changing mode persists and relaunches (settings roll back if relaunch fails). Preload fails closed to client-only if mode state is missing. Local environment bootstraps return empty in client-only.

Web UI: Client-only is routed like hosted static (no implicit primary from t3code://). EnvironmentPresenceScope / ownsLocalEnvironment fix sidebar and thread “remote” labeling when the app has no local backend. Connections adds a Backend mode control (blocks client-only without a saved non-local environment).

Reviewed by Cursor Bugbot for commit 4e25680. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cb28d286-bbcd-4f70-adba-9207d4ddbc5e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jul 24, 2026
Comment thread apps/server/src/serverRuntimeStartup.ts
Comment thread apps/web/src/environments/primary/target.ts
Comment thread apps/desktop/src/app/DesktopLocalServerDiscovery.ts Outdated
Comment thread apps/desktop/src/electron/ElectronProtocol.ts
@macroscopeapp

macroscopeapp Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces a significant new feature (client-only desktop mode) with substantial architectural changes to desktop bootstrap, IPC, and environment handling. Additionally, there are 3 unresolved medium-severity findings regarding error handling and resource cleanup that should be addressed.

You can customize Macroscope's approvability policy. Learn more.

@colonelpanic8
colonelpanic8 force-pushed the desktop-client-only-discovery branch from a0f2a4f to 9d6b3f5 Compare July 24, 2026 17:00
Comment thread apps/server/src/localServerAdvertisement.ts Outdated
yield* revoke(yield* Ref.get(currentAccessInfo));
});

const initialPublish = yield* Effect.exit(publish(initialAccessInfo));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/localServerAdvertisement.ts:177

If the startup fiber is interrupted after publish(initialAccessInfo) succeeds at line 177 but before forkScoped(rotate…) runs at line 224, no cleanup is registered: the advertisement file stays on disk and the newly issued pairing credential remains valid until it expires. The cleanup finalizer is only attached to the rotation fiber via Effect.ensuring inside forkScoped, so any interruption in between leaks both resources. Consider wrapping the initial publish and the rotation fork in a single Effect.acquireRelease (or otherwise registering cleanup before the first write) so the finalizer is in place before the file is created.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/localServerAdvertisement.ts around line 177:

If the startup fiber is interrupted after `publish(initialAccessInfo)` succeeds at line 177 but before `forkScoped(rotate…)` runs at line 224, no cleanup is registered: the advertisement file stays on disk and the newly issued pairing credential remains valid until it expires. The `cleanup` finalizer is only attached to the rotation fiber via `Effect.ensuring` inside `forkScoped`, so any interruption in between leaks both resources. Consider wrapping the initial publish and the rotation fork in a single `Effect.acquireRelease` (or otherwise registering cleanup before the first write) so the finalizer is in place before the file is created.

@colonelpanic8
colonelpanic8 force-pushed the desktop-client-only-discovery branch from 32f076d to 048f271 Compare July 24, 2026 19:48
@colonelpanic8 colonelpanic8 changed the title Add client-only desktop mode with local server discovery Add client-only desktop backend mode Jul 24, 2026
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:XXL 1,000+ changed lines (additions + deletions). labels Jul 24, 2026
Comment thread apps/desktop/src/preload.ts
Comment thread apps/desktop/src/app/DesktopApp.ts
Comment thread apps/desktop/src/ipc/methods/backendMode.ts
Comment thread apps/desktop/src/app/DesktopLifecycle.ts
@colonelpanic8
colonelpanic8 force-pushed the desktop-client-only-discovery branch from bb2c567 to 704649e Compare July 24, 2026 21:59
colonelpanic8 and others added 3 commits July 24, 2026 16:16
Let the desktop app run as a pure client for remote or independently
managed backends, instead of always owning a local backend process.

- Add a persisted `managed` / `client-only` backend mode with a
  launch-only `--backend-mode` override, exposed over IPC.
- In client-only mode, bootstrap skips the backend pool entirely and
  serves the renderer from packaged static assets (or the dev proxy),
  opening the window on renderer readiness rather than backend
  readiness, and skipping backend shutdown on quit.
- `ElectronProtocol` takes an explicit `source: "proxy" | "static"`.
  Static serving adds path-safe file resolution, SPA fallback, MIME
  types, CSP and HEAD support.
- Web routing treats client-only like hosted static, and local
  environment bootstraps are empty, so a client-only desktop is never
  stranded on a dead primary-backend state.

Co-Authored-By: Claude <noreply@anthropic.com>
@colonelpanic8
colonelpanic8 force-pushed the desktop-client-only-discovery branch from 704649e to 20ba333 Compare July 24, 2026 23:18
Comment on lines +202 to +209
yield* desktopWindow.handleRendererReady.pipe(
Effect.catch((error) =>
logBootstrapWarning("failed to open main window after renderer readiness", {
error: error.message,
}),
),
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium app/DesktopApp.ts:202

In the client-only branch of bootstrap, any DesktopWindowError from desktopWindow.handleRendererReady is caught and logged, then the function returns successfully. If initial window creation or renderer loading fails, startup proceeds to shutdown.awaitRequest and waits indefinitely — there is no window from which a user can trigger a shutdown or retry, leaving a headless desktop process. The non-client-only path surfaces such failures through fatalStartupCause, but this path silently swallows them. Consider propagating the error to fatalStartupCause (or explicitly requesting shutdown) instead of only logging it.

-    if (!(yield* Ref.get(state.quitting))) {
-      yield* desktopWindow.handleRendererReady.pipe(
-        Effect.catch((error) =>
-          logBootstrapWarning("failed to open main window after renderer readiness", {
-            error: error.message,
-          }),
-        ),
-      );
-    }
+    if (!(yield* Ref.get(state.quitting))) {
+      yield* desktopWindow.handleRendererReady.pipe(
+        Effect.catch((error) =>
+          logBootstrapWarning("failed to open main window after renderer readiness", {
+            error: error.message,
+          }).pipe(Effect.andThen(Effect.fail(error))),
+        ),
+      );
+    }
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/app/DesktopApp.ts around lines 202-209:

In the `client-only` branch of `bootstrap`, any `DesktopWindowError` from `desktopWindow.handleRendererReady` is caught and logged, then the function returns successfully. If initial window creation or renderer loading fails, `startup` proceeds to `shutdown.awaitRequest` and waits indefinitely — there is no window from which a user can trigger a shutdown or retry, leaving a headless desktop process. The non-client-only path surfaces such failures through `fatalStartupCause`, but this path silently swallows them. Consider propagating the error to `fatalStartupCause` (or explicitly requesting shutdown) instead of only logging it.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 20ba333. Configure here.

yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir });
yield* desktopSettings.load;
const settings = yield* desktopSettings.load;
const launchMode = yield* backendMode.latch(settings.backendMode);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invalid backend-mode flag silent failure

Medium Severity

When launch arguments include an invalid or repeated --backend-mode, backendMode.latch fails during startup before the existing fatal-startup handlers run. The desktop process exits without the standard startup error dialog, so a bad shortcut or script flag can fail opaquely instead of explaining the flag problem.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 20ba333. Configure here.

The sidebar decided a thread or project was remote by comparing its
environment against the primary environment id. A client-only desktop
(and the hosted static web app) never registers a primary, so that id is
always null and every row read as local: no cloud/server icon, and no
environment name on the project header.

Make the comparison explicit about the two different meanings of a null
primary id. `isRemoteEnvironmentId` now takes an `ownsLocalEnvironment`
flag: when the app can never serve an environment from its own backend,
every environment is remote; when it can, a null primary id still means
"the local backend has not registered yet" and nothing is flagged remote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant