[Mac app] Native desktop shell hosting the web app
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
main54681b55(2026-08-24). All 10 required CI checks green on956d1dbc. Three commits landed after the last hand-run verification and were not described here before:913a52f4(rebase-driven XSS assertion rewrite) andc0890b08+956d1dbc(two CodeQLjs/incomplete-url-substring-sanitizationfixes). 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
- On
main, bring up the stack and configure an MCP connector that uses OAuth (Settings → Connectors → Connect). - Consent at the provider. The popup returns to
GET /user/mcp-connectors/oauth/callback. - Observe: the popup closes itself and the UI reports "OAuth authorization window was closed." - even though the connector was authorized server-side.
- Why, in DevTools:
backend/src/app.tsapplieshelmet()without disablingcrossOriginOpenerPolicy, so every response carriesCross-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 andwindow.openerbecomesnull. The callback page'sif (window.opener && !window.opener.closed) postMessage(...)guard therefore never fires; the opener's 700 mspopup.closedpoll wins the race and raises the error. - 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
- On
main,docker compose up, upload a PDF to Library, use the row menu → Download. - Observe: nothing downloads. Chrome shows
ERR_NAME_NOT_RESOLVED. - Why:
GET /single-documents/:id/urlreturns{"url":"http://storage:9000/mike/<key>?X-Amz-Signature=..."}.storageis a compose-internal hostname; the storage port is published127.0.0.1-only, so the browser cannot resolve it.DocTable.tsx'sdownloadDocjust setsa.href = url; a.click(). - Why a string replace can't fix it: the SigV4 signature is computed over the signed
host, so rewriting
storage:9000→localhost:9000after 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 sendsCOOP: unsafe-none, scoped to that one route; the strict nonce CSP is intact. (The?error=detail is also<-escaped inmcpOAuthPopupHtml- but see the correction below: since main'sa104accethat 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 tomain). 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_URLmust equalMIKE_E2E_URL: the OAuth callback posts its result tonew URL(frontendUrl()).origin, and flows step 4 asserts the opener receives it.R2_PUBLIC_ENDPOINT_URLdefaults tohttp://localhost:${STORAGE_PORT:-9000}indocker-compose.yml, so it followsSTORAGE_PORTautomatically. 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:
- signed up + signed in
- oauth popup:
about:blankwindow.openallowed in-shell - oauth popup: survives cross-origin navigation
- oauth popup: live opener back-channel, no
mikeDesktopbridge - external link: no in-shell window, URL captured
- renderer
file:navigation of the main window does not land - popup cannot drive its opener into a silent download
- failing iframe does not trigger the connect screen
- blob download is saved by the shell download handler
- library upload + row-menu Download stays in-shell
- 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 elevenstep()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→ newdescribe("GET /user/mcp-connectors/oauth/callback"), 3 cases: COOP relaxed alongside the nonce CSP; helmet's defaultsame-originCOOP still applied on every other route (contrast probe againstGET /user/profile); a</script>breakout in?error=never reaches the popup page.backend/src/lib/__tests__/storagePresign.test.ts→ 2 cases: signs againstR2_PUBLIC_ENDPOINT_URLrather than the internal endpoint (asserts the signed host and thatX-Amz-Signaturesurvives); falls back to theR2_ENDPOINT_URLhost when no public endpoint is set. Deliberately does not mock the presigner - it usesvi.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:
mainrewrote the signup page - it now has only Email / Password (Min. 10 Characters) / Confirm Password and redirects to/onboarding/profile- while bothapp.e2e.mjsandflows.e2e.mjsstill filledYour name,Your organisation,Enter your email, andCreate a password (min. 6 characters). Step 1 of both suites failed against currentmain's frontend, which zeroed out every assertion behind it.Now (
98baec3b), both suites drive the real contract:
- signup fills
#password/#confirmPasswordand submits the page's lonetype="submit"(the Google button istype="button"); the run password already clearedMIN_PASSWORD_LENGTH = 10and now says so in a comment.- onboarding is walked, not skipped:
OnboardingGatebounces any signed-in user withprofile.onboardingComplete === falseback to/onboarding/profilefrom every non-auth route, so the suites fill#name/#organisation→ Continue (step 1 of 2), then click Skip on the practice form (step 2 of 2), which callscompleteOnboarding({})and replaces the URL with/assistant.- the post-signup URL assertion now excludes
/onboardingas 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 fromprocess.arch(arm64→mac-arm64, otherwisemac) at the single placeAPP_BINARYis 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 passnode --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.mjscomments citecomponents/shared/DocTable.tsx(nowcomponents/documents/DocTable.tsx) and several stale line numbers;npm run e2e:flowsis not documented indesktop/README.md; andMIKE_DESKTOP_DEV, set bynpm run dev, is read nowhere inmain.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.
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.plistare in place, andnpm run dist:signedsigns- 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 + .zipStep-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 indesktop/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.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
ziptarget is already emitted for it; without it every release is a manual re-download. arm64-only vsarm64+x64vs universal binary. The e2e suites no longer stand in the way: as of98baec3bboth derive the mac output directory fromprocess.arch(arm64→dist/mac-arm64, otherwisedist/mac) instead of hardcodingmac-arm64.
- Release workflow: none of the ten CI workflows touch
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 registeredmike://protocol handler. Login itself is unaffected (email/password only, no social OAuth). Tell me if you want deep links and I'll implement them.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.
Self-hosters:
R2_PUBLIC_ENDPOINT_URLfor 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 indocker-compose.ymlandbackend/.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.