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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user