# 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, plus opt-in import from a paired browser | The jar is mandatory either way; the import only saves first logins |
| 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`.
- **Device-bound sessions defeat cookie import.** Google and Microsoft increasingly bind a
session to the browser that created it. Imported cookies will sometimes be rejected and
the tool asks for a real login once. The persistent jar keeps it from then on.
- **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.
### Browser pairing
Settings lists the browsers actually installed. Pairing with a Chromium browser:
1. Copy the profile's `Cookies` SQLite file — Chrome holds a lock on the original — and
read it with `rusqlite`.
2. Read the `Chrome Safe Storage` key from the login Keychain, derive AES-128 with
PBKDF2-HMAC-SHA1 (salt `saltysalt`, 1003 iterations), and decrypt the `v10` values.
3. Keep only cookies whose domain matches a configured app's scope or the identity-provider
list. Nothing else is read out of the browser.
4. Inject them into `WKHTTPCookieStore`.
Pairing is a button, not a background job. Cookies rotate; a silent task that periodically
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.
Service-worker push is **not** covered — only notifications a page raises while it is open.
## 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.