# 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 `
` 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`. 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 ``. 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.