Work App: a dedicated browser for work tools

A Tauri 2 shell with one child webview per configured tool. Nav on the left
with groups and a collapsible icon rail; links between configured apps switch
tabs, everything else leaves for the real browser.

Design spec in docs/superpowers/specs/2026-09-01-work-app-design.md.
This commit is contained in:
2026-09-01 11:37:35 +02:00
commit a6c8e4336b
53 changed files with 15580 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
src-tauri/target
.DS_Store
+88
View File
@@ -0,0 +1,88 @@
# Work
A desktop browser for one thing only: the web tools you work in. A left nav lists them,
clicking one shows it, and links between them navigate inside the app. Everything else
opens in your real browser.
Tauri 2 · Rust · React 19 · Tailwind CSS 4 · macOS
There is no address bar, no tab strip, and no way to reach a site that is not on the list.
That is the point.
## How it works
**Every app is its own webview.** Not an iframe — Google, Microsoft and most SaaS send
`X-Frame-Options: DENY`, so an iframe-based version of this app cannot exist. Each tool
gets a real child webview (`Window::add_child`), and they all stay loaded, so switching
keeps your scroll position, your half-typed draft, and anything counting down.
**The shell measures, Rust positions.** A child webview is a native view that takes no part
in CSS layout. The React shell leaves an empty `<div id="stage">`, measures it with a
`ResizeObserver`, and reports the rect; Rust sizes the active webview to it and hides the
rest. It also means a native view paints over anything the shell draws, which is why
opening Settings hides the stage first.
**Links are routed by intent, not by URL alone.** An injected script catches genuine user
clicks and `target=_blank`, and only those. A URL outside the app's own hosts is handed to
Rust over a made-up `workapp-route:` scheme — deliberately not Tauri IPC, which would mean
granting google.com the ability to call into this app. Rust then decides:
| Target | What happens |
|---|---|
| The current app's hosts | Nothing — ordinary navigation |
| A known identity provider | Stays inside, so SSO can complete |
| Another app on your list | Switches to that app and navigates it |
| Anything else | Opens in your default browser |
Redirects are never blocked. `on_navigation` returns `true` for everything that is not the
sentinel, because a strict navigation filter breaks every OAuth chain the moment it bounces
through `accounts.google.com`.
**Sessions persist.** Each webview keeps its cookies across restarts, so you log into a
tool once. Every app also claims a real Chrome user agent by default, because Google
refuses logins from anything it identifies as an embedded webview.
## Apps and groups
An app owns the **exact host** of its URL — `mail.google.com`, not `google.com` — or Gmail
and Drive would each swallow the other's links. Where two scopes match, the longest wins.
Extra hosts can be added per app.
Groups are for the nav only. Deleting one keeps its apps, ungrouped: deleting a folder
should never be a way to lose the things inside it.
The nav collapses to a 52px icon rail that is still clickable, so switching apps never
requires expanding it first.
## Running it
```bash
npm install && npm run tauri dev
```
## Shipping it
Build, replace the copy in `/Applications`, and relaunch — one command:
```bash
npm run ship
```
No disk image; nothing here is being distributed.
## Configuration
`apps.json`, under `~/Library/Application Support/com.vincent.workapp/`. Hand-editing it is
supported — missing fields fall back to their defaults, and an app with no `scope` gets its
URL's host.
## Tests
```bash
cd src-tauri && cargo test
```
Routing and config are pure and carry real tests: scope matching and its precedence,
identity-provider passthrough, the routing decisions, sentinel round-trips, and config
round-trips. Webview orchestration and bounds sync have no seam a unit test can reach and
are verified by running the app.
@@ -0,0 +1,188 @@
# 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`.
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.
## 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.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Work</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2687
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "work-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"ship": "./scripts/ship.sh"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@tauri-apps/cli": "^2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"tailwindcss": "^4.3.3",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
#
# Build, install to /Applications, relaunch.
#
# The standing workflow for seeing a change: this replaces the installed app
# rather than producing a disk image, because nothing here is being distributed.
set -euo pipefail
cd "$(dirname "$0")/.."
APP="Work.app"
BUILT="src-tauri/target/release/bundle/macos/$APP"
DEST="/Applications/$APP"
echo "==> building"
npm run tauri build -- --bundles app
echo "==> quitting the running copy"
osascript -e 'quit app "Work"' >/dev/null 2>&1 || true
# A hung window would otherwise keep the bundle busy while it is replaced.
pkill -x Work >/dev/null 2>&1 || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
pgrep -x Work >/dev/null || break
sleep 0.3
done
echo "==> installing to $DEST"
rm -rf "$DEST"
cp -R "$BUILT" "$DEST"
echo "==> launching"
open "$DEST"
echo "==> done"
+5030
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "work-app"
version = "0.1.0"
description = "A browser for the tools you work in"
authors = ["Vincent"]
edition = "2021"
[lib]
name = "work_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
# Pinned: multi-webview lives behind `unstable`, whose API can move between minors.
tauri = { version = "=2.11.5", features = ["unstable"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["time"] }
url = "2"
uuid = { version = "1", features = ["v4"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "The shell webview. App webviews are deliberately absent: remote content gets no IPC.",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"opener:default"
]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"The shell webview. App webviews are deliberately absent: remote content gets no IPC.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","opener:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

+363
View File
@@ -0,0 +1,363 @@
//! The command surface. The shell owns "which app is active"; Rust owns the
//! webviews and the file on disk.
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{AppHandle, Manager, State};
use url::Url;
use crate::config::{self, App, Config, Group};
use crate::webviews;
/// Stage rect in logical pixels: x, y, width, height.
pub type Stage = (f64, f64, f64, f64);
pub struct AppState {
pub dir: PathBuf,
pub config: Mutex<Config>,
pub active: Mutex<Option<String>>,
pub stage: Mutex<Stage>,
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
pub booted: Mutex<bool>,
}
impl AppState {
pub fn cfg(&self) -> Config {
self.config.lock().unwrap().clone()
}
fn persist(&self) -> Result<(), String> {
config::save(&self.dir, &self.config.lock().unwrap())
}
}
pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
let dir = handle
.path()
.app_config_dir()
.map_err(|e| format!("no config directory: {e}"))?;
let config = config::load(&dir)?;
Ok(AppState {
dir,
config: Mutex::new(config),
active: Mutex::new(None),
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
booted: Mutex::new(false),
})
}
#[tauri::command]
pub fn get_config(state: State<'_, AppState>) -> Config {
state.cfg()
}
/// Records the rect the shell has left for an app, and resizes the one showing.
#[tauri::command]
pub fn set_stage(
x: f64,
y: f64,
width: f64,
height: f64,
app: AppHandle,
state: State<'_, AppState>,
) {
let stage = (x, y, width.max(0.0), height.max(0.0));
*state.stage.lock().unwrap() = stage;
let active = state.active.lock().unwrap().clone();
webviews::set_stage(&app, active.as_deref(), stage);
}
/// Creates every app's webview: the active one first, the rest staggered.
///
/// Called by the shell once it has measured the stage, so the views are born
/// at the right size instead of being built against a guess and corrected.
#[tauri::command]
pub fn bootstrap(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
{
let mut booted = state.booted.lock().unwrap();
if *booted {
return Ok(());
}
*booted = true;
}
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
let active = state.active.lock().unwrap().clone();
let first = active
.clone()
.or_else(|| cfg.ordered().first().map(|a| a.id.clone()));
if let Some(id) = &first {
if let Some(a) = cfg.app(id) {
webviews::create(&app, a, &cfg, stage)?;
*state.active.lock().unwrap() = Some(id.clone());
webviews::show_only(&app, Some(id), &cfg, stage);
}
}
// The rest follow with a gap, so launching does not fire a dozen
// simultaneous page loads at the network and the CPU.
let rest: Vec<App> = cfg
.ordered()
.into_iter()
.filter(|a| Some(&a.id) != first.as_ref())
.cloned()
.collect();
tauri::async_runtime::spawn(async move {
for a in rest {
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
let state = app.state::<AppState>();
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
if let Err(e) = webviews::create(&app, &a, &cfg, stage) {
eprintln!("could not create webview for {}: {e}", a.name);
}
}
});
Ok(())
}
#[tauri::command]
pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) {
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
*state.active.lock().unwrap() = Some(app_id.clone());
webviews::show_only(&app, Some(&app_id), &cfg, stage);
}
/// Hides every app, so a dialog is not painted over by a native view.
#[tauri::command]
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
webviews::hide_all(&app, &state.cfg());
}
/// Puts the active app back after a dialog closes.
#[tauri::command]
pub fn show_stage(app: AppHandle, state: State<'_, AppState>) {
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &cfg, stage);
}
#[tauri::command]
pub fn navigate_app(app_id: String, url: String, app: AppHandle) -> Result<(), String> {
let wv = app
.get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?;
let url = Url::parse(&url).map_err(|e| e.to_string())?;
wv.navigate(url).map_err(|e| e.to_string())
}
/// Back, forward and reload, run inside the app's own page.
#[tauri::command]
pub fn history_go(app_id: String, delta: i32, app: AppHandle) -> Result<(), String> {
let wv = app
.get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?;
let script = if delta == 0 {
"location.reload()".to_string()
} else {
format!("history.go({delta})")
};
wv.eval(&script).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn current_url(app_id: String, app: AppHandle) -> Option<String> {
app.get_webview(&webviews::label_for(&app_id))
.and_then(|wv| wv.url().ok())
.map(|u| u.to_string())
}
#[tauri::command]
pub fn open_external(url: String) -> Result<(), String> {
tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------- apps
#[tauri::command]
pub fn add_app(
name: String,
url: String,
group_id: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
let url = config::normalize_url(&url);
let scope = config::default_scope(&url)
.ok_or_else(|| format!("{url} is not a URL this can open"))?;
let new = {
let mut cfg = state.config.lock().unwrap();
let order = cfg
.apps
.iter()
.filter(|a| a.group_id == group_id)
.map(|a| a.order)
.max()
.map_or(0, |m| m + 1);
let new = App {
id: config::new_id(),
name: name.trim().to_string(),
url,
scope: vec![scope],
group_id,
user_agent: None,
order,
};
cfg.apps.push(new.clone());
new
};
state.persist()?;
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
if *state.booted.lock().unwrap() {
webviews::create(&app, &new, &cfg, stage)?;
}
Ok(cfg)
}
/// Applies an edit. A change to url, scope or user agent rebuilds the webview,
/// since none of the three can be altered on a live one.
#[tauri::command]
pub fn update_app(
updated: App,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
let rebuild = {
let mut cfg = state.config.lock().unwrap();
let existing = cfg
.apps
.iter_mut()
.find(|a| a.id == updated.id)
.ok_or_else(|| format!("no app {}", updated.id))?;
let rebuild = existing.url != updated.url
|| existing.scope != updated.scope
|| existing.user_agent != updated.user_agent;
*existing = updated.clone();
rebuild
};
state.persist()?;
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
if rebuild && *state.booted.lock().unwrap() {
webviews::destroy(&app, &updated.id);
webviews::create(&app, &updated, &cfg, stage)?;
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &cfg, stage);
}
Ok(cfg)
}
#[tauri::command]
pub fn delete_app(
app_id: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
cfg.apps.retain(|a| a.id != app_id);
}
state.persist()?;
webviews::destroy(&app, &app_id);
let mut active = state.active.lock().unwrap();
if active.as_deref() == Some(app_id.as_str()) {
*active = None;
}
Ok(state.cfg())
}
/// Rewrites group membership and position in one go, for a drag.
#[tauri::command]
pub fn reorder_apps(
ordering: Vec<(String, Option<String>, i32)>,
state: State<'_, AppState>,
) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
for (id, group_id, order) in ordering {
if let Some(a) = cfg.apps.iter_mut().find(|a| a.id == id) {
a.group_id = group_id;
a.order = order;
}
}
}
state.persist()?;
Ok(state.cfg())
}
// -------------------------------------------------------------- groups
#[tauri::command]
pub fn add_group(name: String, state: State<'_, AppState>) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
let order = cfg.groups.iter().map(|g| g.order).max().map_or(0, |m| m + 1);
cfg.groups.push(Group {
id: config::new_id(),
name: name.trim().to_string(),
collapsed: false,
order,
});
}
state.persist()?;
Ok(state.cfg())
}
#[tauri::command]
pub fn update_group(updated: Group, state: State<'_, AppState>) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
let g = cfg
.groups
.iter_mut()
.find(|g| g.id == updated.id)
.ok_or_else(|| format!("no group {}", updated.id))?;
*g = updated;
}
state.persist()?;
Ok(state.cfg())
}
/// Removes a group. Its apps survive, ungrouped — deleting a folder should
/// never be a way to lose the things inside it by accident.
#[tauri::command]
pub fn delete_group(group_id: String, state: State<'_, AppState>) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
cfg.groups.retain(|g| g.id != group_id);
for a in cfg.apps.iter_mut() {
if a.group_id.as_deref() == Some(group_id.as_str()) {
a.group_id = None;
}
}
}
state.persist()?;
Ok(state.cfg())
}
// ------------------------------------------------------------ settings
#[tauri::command]
pub fn set_nav_collapsed(collapsed: bool, state: State<'_, AppState>) -> Result<(), String> {
state.config.lock().unwrap().settings.nav_collapsed = collapsed;
state.persist()
}
#[tauri::command]
pub fn set_theme(theme: String, state: State<'_, AppState>) -> Result<(), String> {
state.config.lock().unwrap().settings.theme = theme;
state.persist()
}
+278
View File
@@ -0,0 +1,278 @@
//! `apps.json`: the list of tools, their groups, and the window's preferences.
//!
//! Apps are flat and carry a `group_id` rather than being nested inside their
//! group, so dragging one between groups is a field change instead of a tree
//! rewrite.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use url::Url;
use crate::routing::AppScope;
/// What the app claims to be. WebKit's own user agent gets a Google login
/// refused as "not a secure browser", which would make half the list unusable.
pub const CHROME_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct App {
pub id: String,
pub name: String,
pub url: String,
/// Hosts this app owns. Defaults to the URL's exact host — `mail.google.com`
/// rather than `google.com`, or Gmail and Drive each swallow the other.
#[serde(default)]
pub scope: Vec<String>,
#[serde(default)]
pub group_id: Option<String>,
#[serde(default)]
pub user_agent: Option<String>,
#[serde(default)]
pub order: i32,
}
impl App {
pub fn scopes(&self) -> Vec<String> {
if self.scope.is_empty() {
default_scope(&self.url).into_iter().collect()
} else {
self.scope.clone()
}
}
pub fn ua(&self) -> String {
self.user_agent.clone().unwrap_or_else(|| CHROME_UA.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Group {
pub id: String,
pub name: String,
#[serde(default)]
pub collapsed: bool,
#[serde(default)]
pub order: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
#[serde(default)]
pub nav_collapsed: bool,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default)]
pub paired_browser: Option<String>,
#[serde(default)]
pub last_paired_at: Option<String>,
}
fn default_theme() -> String {
"system".into()
}
impl Default for Settings {
fn default() -> Self {
Self {
nav_collapsed: false,
theme: default_theme(),
paired_browser: None,
last_paired_at: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Config {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub groups: Vec<Group>,
#[serde(default)]
pub apps: Vec<App>,
#[serde(default)]
pub settings: Settings,
}
fn default_version() -> u32 {
1
}
impl Default for Config {
fn default() -> Self {
Self {
version: 1,
groups: Vec::new(),
apps: Vec::new(),
settings: Settings::default(),
}
}
}
impl Config {
/// Scopes in the shape routing wants.
pub fn scopes(&self) -> Vec<AppScope> {
self.apps
.iter()
.map(|a| AppScope { id: a.id.clone(), scope: a.scopes() })
.collect()
}
pub fn app(&self, id: &str) -> Option<&App> {
self.apps.iter().find(|a| a.id == id)
}
/// Apps in the order the nav shows them: by group, then by position.
pub fn ordered(&self) -> Vec<&App> {
let group_rank = |id: &Option<String>| -> i32 {
match id {
None => -1,
Some(g) => self
.groups
.iter()
.find(|x| &x.id == g)
.map(|x| x.order)
.unwrap_or(i32::MAX),
}
};
let mut apps: Vec<&App> = self.apps.iter().collect();
apps.sort_by_key(|a| (group_rank(&a.group_id), a.order));
apps
}
}
/// The host of a URL, which is the app's scope until the user widens it.
pub fn default_scope(url: &str) -> Option<String> {
Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|h| h.trim_start_matches("www.").to_string()))
}
/// Normalises what someone types into an app's URL field.
pub fn normalize_url(input: &str) -> String {
let t = input.trim();
if t.starts_with("http://") || t.starts_with("https://") {
t.to_string()
} else {
format!("https://{t}")
}
}
pub fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
/// First-run contents: enough apps, across enough groups, that grouping and
/// cross-app link routing can both be seen working without typing anything.
pub fn seed() -> Config {
let mk = |name: &str, url: &str, group: &str, order: i32| App {
id: new_id(),
name: name.into(),
url: url.into(),
scope: default_scope(url).into_iter().collect(),
group_id: Some(group.into()),
user_agent: None,
order,
};
Config {
version: 1,
groups: vec![
Group { id: "g-google".into(), name: "Google".into(), collapsed: false, order: 0 },
Group { id: "g-dev".into(), name: "Dev".into(), collapsed: false, order: 1 },
Group { id: "g-ref".into(), name: "Reference".into(), collapsed: false, order: 2 },
],
apps: vec![
mk("Google", "https://www.google.com", "g-google", 0),
mk("YouTube", "https://www.youtube.com", "g-google", 1),
mk("GitHub", "https://github.com", "g-dev", 0),
mk("Hacker News", "https://news.ycombinator.com", "g-dev", 1),
mk("Wikipedia", "https://en.wikipedia.org", "g-ref", 0),
mk("MDN", "https://developer.mozilla.org", "g-ref", 1),
],
settings: Settings::default(),
}
}
pub fn config_path(dir: &PathBuf) -> PathBuf {
dir.join("apps.json")
}
/// Reads `apps.json`, seeding it on first run.
///
/// A file that fails to parse is kept, not replaced: losing someone's app list
/// to a bad write is worse than starting with the seed and telling them.
pub fn load(dir: &PathBuf) -> Result<Config, String> {
let path = config_path(dir);
if !path.exists() {
let cfg = seed();
save(dir, &cfg)?;
return Ok(cfg);
}
let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
serde_json::from_str(&raw).map_err(|e| format!("{} is not readable: {e}", path.display()))
}
pub fn save(dir: &PathBuf, cfg: &Config) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let json = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
std::fs::write(config_path(dir), json).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_scope_is_the_host_without_www() {
assert_eq!(default_scope("https://www.google.com/x"), Some("google.com".into()));
assert_eq!(default_scope("https://mail.google.com"), Some("mail.google.com".into()));
assert_eq!(default_scope("nonsense"), None);
}
#[test]
fn normalize_url_adds_a_scheme_but_keeps_an_explicit_one() {
assert_eq!(normalize_url(" github.com "), "https://github.com");
assert_eq!(normalize_url("http://intranet.local"), "http://intranet.local");
}
#[test]
fn seed_apps_all_carry_a_scope() {
let cfg = seed();
assert_eq!(cfg.apps.len(), 6);
assert!(cfg.apps.iter().all(|a| !a.scopes().is_empty()));
}
#[test]
fn ordering_follows_group_then_position() {
let cfg = seed();
let names: Vec<&str> = cfg.ordered().iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, ["Google", "YouTube", "GitHub", "Hacker News", "Wikipedia", "MDN"]);
}
#[test]
fn config_survives_a_serde_round_trip() {
let cfg = seed();
let json = serde_json::to_string(&cfg).unwrap();
let back: Config = serde_json::from_str(&json).unwrap();
assert_eq!(back.apps.len(), cfg.apps.len());
assert_eq!(back.groups.len(), cfg.groups.len());
}
#[test]
fn a_sparse_file_fills_in_its_defaults() {
// Hand-edited config files are a supported way to add an app.
let cfg: Config = serde_json::from_str(
r#"{"apps":[{"id":"a","name":"X","url":"https://x.com"}]}"#,
)
.unwrap();
assert_eq!(cfg.version, 1);
assert_eq!(cfg.settings.theme, "system");
assert_eq!(cfg.apps[0].scopes(), vec!["x.com".to_string()]);
assert!(cfg.apps[0].ua().contains("Chrome/"));
}
}
+81
View File
@@ -0,0 +1,81 @@
pub mod commands;
pub mod config;
pub mod routing;
pub mod webviews;
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
use tauri::Manager;
/// The macOS menu bar. Edit has no entry of its own, but its items live under
/// the app menu because they are what make ⌘X/⌘C/⌘V work in a text field.
fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
let app_menu = Submenu::with_items(
app,
"Work",
true,
&[
&PredefinedMenuItem::about(app, None, Some(AboutMetadata::default()))?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::hide(app, None)?,
&PredefinedMenuItem::hide_others(app, None)?,
&PredefinedMenuItem::show_all(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::cut(app, None)?,
&PredefinedMenuItem::copy(app, None)?,
&PredefinedMenuItem::paste(app, None)?,
&PredefinedMenuItem::select_all(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::quit(app, None)?,
],
)?;
let window_menu = Submenu::with_items(
app,
"Window",
true,
&[
&PredefinedMenuItem::minimize(app, None)?,
&PredefinedMenuItem::maximize(app, None)?,
&PredefinedMenuItem::fullscreen(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::close_window(app, None)?,
],
)?;
Menu::with_items(app, &[&app_menu, &window_menu])
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.menu(build_menu)
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let state = commands::build_state(&app.handle().clone())?;
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::get_config,
commands::bootstrap,
commands::set_stage,
commands::set_active,
commands::hide_stage,
commands::show_stage,
commands::navigate_app,
commands::history_go,
commands::current_url,
commands::open_external,
commands::add_app,
commands::update_app,
commands::delete_app,
commands::reorder_apps,
commands::add_group,
commands::update_group,
commands::delete_group,
commands::set_nav_collapsed,
commands::set_theme,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents an additional console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
work_app_lib::run()
}
+210
View File
@@ -0,0 +1,210 @@
//! Where a URL should open: here, another app, or the real browser.
//!
//! Pure. No I/O, no Tauri types, no webviews — which is what makes the rules
//! testable, and they are the rules the whole app is judged on.
use serde::{Deserialize, Serialize};
use url::Url;
/// Hosts that always stay inside the app, whichever app is showing.
///
/// A "Sign in with Google" button is a user click to a foreign host. Without
/// this list the ordinary rule hands it to the external browser, and the login
/// completes over there — in the one place whose cookies this app cannot see.
const IDENTITY_PROVIDERS: &[&str] = &[
"accounts.google.com",
"accounts.youtube.com",
"login.microsoftonline.com",
"login.microsoft.com",
"login.live.com",
"login.windows.net",
"sts.windows.net",
"device.login.microsoftonline.com",
"okta.com",
"oktapreview.com",
"auth0.com",
"duosecurity.com",
"appleid.apple.com",
"signin.aws.amazon.com",
"accounts.zoho.com",
"id.atlassian.com",
"auth.atlassian.com",
"slack.com",
"onelogin.com",
"pingidentity.com",
"authsvc.teams.microsoft.com",
];
/// The scope of one configured app, as routing needs it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppScope {
pub id: String,
/// Hosts this app owns. A URL matches on an exact host or a subdomain.
pub scope: Vec<String>,
}
/// What to do with a URL a webview is about to open.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Decision {
/// Let the current webview have it.
Stay,
/// Another configured app owns this host: switch to it and navigate there.
Switch { app_id: String, url: String },
/// Not ours. Hand it to the default browser.
External { url: String },
}
/// True when `host` is `scope` or a subdomain of it.
///
/// Written out rather than a suffix test, because `evilgoogle.com` ends with
/// `google.com` and must not match it.
pub fn host_matches(host: &str, scope: &str) -> bool {
let host = host.trim_start_matches("www.").to_ascii_lowercase();
let scope = scope.trim_start_matches("www.").to_ascii_lowercase();
host == scope || host.ends_with(&format!(".{scope}"))
}
fn is_identity_provider(host: &str) -> bool {
IDENTITY_PROVIDERS.iter().any(|p| host_matches(host, p))
}
/// The app whose scope matches `host` most specifically.
///
/// Longest match wins, so an app scoped to `mail.google.com` beats one scoped
/// to `google.com` for a Gmail URL rather than the answer depending on order.
fn best_match<'a>(host: &str, apps: &'a [AppScope]) -> Option<&'a AppScope> {
apps.iter()
.filter_map(|a| {
a.scope
.iter()
.filter(|s| host_matches(host, s))
.map(|s| s.len())
.max()
.map(|len| (len, a))
})
.max_by_key(|(len, _)| *len)
.map(|(_, a)| a)
}
/// Decide where `url` opens, given which app is showing.
///
/// `current` is the id of the app the click came from; `None` means the
/// decision is being made outside any app.
pub fn decide(url: &str, current: Option<&str>, apps: &[AppScope]) -> Decision {
let Ok(parsed) = Url::parse(url) else {
return Decision::External { url: url.to_string() };
};
// mailto:, tel:, zoommtg: and friends are for the OS to place, not us.
if !matches!(parsed.scheme(), "http" | "https") {
return Decision::External { url: url.to_string() };
}
let Some(host) = parsed.host_str() else {
return Decision::External { url: url.to_string() };
};
// The app showing right now gets first refusal, so an app whose scope
// overlaps another's never steals its own internal navigation.
if let Some(id) = current {
if let Some(app) = apps.iter().find(|a| a.id == id) {
if app.scope.iter().any(|s| host_matches(host, s)) {
return Decision::Stay;
}
}
}
if is_identity_provider(host) {
return Decision::Stay;
}
match best_match(host, apps) {
Some(app) => Decision::Switch {
app_id: app.id.clone(),
url: url.to_string(),
},
None => Decision::External { url: url.to_string() },
}
}
#[cfg(test)]
mod tests {
use super::*;
fn apps() -> Vec<AppScope> {
vec![
AppScope { id: "gmail".into(), scope: vec!["mail.google.com".into()] },
AppScope { id: "drive".into(), scope: vec!["drive.google.com".into()] },
AppScope { id: "google".into(), scope: vec!["google.com".into()] },
AppScope { id: "gh".into(), scope: vec!["github.com".into()] },
]
}
#[test]
fn host_matches_exact_and_subdomain() {
assert!(host_matches("github.com", "github.com"));
assert!(host_matches("gist.github.com", "github.com"));
assert!(host_matches("www.github.com", "github.com"));
}
#[test]
fn host_matches_rejects_suffix_lookalikes() {
assert!(!host_matches("evilgithub.com", "github.com"));
assert!(!host_matches("github.com.evil.net", "github.com"));
}
#[test]
fn stays_within_the_current_app() {
let d = decide("https://mail.google.com/mail/u/0/#inbox", Some("gmail"), &apps());
assert_eq!(d, Decision::Stay);
}
#[test]
fn switches_to_the_app_that_owns_the_host() {
let d = decide("https://drive.google.com/file/d/123", Some("gmail"), &apps());
assert_eq!(
d,
Decision::Switch { app_id: "drive".into(), url: "https://drive.google.com/file/d/123".into() }
);
}
#[test]
fn longest_scope_wins_over_a_broader_one() {
// Both `google.com` and `mail.google.com` match; the specific app must win.
let d = decide("https://mail.google.com/", Some("gh"), &apps());
assert!(matches!(d, Decision::Switch { ref app_id, .. } if app_id == "gmail"));
}
#[test]
fn unknown_hosts_leave_for_the_browser() {
let d = decide("https://news.ycombinator.com/", Some("gh"), &apps());
assert_eq!(d, Decision::External { url: "https://news.ycombinator.com/".into() });
}
#[test]
fn identity_providers_stay_inside() {
// Otherwise every SSO login completes in a browser this app cannot read.
let d = decide("https://accounts.google.com/o/oauth2/auth?x=1", Some("gh"), &apps());
assert_eq!(d, Decision::Stay);
}
#[test]
fn non_web_schemes_go_to_the_os() {
let d = decide("mailto:someone@example.com", Some("gmail"), &apps());
assert!(matches!(d, Decision::External { .. }));
}
#[test]
fn garbage_urls_do_not_panic() {
assert!(matches!(decide("not a url", None, &apps()), Decision::External { .. }));
}
#[test]
fn current_app_wins_even_when_another_scope_is_longer() {
// `google` is showing and clicks a google.com link: it keeps it, rather
// than the more specific gmail app stealing an internal navigation.
let d = decide("https://google.com/search?q=x", Some("google"), &apps());
assert_eq!(d, Decision::Stay);
}
}
+279
View File
@@ -0,0 +1,279 @@
//! One child webview per configured app, stacked in the stage rect.
//!
//! Child webviews are native views layered above the shell's content. They take
//! no part in CSS layout, so the shell measures the stage and reports it here,
//! and anything the shell wants to draw over an app (a dialog) requires hiding
//! the app first.
use serde::Serialize;
use tauri::{
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, WebviewUrl,
webview::{NewWindowResponse, WebviewBuilder},
};
use url::Url;
use crate::config::{App, Config};
use crate::routing::{self, AppScope, Decision};
/// Scheme the injected interceptor uses to hand a URL back for a decision.
///
/// A made-up scheme rather than Tauri IPC: IPC to a remote origin means
/// granting google.com the ability to call into this app, and routing a link
/// does not need anything that dangerous. `on_navigation` sees this, answers
/// it, and cancels the navigation — so nothing ever loads.
const ROUTE_SCHEME: &str = "workapp-route";
pub fn label_for(app_id: &str) -> String {
format!("app-{app_id}")
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SwitchEvent {
pub app_id: String,
pub url: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UrlEvent {
pub app_id: String,
pub url: String,
}
/// The click interceptor, specialised for one app's scope.
///
/// It only decides *intent*: a link the user clicked that leaves this app's
/// hosts is prevented and handed to Rust. Redirects, form posts and OAuth
/// bounces are untouched, which is what keeps sign-in flows alive.
fn interceptor_script(scopes: &[String]) -> String {
let json = serde_json::to_string(scopes).unwrap_or_else(|_| "[]".into());
format!(
r#"(function () {{
if (window.__workAppRouter) return;
window.__workAppRouter = true;
var SCOPES = {json};
function abs(href) {{ try {{ return new URL(href, document.baseURI).href; }} catch (e) {{ return null; }} }}
function inScope(u) {{
try {{
var h = new URL(u).hostname.replace(/^www\./, '').toLowerCase();
return SCOPES.some(function (s) {{
s = String(s).replace(/^www\./, '').toLowerCase();
return h === s || h.endsWith('.' + s);
}});
}} catch (e) {{ return false; }}
}}
function ask(u) {{
try {{ window.location.href = '{scheme}:/?u=' + encodeURIComponent(u); }} catch (e) {{}}
}}
document.addEventListener('click', function (e) {{
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
if (!a) return;
var href = a.getAttribute('href');
if (!href || href.charAt(0) === '#') return;
if (/^(javascript|blob|data):/i.test(href)) return;
var u = abs(href);
if (!u) return;
if (inScope(u)) {{
// Our own host. A new-tab link has nowhere to go in a tabless app, so
// it takes over this view rather than being swallowed.
if (a.target === '_blank') {{ e.preventDefault(); window.location.href = u; }}
return;
}}
e.preventDefault();
e.stopPropagation();
ask(u);
}}, true);
var nativeOpen = window.open;
window.open = function (url) {{
if (!url) return null;
var u = abs(url);
if (!u) return null;
if (inScope(u)) {{ window.location.href = u; return null; }}
ask(u);
return null;
}};
void nativeOpen;
}})();"#,
json = json,
scheme = ROUTE_SCHEME
)
}
/// Pulls the URL back out of a `workapp-route:/?u=…` sentinel.
pub fn route_target(url: &Url) -> Option<String> {
if url.scheme() != ROUTE_SCHEME {
return None;
}
url.query_pairs()
.find(|(k, _)| k == "u")
.map(|(_, v)| v.to_string())
}
/// Acts on a decision. Called off the navigation delegate, never on it.
fn apply(handle: &AppHandle, from: &str, decision: Decision) {
match decision {
// Only an identity provider reaches here, and the click that produced
// it was already cancelled — so this view has to be sent there.
Decision::Stay => {}
Decision::Switch { app_id, url } => {
let _ = handle.emit("switch-app", SwitchEvent { app_id, url });
}
Decision::External { url } => {
let _ = tauri_plugin_opener::open_url(url, None::<&str>);
}
}
let _ = from;
}
/// Handles a sentinel URL: decide, then act without blocking the delegate.
fn handle_route(handle: &AppHandle, from: &str, target: String, scopes: Vec<AppScope>) {
let handle = handle.clone();
let from = from.to_string();
tauri::async_runtime::spawn(async move {
match routing::decide(&target, Some(&from), &scopes) {
Decision::Stay => {
// The click was cancelled to ask the question, so completing it
// is now this side's job.
if let Some(wv) = handle.get_webview(&label_for(&from)) {
if let Ok(u) = Url::parse(&target) {
let _ = wv.navigate(u);
}
}
}
other => apply(&handle, &from, other),
}
});
}
/// Builds the child webview for one app and parks it in the stage rect.
pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64, f64)) -> Result<(), String> {
let window = handle
.get_window("main")
.ok_or_else(|| "main window is gone".to_string())?;
let url = Url::parse(&app.url).map_err(|e| format!("{}: {e}", app.url))?;
let id = app.id.clone();
let scopes = cfg.scopes();
let nav_handle = handle.clone();
let nav_id = id.clone();
let nav_scopes = scopes.clone();
let win_handle = handle.clone();
let win_id = id.clone();
let win_scopes = scopes.clone();
let builder = WebviewBuilder::new(label_for(&id), WebviewUrl::External(url))
.user_agent(&app.ua())
.initialization_script(interceptor_script(&app.scopes()))
.on_navigation(move |url| {
// The sentinel is a question, not a destination: answer it and
// cancel. Everything else is allowed — a strict filter here would
// break every OAuth redirect chain.
if let Some(target) = route_target(url) {
handle_route(&nav_handle, &nav_id, target, nav_scopes.clone());
return false;
}
let _ = nav_handle.emit(
"url-changed",
UrlEvent { app_id: nav_id.clone(), url: url.to_string() },
);
true
})
.on_new_window(move |url, _features| {
// Nothing may open a window of its own; the same rules apply.
let decision = routing::decide(url.as_str(), Some(&win_id), &win_scopes);
match decision {
Decision::Stay => {
if let Some(wv) = win_handle.get_webview(&label_for(&win_id)) {
let _ = wv.navigate(url);
}
}
other => apply(&win_handle, &win_id, other),
}
NewWindowResponse::Deny
});
window
.add_child(
builder,
LogicalPosition::new(stage.0, stage.1),
LogicalSize::new(stage.2, stage.3),
)
.map_err(|e| e.to_string())?;
// Created hidden. `show` is what puts one on screen, so startup does not
// flash every app in turn as they are built.
if let Some(wv) = handle.get_webview(&label_for(&id)) {
let _ = wv.hide();
}
Ok(())
}
/// Shows one app and hides the rest, sizing it to the stage.
pub fn show_only(handle: &AppHandle, app_id: Option<&str>, cfg: &Config, stage: (f64, f64, f64, f64)) {
for app in &cfg.apps {
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
if Some(app.id.as_str()) == app_id {
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
let _ = wv.show();
} else {
let _ = wv.hide();
}
}
}
/// Re-sizes whichever app is showing. Called on every layout change.
pub fn set_stage(handle: &AppHandle, active: Option<&str>, stage: (f64, f64, f64, f64)) {
let Some(id) = active else { return };
let Some(wv) = handle.get_webview(&label_for(id)) else { return };
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
}
/// Hides every app webview, so the shell can draw over the whole window.
pub fn hide_all(handle: &AppHandle, cfg: &Config) {
for app in &cfg.apps {
if let Some(wv) = handle.get_webview(&label_for(&app.id)) {
let _ = wv.hide();
}
}
}
pub fn destroy(handle: &AppHandle, app_id: &str) {
if let Some(wv) = handle.get_webview(&label_for(app_id)) {
let _ = wv.close();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_target_round_trips_a_url_with_a_query() {
let u = Url::parse("workapp-route:/?u=https%3A%2F%2Fx.com%2Fa%3Fb%3D1%26c%3D2").unwrap();
assert_eq!(route_target(&u).unwrap(), "https://x.com/a?b=1&c=2");
}
#[test]
fn ordinary_urls_are_not_sentinels() {
let u = Url::parse("https://github.com/?u=x").unwrap();
assert!(route_target(&u).is_none());
}
#[test]
fn the_script_carries_the_apps_own_scope() {
let s = interceptor_script(&["mail.google.com".into()]);
assert!(s.contains("mail.google.com"));
assert!(s.contains("workapp-route:/?u="));
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Work",
"version": "0.1.0",
"identifier": "com.vincent.workapp",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Work",
"width": 1440,
"height": 900,
"minWidth": 900,
"minHeight": 600,
"center": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true
}
],
"security": { "csp": null }
},
"bundle": {
"active": true,
"targets": ["app"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+179
View File
@@ -0,0 +1,179 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import * as api from "./api";
import Nav, { navWidth } from "./components/Nav";
import Settings from "./components/Settings";
import TopBar from "./components/TopBar";
import { BTN_PRIMARY } from "./components/ui";
import { useAppearance, type Theme } from "./hooks/useAppearance";
import type { Config, Group, SwitchEvent, UrlEvent } from "./types";
export default function App() {
const [config, setConfig] = useState<Config | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [theme, setTheme] = useAppearance("system");
const stageRef = useRef<HTMLDivElement>(null);
const booted = useRef(false);
// Read inside listeners, which are registered once and would otherwise
// capture the first render's value forever.
const activeRef = useRef<string | null>(null);
activeRef.current = activeId;
const collapsed = config?.settings.navCollapsed ?? false;
const activeApp = config?.apps.find((a) => a.id === activeId) ?? null;
useEffect(() => {
api.getConfig().then((c) => {
setConfig(c);
setTheme(c.settings.theme);
const first = [...c.apps].sort((a, b) => a.order - b.order)[0];
setActiveId(first?.id ?? null);
});
}, [setTheme]);
/**
* Tells Rust where the stage is.
*
* An app's webview is a native view that takes no part in CSS layout, so the
* only way it lands in the right place is for the shell to measure the hole
* it left and report it.
*/
const report = useCallback(() => {
const el = stageRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
void api.setStage(r.x, r.y, r.width, r.height);
}, []);
useLayoutEffect(() => {
const el = stageRef.current;
if (!el || !config) return;
report();
const ro = new ResizeObserver(report);
ro.observe(el);
window.addEventListener("resize", report);
return () => {
ro.disconnect();
window.removeEventListener("resize", report);
};
}, [config, report]);
// Webviews are built only once the stage has been measured, so they are born
// at the right size instead of against a guess.
useEffect(() => {
if (!config || booted.current || config.apps.length === 0) return;
booted.current = true;
report();
void api.bootstrap();
}, [config, report]);
useEffect(() => {
if (!activeId) return;
void api.setActive(activeId);
void api.currentUrl(activeId).then(setUrl);
}, [activeId]);
// A link in one app that points at another: Rust decided, the shell moves.
useEffect(() => {
const unlisten = [
listen<SwitchEvent>("switch-app", async (e) => {
setActiveId(e.payload.appId);
await api.navigateApp(e.payload.appId, e.payload.url);
}),
listen<UrlEvent>("url-changed", (e) => {
if (e.payload.appId === activeRef.current) setUrl(e.payload.url);
}),
];
return () => {
unlisten.forEach((p) => p.then((f) => f()));
};
}, []);
// A native view paints over anything the shell draws, so a dialog needs the
// stage out of the way rather than merely on a higher z-index.
useEffect(() => {
if (!config) return;
void (settingsOpen ? api.hideStage() : api.showStage());
}, [settingsOpen, config]);
const toggleCollapse = () => {
if (!config) return;
const next = !collapsed;
setConfig({ ...config, settings: { ...config.settings, navCollapsed: next } });
void api.setNavCollapsed(next);
};
const toggleGroup = (g: Group) => {
if (!config) return;
const updated = { ...g, collapsed: !g.collapsed };
setConfig({
...config,
groups: config.groups.map((x) => (x.id === g.id ? updated : x)),
});
void api.updateGroup(updated);
};
const onTheme = (t: Theme) => {
setTheme(t);
void api.setTheme(t);
};
if (!config) return null;
return (
<div className="flex h-screen flex-col">
<TopBar
url={url}
appName={activeApp?.name ?? null}
onBack={() => activeId && api.historyGo(activeId, -1)}
onForward={() => activeId && api.historyGo(activeId, 1)}
onReload={() => activeId && api.historyGo(activeId, 0)}
onOpenExternal={() => url && api.openExternal(url)}
/>
<div className="flex min-h-0 flex-1">
<Nav
config={config}
activeId={activeId}
collapsed={collapsed}
onSelect={setActiveId}
onToggleCollapse={toggleCollapse}
onOpenSettings={() => setSettingsOpen(true)}
onToggleGroup={toggleGroup}
/>
{/* The hole an app's native webview is positioned into. It stays empty
on purpose — anything drawn here would be painted over. */}
<div ref={stageRef} className="min-w-0 flex-1 bg-slate-100 dark:bg-slate-950">
{config.apps.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="text-[13px] text-slate-500 dark:text-slate-400">
No apps yet. Add the tools you work in and they appear in the nav.
</p>
<button onClick={() => setSettingsOpen(true)} className={BTN_PRIMARY}>
Add your first app
</button>
</div>
)}
</div>
</div>
{settingsOpen && (
<Settings
config={config}
theme={theme}
onConfig={setConfig}
onTheme={onTheme}
onClose={() => setSettingsOpen(false)}
/>
)}
{/* Keeps the nav width honest for the stage measurement above. */}
<span hidden>{navWidth(collapsed)}</span>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
/** Every call into Rust, in one place. */
import { invoke } from "@tauri-apps/api/core";
import type { Config, Group, WorkApp } from "./types";
export const getConfig = () => invoke<Config>("get_config");
export const bootstrap = () => invoke<void>("bootstrap");
export const setStage = (x: number, y: number, width: number, height: number) =>
invoke<void>("set_stage", { x, y, width, height });
export const setActive = (appId: string) => invoke<void>("set_active", { appId });
export const hideStage = () => invoke<void>("hide_stage");
export const showStage = () => invoke<void>("show_stage");
export const navigateApp = (appId: string, url: string) =>
invoke<void>("navigate_app", { appId, url });
export const historyGo = (appId: string, delta: number) =>
invoke<void>("history_go", { appId, delta });
export const currentUrl = (appId: string) =>
invoke<string | null>("current_url", { appId });
export const openExternal = (url: string) => invoke<void>("open_external", { url });
export const addApp = (name: string, url: string, groupId: string | null) =>
invoke<Config>("add_app", { name, url, groupId });
export const updateApp = (updated: WorkApp) => invoke<Config>("update_app", { updated });
export const deleteApp = (appId: string) => invoke<Config>("delete_app", { appId });
export const reorderApps = (ordering: [string, string | null, number][]) =>
invoke<Config>("reorder_apps", { ordering });
export const addGroup = (name: string) => invoke<Config>("add_group", { name });
export const updateGroup = (updated: Group) => invoke<Config>("update_group", { updated });
export const deleteGroup = (groupId: string) => invoke<Config>("delete_group", { groupId });
export const setNavCollapsed = (collapsed: boolean) =>
invoke<void>("set_nav_collapsed", { collapsed });
export const setTheme = (theme: string) => invoke<void>("set_theme", { theme });
+185
View File
@@ -0,0 +1,185 @@
import type { Config, Group, WorkApp } from "../types";
import { Favicon, HEADING, ICON_CHROME } from "./ui";
interface Props {
config: Config;
activeId: string | null;
collapsed: boolean;
onSelect: (id: string) => void;
onToggleCollapse: () => void;
onOpenSettings: () => void;
onToggleGroup: (g: Group) => void;
}
const RAIL = 52;
const PANEL = 240;
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
function Chevron({ dir }: { dir: "left" | "right" }) {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path
strokeLinecap="round"
strokeLinejoin="round"
d={dir === "left" ? "M15 6l-6 6 6 6" : "M9 6l6 6-6 6"}
/>
</svg>
);
}
function Cog() {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="3.2" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
</svg>
);
}
export default function Nav({
config,
activeId,
collapsed,
onSelect,
onToggleCollapse,
onOpenSettings,
onToggleGroup,
}: Props) {
const groups = [...config.groups].sort((a, b) => a.order - b.order);
const inGroup = (id: string | null) =>
config.apps.filter((a) => a.groupId === id).sort((a, b) => a.order - b.order);
const ungrouped = inGroup(null);
const shell =
"flex shrink-0 flex-col overflow-hidden border-r border-slate-300 bg-white " +
"dark:border-slate-800 dark:bg-slate-900";
// ------------------------------------------------------------ icon rail
if (collapsed) {
const railBtn = (app: WorkApp) => {
const active = app.id === activeId;
return (
<button
key={app.id}
onClick={() => onSelect(app.id)}
title={app.name}
aria-label={app.name}
className={
"relative grid size-9 cursor-pointer place-items-center rounded-lg transition-colors " +
(active
? "bg-slate-100 dark:bg-slate-800"
: "opacity-70 hover:bg-slate-100 hover:opacity-100 dark:hover:bg-slate-800")
}
>
{active && (
<span className="absolute -left-2 h-5 w-[3px] rounded-full bg-sky-500" />
)}
<Favicon url={app.url} name={app.name} size={18} />
</button>
);
};
return (
<aside className={`${shell} w-[52px] items-center`} style={{ width: RAIL }}>
<div className="flex w-full flex-col items-center gap-1 border-b border-slate-200 py-2 dark:border-slate-800">
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
<Chevron dir="right" />
</button>
</div>
<nav className="flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto py-3">
{ungrouped.map(railBtn)}
{ungrouped.length > 0 && groups.length > 0 && (
<span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />
)}
{groups.map((g, i) => {
const apps = inGroup(g.id);
if (apps.length === 0) return null;
return (
<div key={g.id} className="flex flex-col items-center gap-1">
{i > 0 && <span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />}
{apps.map(railBtn)}
</div>
);
})}
</nav>
</aside>
);
}
// -------------------------------------------------------------- panel
const row =
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-[13px] transition-colors";
const inactive =
"text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800";
const active = "bg-slate-900 text-white dark:bg-white dark:text-slate-900";
const appRow = (app: WorkApp) => (
<li key={app.id}>
<button
onClick={() => onSelect(app.id)}
title={app.url}
className={`${row} ${app.id === activeId ? active : inactive}`}
>
<Favicon url={app.url} name={app.name} />
<span className="truncate">{app.name}</span>
</button>
</li>
);
return (
<aside className={shell} style={{ width: PANEL }}>
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-800">
<span className="truncate text-[13px] font-semibold tracking-tight">Apps</span>
<div className="flex shrink-0 items-center gap-1">
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
<Cog />
</button>
<button onClick={onToggleCollapse} title="Collapse" className={ICON_CHROME}>
<Chevron dir="left" />
</button>
</div>
</header>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
{ungrouped.length > 0 && <ul className="space-y-0.5">{ungrouped.map(appRow)}</ul>}
{groups.map((g) => {
const apps = inGroup(g.id);
return (
<section key={g.id} className="pt-3 first:pt-0">
<button
onClick={() => onToggleGroup(g)}
className={`${HEADING} flex w-full cursor-pointer items-center gap-1 px-2 pb-1
hover:text-slate-700 dark:hover:text-slate-200`}
>
<svg
viewBox="0 0 24 24"
className={`size-3 transition-transform ${g.collapsed ? "" : "rotate-90"}`}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
</svg>
<span className="truncate">{g.name}</span>
<span className="ml-auto font-mono text-[10px] tracking-normal opacity-60">
{apps.length}
</span>
</button>
{!g.collapsed && <ul className="space-y-0.5">{apps.map(appRow)}</ul>}
</section>
);
})}
{config.apps.length === 0 && (
<p className="px-2 py-3 text-[11px] leading-snug text-slate-500 dark:text-slate-400">
No apps yet. Open Settings to add the first one.
</p>
)}
</nav>
</aside>
);
}
+265
View File
@@ -0,0 +1,265 @@
import { useState } from "react";
import * as api from "../api";
import type { Config, Group, WorkApp } from "../types";
import type { Theme } from "../hooks/useAppearance";
import {
BTN,
BTN_PRIMARY,
Dialog,
Favicon,
HELP,
ICON_CHROME,
INPUT,
LABEL,
SectionHeading,
Segmented,
SUBPANEL,
} from "./ui";
interface Props {
config: Config;
theme: Theme;
onConfig: (c: Config) => void;
onTheme: (t: Theme) => void;
onClose: () => void;
}
function TrashIcon() {
return (
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor"
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16M10 11v6M14 11v6" />
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
);
}
export default function Settings({ config, theme, onConfig, onTheme, onClose }: Props) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [groupId, setGroupId] = useState<string>("");
const [groupName, setGroupName] = useState("");
const [error, setError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
const groups = [...config.groups].sort((a, b) => a.order - b.order);
const run = async (fn: () => Promise<Config>) => {
try {
onConfig(await fn());
setError(null);
} catch (e) {
setError(String(e));
}
};
const addApp = async () => {
if (!name.trim() || !url.trim()) return;
await run(() => api.addApp(name, url, groupId || null));
setName("");
setUrl("");
};
const addGroup = async () => {
if (!groupName.trim()) return;
await run(() => api.addGroup(groupName));
setGroupName("");
};
const patch = (app: WorkApp, fields: Partial<WorkApp>) =>
run(() => api.updateApp({ ...app, ...fields }));
return (
<>
<Dialog title="Settings" onCancel={onClose} wide footer={<div />}>
<div className="space-y-6">
{error && (
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-[12px] text-red-600 dark:text-red-400">
{error}
</p>
)}
{/* ----------------------------------------------------- apps */}
<section className="space-y-2">
<SectionHeading>Apps</SectionHeading>
<div className="space-y-1.5">
{config.apps.length === 0 && (
<p className={HELP}>Nothing yet. Add the first one below.</p>
)}
{[...config.apps]
.sort((a, b) => a.order - b.order)
.map((app) => (
<div
key={app.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<Favicon url={app.url} name={app.name} />
<input
value={app.name}
onChange={(e) => patch(app, { name: e.target.value })}
className={`${INPUT} h-[26px] flex-1`}
/>
<input
value={app.url}
onChange={(e) => patch(app, { url: e.target.value })}
title="Changing this rebuilds the app's view"
className={`${INPUT} h-[26px] flex-[1.4] font-mono text-[11px]`}
/>
<select
value={app.groupId ?? ""}
onChange={(e) => patch(app, { groupId: e.target.value || null })}
className={`${INPUT} h-[26px] w-[110px] cursor-pointer`}
>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<button
onClick={() => setConfirmDelete(app)}
title={`Remove ${app.name}`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<TrashIcon />
</button>
</div>
))}
</div>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>Name</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Odoo"
className={INPUT}
/>
</label>
<label className="flex-[1.4] space-y-1">
<span className={LABEL}>URL</span>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addApp()}
placeholder="example.com"
className={`${INPUT} font-mono text-[11px]`}
/>
</label>
<label className="w-[110px] space-y-1">
<span className={LABEL}>Group</span>
<select
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
className={`${INPUT} cursor-pointer`}
>
<option value="">No group</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</label>
<button onClick={addApp} className={BTN_PRIMARY}>
Add
</button>
</div>
<p className={HELP}>
An app owns its URL's host. Links to any other app on this list switch to it;
everything else opens in your browser.
</p>
</section>
{/* --------------------------------------------------- groups */}
<section className="space-y-2">
<SectionHeading>Groups</SectionHeading>
<div className="space-y-1.5">
{groups.length === 0 && <p className={HELP}>No groups yet.</p>}
{groups.map((g: Group) => (
<div
key={g.id}
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
>
<input
value={g.name}
onChange={(e) =>
run(() => api.updateGroup({ ...g, name: e.target.value }))
}
className={`${INPUT} h-[26px] flex-1`}
/>
<span className="shrink-0 font-mono text-[10px] text-slate-400">
{config.apps.filter((a) => a.groupId === g.id).length} apps
</span>
<button
onClick={() => run(() => api.deleteGroup(g.id))}
title={`Delete ${g.name} — its apps stay, ungrouped`}
className={`${ICON_CHROME} hover:text-red-500!`}
>
<TrashIcon />
</button>
</div>
))}
</div>
<div className={`${SUBPANEL} flex items-end gap-2`}>
<label className="flex-1 space-y-1">
<span className={LABEL}>New group</span>
<input
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addGroup()}
placeholder="Finance"
className={INPUT}
/>
</label>
<button onClick={addGroup} className={BTN}>
Add group
</button>
</div>
</section>
{/* ----------------------------------------------- appearance */}
<section className="space-y-2">
<SectionHeading>Appearance</SectionHeading>
<Segmented<Theme>
value={theme}
onChange={onTheme}
options={[
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
/>
</section>
<div className="flex justify-end border-t border-slate-200 pt-4 dark:border-slate-800">
<button onClick={onClose} className={BTN}>
Done
</button>
</div>
</div>
</Dialog>
{confirmDelete && (
<Dialog
title={`Remove ${confirmDelete.name}?`}
onCancel={() => setConfirmDelete(null)}
onConfirm={async () => {
await run(() => api.deleteApp(confirmDelete.id));
setConfirmDelete(null);
}}
confirmLabel="Remove"
destructive
>
It disappears from the nav and its view is closed. The session it holds for{" "}
<span className="font-mono text-[12px]">{confirmDelete.url}</span> is kept, so
adding it back does not mean logging in again.
</Dialog>
)}
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { ICON_CHROME } from "./ui";
interface Props {
url: string | null;
appName: string | null;
onBack: () => void;
onForward: () => void;
onReload: () => void;
onOpenExternal: () => void;
}
/** Reserves the strip the macOS traffic lights sit in. */
const TRAFFIC_LIGHTS = 78;
function Icon({ d }: { d: string }) {
return (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
</svg>
);
}
/**
* Browser chrome, kept to the four things a tabless app still needs. It spans
* the whole window rather than sitting inside the stage, so the traffic lights
* have somewhere to live no matter how narrow the nav gets.
*/
export default function TopBar({
url,
appName,
onBack,
onForward,
onReload,
onOpenExternal,
}: Props) {
return (
<header
data-tauri-drag-region
className="flex h-[38px] shrink-0 items-center gap-1 border-b border-slate-300
bg-white px-2 dark:border-slate-800 dark:bg-slate-900"
style={{ paddingLeft: TRAFFIC_LIGHTS }}
>
<button onClick={onBack} title="Back" className={ICON_CHROME}>
<Icon d="M15 6l-6 6 6 6" />
</button>
<button onClick={onForward} title="Forward" className={ICON_CHROME}>
<Icon d="M9 6l6 6-6 6" />
</button>
<button onClick={onReload} title="Reload" className={ICON_CHROME}>
<Icon d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" />
</button>
<div
data-tauri-drag-region
className="mx-2 flex min-w-0 flex-1 items-center gap-2 text-[11px]"
>
{appName && (
<span className="shrink-0 font-medium text-slate-600 dark:text-slate-300">
{appName}
</span>
)}
<span className="truncate text-slate-400 dark:text-slate-500">{url ?? ""}</span>
</div>
<button
onClick={onOpenExternal}
title="Open this page in your browser"
className={ICON_CHROME}
disabled={!url}
>
<Icon d="M14 5h5v5M19 5l-8 8M18 14v4a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h4" />
</button>
</header>
);
}
+233
View File
@@ -0,0 +1,233 @@
/**
* The design system's component vocabulary, in one place. Every other file
* composes these rather than re-spelling the class strings.
*
* Ported from FlightTube: slate and sky, a 915px type ladder, outline-first
* controls, borders for separation and shadows only for elevation.
*/
import type { ReactNode } from "react";
/** Every control in the app is this tall, so a row of mixed ones lines up. */
export const CONTROL_H = "h-[30px]";
export const ICON_BTN =
`grid ${CONTROL_H} w-[30px] shrink-0 place-items-center rounded-lg cursor-pointer ` +
"disabled:cursor-not-allowed disabled:opacity-40";
export const HEADING =
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
export const HELP = "text-[11px] leading-snug text-slate-500 dark:text-slate-400";
export const SECTION = "border-b border-slate-200 px-4 py-4 dark:border-slate-800";
export const INPUT =
`w-full ${CONTROL_H} rounded-lg border border-slate-300 bg-white px-3 text-[12px] outline-none ` +
"placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500";
export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50";
const BTN_BASE =
`inline-flex ${CONTROL_H} items-center justify-center rounded-lg text-[12px] ` +
"disabled:cursor-not-allowed cursor-pointer";
export const BTN =
`${BTN_BASE} border border-slate-300 px-2.5 font-medium ` +
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-40 " +
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
export const BTN_PRIMARY =
`${BTN_BASE} bg-sky-500 px-3 font-semibold text-white hover:bg-sky-400 disabled:opacity-40`;
export const BTN_DANGER =
`${BTN_BASE} bg-red-600 px-3 font-semibold text-white hover:bg-red-500`;
/** Header actions: quieter than a secondary button, still a real target. */
export const BTN_CHROME =
`inline-flex ${CONTROL_H} items-center rounded-lg px-2 text-[12px] font-medium text-slate-500 ` +
"cursor-pointer hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 " +
"disabled:hover:bg-transparent dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
export const BTN_QUIET =
"text-[11px] text-slate-500 underline underline-offset-2 cursor-pointer " +
"hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400";
/** Icon-button skin used throughout the chrome. */
export const ICON_CHROME =
`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 ` +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
export function SectionHeading({ children }: { children: ReactNode }) {
return <h2 className={`flex items-center gap-2 ${HEADING}`}>{children}</h2>;
}
/** Bordered container, borderless children — the group's outline does the framing. */
export function Segmented<T extends string>({
options,
value,
onChange,
}: {
options: Array<{ value: T; label: ReactNode; title?: string }>;
value: T;
onChange: (v: T) => void;
}) {
return (
<div
role="group"
className={`flex ${CONTROL_H} items-center rounded-lg border border-slate-300 p-0.5 dark:border-slate-700`}
>
{options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
onClick={() => onChange(o.value)}
title={o.title}
className={
"inline-flex h-full cursor-pointer items-center justify-center rounded-md px-2.5 " +
"text-[12px] font-medium transition-colors " +
(active
? "bg-slate-900! text-white! dark:bg-white! dark:text-slate-900!"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900 " +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white")
}
>
{o.label}
</button>
);
})}
</div>
);
}
/** States a fact in passing — a quality, a status. Never a control. */
export function Badge({
children,
tone = "neutral",
title,
}: {
children: ReactNode;
tone?: "neutral" | "accent" | "danger";
title?: string;
}) {
const skin =
tone === "accent"
? "bg-sky-500/15 text-sky-700 dark:text-sky-300"
: tone === "danger"
? "bg-red-500/15 text-red-600 dark:text-red-400"
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300";
return (
<span
title={title}
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${skin}`}
>
{children}
</span>
);
}
/** A modal for anything needing a decision or reporting a failure. */
export function Dialog({
title,
children,
onCancel,
confirmLabel,
onConfirm,
destructive,
wide,
footer,
}: {
title: string;
children: ReactNode;
onCancel: () => void;
confirmLabel?: string;
onConfirm?: () => void;
destructive?: boolean;
wide?: boolean;
footer?: ReactNode;
}) {
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-slate-950/70 p-5"
onClick={onCancel}
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className={`w-full ${wide ? "max-w-2xl max-h-[82vh] overflow-y-auto" : "max-w-sm"}
rounded-2xl border border-slate-300 bg-white p-5 shadow-2xl
dark:border-slate-700 dark:bg-slate-900`}
>
<h2 className="mb-1.5 text-[15px] font-semibold tracking-tight">{title}</h2>
<div className="mb-4 text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
{children}
</div>
{footer ?? (
<div className="flex justify-end gap-2">
<button onClick={onCancel} className={BTN}>
{onConfirm ? "Cancel" : "Close"}
</button>
{onConfirm && (
<button
onClick={onConfirm}
className={destructive ? BTN_DANGER : BTN_PRIMARY}
>
{confirmLabel ?? "Continue"}
</button>
)}
</div>
)}
</div>
</div>
);
}
/**
* An app's mark. Google's favicon service is used rather than the site's own
* /favicon.ico, because many work tools sit behind a login that would return
* the sign-in page's icon — or a 403 — to a request without a session.
*/
export function Favicon({
url,
name,
size = 16,
}: {
url: string;
name: string;
size?: number;
}) {
let host = "";
try {
host = new URL(url).hostname;
} catch {
host = "";
}
const letter = name.trim().charAt(0).toUpperCase() || "?";
return (
<span
className="relative grid shrink-0 place-items-center overflow-hidden rounded"
style={{ width: size, height: size }}
>
<span
aria-hidden
className="absolute inset-0 grid place-items-center rounded bg-slate-200
text-[9px] font-bold text-slate-500 dark:bg-slate-700 dark:text-slate-300"
style={{ fontSize: Math.max(9, size * 0.5) }}
>
{letter}
</span>
{host && (
<img
src={`https://www.google.com/s2/favicons?sz=64&domain=${host}`}
alt=""
width={size}
height={size}
loading="lazy"
className="relative rounded"
onError={(e) => {
// Leave the letter showing rather than a broken-image glyph.
e.currentTarget.style.display = "none";
}}
/>
)}
</span>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
export type Theme = "system" | "light" | "dark";
/**
* Applies System / Light / Dark to the document.
*
* System is a live subscription rather than a read at startup: the OS can flip
* at sunset while the window is open.
*/
export function useAppearance(initial: Theme) {
const [theme, setTheme] = useState<Theme>(initial);
useEffect(() => setTheme(initial), [initial]);
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
const dark = theme === "dark" || (theme === "system" && media.matches);
document.documentElement.classList.toggle("dark", dark);
};
apply();
if (theme !== "system") return;
media.addEventListener("change", apply);
return () => media.removeEventListener("change", apply);
}, [theme]);
return [theme, setTheme] as const;
}
+56
View File
@@ -0,0 +1,56 @@
@import "tailwindcss";
/* Class-based dark mode, so the app can offer System / Light / Dark rather
than only following the OS. */
@custom-variant dark (&:where(.dark, .dark *));
@layer base {
[hidden] { display: none !important; }
/* It is a tool, not a document: dragging across it should not leave a
selection behind. Text fields opt back in. */
html { -webkit-user-select: none; user-select: none; }
input, textarea, [contenteditable="true"] { -webkit-user-select: text; user-select: text; }
img, a { -webkit-user-drag: none; }
/* No focus ring anywhere. :focus-visible has to go too — WebKit counts a
click on a select or checkbox as "focus worth showing" and draws its own,
which survives a :focus rule alone. */
*, *::before, *::after, :focus, :focus-visible, :focus-within {
outline: none !important;
outline-offset: 0 !important;
-webkit-tap-highlight-color: transparent;
}
button:focus, button:focus-visible, select:focus, [contenteditable]:focus {
outline: none !important;
}
html, body, #root { height: 100%; }
body {
margin: 0;
-webkit-font-smoothing: antialiased;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
background: var(--color-slate-100);
color: var(--color-slate-800);
}
.dark body {
background: var(--color-slate-950);
color: var(--color-slate-100);
}
/* Borders do the separating; the scrollbar should not compete. */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--color-slate-300);
border-radius: 9999px;
border: 3px solid transparent;
background-clip: content-box;
}
.dark ::-webkit-scrollbar-thumb {
background: var(--color-slate-700);
background-clip: content-box;
}
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+41
View File
@@ -0,0 +1,41 @@
export interface WorkApp {
id: string;
name: string;
url: string;
scope: string[];
groupId: string | null;
userAgent: string | null;
order: number;
}
export interface Group {
id: string;
name: string;
collapsed: boolean;
order: number;
}
export interface Settings {
navCollapsed: boolean;
theme: "system" | "light" | "dark";
pairedBrowser: string | null;
lastPairedAt: string | null;
}
export interface Config {
version: number;
groups: Group[];
apps: WorkApp[];
settings: Settings;
}
/** Rust asked the shell to switch apps because a link pointed at another one. */
export interface SwitchEvent {
appId: string;
url: string;
}
export interface UrlEvent {
appId: string;
url: string;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"emitDeclarationOnly": true,
"outDir": "./node_modules/.tmp"
},
"include": [
"vite.config.ts"
]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
watch: { ignored: ["**/src-tauri/**"] },
},
}));