Files
work-app/docs/superpowers/specs/2026-09-01-work-app-design.md
T
Vincent f057268103 Prove cookie injection lands, rather than assuming it
setCookie is fire-and-forget, so the import's count was what WebKit was
handed, not what it kept. A probe from inside the page reads back the
other end: pairing against Arc gave `names=tz,cids,frontend_lang` for
aputure.odoo.com, and `tz` exists only in Arc's store — so the import
demonstrably landed.

The sites still ask for sign-in. That is the far end refusing the
session, not a broken import, and the two are now distinguishable
instead of being guessed at.
2026-09-01 12:21:41 +02:00

223 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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`.
**Verified on the machine.** Pairing against Arc imported 43 cookies across 9 domains,
and a probe from inside the page then read back `host=example.odoo.com
names=tz,cids,frontend_lang visible=3``tz` existing only in Arc's store, which is what
proves the import landed rather than merely being handed over. `setCookie` is
fire-and-forget, so the import's own count could never have shown this.
The sites still presented sign-in pages. That is the documented limitation, not a broken
import: the cookies are in the store and visible to the page, and the session is being
refused at the far end. Settings keeps the probe as **Check cookies**, so the same
question can be answered again without guessing.
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.