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:
@@ -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.
|
||||||
Reference in New Issue
Block a user