On a machine where rustup has no default toolchain, tauri-cli calls `cargo metadata` without specifying one and panics in its metadata thread with no useful error — just an abort. The file lives at the project root (not src-tauri/) because tauri-cli invokes cargo from there; rustup walks up from the CWD, so the root copy is the one that gets picked up. Also notes this in FORK.md so a future forker knows what they would hit and why the file must exist.
6.7 KiB
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.
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
rust-toolchain.toml at the project root pins the channel to stable and must be
present. On a fresh machine that has never set a rustup default, tauri-cli calls
cargo metadata without specifying a toolchain and panics in its metadata thread —
not a clear error message, just an abort. The file guarantees the toolchain is always
resolved correctly regardless of what the machine's rustup default is configured to.
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.