The end-to-end note for background liveness quoted a real unread count from a real mailbox. The point it makes is that a background app's count rises untouched, which it makes just as well without the number.
365 lines
19 KiB
Markdown
365 lines
19 KiB
Markdown
# 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 `<div id="stage">`
|
||
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:** with a mail app sitting behind another, its unread count rose on
|
||
its own without the app being touched, and a banner was raised from it.
|
||
|
||
### 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.
|
||
|
||
### Borders
|
||
|
||
One weight everywhere, and the lightest that still separates: `slate-200` in light,
|
||
`slate-800` in dark. Nothing is drawn heavier than it needs to be to read as an edge.
|
||
|
||
The stage rect is rounded to whole pixels, and to the same edges every time. A fractional
|
||
left edge leaves a sliver of the stage's own background between the nav's border and the
|
||
app beside it, and at a hairline weight that sliver reads as a second border line running
|
||
the full height of the window.
|
||
|
||
## 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.
|
||
|
||
## Passwords
|
||
|
||
Submitting a form that contains a password offers to remember it, the way a browser does.
|
||
|
||
It goes into the **macOS Keychain** — encrypted at rest, unlocked with the login session,
|
||
and the one place on this machine actually built to hold a password. Never `apps.json`,
|
||
never a log.
|
||
|
||
The value also travels as little as possible: the injected script hands it straight to
|
||
Rust, which holds it in memory and tells the shell only *which host* and *which username*,
|
||
because that is all the shell needs to ask the question. It is written on Save and dropped
|
||
on anything else. Autofill is not built — reading a password back out and injecting it into
|
||
a page is a larger surface than offering to store one, and worth deciding on separately.
|
||
|
||
## 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 `<head>`. 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.
|