[Mac app] Native desktop shell hosting the web app

🟢 open · #302 · open-legal-products/mike ← amal66/mike · opened 29d ago by amal66 · +6,653-13 across 23 files · ↗ on GitHub

From the PR description

Mike for Mac - native desktop shell

A thin Electron shell that gives the Mike web app a first-class macOS home: real menu bar with shortcuts, window-state persistence, external links opening in the default browser, a connection screen when the server is unreachable, and a single-instance dock presence. It is shell-over-rewrite by design - the web app is the single source of truth and gets zero privileged APIs. Everything lives in desktop/; reverting is rm -rf desktop/ plus the two backend fixes and the one docker-compose.yml env line below.

Works out of the box: the app defaults to the hosted service at https://app.mikeoss.com, so a downloaded Mike.app is usable with nothing else installed - open it, log in, done. No local stack, no configuration. Self-hosters retarget it at their own deployment via the connect screen (⌘⇧,), --server-url=, or MIKE_SERVER_URL; the connect screen now only appears when the configured server is actually unreachable (offline, or a self-host URL that's down), not as the first-run experience.

Rebased onto main 54681b55 (2026-08-24). All 10 required CI checks green on 956d1dbc. Three commits landed after the last hand-run verification and were not described here before: 913a52f4 (rebase-driven XSS assertion rewrite) and c0890b08 + 956d1dbc (two CodeQL js/incomplete-url-substring-sanitization fixes). See What changed on the rebase - it includes one thing a reviewer must know before running the desktop e2e suites.


Base case - what you can observe on main today

desktop/ does not exist on main (git ls-tree origin/main -- desktop/ is empty), so "there is no Mac app" is the trivial half. The interesting half is that two of the fixes in this PR are browser bugs on main, reproducible with no Electron involved at all. Both are worth seeing before reading the fix.

A. MCP connector OAuth silently fails in every browser

  1. On main, bring up the stack and configure an MCP connector that uses OAuth (Settings → Connectors → Connect).
  2. Consent at the provider. The popup returns to GET /user/mcp-connectors/oauth/callback.
  3. Observe: the popup closes itself and the UI reports "OAuth authorization window was closed." - even though the connector was authorized server-side.
  4. Why, in DevTools: backend/src/app.ts applies helmet() without disabling crossOriginOpenerPolicy, so every response carries Cross-Origin-Opener-Policy: same-origin. When the popup arrives at that callback from a cross-origin consent page, the browser moves it into a fresh browsing-context group and window.opener becomes null. The callback page's if (window.opener && !window.opener.closed) postMessage(...) guard therefore never fires; the opener's 700 ms popup.closed poll wins the race and raises the error.
  5. One-line proof, no OAuth provider needed:
    curl -sD- 'http://localhost:3001/user/mcp-connectors/oauth/callback' | grep -i cross-origin-opener
    # main:        cross-origin-opener-policy: same-origin
    # this branch: cross-origin-opener-policy: unsafe-none
    

B. Document downloads are dead on any docker-compose deployment

  1. On main, docker compose up, upload a PDF to Library, use the row menu → Download.
  2. Observe: nothing downloads. Chrome shows ERR_NAME_NOT_RESOLVED.
  3. Why: GET /single-documents/:id/url returns {"url":"http://storage:9000/mike/<key>?X-Amz-Signature=..."}. storage is a compose-internal hostname; the storage port is published 127.0.0.1-only, so the browser cannot resolve it. DocTable.tsx's downloadDoc just sets a.href = url; a.click().
  4. Why a string replace can't fix it: the SigV4 signature is computed over the signed host, so rewriting storage:9000localhost:9000 after the fact invalidates the signature. The fix has to be a second, separately-configured presign client - which is what this PR does.

What was broken in the shell, and is now fixed

