Files
work-app/docs/superpowers/specs/2026-09-01-work-app-design.md
T
Vincent ff4a0c6bc4 Browser pairing, notifications, element hiding; drop the top bar
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.
2026-09-01 12:17:31 +02:00

11 KiB
Raw Blame History

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 <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 hostmail.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.

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 915px 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.