Fix the desktop blank window: Better Auth rejects tauri:// as a base URL [skip-ui-docs] - #24
Merged
Merged
Conversation
…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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Terms used below. Asset protocol — Tauri serves a packaged app's files from the
custom scheme
tauri://localhostrather than over HTTP. Error boundary — a Reactcomponent 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:
Four concrete problems sat behind that:
button nobody had pressed.
window.onerrorlistener 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.
frontendDistatcompile time, but Cargo does not track
dist-desktop/as a build input. Rebuilding thefrontend 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.
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:
createAuthClient()infers its base URL fromwindow.location.originwhen none is given.On the web that is
https://…and everything is fine. Under Tauri the origin is the customscheme
tauri://localhost, whichbetter-auth/dist/utils/url.mjs:36rejects for anyprotocol that is not
http:/https:.The critical detail is when it throws.
authClientis a module-scopeconst, so thethrow happens while
src/lib/auth/client.tsis being evaluated — during dynamic importof 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-uishares this stack and works, which made the differencelook mysterious. It is not: its root route renders its app shell directly and never touches
auth at boot. ForgeNotes'
AppShellcallsauthClient.useSession()on mount, so ForgeNoteswalks 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.tsSupplies an explicit
baseURLonly when the page origin is not http(s):undefinedmeans "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.src/router.tsx+src/lib/report-client-errors.tsWires error reporting to
defaultOnCatch, which is the router boundary's own catch pointand receives exactly the error the boundary is about to hide. The pre-existing
windowlisteners stay for genuinely uncaught errors; they are simply blind to this class.
scripts/build-desktop.mjsBumps 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
.vercel/build artifacts committed by an earliergit add -A— the samemistake PR Untrack dist-desktop — build artifact committed by accident #23 undid for
dist-desktop/. Both are now gitignored.dist-desktop/**andsrc-tauri/target/**to the ESLint ignore list. 10,468 ofthe 10,990 reported "errors" were minified bundles. The real count is 1 error (the
known pre-existing
rules-of-hooksatAppShell.tsx:78) and 12 warnings.Corrects a claim that was wrong
An earlier commit added
safe-storage.tswith a docstring asserting that WebKit denies DOMstorage to
tauri://localhostand that this "crashed the desktop build on boot for everyuser, 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, andsafe-storage.test.tsnow say what is actually true.How it was verified
Not by inference. The debug binary was rebuilt with the
mcp-bridgefeature so it runsunder the asset protocol (
tauri://localhost, the real packaged code path — not thedev server, which
npm run desktop:mcpreuses and which therefore cannot reproduce this atall), then queried directly:
document.body.innerTextSomething went wrong! …[data-block-id]count<button>countPlus:
npm run typecheckclean;npx vitest run43/43 across 6 files, including 4 newtests on
resolveAuthBaseURL; release bundle and the copy in/Applicationsboth verifiedby 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
/api/auth/*has nothing to answer it. The base URL is set tohttp://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.
.dmgis unsigned and un-notarised. Gatekeeper will block it on any other Mac.rules-of-hooks, unused-var warnings).mounts-store.tsandai/settings-store.tsstill use rawlocalStorage. Harmless giventhe finding above; worth aligning if the wrapper is ever load-bearing.
Ticket glossary
01KZ4FQNVT4N2H3XV0QYY9X1SStauri://01KZ4D4P3YE5H94JXVZKCA08YY01KZ3Z46SDWDGVD3CFZ0Z1S9FB🤖 Generated with Claude Code
https://claude.ai/code/session_012o4dVLL1GeMETrCbD8Hgc2