Skip to content

[Mac app] Native desktop shell hosting the web app - #302

Draft
amal66 wants to merge 9 commits into
open-legal-products:mainfrom
amal66:feat/mac-desktop-shell
Draft

[Mac app] Native desktop shell hosting the web app#302
amal66 wants to merge 9 commits into
open-legal-products:mainfrom
amal66:feat/mac-desktop-shell

Conversation

@amal66

@amal66 amal66 commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

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
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.

This branch has moved past the prototype: it is rebased onto current main and
every web-app flow that broke inside the shell has been fixed and covered by an
end-to-end test that drives the packaged Mike.app against a real local
stack.


What was broken, 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
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
Failing iframe Any subframe/subresource failure showed the "can't reach server" screen did-fail-load honors isMainFrame
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)
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 (⌘1–6, ⌘, Settings); min 800×600

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

  • OAuth popup window.opener — helmet's default Cross-Origin-Opener-Policy: same-origin severs window.opener once the popup returns from the
    cross-origin consent page, so the postMessage result was lost. The callback
    route now sends COOP: unsafe-none (scoped to that one route; strict nonce CSP
    intact; 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't
    resolve. 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 flow regression suite (desktop/e2e/flows.e2e.mjs) — 12/12 steps:
    OAuth popup mechanics (real popup, cross-origin survival, live window.opener,
    API-origin postMessage received, 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.
  • Baseline packaged-app e2e (desktop/e2e/app.e2e.mjs) — signup → project
    create → chrome sanity: PASS.
  • Backend unit suite — 613 passing (incl. new tests for the COOP/CSP headers
    and the presign endpoint split).
  • Zero-config first run — packaged Mike.app, fresh user-data dir, no
    overrides → loads the hosted login at https://app.mikeoss.com in-shell
    (CDP-verified). With an unreachable --server-url= the connect screen
    appears with the URL prefilled, as before. Both e2e suites pass
    --server-url= explicitly, so the hosted default can never leak into tests.

Reproduce locally:

# stack (shifted ports so it won't collide with a dev server on :3000/:3001)
cd desktop && npm run dist           # package Mike.app
MIKE_E2E_URL=http://localhost:3100 MIKE_E2E_API=http://localhost:3101 \
  node e2e/flows.e2e.mjs
cd ../backend && npx vitest run

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:

cd desktop && npm install && npm run dist
open dist/mac-arm64/Mike.app                       # loads https://app.mikeoss.com
# or aim it at a local/self-hosted stack:
open dist/mac-arm64/Mike.app --args --server-url=http://localhost:3100

Signing/notarization (below) only matters for shipping a downloadable .dmg to
end 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:

  • Menu bar — ⌘N (New Chat), ⌘1–⌘6 (Assistant/Projects/Library/Tabular
    Review/Workflows/History), ⌘, (product Settings), ⌘⇧, (Change Server →
    connect screen appears).
  • 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.
  • (if you have connector credentials) one real Drive/Slack connect
    consent popup opens, closes itself, the connector shows as connected.

⚠️ 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 six CI workflows touch desktop/ today, so
      the 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.
    • 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.
  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.

🤖 Generated with Claude Code

@amal66
amal66 marked this pull request as draft August 12, 2026 16:22
@amal66
amal66 force-pushed the feat/mac-desktop-shell branch from e66f4d6 to 556c8d6 Compare August 16, 2026 21:58
@amal66 amal66 changed the title [Prototype] feat: Mike for Mac — native desktop shell hosting the web app (menu bar, origin fence, offline screen, packaged .app + e2e) feat: Mike for Mac — native desktop shell hosting the web app Aug 16, 2026
Comment thread desktop/e2e/flows.e2e.mjs Fixed
@amal66 amal66 changed the title feat: Mike for Mac — native desktop shell hosting the web app [Mac app] Native desktop shell hosting the web app Aug 16, 2026
@amal66
amal66 marked this pull request as ready for review August 16, 2026 23:15
Comment thread desktop/e2e/flows.e2e.mjs
Comment on lines +379 to +381
return readFileSync(CAPTURE_FILE, "utf8")
.split("\n")
.includes(EXTERNAL_URL);
@amal66
amal66 requested a review from willchen96 August 17, 2026 04:46
@amal66
amal66 marked this pull request as draft August 18, 2026 14:06
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

amal66 and others added 9 commits August 21, 2026 10:21
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
amal66 force-pushed the feat/mac-desktop-shell branch from 983afb9 to 17f9346 Compare August 21, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants