Skip to content

Fix the desktop blank window: Better Auth rejects tauri:// as a base URL [skip-ui-docs] - #24

Merged
RichardHightower merged 5 commits into
mainfrom
fix/desktop-spa-build
Aug 4, 2026
Merged

Fix the desktop blank window: Better Auth rejects tauri:// as a base URL [skip-ui-docs]#24
RichardHightower merged 5 commits into
mainfrom
fix/desktop-spa-build

Conversation

@RichardHightower

Copy link
Copy Markdown
Collaborator

The big picture

ForgeNotes ships as two products from one React SPA: a web app on Vercel and a macOS
desktop app in a Tauri 2 shell. The web half has been exercised continuously. The desktop
half had never actually rendered — every launch since the desktop path was first wired
showed either a static placeholder or an error boundary.

This PR is the last step of getting the desktop shell to boot:

Step 1: SPA-mode build so a static index.html exists at all   (earlier commits)
Step 2: honest build verification — fail on a scriptless shell (earlier commits)
Step 3: fix the icon filename that broke `cargo tauri build`   (earlier commit)
Step 4: make the desktop window actually render                <== THIS PR
Step 5: sign / notarise the .dmg so it opens off this machine   (not started)

Terms used below. Asset protocol — Tauri serves a packaged app's files from the
custom scheme tauri://localhost rather than over HTTP. Error boundary — a React
component that catches an exception thrown by its children and renders a fallback instead
of crashing; TanStack Router installs one by default. Embed — Tauri copies the frontend
bundle into the compiled binary, so the binary and the bundle on disk can disagree.

The problem this PR solves

The packaged desktop app opened to this, on a blank page:

Something went wrong!   [Show Error]

Four concrete problems sat behind that:

  1. The app threw before it rendered anything, and the message was hidden behind a
    button nobody had pressed.
  2. The instrumentation added to read that message could not see it. A window.onerror
    listener only fires for uncaught exceptions. A React error boundary catches the
    exception — by design, it never reaches the window. So the reporter logged nothing, and
    that nothing was read as "no error", which is precisely backwards.
  3. Fixes were not reaching the binary under test. Tauri embeds frontendDist at
    compile time, but Cargo does not track dist-desktop/ as a build input. Rebuilding the
    frontend and rebuilding the app produced a binary carrying the previous bundle, with
    no warning. Three rounds of fixes were built, installed to /Applications, launched,
    and observed still-broken — because none of them were in the binary being launched.
  4. There was no way to see the running app. This shell has no macOS Screen Recording
    or Accessibility permission, so screenshots come back black. Verification depended on
    asking a human to look, which is slow and is how (3) survived three rounds.

The root cause

One line of validation in Better Auth:

Invalid base URL: tauri://localhost. URL must include 'http://' or 'https://'

createAuthClient() infers its base URL from window.location.origin when none is given.
On the web that is https://… and everything is fine. Under Tauri the origin is the custom
scheme tauri://localhost, which better-auth/dist/utils/url.mjs:36 rejects for any
protocol that is not http:/https:.

The critical detail is when it throws. authClient is a module-scope const, so the
throw happens while src/lib/auth/client.ts is being evaluated — during dynamic import
of the route chunk, before a single component renders. The router boundary catches it, and
the entire app is replaced by the fallback.

The sibling app agent-brain-ui shares this stack and works, which made the difference
look mysterious. It is not: its root route renders its app shell directly and never touches
auth at boot. ForgeNotes' AppShell calls authClient.useSession() on mount, so ForgeNotes
walks into the validator and agent-brain-ui does not.

What this PR actually does

src/lib/auth/base-url.ts (new) + src/lib/auth/client.ts

Supplies an explicit baseURL only when the page origin is not http(s):

export function resolveAuthBaseURL(protocol: string | undefined): string | undefined {
  if (protocol === "http:" || protocol === "https:") return undefined;
  return protocol ? DESKTOP_AUTH_ORIGIN : undefined;
}

undefined means "let Better Auth infer it as before". Every web origin — production, dev,
and the Grok live preview — takes that branch and is bit-for-bit unchanged in behaviour.
Only tauri:// gets an override.

