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.
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
src-tauri/target
|
||||||
|
.DS_Store
|
||||||
@@ -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 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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"] }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]}}
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 304 B |
@@ -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()
|
||||||
|
}
|
||||||
@@ -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/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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="));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 9–15px 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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/**"] },
|
||||||
|
},
|
||||||
|
}));
|
||||||