Add a handover note for forking

The useful thing to hand someone is not what the code does - the README
and the spec cover that - but the constraints that cost time to find:
native view layering, why an inactive webview must not be hidden, why a
background page must not be told it is hidden, and the several APIs that
deadlock or silently return a fallback when called from the wrong thread.
This commit is contained in:
2026-09-02 20:00:44 +02:00
parent 36506718c2
commit 8cc34c48ed
+113
View File
@@ -0,0 +1,113 @@
# Work — notes for whoever picks this up
A macOS desktop app that is a browser for one thing: the web tools you work in. A left
nav lists them, clicking one shows it, links between them navigate inside the app, and
everything else opens in your real browser. No address bar, no tab strip, no way to reach
a site that is not on the list. That last part is the point, not a limitation.
Tauri 2 · Rust · React 19 · TypeScript · Tailwind 4 · macOS only.
```bash
npm install
npm run tauri dev # develop
npm run ship # build, replace /Applications/Work.app, relaunch
npm run ship -- --dmg # …and put an installer on the Desktop
cd src-tauri && cargo test # 26 tests, all pure logic
```
Read `README.md` for what it does and
`docs/superpowers/specs/2026-09-01-work-app-design.md` for why it is built this way. The
git history is included and the commit messages carry the reasoning — most of them
document a wrong turn before the fix.
## The shape of it
`src/` is the **shell**: the window's own webview, drawing the nav, dialogs, and an empty
`<div id="stage">`. `src-tauri/src/` is Rust. Each configured app is its **own child
webview** (`Window::add_child`, behind Tauri's `unstable` feature) positioned into the
stage rect the shell measures and reports.
| File | What it owns |
|---|---|
| `src-tauri/src/routing.rs` | Pure URL → stay / switch app / open externally. Unit-tested. |
| `src-tauri/src/config.rs` | `apps.json`: apps, groups, hidden selectors, zoom, settings. |
| `src-tauri/src/webviews.rs` | The child webviews, and every message from a page. |
| `src-tauri/src/inject.js` | Runs inside every app: routing, hiding, notifications, unread, passwords. |
| `src-tauri/src/downloads.rs` | Where downloads go and how they are announced. |
| `src-tauri/src/commands.rs` | The command surface and all shared state. |
**Pages talk to Rust over a made-up URL scheme**, not Tauri IPC — `workapp-route:`,
`workapp-notify:`, `workapp-hide:` and friends. `on_navigation` answers them and cancels
the navigation, so nothing ever loads. This is deliberate: IPC to a remote origin would
mean granting google.com the ability to call into the app, and routing a link does not
need anything that dangerous.
## Things that cost real time here
Every one of these was found the hard way. None is obvious from the docs.
**A child webview is a native view painted above everything the shell draws.** A dialog
cannot simply sit on a higher z-index — the app has to be moved out of the way first. A
toast floating over the page is a toast *behind* the page. Anything the shell draws must
either live in the nav, or the webviews must be moved aside for it.
**Never `hide()` an inactive webview.** WebKit reads a hidden `NSView` as a page that is
not visible, throttles its timers, and eventually suspends it — a backgrounded Gmail then
stops fetching mail. Nothing is hidden; the active view is ordered on top and the rest
stay full-size behind it.
**Do not tell a background page it is hidden either.** Spoofing `document.visibilityState`
seemed like the way to make it notify rather than stay quiet. It made Gmail throttle its
*own* syncing instead: its unread count sat unchanged for two and a half minutes. Pages are
told nothing. Instead every background app is poked every 45s with the events a page uses
to catch up after you return to a tab.
**Emit events from a spawned task, never from a webview callback.** `on_download` and
friends run on the main thread inside WebKit's delegate, and delivering a Tauri event means
running JavaScript in a webview — which cannot happen from in there. Emitted directly, the
event is silently lost.
**Anything that waits on a main-thread answer must not run on the main thread.** Snapshots,
the traffic-light geometry, the title bar height: each deadlocks until its timeout and
returns a fallback, silently, if called straight from a sync command. Measure once at
startup or use `spawn_blocking`.
**Tauri cannot tell you the title bar height on macOS.** `inner_position`/`outer_position`
return the same point and `inner_size`/`outer_size` the same size, against a window whose
content is plainly a title bar shorter than its frame. `NSWindow.contentLayoutRect` knows.
The window's content also runs the *full* height underneath the bar, so the shell insets
itself — and those inset strips must be `data-tauri-drag-region` or the window cannot be
dragged.
**WKWebView defines `window.Notification` and it does nothing.** No error, no banner — a
page believes it notified you. It is replaced outright. Notifications are raised through
`mac-notification-sys` rather than Tauri's plugin, because the plugin cannot report a
click; `set_application` must be called or they arrive as `com.apple.Finder`; and the
delegate needs a `shouldPresentNotification:` added at runtime or macOS suppresses the
banner whenever the app is frontmost.
**Icons come from the page, not an icon service.** A service asked about a domain can only
guess — it returns a marketing site's icon for anything behind a login and nothing for a
private host. The injected script reads `link[rel~="icon"]` instead, which is how Gmail's
unread badge ends up in the nav.
**Scope is the exact host.** `mail.google.com`, not `google.com`, or Gmail and Drive
swallow each other's links. Longest match wins, and the app you are in gets first refusal.
## Deliberately not built
- **Browser cookie import.** It was built, measured, and removed: 43 cookies decrypted
from Arc and verifiably visible to the page, and Google, Microsoft and Odoo all refused
the imported sessions anyway. Signing in once inside the app is simpler and works.
- **Windows and Linux.** Every hard-won thing above is AppKit-specific.
- **Tabs, history, bookmarks, an address bar.** Not a general browser.
- **Password autofill on page load.** Saving and an explicit right-click fill exist;
filling automatically is a much larger surface and was left as its own decision.
## Known gaps
- Service-worker push notifications are not covered — only what a page raises while open.
- Downloads report a start and a finish and no progress; the file is polled as it grows.
- One saved login per app; a second account on the same tool overwrites the first.
- Ad-hoc signed, not notarised: a first launch elsewhere needs right-click → Open.
- Apple Silicon only as configured.