Work App: a dedicated browser for work tools

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

Design spec in docs/superpowers/specs/2026-09-01-work-app-design.md.
This commit is contained in:
2026-09-01 11:37:35 +02:00
commit a6c8e4336b
53 changed files with 15580 additions and 0 deletions
+5030
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "work-app"
version = "0.1.0"
description = "A browser for the tools you work in"
authors = ["Vincent"]
edition = "2021"
[lib]
name = "work_app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
# Pinned: multi-webview lives behind `unstable`, whose API can move between minors.
tauri = { version = "=2.11.5", features = ["unstable"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["time"] }
url = "2"
uuid = { version = "1", features = ["v4"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "The shell webview. App webviews are deliberately absent: remote content gets no IPC.",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"opener:default"
]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"The shell webview. App webviews are deliberately absent: remote content gets no IPC.","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","opener:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

+363
View File
@@ -0,0 +1,363 @@
//! The command surface. The shell owns "which app is active"; Rust owns the
//! webviews and the file on disk.
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{AppHandle, Manager, State};
use url::Url;
use crate::config::{self, App, Config, Group};
use crate::webviews;
/// Stage rect in logical pixels: x, y, width, height.
pub type Stage = (f64, f64, f64, f64);
pub struct AppState {
pub dir: PathBuf,
pub config: Mutex<Config>,
pub active: Mutex<Option<String>>,
pub stage: Mutex<Stage>,
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
pub booted: Mutex<bool>,
}
impl AppState {
pub fn cfg(&self) -> Config {
self.config.lock().unwrap().clone()
}
fn persist(&self) -> Result<(), String> {
config::save(&self.dir, &self.config.lock().unwrap())
}
}
pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
let dir = handle
.path()
.app_config_dir()
.map_err(|e| format!("no config directory: {e}"))?;
let config = config::load(&dir)?;
Ok(AppState {
dir,
config: Mutex::new(config),
active: Mutex::new(None),
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
booted: Mutex::new(false),
})
}
#[tauri::command]
pub fn get_config(state: State<'_, AppState>) -> Config {
state.cfg()
}
/// Records the rect the shell has left for an app, and resizes the one showing.
#[tauri::command]
pub fn set_stage(
x: f64,
y: f64,
width: f64,
height: f64,
app: AppHandle,
state: State<'_, AppState>,
) {
let stage = (x, y, width.max(0.0), height.max(0.0));
*state.stage.lock().unwrap() = stage;
let active = state.active.lock().unwrap().clone();
webviews::set_stage(&app, active.as_deref(), stage);
}
/// Creates every app's webview: the active one first, the rest staggered.
///
/// Called by the shell once it has measured the stage, so the views are born
/// at the right size instead of being built against a guess and corrected.
#[tauri::command]
pub fn bootstrap(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
{
let mut booted = state.booted.lock().unwrap();
if *booted {
return Ok(());
}
*booted = true;
}
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
let active = state.active.lock().unwrap().clone();
let first = active
.clone()
.or_else(|| cfg.ordered().first().map(|a| a.id.clone()));
if let Some(id) = &first {
if let Some(a) = cfg.app(id) {
webviews::create(&app, a, &cfg, stage)?;
*state.active.lock().unwrap() = Some(id.clone());
webviews::show_only(&app, Some(id), &cfg, stage);
}
}
// The rest follow with a gap, so launching does not fire a dozen
// simultaneous page loads at the network and the CPU.
let rest: Vec<App> = cfg
.ordered()
.into_iter()
.filter(|a| Some(&a.id) != first.as_ref())
.cloned()
.collect();
tauri::async_runtime::spawn(async move {
for a in rest {
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
let state = app.state::<AppState>();
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
if let Err(e) = webviews::create(&app, &a, &cfg, stage) {
eprintln!("could not create webview for {}: {e}", a.name);
}
}
});
Ok(())
}
#[tauri::command]
pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) {
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
*state.active.lock().unwrap() = Some(app_id.clone());
webviews::show_only(&app, Some(&app_id), &cfg, stage);
}
/// Hides every app, so a dialog is not painted over by a native view.
#[tauri::command]
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
webviews::hide_all(&app, &state.cfg());
}
/// Puts the active app back after a dialog closes.
#[tauri::command]
pub fn show_stage(app: AppHandle, state: State<'_, AppState>) {
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &cfg, stage);
}
#[tauri::command]
pub fn navigate_app(app_id: String, url: String, app: AppHandle) -> Result<(), String> {
let wv = app
.get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?;
let url = Url::parse(&url).map_err(|e| e.to_string())?;
wv.navigate(url).map_err(|e| e.to_string())
}
/// Back, forward and reload, run inside the app's own page.
#[tauri::command]
pub fn history_go(app_id: String, delta: i32, app: AppHandle) -> Result<(), String> {
let wv = app
.get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?;
let script = if delta == 0 {
"location.reload()".to_string()
} else {
format!("history.go({delta})")
};
wv.eval(&script).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn current_url(app_id: String, app: AppHandle) -> Option<String> {
app.get_webview(&webviews::label_for(&app_id))
.and_then(|wv| wv.url().ok())
.map(|u| u.to_string())
}
#[tauri::command]
pub fn open_external(url: String) -> Result<(), String> {
tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------- apps
#[tauri::command]
pub fn add_app(
name: String,
url: String,
group_id: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
let url = config::normalize_url(&url);
let scope = config::default_scope(&url)
.ok_or_else(|| format!("{url} is not a URL this can open"))?;
let new = {
let mut cfg = state.config.lock().unwrap();
let order = cfg
.apps
.iter()
.filter(|a| a.group_id == group_id)
.map(|a| a.order)
.max()
.map_or(0, |m| m + 1);
let new = App {
id: config::new_id(),
name: name.trim().to_string(),
url,
scope: vec![scope],
group_id,
user_agent: None,
order,
};
cfg.apps.push(new.clone());
new
};
state.persist()?;
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
if *state.booted.lock().unwrap() {
webviews::create(&app, &new, &cfg, stage)?;
}
Ok(cfg)
}
/// Applies an edit. A change to url, scope or user agent rebuilds the webview,
/// since none of the three can be altered on a live one.
#[tauri::command]
pub fn update_app(
updated: App,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
let rebuild = {
let mut cfg = state.config.lock().unwrap();
let existing = cfg
.apps
.iter_mut()
.find(|a| a.id == updated.id)
.ok_or_else(|| format!("no app {}", updated.id))?;
let rebuild = existing.url != updated.url
|| existing.scope != updated.scope
|| existing.user_agent != updated.user_agent;
*existing = updated.clone();
rebuild
};
state.persist()?;
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
if rebuild && *state.booted.lock().unwrap() {
webviews::destroy(&app, &updated.id);
webviews::create(&app, &updated, &cfg, stage)?;
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &cfg, stage);
}
Ok(cfg)
}
#[tauri::command]
pub fn delete_app(
app_id: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
cfg.apps.retain(|a| a.id != app_id);
}
state.persist()?;
webviews::destroy(&app, &app_id);
let mut active = state.active.lock().unwrap();
if active.as_deref() == Some(app_id.as_str()) {
*active = None;
}
Ok(state.cfg())
}
/// Rewrites group membership and position in one go, for a drag.
#[tauri::command]
pub fn reorder_apps(
ordering: Vec<(String, Option<String>, i32)>,
state: State<'_, AppState>,
) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
for (id, group_id, order) in ordering {
if let Some(a) = cfg.apps.iter_mut().find(|a| a.id == id) {
a.group_id = group_id;
a.order = order;
}
}
}
state.persist()?;
Ok(state.cfg())
}
// -------------------------------------------------------------- groups
#[tauri::command]
pub fn add_group(name: String, state: State<'_, AppState>) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
let order = cfg.groups.iter().map(|g| g.order).max().map_or(0, |m| m + 1);
cfg.groups.push(Group {
id: config::new_id(),
name: name.trim().to_string(),
collapsed: false,
order,
});
}
state.persist()?;
Ok(state.cfg())
}
#[tauri::command]
pub fn update_group(updated: Group, state: State<'_, AppState>) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
let g = cfg
.groups
.iter_mut()
.find(|g| g.id == updated.id)
.ok_or_else(|| format!("no group {}", updated.id))?;
*g = updated;
}
state.persist()?;
Ok(state.cfg())
}
/// Removes a group. Its apps survive, ungrouped — deleting a folder should
/// never be a way to lose the things inside it by accident.
#[tauri::command]
pub fn delete_group(group_id: String, state: State<'_, AppState>) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
cfg.groups.retain(|g| g.id != group_id);
for a in cfg.apps.iter_mut() {
if a.group_id.as_deref() == Some(group_id.as_str()) {
a.group_id = None;
}
}
}
state.persist()?;
Ok(state.cfg())
}
// ------------------------------------------------------------ settings
#[tauri::command]
pub fn set_nav_collapsed(collapsed: bool, state: State<'_, AppState>) -> Result<(), String> {
state.config.lock().unwrap().settings.nav_collapsed = collapsed;
state.persist()
}
#[tauri::command]
pub fn set_theme(theme: String, state: State<'_, AppState>) -> Result<(), String> {
state.config.lock().unwrap().settings.theme = theme;
state.persist()
}
+278
View File
@@ -0,0 +1,278 @@
//! `apps.json`: the list of tools, their groups, and the window's preferences.
//!
//! Apps are flat and carry a `group_id` rather than being nested inside their
//! group, so dragging one between groups is a field change instead of a tree
//! rewrite.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use url::Url;
use crate::routing::AppScope;
/// What the app claims to be. WebKit's own user agent gets a Google login
/// refused as "not a secure browser", which would make half the list unusable.
pub const CHROME_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct App {
pub id: String,
pub name: String,
pub url: String,
/// Hosts this app owns. Defaults to the URL's exact host — `mail.google.com`
/// rather than `google.com`, or Gmail and Drive each swallow the other.
#[serde(default)]
pub scope: Vec<String>,
#[serde(default)]
pub group_id: Option<String>,
#[serde(default)]
pub user_agent: Option<String>,
#[serde(default)]
pub order: i32,
}
impl App {
pub fn scopes(&self) -> Vec<String> {
if self.scope.is_empty() {
default_scope(&self.url).into_iter().collect()
} else {
self.scope.clone()
}
}
pub fn ua(&self) -> String {
self.user_agent.clone().unwrap_or_else(|| CHROME_UA.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Group {
pub id: String,
pub name: String,
#[serde(default)]
pub collapsed: bool,
#[serde(default)]
pub order: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
#[serde(default)]
pub nav_collapsed: bool,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default)]
pub paired_browser: Option<String>,
#[serde(default)]
pub last_paired_at: Option<String>,
}
fn default_theme() -> String {
"system".into()
}
impl Default for Settings {
fn default() -> Self {
Self {
nav_collapsed: false,
theme: default_theme(),
paired_browser: None,
last_paired_at: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Config {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub groups: Vec<Group>,
#[serde(default)]
pub apps: Vec<App>,
#[serde(default)]
pub settings: Settings,
}
fn default_version() -> u32 {
1
}
impl Default for Config {
fn default() -> Self {
Self {
version: 1,
groups: Vec::new(),
apps: Vec::new(),
settings: Settings::default(),
}
}
}
impl Config {
/// Scopes in the shape routing wants.
pub fn scopes(&self) -> Vec<AppScope> {
self.apps
.iter()
.map(|a| AppScope { id: a.id.clone(), scope: a.scopes() })
.collect()
}
pub fn app(&self, id: &str) -> Option<&App> {
self.apps.iter().find(|a| a.id == id)
}
/// Apps in the order the nav shows them: by group, then by position.
pub fn ordered(&self) -> Vec<&App> {
let group_rank = |id: &Option<String>| -> i32 {
match id {
None => -1,
Some(g) => self
.groups
.iter()
.find(|x| &x.id == g)
.map(|x| x.order)
.unwrap_or(i32::MAX),
}
};
let mut apps: Vec<&App> = self.apps.iter().collect();
apps.sort_by_key(|a| (group_rank(&a.group_id), a.order));
apps
}
}
/// The host of a URL, which is the app's scope until the user widens it.
pub fn default_scope(url: &str) -> Option<String> {
Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(|h| h.trim_start_matches("www.").to_string()))
}
/// Normalises what someone types into an app's URL field.
pub fn normalize_url(input: &str) -> String {
let t = input.trim();
if t.starts_with("http://") || t.starts_with("https://") {
t.to_string()
} else {
format!("https://{t}")
}
}
pub fn new_id() -> String {
uuid::Uuid::new_v4().to_string()
}
/// First-run contents: enough apps, across enough groups, that grouping and
/// cross-app link routing can both be seen working without typing anything.
pub fn seed() -> Config {
let mk = |name: &str, url: &str, group: &str, order: i32| App {
id: new_id(),
name: name.into(),
url: url.into(),
scope: default_scope(url).into_iter().collect(),
group_id: Some(group.into()),
user_agent: None,
order,
};
Config {
version: 1,
groups: vec![
Group { id: "g-google".into(), name: "Google".into(), collapsed: false, order: 0 },
Group { id: "g-dev".into(), name: "Dev".into(), collapsed: false, order: 1 },
Group { id: "g-ref".into(), name: "Reference".into(), collapsed: false, order: 2 },
],
apps: vec![
mk("Google", "https://www.google.com", "g-google", 0),
mk("YouTube", "https://www.youtube.com", "g-google", 1),
mk("GitHub", "https://github.com", "g-dev", 0),
mk("Hacker News", "https://news.ycombinator.com", "g-dev", 1),
mk("Wikipedia", "https://en.wikipedia.org", "g-ref", 0),
mk("MDN", "https://developer.mozilla.org", "g-ref", 1),
],
settings: Settings::default(),
}
}
pub fn config_path(dir: &PathBuf) -> PathBuf {
dir.join("apps.json")
}
/// Reads `apps.json`, seeding it on first run.
///
/// A file that fails to parse is kept, not replaced: losing someone's app list
/// to a bad write is worse than starting with the seed and telling them.
pub fn load(dir: &PathBuf) -> Result<Config, String> {
let path = config_path(dir);
if !path.exists() {
let cfg = seed();
save(dir, &cfg)?;
return Ok(cfg);
}
let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
serde_json::from_str(&raw).map_err(|e| format!("{} is not readable: {e}", path.display()))
}
pub fn save(dir: &PathBuf, cfg: &Config) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let json = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
std::fs::write(config_path(dir), json).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_scope_is_the_host_without_www() {
assert_eq!(default_scope("https://www.google.com/x"), Some("google.com".into()));
assert_eq!(default_scope("https://mail.google.com"), Some("mail.google.com".into()));
assert_eq!(default_scope("nonsense"), None);
}
#[test]
fn normalize_url_adds_a_scheme_but_keeps_an_explicit_one() {
assert_eq!(normalize_url(" github.com "), "https://github.com");
assert_eq!(normalize_url("http://intranet.local"), "http://intranet.local");
}
#[test]
fn seed_apps_all_carry_a_scope() {
let cfg = seed();
assert_eq!(cfg.apps.len(), 6);
assert!(cfg.apps.iter().all(|a| !a.scopes().is_empty()));
}
#[test]
fn ordering_follows_group_then_position() {
let cfg = seed();
let names: Vec<&str> = cfg.ordered().iter().map(|a| a.name.as_str()).collect();
assert_eq!(names, ["Google", "YouTube", "GitHub", "Hacker News", "Wikipedia", "MDN"]);
}
#[test]
fn config_survives_a_serde_round_trip() {
let cfg = seed();
let json = serde_json::to_string(&cfg).unwrap();
let back: Config = serde_json::from_str(&json).unwrap();
assert_eq!(back.apps.len(), cfg.apps.len());
assert_eq!(back.groups.len(), cfg.groups.len());
}
#[test]
fn a_sparse_file_fills_in_its_defaults() {
// Hand-edited config files are a supported way to add an app.
let cfg: Config = serde_json::from_str(
r#"{"apps":[{"id":"a","name":"X","url":"https://x.com"}]}"#,
)
.unwrap();
assert_eq!(cfg.version, 1);
assert_eq!(cfg.settings.theme, "system");
assert_eq!(cfg.apps[0].scopes(), vec!["x.com".to_string()]);
assert!(cfg.apps[0].ua().contains("Chrome/"));
}
}
+81
View File
@@ -0,0 +1,81 @@
pub mod commands;
pub mod config;
pub mod routing;
pub mod webviews;
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
use tauri::Manager;
/// The macOS menu bar. Edit has no entry of its own, but its items live under
/// the app menu because they are what make ⌘X/⌘C/⌘V work in a text field.
fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
let app_menu = Submenu::with_items(
app,
"Work",
true,
&[
&PredefinedMenuItem::about(app, None, Some(AboutMetadata::default()))?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::hide(app, None)?,
&PredefinedMenuItem::hide_others(app, None)?,
&PredefinedMenuItem::show_all(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::cut(app, None)?,
&PredefinedMenuItem::copy(app, None)?,
&PredefinedMenuItem::paste(app, None)?,
&PredefinedMenuItem::select_all(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::quit(app, None)?,
],
)?;
let window_menu = Submenu::with_items(
app,
"Window",
true,
&[
&PredefinedMenuItem::minimize(app, None)?,
&PredefinedMenuItem::maximize(app, None)?,
&PredefinedMenuItem::fullscreen(app, None)?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::close_window(app, None)?,
],
)?;
Menu::with_items(app, &[&app_menu, &window_menu])
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.menu(build_menu)
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let state = commands::build_state(&app.handle().clone())?;
app.manage(state);
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::get_config,
commands::bootstrap,
commands::set_stage,
commands::set_active,
commands::hide_stage,
commands::show_stage,
commands::navigate_app,
commands::history_go,
commands::current_url,
commands::open_external,
commands::add_app,
commands::update_app,
commands::delete_app,
commands::reorder_apps,
commands::add_group,
commands::update_group,
commands::delete_group,
commands::set_nav_collapsed,
commands::set_theme,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents an additional console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
work_app_lib::run()
}
+210
View File
@@ -0,0 +1,210 @@
//! Where a URL should open: here, another app, or the real browser.
//!
//! Pure. No I/O, no Tauri types, no webviews — which is what makes the rules
//! testable, and they are the rules the whole app is judged on.
use serde::{Deserialize, Serialize};
use url::Url;
/// Hosts that always stay inside the app, whichever app is showing.
///
/// A "Sign in with Google" button is a user click to a foreign host. Without
/// this list the ordinary rule hands it to the external browser, and the login
/// completes over there — in the one place whose cookies this app cannot see.
const IDENTITY_PROVIDERS: &[&str] = &[
"accounts.google.com",
"accounts.youtube.com",
"login.microsoftonline.com",
"login.microsoft.com",
"login.live.com",
"login.windows.net",
"sts.windows.net",
"device.login.microsoftonline.com",
"okta.com",
"oktapreview.com",
"auth0.com",
"duosecurity.com",
"appleid.apple.com",
"signin.aws.amazon.com",
"accounts.zoho.com",
"id.atlassian.com",
"auth.atlassian.com",
"slack.com",
"onelogin.com",
"pingidentity.com",
"authsvc.teams.microsoft.com",
];
/// The scope of one configured app, as routing needs it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppScope {
pub id: String,
/// Hosts this app owns. A URL matches on an exact host or a subdomain.
pub scope: Vec<String>,
}
/// What to do with a URL a webview is about to open.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Decision {
/// Let the current webview have it.
Stay,
/// Another configured app owns this host: switch to it and navigate there.
Switch { app_id: String, url: String },
/// Not ours. Hand it to the default browser.
External { url: String },
}
/// True when `host` is `scope` or a subdomain of it.
///
/// Written out rather than a suffix test, because `evilgoogle.com` ends with
/// `google.com` and must not match it.
pub fn host_matches(host: &str, scope: &str) -> bool {
let host = host.trim_start_matches("www.").to_ascii_lowercase();
let scope = scope.trim_start_matches("www.").to_ascii_lowercase();
host == scope || host.ends_with(&format!(".{scope}"))
}
fn is_identity_provider(host: &str) -> bool {
IDENTITY_PROVIDERS.iter().any(|p| host_matches(host, p))
}
/// The app whose scope matches `host` most specifically.
///
/// Longest match wins, so an app scoped to `mail.google.com` beats one scoped
/// to `google.com` for a Gmail URL rather than the answer depending on order.
fn best_match<'a>(host: &str, apps: &'a [AppScope]) -> Option<&'a AppScope> {
apps.iter()
.filter_map(|a| {
a.scope
.iter()
.filter(|s| host_matches(host, s))
.map(|s| s.len())
.max()
.map(|len| (len, a))
})
.max_by_key(|(len, _)| *len)
.map(|(_, a)| a)
}
/// Decide where `url` opens, given which app is showing.
///
/// `current` is the id of the app the click came from; `None` means the
/// decision is being made outside any app.
pub fn decide(url: &str, current: Option<&str>, apps: &[AppScope]) -> Decision {
let Ok(parsed) = Url::parse(url) else {
return Decision::External { url: url.to_string() };
};
// mailto:, tel:, zoommtg: and friends are for the OS to place, not us.
if !matches!(parsed.scheme(), "http" | "https") {
return Decision::External { url: url.to_string() };
}
let Some(host) = parsed.host_str() else {
return Decision::External { url: url.to_string() };
};
// The app showing right now gets first refusal, so an app whose scope
// overlaps another's never steals its own internal navigation.
if let Some(id) = current {
if let Some(app) = apps.iter().find(|a| a.id == id) {
if app.scope.iter().any(|s| host_matches(host, s)) {
return Decision::Stay;
}
}
}
if is_identity_provider(host) {
return Decision::Stay;
}
match best_match(host, apps) {
Some(app) => Decision::Switch {
app_id: app.id.clone(),
url: url.to_string(),
},
None => Decision::External { url: url.to_string() },
}
}
#[cfg(test)]
mod tests {
use super::*;
fn apps() -> Vec<AppScope> {
vec![
AppScope { id: "gmail".into(), scope: vec!["mail.google.com".into()] },
AppScope { id: "drive".into(), scope: vec!["drive.google.com".into()] },
AppScope { id: "google".into(), scope: vec!["google.com".into()] },
AppScope { id: "gh".into(), scope: vec!["github.com".into()] },
]
}
#[test]
fn host_matches_exact_and_subdomain() {
assert!(host_matches("github.com", "github.com"));
assert!(host_matches("gist.github.com", "github.com"));
assert!(host_matches("www.github.com", "github.com"));
}
#[test]
fn host_matches_rejects_suffix_lookalikes() {
assert!(!host_matches("evilgithub.com", "github.com"));
assert!(!host_matches("github.com.evil.net", "github.com"));
}
#[test]
fn stays_within_the_current_app() {
let d = decide("https://mail.google.com/mail/u/0/#inbox", Some("gmail"), &apps());
assert_eq!(d, Decision::Stay);
}
#[test]
fn switches_to_the_app_that_owns_the_host() {
let d = decide("https://drive.google.com/file/d/123", Some("gmail"), &apps());
assert_eq!(
d,
Decision::Switch { app_id: "drive".into(), url: "https://drive.google.com/file/d/123".into() }
);
}
#[test]
fn longest_scope_wins_over_a_broader_one() {
// Both `google.com` and `mail.google.com` match; the specific app must win.
let d = decide("https://mail.google.com/", Some("gh"), &apps());
assert!(matches!(d, Decision::Switch { ref app_id, .. } if app_id == "gmail"));
}
#[test]
fn unknown_hosts_leave_for_the_browser() {
let d = decide("https://news.ycombinator.com/", Some("gh"), &apps());
assert_eq!(d, Decision::External { url: "https://news.ycombinator.com/".into() });
}
#[test]
fn identity_providers_stay_inside() {
// Otherwise every SSO login completes in a browser this app cannot read.
let d = decide("https://accounts.google.com/o/oauth2/auth?x=1", Some("gh"), &apps());
assert_eq!(d, Decision::Stay);
}
#[test]
fn non_web_schemes_go_to_the_os() {
let d = decide("mailto:someone@example.com", Some("gmail"), &apps());
assert!(matches!(d, Decision::External { .. }));
}
#[test]
fn garbage_urls_do_not_panic() {
assert!(matches!(decide("not a url", None, &apps()), Decision::External { .. }));
}
#[test]
fn current_app_wins_even_when_another_scope_is_longer() {
// `google` is showing and clicks a google.com link: it keeps it, rather
// than the more specific gmail app stealing an internal navigation.
let d = decide("https://google.com/search?q=x", Some("google"), &apps());
assert_eq!(d, Decision::Stay);
}
}
+279
View File
@@ -0,0 +1,279 @@
//! One child webview per configured app, stacked in the stage rect.
//!
//! Child webviews are native views layered above the shell's content. They take
//! no part in CSS layout, so the shell measures the stage and reports it here,
//! and anything the shell wants to draw over an app (a dialog) requires hiding
//! the app first.
use serde::Serialize;
use tauri::{
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, WebviewUrl,
webview::{NewWindowResponse, WebviewBuilder},
};
use url::Url;
use crate::config::{App, Config};
use crate::routing::{self, AppScope, Decision};
/// Scheme the injected interceptor uses to hand a URL back for a decision.
///
/// A made-up scheme rather than Tauri IPC: IPC to a remote origin means
/// granting google.com the ability to call into this app, and routing a link
/// does not need anything that dangerous. `on_navigation` sees this, answers
/// it, and cancels the navigation — so nothing ever loads.
const ROUTE_SCHEME: &str = "workapp-route";
pub fn label_for(app_id: &str) -> String {
format!("app-{app_id}")
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SwitchEvent {
pub app_id: String,
pub url: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UrlEvent {
pub app_id: String,
pub url: String,
}
/// The click interceptor, specialised for one app's scope.
///
/// It only decides *intent*: a link the user clicked that leaves this app's
/// hosts is prevented and handed to Rust. Redirects, form posts and OAuth
/// bounces are untouched, which is what keeps sign-in flows alive.
fn interceptor_script(scopes: &[String]) -> String {
let json = serde_json::to_string(scopes).unwrap_or_else(|_| "[]".into());
format!(
r#"(function () {{
if (window.__workAppRouter) return;
window.__workAppRouter = true;
var SCOPES = {json};
function abs(href) {{ try {{ return new URL(href, document.baseURI).href; }} catch (e) {{ return null; }} }}
function inScope(u) {{
try {{
var h = new URL(u).hostname.replace(/^www\./, '').toLowerCase();
return SCOPES.some(function (s) {{
s = String(s).replace(/^www\./, '').toLowerCase();
return h === s || h.endsWith('.' + s);
}});
}} catch (e) {{ return false; }}
}}
function ask(u) {{
try {{ window.location.href = '{scheme}:/?u=' + encodeURIComponent(u); }} catch (e) {{}}
}}
document.addEventListener('click', function (e) {{
if (e.defaultPrevented || e.button !== 0) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
if (!a) return;
var href = a.getAttribute('href');
if (!href || href.charAt(0) === '#') return;
if (/^(javascript|blob|data):/i.test(href)) return;
var u = abs(href);
if (!u) return;
if (inScope(u)) {{
// Our own host. A new-tab link has nowhere to go in a tabless app, so
// it takes over this view rather than being swallowed.
if (a.target === '_blank') {{ e.preventDefault(); window.location.href = u; }}
return;
}}
e.preventDefault();
e.stopPropagation();
ask(u);
}}, true);
var nativeOpen = window.open;
window.open = function (url) {{
if (!url) return null;
var u = abs(url);
if (!u) return null;
if (inScope(u)) {{ window.location.href = u; return null; }}
ask(u);
return null;
}};
void nativeOpen;
}})();"#,
json = json,
scheme = ROUTE_SCHEME
)
}
/// Pulls the URL back out of a `workapp-route:/?u=…` sentinel.
pub fn route_target(url: &Url) -> Option<String> {
if url.scheme() != ROUTE_SCHEME {
return None;
}
url.query_pairs()
.find(|(k, _)| k == "u")
.map(|(_, v)| v.to_string())
}
/// Acts on a decision. Called off the navigation delegate, never on it.
fn apply(handle: &AppHandle, from: &str, decision: Decision) {
match decision {
// Only an identity provider reaches here, and the click that produced
// it was already cancelled — so this view has to be sent there.
Decision::Stay => {}
Decision::Switch { app_id, url } => {
let _ = handle.emit("switch-app", SwitchEvent { app_id, url });
}
Decision::External { url } => {
let _ = tauri_plugin_opener::open_url(url, None::<&str>);
}
}
let _ = from;
}
/// Handles a sentinel URL: decide, then act without blocking the delegate.
fn handle_route(handle: &AppHandle, from: &str, target: String, scopes: Vec<AppScope>) {
let handle = handle.clone();
let from = from.to_string();
tauri::async_runtime::spawn(async move {
match routing::decide(&target, Some(&from), &scopes) {
Decision::Stay => {
// The click was cancelled to ask the question, so completing it
// is now this side's job.
if let Some(wv) = handle.get_webview(&label_for(&from)) {
if let Ok(u) = Url::parse(&target) {
let _ = wv.navigate(u);
}
}
}
other => apply(&handle, &from, other),
}
});
}
/// Builds the child webview for one app and parks it in the stage rect.
pub fn create(handle: &AppHandle, app: &App, cfg: &Config, stage: (f64, f64, f64, f64)) -> Result<(), String> {
let window = handle
.get_window("main")
.ok_or_else(|| "main window is gone".to_string())?;
let url = Url::parse(&app.url).map_err(|e| format!("{}: {e}", app.url))?;
let id = app.id.clone();
let scopes = cfg.scopes();
let nav_handle = handle.clone();
let nav_id = id.clone();
let nav_scopes = scopes.clone();
let win_handle = handle.clone();
let win_id = id.clone();
let win_scopes = scopes.clone();
let builder = WebviewBuilder::new(label_for(&id), WebviewUrl::External(url))
.user_agent(&app.ua())
.initialization_script(interceptor_script(&app.scopes()))
.on_navigation(move |url| {
// The sentinel is a question, not a destination: answer it and
// cancel. Everything else is allowed — a strict filter here would
// break every OAuth redirect chain.
if let Some(target) = route_target(url) {
handle_route(&nav_handle, &nav_id, target, nav_scopes.clone());
return false;
}
let _ = nav_handle.emit(
"url-changed",
UrlEvent { app_id: nav_id.clone(), url: url.to_string() },
);
true
})
.on_new_window(move |url, _features| {
// Nothing may open a window of its own; the same rules apply.
let decision = routing::decide(url.as_str(), Some(&win_id), &win_scopes);
match decision {
Decision::Stay => {
if let Some(wv) = win_handle.get_webview(&label_for(&win_id)) {
let _ = wv.navigate(url);
}
}
other => apply(&win_handle, &win_id, other),
}
NewWindowResponse::Deny
});
window
.add_child(
builder,
LogicalPosition::new(stage.0, stage.1),
LogicalSize::new(stage.2, stage.3),
)
.map_err(|e| e.to_string())?;
// Created hidden. `show` is what puts one on screen, so startup does not
// flash every app in turn as they are built.
if let Some(wv) = handle.get_webview(&label_for(&id)) {
let _ = wv.hide();
}
Ok(())
}
/// Shows one app and hides the rest, sizing it to the stage.
pub fn show_only(handle: &AppHandle, app_id: Option<&str>, cfg: &Config, stage: (f64, f64, f64, f64)) {
for app in &cfg.apps {
let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue };
if Some(app.id.as_str()) == app_id {
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
let _ = wv.show();
} else {
let _ = wv.hide();
}
}
}
/// Re-sizes whichever app is showing. Called on every layout change.
pub fn set_stage(handle: &AppHandle, active: Option<&str>, stage: (f64, f64, f64, f64)) {
let Some(id) = active else { return };
let Some(wv) = handle.get_webview(&label_for(id)) else { return };
let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1));
let _ = wv.set_size(LogicalSize::new(stage.2, stage.3));
}
/// Hides every app webview, so the shell can draw over the whole window.
pub fn hide_all(handle: &AppHandle, cfg: &Config) {
for app in &cfg.apps {
if let Some(wv) = handle.get_webview(&label_for(&app.id)) {
let _ = wv.hide();
}
}
}
pub fn destroy(handle: &AppHandle, app_id: &str) {
if let Some(wv) = handle.get_webview(&label_for(app_id)) {
let _ = wv.close();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_target_round_trips_a_url_with_a_query() {
let u = Url::parse("workapp-route:/?u=https%3A%2F%2Fx.com%2Fa%3Fb%3D1%26c%3D2").unwrap();
assert_eq!(route_target(&u).unwrap(), "https://x.com/a?b=1&c=2");
}
#[test]
fn ordinary_urls_are_not_sentinels() {
let u = Url::parse("https://github.com/?u=x").unwrap();
assert!(route_target(&u).is_none());
}
#[test]
fn the_script_carries_the_apps_own_scope() {
let s = interceptor_script(&["mail.google.com".into()]);
assert!(s.contains("mail.google.com"));
assert!(s.contains("workapp-route:/?u="));
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Work",
"version": "0.1.0",
"identifier": "com.vincent.workapp",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "Work",
"width": 1440,
"height": 900,
"minWidth": 900,
"minHeight": 600,
"center": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true
}
],
"security": { "csp": null }
},
"bundle": {
"active": true,
"targets": ["app"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}