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.
223 lines
11 KiB
Markdown
223 lines
11 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, 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 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.
|