Flow Before After
MCP connector OAuth (Drive/Slack/...) Dead - the about:blank popup was pushed to the system browser, so window.opener was null and the result never returned Popup is a real, fenced in-shell window; window.opener survives the whole consent → callback chain
Popup privilege Child windows inherited the shell's mikeDesktop bridge Preload stripped from every child window (opener works, bridge does not leak)
Opener tabnabbing A popup could drive window.opener.location to a foreign binary → silent download Only the main window's own frame can trigger the download/browser path (event.initiator === win.webContents.mainFrame)
Off-origin redirects will-navigate didn't fire for 30x → a foreign page could load in the main (bridged) window will-redirect now shares the same fence
OS file drop A file dropped outside the chat dropzone navigated the whole app to that local file Only the shell's own bundled pages may be file: URLs (same prefix also gates the IPC bridge)
Failing iframe Any subframe/subresource failure showed the "can't reach server" screen did-fail-load honors isMainFrame (and ignores -3/aborted and file:)
Document downloads Presigned-URL <a download> clicks bounced to the system browser Probed and saved in-shell via a will-download handler - no save dialog, Finder-style (2) collision suffixes, dock bounce on completion
Native feel No right-click menu; menu bar didn't match the sidebar; window could go mobile-width Context menu (spellcheck/copy/paste/links); menu mirrors the sidebar; min 800×600

