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
+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="));
}
}