This edits a file CLAUDE.md marks frozen. src/lib/auth/* is pre-wired template
code and the rule exists because rewriting it breaks live-preview sign-in in ways that
are slow to diagnose. There is no other correct location: the throw is inside
createAuthClient, at module scope, in that file. The change is two lines, purely
additive, and inert on every origin the freeze is there to protect. Flagging it
explicitly rather than letting it pass unnoticed in a diff.

src/router.tsx + src/lib/report-client-errors.ts

Wires error reporting to defaultOnCatch, which is the router boundary's own catch point
and receives exactly the error the boundary is about to hide. The pre-existing window
listeners stay for genuinely uncaught errors; they are simply blind to this class.

scripts/build-desktop.mjs

Bumps the mtime of a tracked Rust source at the end of the build, forcing Cargo to
re-embed. One extra compile of one crate, and problem (3) above cannot recur.

Housekeeping

  • Untracks 249 .vercel/ build artifacts committed by an earlier git add -A — the same
    mistake PR Untrack dist-desktop — build artifact committed by accident #23 undid for dist-desktop/. Both are now gitignored.
  • Adds dist-desktop/** and src-tauri/target/** to the ESLint ignore list. 10,468 of
    the 10,990 reported "errors" were minified bundles.
    The real count is 1 error (the
    known pre-existing rules-of-hooks at AppShell.tsx:78) and 12 warnings.

Corrects a claim that was wrong

An earlier commit added safe-storage.ts with a docstring asserting that WebKit denies DOM
storage to tauri://localhost and that this "crashed the desktop build on boot for every
user, every time." That is false. Storage was measured working in that webview:

{"storage": "works", "origin": "tauri://localhost", "secureContext": true}

Denying storage synthetically reproduced the same error-boundary text, and a reproduction
that matched the symptom was mistaken for finding the cause. The wrapper is kept — it is
real hardening for Safari private browsing and partitioned iframes — but the comments in
safe-storage.ts, store.ts, and safe-storage.test.ts now say what is actually true.

How it was verified

Not by inference. The debug binary was rebuilt with the mcp-bridge feature so it runs
under the asset protocol (tauri://localhost, the real packaged code path — not the
dev server, which npm run desktop:mcp reuses and which therefore cannot reproduce this at
all), then queried directly:

Check Before After
document.body.innerText Something went wrong! … full workspace chrome
[data-block-id] count 0 20
<button> count 2 97
sidebar present no yes
boundary errors on stderr 1 0

Plus: npm run typecheck clean; npx vitest run 43/43 across 6 files, including 4 new
tests on resolveAuthBaseURL; release bundle and the copy in /Applications both verified
by binary inspection to embed the current entry chunk (index-ChMLEjem.js).

The human confirmed the same error text independently from a screenshot before the fix
landed, which is what ruled out an artifact of the measurement setup.

Deliberately out of scope

  • Auth does not function on desktop. The packaged app has no server, so
    /api/auth/* has nothing to answer it. The base URL is set to http://127.0.0.1:8080,
    which works when a dev server is up and otherwise fails the session fetch gracefully —
    the app renders signed-out instead of dying. Making desktop auth actually work needs a
    real decision about where the desktop backend lives.
  • The .dmg is unsigned and un-notarised. Gatekeeper will block it on any other Mac.
  • The two remaining lint findings (rules-of-hooks, unused-var warnings).
  • mounts-store.ts and ai/settings-store.ts still use raw localStorage. Harmless given
    the finding above; worth aligning if the wrapper is ever load-bearing.

Ticket glossary

Ticket What it is Status
01KZ4FQNVT4N2H3XV0QYY9X1SS Desktop window blanks: Better Auth rejects tauri:// Closed by this PR
01KZ4D4P3YE5H94JXVZKCA08YY Desktop ships a dead placeholder — SSR build has no static entry Closed by this PR
01KZ3Z46SDWDGVD3CFZ0Z1S9FB Parent: desktop packaging path (v0.3.1) In progress

🤖 Generated with Claude Code

https://claude.ai/code/session_012o4dVLL1GeMETrCbD8Hgc2

RichardHightower and others added 5 commits August 3, 2026 13:58
…URL [01KZ4FQNVT4N2H3XV0QYY9X1SS]

The desktop app opened to "Something went wrong!" and nothing else. The cause
was one line of validation in Better Auth:

    Invalid base URL: tauri://localhost. URL must include 'http://' or 'https://'

`createAuthClient()` infers its base URL from `window.location.origin`. On the
web that is https. Under Tauri it is the custom scheme `tauri://localhost`,
which better-auth/dist/utils/url.mjs:36 rejects outright — and it throws while
`auth/client.ts` is being EVALUATED, so the whole route chunk dies before React
renders. `auth/base-url.ts` now overrides the base URL only when the origin is
not http(s), so every web origin, including the live preview, is untouched.

Two things kept this hidden for three rounds of fixes:

1. The binary embedded a STALE frontend. Tauri bakes `frontendDist` in at
   compile time, but Cargo does not track `dist-desktop/` as an input, so
   rebuilding the bundle and rebuilding the app produced a binary carrying the
   previous assets — silently. Every fix was installed and tested without ever
   reaching the running app. `build-desktop.mjs` now bumps a tracked Rust
   source so the re-embed cannot be skipped.

2. The error channel was at the wrong layer. `window.onerror` never fires for
   an exception a React error boundary catches, so the "zero client errors"
   reading was structurally guaranteed rather than evidence. Reporting now
   hangs off the router's `defaultOnCatch`, which is the boundary's own catch
   point.

Also corrects a false claim in `safe-storage.ts`: storage under
`tauri://localhost` was measured WORKING. Denying it synthetically reproduced
the same boundary text, and matching the symptom was mistaken for finding the
cause. The wrapper stays for Safari private browsing and partitioned iframes,
on its own merits.

Housekeeping: untrack 249 `.vercel/` build artifacts committed by an earlier
`git add -A`, and stop ESLint from linting build output (10,468 of 10,990
reported errors were minified bundles).

Verified against the real webview over the MCP bridge, not by inference:
sidebar present, 20 blocks, 97 controls, no boundary error. Release bundle and
/Applications both verified to embed the current entry chunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o4dVLL1GeMETrCbD8Hgc2
…3XV0QYY9X1SS]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o4dVLL1GeMETrCbD8Hgc2
…Y7FM6Q4SQHKNC]

Pressing Run in the desktop app produced no output, no error, and no sign that
anything had happened.

`streamAi` guarded on `res.ok` alone. That is not enough here: the packaged
desktop app has no server, and Tauri's asset protocol answers an unknown path
with `index.html` and HTTP **200**. So the request "succeeded", the SSE parser
was handed HTML, found no `data:` lines, and returned `{text: "", provider:
"local"}` — a successful empty result. Nothing threw, so nothing was displayed.

Now the content type has to actually be `text/event-stream`. An HTML body gets
a message naming the real cause; anything else reports what came back instead.
Confirmed in the running desktop app, not just in tests: Run now surfaces
"AI needs the ForgeNotes server, and this build has none reachable."

This makes the failure visible. It does NOT make AI work on the packaged
desktop — that needs a server the bundle does not currently contain, which is a
design decision rather than a bug fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o4dVLL1GeMETrCbD8Hgc2
…REW6Q1W]

AI now works in the packaged desktop app, with Grok CLI as the default.

The previous commit made the failure visible; this removes it. The desktop app
has no server to spawn a CLI on its behalf — but the CLIs are already installed
on the machine, so the server was never a missing piece to rebuild, just a
detour to skip. `src-tauri/src/ai_cli.rs` runs them directly and streams stdout
back over a Tauri channel; `streamAi` takes that path instead of HTTP whenever
it is running under Tauri with a CLI backend.

Three decisions worth stating:

**Spawning is in Rust, not the shell plugin.** Letting the frontend call
`Command.create` would mean scoping `shell:allow-execute` with `args: true`,
which is a webview → arbitrary-argv bridge. Here the webview can only ask for
"backend X, prompt P": the binary comes from a three-entry allowlist and every
other argument is built in Rust, where page content cannot become a flag.

**Binaries are resolved by searching, not by PATH.** A `.app` launched from
Finder inherits roughly `/usr/bin:/bin:/usr/sbin:/sbin` — and all three CLIs
install outside it (`~/.grok/bin`, `~/.local/bin`, Homebrew). A bare
`Command::new("grok")` would work from a terminal and fail from the Dock, which
is the kind of asymmetry that gets diagnosed as "works on my machine".

**Grok is the default, then Claude, then Codex.** The shipped default is
`deepagents`, which needs both an API key and a server, so on desktop it can
only fail. A fresh install is moved to an installed CLI on rehydrate, and
because that check is async, `streamAi` also falls back at call time — otherwise
a Run pressed in the first second still took the dead path. Verified against
`deepagents` + `setupComplete: true`, where the rehydrate default cannot help.

Also splits the stdout parsing into `cli-protocol.ts`, since `cli-backends.ts`
imports `node:child_process` and can only run server-side; both halves now share
one implementation instead of two copies.

Verified in the running desktop app over the MCP bridge, end to end: the CLI is
found at `~/.local/bin/grok`, streams, exits 0, and its three summary bullets are
parsed and inserted into the page — with no `/api/ai/stream` request made at any
point. 53 unit tests, typecheck clean, lint unchanged (1 pre-existing error).

Docs: CLAUDE.md gains the asset-protocol/no-server trap and the stale-embed one;
FEATURES.md and USER_GUIDE.md now say what actually works on desktop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o4dVLL1GeMETrCbD8Hgc2
@RichardHightower
RichardHightower merged commit 326e33c into main Aug 4, 2026
8 checks passed
@RichardHightower
RichardHightower deleted the fix/desktop-spa-build branch August 4, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant