Pairing decrypts a Chromium browser's cookie store (PBKDF2-HMAC-SHA1 against its Keychain key, then AES-128-CBC) and injects the result into WKHTTPCookieStore. Only the configured apps' hosts and their sign-in hosts survive the filter. Browsers are offered most-recently-used first, since the first entry becomes the default and someone with four Chromium browsers installed wants the one they actually browse in. WKWebView defines window.Notification but it does nothing: constructing one throws no error and shows no banner, so a page believes it notified you. Measured on the machine as `api=function shim=no` before the shim was made unconditional; `from page: Odoo - Test notification -> raised` after. Anything on a page can be right-clicked away. The rule is re-asserted on every navigation, because the injected script only carries a snapshot from when the view was built and a selector added since would otherwise come back on reload. The top bar is gone. Navigation lives beside the cog, the nav carries the traffic lights, and two-finger swipe goes back and forward. The seed is now the real app list, scoped to exact hosts so a Drive link inside Gmail switches rather than being swallowed.
11 KiB
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
unstablein Tauri. The API can change between minor versions, sotauriis 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/cargo1.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
ResizeObserveron an empty<div id="stage">sends its bounding rect to Rust in logical pixels; Rust callsset_boundson the active webview andhide()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:
- Copy the profile's
CookiesSQLite file — Chrome holds a lock on the original — and read it withrusqlite. - Read the
Chrome Safe Storagekey from the login Keychain, derive AES-128 with PBKDF2-HMAC-SHA1 (saltsaltysalt, 1003 iterations), and decrypt thev10values. - Keep only cookies whose domain matches a configured app's scope or the identity-provider list. Nothing else is read out of the browser.
- 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 <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.