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/**"] },
|
||||
},
|
||||
}));
|
||||