Files
Vincent Rozenberg 76f2aa5928 Pin Rust toolchain to stable so tauri-cli never panics on a fresh machine
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.
2026-09-03 12:44:10 +02:00

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.