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:
@@ -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="));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user