Two of these needed backend changes that fix the flow in every browser, not just the shell:

  • OAuth popup window.opener - the callback route now sends COOP: unsafe-none, scoped to that one route; the strict nonce CSP is intact. (The ?error= detail is also <-escaped in mcpOAuthPopupHtml - but see the correction below: since main's a104acce that escaping is defense-in-depth for future fields, not the thing protecting this page.)

  • Presigned download URLs - presigning now uses a browser-reachable public endpoint via a second S3 client built from R2_PUBLIC_ENDPOINT_URL, with cloud R2/S3 deploys unchanged (unset ⇒ identical behavior to main). Upload/delete/read paths are untouched.

    Testing video: https://drive.google.com/file/d/1Q3Y_0H8DqbL7JVK6od1nLWbWgvuqZPG2/view?usp=sharing

Each fix is explained in depth in its commit message.


Replicate this PR

0. Bring up a stack this branch's e2e can drive

Nothing in this branch shifts ports - the :3100/:3101 in the old repro block were the reviewer's own overrides, which was never stated. Use whatever ports you like, but three env vars must agree or two of the eleven e2e steps fail by design:

# from the repo root, on this branch
FRONTEND_PORT=3100 BACKEND_PORT=3101 STORAGE_PORT=9100 \
FRONTEND_URL=http://localhost:3100 \
  docker compose up --build -d
  • FRONTEND_URL must equal MIKE_E2E_URL: the OAuth callback posts its result to new URL(frontendUrl()).origin, and flows step 4 asserts the opener receives it.
  • R2_PUBLIC_ENDPOINT_URL defaults to http://localhost:${STORAGE_PORT:-9000} in docker-compose.yml, so it follows STORAGE_PORT automatically. Steps 10/11 need it.

1. Run the app by hand (no Apple account needed)

A locally-built app runs with no Gatekeeper prompt - the "damaged / can't be opened" block only hits apps downloaded from the internet, not ones you build yourself.

cd desktop && npm install && npm run dist
open dist/mac-arm64/Mike.app                              # loads https://app.mikeoss.com
open dist/mac-arm64/Mike.app --args --server-url=http://localhost:3100   # or your stack

Signing/notarization only matters for shipping a downloadable .dmg; it changes nothing about how the app behaves and does not block reviewing, testing, or merging. Full walkthrough in desktop/README.md.

2. The automated suites

Both suites drive the packaged app over CDP and both hardcode dist/mac-arm64/Mike.app - on an Intel Mac they will fail at spawn.

cd desktop
# baseline: signup → project create → title sanity (CDP port 9223)
MIKE_E2E_URL=http://localhost:3100 npm run e2e
# regression suite: 11 steps (CDP port 9224); needs e2e/fixtures/test.pdf at the repo root
MIKE_E2E_URL=http://localhost:3100 MIKE_E2E_API=http://localhost:3101 npm run e2e:flows
# backend units
cd ../backend && npx vitest run

flows.e2e.mjs prints one line per step(); the eleven are:

  1. signed up + signed in
  2. oauth popup: about:blank window.open allowed in-shell
  3. oauth popup: survives cross-origin navigation
  4. oauth popup: live opener back-channel, no mikeDesktop bridge
  5. external link: no in-shell window, URL captured
  6. renderer file: navigation of the main window does not land
  7. popup cannot drive its opener into a silent download
  8. failing iframe does not trigger the connect screen
  9. blob download is saved by the shell download handler
  10. library upload + row-menu Download stays in-shell
  11. presigned-URL anchor click resolves to an in-shell download

Correction: earlier versions of this description (and commit 9a9661ff's message) said "12/12". There are eleven step() calls. Nothing regressed - the number was simply wrong.

3. Manual smoke test (the native surfaces automation can't reach)

The suites cover the web flows over CDP but not the macOS-native chrome. Two minutes by hand:

  • Menu bar - ⌘N (New Chat), ⌘1-⌘6 (Assistant / Projects / Library / Tabular Review / Workflows / History), ⌘⇧H (Home), ⌘, (product Settings), ⌘⇧, (Change Server → connect screen appears), Help → "Mike on GitHub" (opens in the browser). Known nit: ⌘N and ⌘1 both navigate to /assistant - ⌘N does not actually start a new chat yet, it just loads the assistant page.
  • Right-click in a chat input → cut/copy/paste + spellcheck suggestions; right-click a link → "Open Link in Browser" / "Copy Link".
  • Drag a file from Finder onto the window outside the chat dropzone → the app must NOT navigate to the local file.
  • External link (e.g. a cited source) → opens in the default browser, not in-shell.
  • Document download → lands in ~/Downloads, no save dialog, dock icon bounces.
  • Window state - resize/move, quit, reopen: bounds restored. Try to shrink below 800×600: it won't.
  • (if you have connector credentials) one real Drive/Slack connect → consent popup opens, closes itself, the connector shows as connected.

Tradeoffs & design decisions (explicit)

1. Shell over native rewrite

Two ways to make a Mac app of a web product: re-implement the UI natively (SwiftUI), or host the existing web app in a native shell. The shell wins for Mike because of the standing rule that every client mirrors the main web app's design and architecture: a rewrite drifts from day one; a shell cannot drift. Cost: no native controls, no offline mode, and the app is only as good as the web app in a Chromium window. Rejected alternatives: SwiftUI rewrite (drift, double maintenance), Tauri (smaller binary, but a different webview per-OS and no in-repo precedent), PWA/Safari → Add to Dock (no menu bar, no download interception, no origin fence).

2. The web app receives zero privileged APIs

The mikeDesktop preload bridge is exposed only to the shell's own bundled file: pages (src/pages/*), gated both ways: the preload is stripped from every child window, and every IPC handler checks event.senderFrame.url.startsWith(SHELL_PAGES_URL_PREFIX). The web app must behave identically in a browser - that is the invariant that keeps the shell from becoming a fork. Cost: anything the product wants from the OS needs a new, explicitly audited bridge call rather than "just add an API".

3. Origin fence: allow in-shell, hand everything else to the browser

Only the configured Mike server renders inside the app. Cited sources, OAuth consent pages, and docs open in the default browser, so third-party pages never execute inside the shell. The one exception is the MCP OAuth popup, which must stay in-shell for window.opener to survive - it gets its own fenced window with no preload. Rejected: allowlisting known OAuth providers (an unbounded list that fails closed on the next connector).

4. Downloads are intercepted, not dialogued

will-download saves straight to ~/Downloads with Finder-style collision suffixes and no save dialog. Rejected: the default Electron save dialog (a modal per document is hostile in a review workflow) and letting the OS browser handle it (breaks the presigned-URL flow, which is what step 11 pins).

5. R2_PUBLIC_ENDPOINT_URL is a second client, not a URL rewrite

Because SigV4 binds the signature to the signed host, the endpoint must differ at signing time. So getPresignClient() builds and caches a separate S3Client and only getSignedUrl() uses it; uploads, deletes, and reads still use the internal endpoint (which is faster and stays inside the compose network). Unset ⇒ byte-identical to main, so cloud R2/S3 deploys are unaffected. Caveat: the value is read once at module load, so changing it needs a backend restart.

6. COOP: unsafe-none is scoped to one route

Relaxing COOP globally would remove cross-origin isolation from the whole API. It is set only on the OAuth callback response, where the entire point is that a cross-origin popup must keep its opener. Strict nonce CSP on that page is untouched.

7. Unsigned prototype build by default

npm run dist sets identity: null and targets dir - no dmg, guaranteed unsigned, so a contributor can always build and run it. dist:signed is fully wired (electron-builder.release.json + assets/entitlements.mac.plist, hardened runtime, notarize) but can only be run with the org's certificate, so it is not claimed as verified.

8. desktop/ is not in CI

None of the ten workflows touch desktop/. That is a deliberate scoping decision for a first landing, and an accepted debt: the shell will silently rot as the frontend moves - which is exactly what happened on this rebase (below). Adding a build+e2e job is a one-request change; see "Human-only steps".


Testing evidence

Automated gates - all 10 required checks green on 956d1dbc (rebased onto 54681b55):

Gate Result
Backend build and tests 730 passed / 25 skipped, 61 files
Frontend build and tests 590 passed, 95 files
Supabase stack integration tests pass
Fresh install vs upgraded deployment pass
playwright 27 passed
CodeQL / Analyze (javascript-typescript) pass (was red before c0890b08/956d1dbc)
Eval harness · gitleaks (full history) · CLA pass

Backend tests this PR adds - 5 cases:

  • backend/src/__tests__/integration/user.routes.test.ts → new describe("GET /user/mcp-connectors/oauth/callback"), 3 cases: COOP relaxed alongside the nonce CSP; helmet's default same-origin COOP still applied on every other route (contrast probe against GET /user/profile); a </script> breakout in ?error= never reaches the popup page.
  • backend/src/lib/__tests__/storagePresign.test.ts2 cases: signs against R2_PUBLIC_ENDPOINT_URL rather than the internal endpoint (asserts the signed host and that X-Amz-Signature survives); falls back to the R2_ENDPOINT_URL host when no public endpoint is set. Deliberately does not mock the presigner - it uses vi.resetModules() + dynamic import, because the endpoint is captured at module load.

Desktop e2e - ⚠️ last hand-run before the 2026-08-24 rebase. At that point: flows.e2e.mjs 11/11 and app.e2e.mjs PASS against a compose stack built from this branch; zero-config first run verified on a packaged Mike.app with a fresh user-data dir (loads the hosted login at https://app.mikeoss.com in-shell, CDP-verified), and an unreachable --server-url= still shows the connect screen with the URL prefilled. Both suites pass --server-url= explicitly, so the hosted default can never leak into a test. They have not been re-run since the rebase; the signup step that main's #365 broke is now fixed and source-verified in 98baec3b, but not execution-verified - see immediately below.


What changed on the rebase (and one thing that needs a follow-up)

Commit What it is
913a52f4 Main landed a104acce fix(security): sanitize user-facing errors, which stops echoing the raw ?error= at all - so this branch's assertion that the response contained </script> failed on rebase even though the page got strictly safer. Main's design wins, so the test now asserts the property (evil() is not reflected; exactly one </script> closes the page) rather than our escaping mechanism. The .replace(/</g, "\\u003c") stays as defense-in-depth for future fields that do carry caller text.
c0890b08 + 956d1dbc CodeQL flagged js/incomplete-url-substring-sanitization high on flows.e2e.mjs. The assertion was already exact (the capture file is one URL per line and we split on \n first), but the query matches the .includes(<url>) call site without resolving whether the receiver is a String or an Array. Rather than argue with a heuristic, the sink moved out of its reach: .some((line) => line === URL). The download step got the same treatment and became strictly tighter - it was the one true substring test in the file. The url.href.startsWith(SERVER_URL) navigation checks are deliberately left alone: those are genuine origin+path prefix assertions, and equality would change what they assert.

✅ Fixed (source-verified; execution pending the next packaged run): the desktop e2e signup step

Was: main rewrote the signup page - it now has only Email / Password (Min. 10 Characters) / Confirm Password and redirects to /onboarding/profile - while both app.e2e.mjs and flows.e2e.mjs still filled Your name, Your organisation, Enter your email, and Create a password (min. 6 characters). Step 1 of both suites failed against current main's frontend, which zeroed out every assertion behind it.

Now (98baec3b), both suites drive the real contract:

  • signup fills #email / #password / #confirmPassword and submits the page's lone type="submit" (the Google button is type="button"); the run password already cleared MIN_PASSWORD_LENGTH = 10 and now says so in a comment.
  • onboarding is walked, not skipped: OnboardingGate bounces any signed-in user with profile.onboardingComplete === false back to /onboarding/profile from every non-auth route, so the suites fill #name/#organisationContinue (step 1 of 2), then click Skip on the practice form (step 2 of 2), which calls completeOnboarding({}) and replaces the URL with /assistant.
  • the post-signup URL assertion now excludes /onboarding as well as /login|/signup - otherwise it would go green on the very state that used to be the failure.

Same commit drops the hardcoded dist/mac-arm64/ in both suites: the mac output directory is derived from process.arch (arm64mac-arm64, otherwise mac) at the single place APP_BINARY is built, so the suites also run on an Intel Mac.

Verification status - source-verified, not execution-verified. Every selector above was traced against the actual component source (frontend/src/app/signup/page.tsx, onboarding/profile/page.tsx, onboarding/practice/page.tsx, components/auth/OnboardingGate.tsx, components/auth/passwordPolicy.ts, components/shared/AppSidebar.tsx) and both files pass node --check, but re-running them needs a packaged Electron build plus the full local stack, which this round could not do. The next packaged run is the real proof. This whole episode is the rot predicted by "desktop/ is not in CI"; the shell code was never affected, only the test's selectors.

Cosmetic companions found at the same time: flows.e2e.mjs comments cite components/shared/DocTable.tsx (now components/documents/DocTable.tsx) and several stale line numbers; npm run e2e:flows is not documented in desktop/README.md; and MIKE_DESKTOP_DEV, set by npm run dev, is read nowhere in main.js.


⚠️ Human-only steps before this is shippable to end users

I can't do these - they need your credentials, hardware identity, or a product decision. Nothing below blocks reviewing or merging the code; they gate a distributable, Gatekeeper-clean build.

  1. Apple Developer signing + notarization (the only blocker for a downloadable release). Because signing needs the org's Apple Developer certificate, only the account holder can do it - a contributor never needs it. The build is already wired for this: electron-builder.release.json + assets/entitlements.mac.plist are in place, and npm run dist:signed signs

    • notarizes + staples once three env vars are set:
    export APPLE_ID="appleid@your-org.com"
    export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx"
    export APPLE_TEAM_ID="ABCDE12345"
    npm run dist:signed        # → signed, notarized Mike-<ver>-arm64.dmg + .zip
    

    Step-by-step for the org account holder - enrolling, creating the Developer ID Application certificate, getting the Team ID, generating the notarization credential, building, and verifying with codesign/stapler/spctl - is written out in desktop/README.md → "Signing & notarization", along with a CI variant that keeps the certificate as encrypted secrets and never on a laptop. That path is pre-wired but can only be run with the org's cert, so it is intentionally not claimed as CI-verified here.

    Not signing is survivable, just rougher for downloaders (System Settings → "Open Anyway", or xattr -dr com.apple.quarantine ...; macOS 15 removed the Control-click bypass). Nothing about the app is degraded - signing is purely the download-trust gate. Details in the README.

  2. Decide the distribution/CI story (product decision).

    • Release workflow: none of the ten CI workflows touch desktop/ today, so the shell will silently rot as the frontend moves - as the signup step broken by #365 (fixed above, but only because it was caught by hand) demonstrates. The signed-build config is ready; I can add the GitHub Action on request.
    • Auto-update (electron-updater) - in v1 or not? The zip target is already emitted for it; without it every release is a manual re-download.
    • arm64-only vs arm64+x64 vs universal binary. The e2e suites no longer stand in the way: as of 98baec3b both derive the mac output directory from process.arch (arm64dist/mac-arm64, otherwise dist/mac) instead of hardcoding mac-arm64.
  3. mike:// deep links (optional, product decision). The email-change confirmation link currently opens in the user's browser (Mail → Supabase → /settings), so that one session update lands in the browser, not the shell. Fixing it properly needs a registered mike:// protocol handler. Login itself is unaffected (email/password only, no social OAuth). Tell me if you want deep links and I'll implement them.

  4. Live OAuth connector smoke test. The popup/callback plumbing is verified end-to-end against the local stack with the real callback route, but a true Google/Slack consent round-trip needs real OAuth app credentials (the same ones tracked for the connectors work). Worth one manual pass once those exist.

  5. Self-hosters: R2_PUBLIC_ENDPOINT_URL for remote deploys. The compose default only serves a browser on the docker host. A remote deploy must set this to a public HTTPS URL that reverse-proxies to storage (the storage port is bound loopback-only). Documented in docker-compose.yml and backend/.env.example. Remember it is read at module load - restart the backend after changing it.

🤖 Generated with Claude Code

Our analysis

Add a native macOS desktop shell — read the full analysis →

Think the analysis missed something the PR description covers?

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-302.md from inside the repo you want the changes in.

⬇ Download capture-pull-302.md