Site-agnostic parts of the X app, behind a small site adapter: - crates/waybetter-host: webview-label security policy and tab rect clamp, account profiles with native-owned accounts (AccountRules), plugin store with bundled-plugin upgrades (PluginHost), signed data-only hotfix channel, update scheduling and crash handshake, tab payloads/link routing/tab-chrome validation, and (feature "tauri") webview helpers and the Windows system menu. - packages/runtime: DOM hub, request hub, plugin API core, OKLab theme color model, split-view rail hiding, link routing and account reporting, each configured by a SiteAdapter. - packages/shell: address bar, pane, link-drop, profile, palette and update-chip models, and window controls with the Tauri API injected. Tests moved/adapted from the app: 45 Rust (headless), 40 TypeScript.
106 lines
3.9 KiB
Rust
106 lines
3.9 KiB
Rust
//! The site adapter: everything the host needs to know about the one site an app extends.
|
|
//!
|
|
//! An app declares one `static SITE: SiteSpec` and passes it to the host helpers. Nothing in the
|
|
//! host hard-codes a site; the security boundary (which webviews count as "site tabs" and may call
|
|
//! site-only commands) is derived from `hosts`.
|
|
|
|
use url::Url;
|
|
|
|
/// How the account signed in inside a profile is validated before it is stored. The page runtime
|
|
/// reads it from the site's own UI; native code accepts only values that pass these rules.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct AccountRules {
|
|
/// A normalized handle (no leading `@`) the site could really have.
|
|
pub valid_handle: fn(&str) -> bool,
|
|
/// Avatar URLs must start with one of these (the site's own image CDN), else they are dropped.
|
|
pub avatar_prefixes: &'static [&'static str],
|
|
/// Display names are cut to this many characters.
|
|
pub max_name_chars: usize,
|
|
/// Avatar URLs longer than this are dropped.
|
|
pub max_avatar_len: usize,
|
|
}
|
|
|
|
impl AccountRules {
|
|
pub fn avatar_allowed(&self, url: &str) -> bool {
|
|
url.len() <= self.max_avatar_len
|
|
&& self.avatar_prefixes.iter().any(|prefix| url.starts_with(prefix))
|
|
&& !url.contains(|ch: char| ch == '"' || ch == '\'' || ch == '\\' || ch.is_whitespace())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct SiteSpec {
|
|
/// Product name for logs and native notifications, e.g. "wayBetter for X".
|
|
pub product: &'static str,
|
|
/// Exact HTTPS hosts (port 443, no credentials) whose pages are site tabs.
|
|
pub hosts: &'static [&'static str],
|
|
/// The page runtime's window global, e.g. `BetterTwttr` (`window.BetterTwttr`).
|
|
pub runtime_global: &'static str,
|
|
pub accounts: AccountRules,
|
|
}
|
|
|
|
impl SiteSpec {
|
|
/// Whether `url` is exactly one of the site's origins (https, default port, no credentials).
|
|
pub fn is_origin(&self, url: &Url) -> bool {
|
|
crate::policy::plain_origin(url)
|
|
&& url.scheme() == "https"
|
|
&& url.host_str().is_some_and(|host| self.hosts.contains(&host))
|
|
&& url.port_or_known_default() == Some(443)
|
|
}
|
|
|
|
/// `https://<first host>`, the origin the page runtime checks before it runs.
|
|
pub fn primary_origin(&self) -> String {
|
|
format!("https://{}", self.hosts.first().copied().unwrap_or_default())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod testing {
|
|
use super::*;
|
|
|
|
fn handle(value: &str) -> bool {
|
|
(1..=15).contains(&value.len()) && value.bytes().all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
|
|
}
|
|
|
|
/// An X-shaped site for the host's own tests.
|
|
pub static SITE: SiteSpec = SiteSpec {
|
|
product: "wayBetter for Test",
|
|
hosts: &["x.com"],
|
|
runtime_global: "BetterTwttr",
|
|
accounts: AccountRules {
|
|
valid_handle: handle,
|
|
avatar_prefixes: &["https://pbs.twimg.com/"],
|
|
max_name_chars: 50,
|
|
max_avatar_len: 512,
|
|
},
|
|
};
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::testing::SITE;
|
|
use url::Url;
|
|
|
|
#[test]
|
|
fn site_origin_is_exact() {
|
|
assert!(SITE.is_origin(&Url::parse("https://x.com/home").unwrap()));
|
|
for bad in [
|
|
"http://x.com/",
|
|
"https://x.com:444/",
|
|
"https://user@x.com/",
|
|
"https://www.x.com/",
|
|
"https://x.com.evil.test/",
|
|
] {
|
|
assert!(!SITE.is_origin(&Url::parse(bad).unwrap()), "{bad}");
|
|
}
|
|
assert_eq!(SITE.primary_origin(), "https://x.com");
|
|
}
|
|
|
|
#[test]
|
|
fn avatars_must_come_from_the_site_cdn() {
|
|
assert!(SITE.accounts.avatar_allowed("https://pbs.twimg.com/profile_images/1/a.jpg"));
|
|
assert!(!SITE.accounts.avatar_allowed("https://evil.test/a.jpg"));
|
|
assert!(!SITE.accounts.avatar_allowed("https://pbs.twimg.com/a b.jpg"));
|
|
assert!(!SITE.accounts.avatar_allowed(&format!("https://pbs.twimg.com/{}", "a".repeat(600))));
|
|
}
|
|
}
|