From 8cc34c48ed5b0f9e8ed9eadaa2485cc158881333 Mon Sep 17 00:00:00 2001 From: Vincent Date: Wed, 2 Sep 2026 20:00:44 +0200 Subject: [PATCH] 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. --- FORK.md | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 FORK.md diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000..3ea847f --- /dev/null +++ b/FORK.md @@ -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 +`
`. `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.