# Work App — Design Spec
**Date:** 2026-09-01
**Status:** Approved for implementation
**Type:** Prototype, iterated in place
## Purpose
A desktop app that is a browser for one thing only: the web tools used for work. A left
nav lists those tools; clicking one shows it. Links between configured tools navigate
inside the app; every other link leaves for the real browser.
It is not a general browser. There is no address bar, no tab strip, and no way to reach a
site that is not on the list.
## Constraints and decisions
Settled during brainstorming. Not open questions.
| Decision | Choice | Rationale |
|---|---|---|
| Shell | Tauri 2.11 · React 19 · Vite 7 · Tailwind 4 · TypeScript | Matches FlightTube, the reference app on this machine |
| App rendering | One child webview per app (`add_child`) | Iframes are impossible: Google, Microsoft and most SaaS send `X-Frame-Options: DENY` |
| Sessions | Persistent per-app cookie jar | You sign in once per tool and it sticks |
| Link routing | Injected JS click interceptor; permissive `on_navigation` | A strict navigation filter breaks every OAuth redirect chain |
| Tab memory | Every app is a live webview, hidden when inactive | Keeps scroll position, drafts and timers across switches |
| Collapsed nav | ~52px icon rail | Still clickable when collapsed |
| Storage | One `apps.json` in the app config dir | A list of a dozen apps does not need SQLite |
| Platform | macOS only | Single webview engine, single cookie store, no cross-platform branches |
### Known limitations, accepted
- **Multi-webview is `unstable` in Tauri.** The API can change between minor versions, so
`tauri` is pinned to `=2.11.5`.
- **Browser cookie import was built, measured, and removed.** It worked mechanically —
43 cookies decrypted from Arc and verifiably visible to the page — but Google, Microsoft
and Odoo all refused the imported sessions, because each binds a session to the browser
that created it. Signing in once inside the app is both simpler and more reliable, so
the whole import path was deleted rather than kept as a feature that mostly fails.
- **Bounds sync trails layout by a frame.** A native webview is positioned from measurements
the shell reports, so during a window resize it can lag. Every app in this class does.
- **The Chrome user agent is a lie.** Sites that sniff deeply may behave oddly. It is on by
default because Google refuses logins from anything it identifies as an embedded webview,
and it is overridable per app.
- **Reading Chrome's cookies raises a Keychain prompt.** Once, on pairing. That is macOS
asking permission, and it is the correct behaviour.
## Verified environment
Confirmed present before writing this spec:
- `rustc` / `cargo` 1.98.0 (aarch64-apple-darwin)
- Node v22.22.2, npm 10.9.7
- Xcode Command Line Tools (sufficient; full Xcode not required)
Every API the design leans on was confirmed to exist in the vendored `tauri` 2.11.5 source:
`Window::add_child`, `Webview::{hide, show, set_bounds, bounds}`,
`WebviewBuilder::{on_navigation, on_new_window, user_agent, initialization_script,
data_store_identifier, on_page_load, on_download}`, and `NewWindowResponse::Deny`.
## Architecture
The window's own webview is the **shell**: React, drawing the nav, the top bar, settings
and dialogs. Each configured app is a **child webview** stacked in a "stage" rect to the
right of the nav.
```
┌ window ─────────────────────────────────────────────┐
│ ┌ nav ──────┐ ┌ stage ───────────────────────────┐ │
│ │ shell │ │ child webview (active app) │ │
│ │ webview │ │ ...others hidden behind it │ │
│ └───────────┘ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
Child webviews are native views layered above the shell's content. They do not participate
in CSS layout and they paint over anything the shell draws beneath them. Two consequences:
- **The shell reports the stage rect.** A `ResizeObserver` on an empty `
`
sends its bounding rect to Rust in logical pixels; Rust calls `set_bounds` on the active
webview and `hide()` on the others.
- **Modals hide the stage.** Opening Settings hides the active app webview, or it would
paint straight over the dialog.
All webviews are created at startup, but their loads are **staggered**: the active app
first, the rest over the following seconds, so launching does not fire a dozen simultaneous
page loads.
### Rust modules
| Module | Responsibility |
|---|---|
| `config.rs` | `apps.json` load, save, seed, migrate |
| `routing.rs` | Pure URL → decision. No I/O, fully unit-tested |
| `webviews.rs` | Child webview registry: create, show, hide, bounds, navigate |
| `cookies/mod.rs` | Installed-browser detection |
| `cookies/chrome.rs` | Chromium cookie DB read and `v10` decryption |
| `cookies/inject.rs` | `WKHTTPCookieStore` injection (macOS) |
| `commands.rs` | The Tauri command surface |
| `lib.rs` | Builder, menu bar, setup |
### Data model
`apps.json`, in the app config dir. Apps are flat with a `groupId` rather than nested, so
dragging one between groups is a field change and not a tree rewrite.
```
apps[]: { id, name, url, scope[], groupId, favicon, userAgent?, order }
groups[]: { id, name, collapsed, order }
settings: { navCollapsed, theme, pairedBrowser, lastPairedAt }
```
`scope` defaults to the URL's **exact host** — `mail.google.com`, not `google.com` — or
Gmail and Drive would each swallow the other's links. Extra hosts are addable per app. A
URL matches an app if its host equals, or is a subdomain of, a scope entry. On ambiguity
the longest match wins.
### Link routing
An `initialization_script` in every app webview intercepts real user clicks and
`target=_blank`, and decides:
| Target | Action |
|---|---|
| In the current app's scope | Allow |
| A known identity provider | Allow — this is what keeps SSO working |
| In another app's scope | `preventDefault()`, switch to that app and navigate it |
| Anything else | `preventDefault()`, hand to the default browser |
The identity-provider list (`accounts.google.com`, `login.microsoftonline.com`,
`login.live.com`, Okta, Auth0, Duo, `appleid.apple.com`, `github.com/login`, …) exists
because a "Sign in with Google" button is a user click to a foreign host, and the naive
rule would send it to the external browser and strand the login there.
`on_navigation` returns `true` unconditionally and only reports the URL back to the shell
for the top bar. Redirects, meta-refreshes and OAuth bounces are never blocked.
`on_new_window` returns `NewWindowResponse::Deny` and re-runs the same decision in Rust, so
`window.open` cannot escape into a stray window. A `_blank` link within the same app
navigates that app's webview in place.
reaches into the Keychain is worse than one the user presses when something logs them out.
## UI
The design system is ported from FlightTube's `ui.tsx`: 30px control height, slate and sky,
outline-first controls, borders for separation and shadows only for elevation, a 9–15px type
ladder, and light and dark both designed rather than one derived from the other.
- **Nav, expanded (~240px)** — traffic-light drag inset, title with cog and collapse
chevron, then groups as collapsible sections with uppercase tracked labels, apps as
favicon-and-name rows. The active row inverts to `bg-slate-900 text-white`.
- **Nav, collapsed (~52px)** — favicons only, active marked with a left accent bar, name on
hover, hairline dividers between groups. Still clickable, so switching does not require
expanding.
- **Top bar (~38px)** — back, forward, reload, the current URL muted and truncated, and
open-in-browser. WKWebView's swipe-back gesture is enabled alongside it.
- **Settings** — Apps, Groups, Browser pairing, Appearance.
- **First boot** — an empty state offering "Add your first app" and "Pair with a browser",
seeded with test apps across three groups so cross-app routing is demonstrable at once.
## Testing
`routing.rs` and `config.rs` are pure and carry real unit tests: scope matching and its
precedence, identity-provider passthrough, the four routing decisions, serde round-trips,
and seeding. Cookie decryption is tested against a fixture database with a known key.
Webview orchestration and bounds sync have no seam that a unit test can reach. They are
verified by running the app and driving it.
## Notifications
WKWebView defines `window.Notification`, but it is inert: constructing one throws nothing
and shows nothing, so a page believes it has notified you and you never hear about it. It
is replaced by a shim that forwards over the same sentinel channel as everything else, and
Rust raises a real macOS notification carrying the app's name.
Verified on the machine: `permission: Granted · direct: raised · page: api=function shim=no`
was the reading that showed the native API existed and the shim had therefore never
installed. The shim now replaces it unconditionally.
A click on the banner switches to the app that raised it and then runs the page's own
click handler, which is the only thing that knows which message the notification was
about. This is why notifications are raised through `mac-notification-sys` rather than
Tauri's notification plugin: the plugin cannot report a click.
Three things had to be right for a banner to actually appear on screen, and each was
wrong at first:
1. **Identity.** `set_application` has to be called with the bundle identifier. Left alone,
mac-notification-sys looks up an application named `"use_default"`, fails, and posts as
`com.apple.Finder` — so notifications arrive wearing another app's alert style.
2. **Presentation while frontmost.** `NSUserNotificationCenter` suppresses the banner
whenever the posting app is in front, delivering it silently to Notification Centre
instead, unless the delegate implements `shouldPresentNotification:`. The crate's
delegate implements only delivery and activation, so the missing method is added to its
class at runtime. Without it, Gmail notifying while you sit in Odoo — the same window,
still frontmost — would never be seen.
3. **Waiting for the click.** `send_notification` only blocks for a response when the
options ask it to. Passing `None` returns immediately with `NotificationResponse::None`,
so no click is ever observed. Waiting costs a parked thread, and a notification left in
Notification Centre never resolves, so the number of waiters is capped at 32; past that
the notification still appears but cannot be clicked through.
Service-worker push is **not** covered — only notifications a page raises while it is open.
## Keeping background apps alive
The app is only worth having if a tool you are not looking at still tells you something
arrived. Getting there took three wrong turns, and each one is why the code looks as it
does:
1. **Hiding the inactive views.** The obvious way to switch apps, and it kills them:
WebKit reads a hidden `NSView` as a page that is not visible, throttles its timers and
eventually suspends it. Now nothing is hidden — every view keeps its full size and stays
in the window, and the active one is simply ordered on top. Sibling views covering each
other is not something WebKit tracks.
2. **Telling the page it was hidden.** The idea was that a page believing itself hidden
would notify rather than stay quiet. It backfired: told it was hidden, Gmail throttled
its *own* syncing, and its unread count sat unchanged for two and a half minutes. The
spoof is gone. Pages are told nothing about visibility.
3. **Assuming that was enough.** It was not. A backgrounded page keeps running its timers
but loses the long-lived connection its updates arrive on, and WKWebView offers no
equivalent of Electron's `backgroundThrottling: false`. So every background app is
**poked every 45 seconds** with the events a page uses to catch up when you return to a
tab. It fetches as if you had just looked at it, and keeps its state — unlike a reload,
which would throw away a half-written reply.
**Measured end to end:** Gmail behind Odoo went from 155 to 157 unread without being
touched, and the app raised `Gmail / 1 new`.
### Unread counting
Notifications do not come from the site's own notification code, because Gmail declines to
raise one while it believes you are looking at it — and with the visibility spoof gone, it
always believes that. They come from the **unread count in the title**: `Inbox (12)`,
`(3) Chat`. Every one of these tools publishes it, it cannot be switched off, and it never
moves into a service worker this app cannot reach. A rise while the app is not the one on
screen raises a banner; a fall is you reading things, and is not news.
The site's own notifications still work when they fire — both paths feed the same channel.
### Counting what was missed
Each rise in an app's unread count while you are elsewhere adds to a per-app tally shown
against its name in the nav — and on the rail, as a dot on the icon. Looking at an app is
the only thing that clears it; nothing else does, because nothing else means you have seen
it. Counts live in memory rather than `apps.json`: a restart reloads every app anyway, and
a number that survived would be a claim the app can no longer support.
## App marks
Each app shows **the icon its own page declares**, exactly as a browser would.
Two earlier attempts were wrong in the same way. Google's favicon service and Simple
Icons both answer a question about a *domain*, and a domain does not know which product
it is serving: the service returned a marketing site's icon for anything behind a login
and nothing for a private host, and Simple Icons returned a single flat brand mark where
the real one is multicoloured and, in Gmail's case, carries the unread count.
The page has the answer already — fetched, authenticated, and current. So the injected
script reads `link[rel~="icon"]` and reports the best one: largest declared `sizes` wins,
an Apple touch icon counts as 180, and an `.ico` is penalised because it is usually the
16px one drawn for a browser tab in 2005. It rechecks on the same tick as the unread
count, because that is exactly when a site like Gmail redraws its icon with a badge on it.
The URL is stored in `apps.json`, so the nav is right the moment it opens rather than
blank until every page has loaded. An app with nothing yet — or an icon that will not
load — falls back to its initial in a tile of the same size, so the row never reflows.
## The window
The window keeps its ordinary macOS title bar, with the traffic lights where every other
window puts them. It follows the app's own Light or Dark choice through `set_theme`;
"System" hands it back to the OS, which is what System means.
An earlier version drew its own frame instead — a hidden title bar, a margin to grab, and
a corner radius applied to each app's layer because a native view sitting on top of the
shell cannot be clipped by the CSS around it. It is gone. A window that behaves like a
window is worth more than one that looks bespoke.
### The title bar offset
A child webview is positioned against the **window frame**; the shell measures the hole it
left from inside the **content view**. With a borderless window those origins coincide, so
this never came up. With a title bar they are a title bar apart, and every app was drawn
that much too high: it painted over the right-hand part of the bar — which read as the bar
being tinted by whichever site was open — and left a strip of the same height along the
bottom.
Tauri cannot report that height. Measured on this machine, `inner_position` and
`outer_position` return the same point, and `inner_size` and `outer_size` return the same
size, against a window whose content is plainly a title bar shorter than its frame. Both
differences are zero and both are useless.
`NSWindow.contentLayoutRect` knows. The height is asked for once through it and cached,
since it does not change.
The margin That margin is the only part of the window that is not a web page, and
therefore the only place left to grab it by. Full screen has no use for it and gets the
room back.
## Dialogs
An app's webview is a native view that paints above the shell, so a dialog cannot simply
sit on a higher z-index — the app has to be moved out of the way first. Once it is moved
there is nothing left behind the dialog to look at, so a still is taken on the way out with
`takeSnapshotWithConfiguration` and shown blurred behind it.
The snapshot is deliberately 640px wide: it is going behind a 16px blur. It is also taken
on a blocking worker rather than the calling thread — its completion handler runs on the
main thread, and waiting for it *there* deadlocks until the timeout and returns nothing
every time.
## Zoom
Per app, on a fixed ladder so ⌘0 returns to exactly 100% rather than to whatever a
repeated multiplier happened to leave behind. The shortcuts are menu-bar accelerators, not
a key listener: the keystroke has to work while an app's own webview has focus, and that
webview is a remote page this app deliberately cannot script for input.
## Hiding elements
Anything on a page can be right-clicked and hidden. The injected script owns a stylesheet
of `display: none` rules, re-added when a single-page app rewrites ``. Selectors are
per app and stored in `apps.json`, editable and reversible in Settings.
The selector generator prefers an id, then up to two stable-looking classes per level,
then position — skipping classes that look hashed or numbered, because those change on
every deploy. A selector that stops matching hides nothing; it never hides the wrong thing.
## Out of scope
- Windows and Linux.
- Tabs, history, bookmarks, an address bar — it is not a general browser.
- Per-app session isolation for multiple accounts on one service. One shared jar for now.
- Automatic background cookie re-sync.
- Notifications, badges and unread counts.