Files
work-app/src-tauri/src/routing.rs
T
Vincent ff4a0c6bc4 Browser pairing, notifications, element hiding; drop the top bar
Pairing decrypts a Chromium browser's cookie store (PBKDF2-HMAC-SHA1
against its Keychain key, then AES-128-CBC) and injects the result into
WKHTTPCookieStore. Only the configured apps' hosts and their sign-in
hosts survive the filter. Browsers are offered most-recently-used first,
since the first entry becomes the default and someone with four
Chromium browsers installed wants the one they actually browse in.

WKWebView defines window.Notification but it does nothing: constructing
one throws no error and shows no banner, so a page believes it notified
you. Measured on the machine as `api=function shim=no` before the shim
was made unconditional; `from page: Odoo - Test notification -> raised`
after.

Anything on a page can be right-clicked away. The rule is re-asserted on
every navigation, because the injected script only carries a snapshot
from when the view was built and a selector added since would otherwise
come back on reload.

The top bar is gone. Navigation lives beside the cog, the nav carries
the traffic lights, and two-finger swipe goes back and forward.

The seed is now the real app list, scoped to exact hosts so a Drive link
inside Gmail switches rather than being swallowed.
2026-09-01 12:17:31 +02:00

217 lines
7.3 KiB
Rust

//! 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}"))
}
/// The sign-in hosts, for the cookie import — a session for a tool is no use
/// without the identity provider's cookie that vouches for it.
pub fn identity_providers() -> &'static [&'static str] {
IDENTITY_PROVIDERS
}
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);
}
}