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.
217 lines
7.3 KiB
Rust
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);
|
|
}
|
|
}
|