//! Tab payloads between the shell and native code, link routing and tab-chrome validation. //! Everything a tab page sends is validated here before it reaches the shell. use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use url::Url; /// Shell events emitted by the host. pub const EVENT_TAB_STATE: &str = "tab-state"; pub const EVENT_TAB_OPEN: &str = "tab-open"; pub const EVENT_TAB_SHORTCUT: &str = "tab-shortcut"; pub const EVENT_LINK_DRAG: &str = "link-drag"; pub const EVENT_PROFILES_CHANGED: &str = "profiles-changed"; /// A site tab asks the shell to open the app's panel (`{ from, section }`). pub const EVENT_OPEN_PANEL: &str = "open-panel"; /// One pane as the shell lays it out (`tabs_sync`). #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TabLayout { pub id: String, /// Used only when the tab's webview does not exist yet. pub url: String, /// Account profile; fixed per webview (a change recreates the tab's webview). #[serde(default = "default_profile")] pub profile: String, pub x: f64, pub y: f64, pub width: f64, pub height: f64, pub visible: bool, /// Not shown for a reason other than a shell overlay (a background tab of its group, a pane /// with no room). Only parked tabs get the low memory target; a tab merely covered by a shell /// menu or the command palette stays at normal. #[serde(default)] pub parked: bool, /// Another site pane of the same account already shows the site's navigation rail. #[serde(default)] pub nav_redundant: bool, /// An ordinary pane, or a Power User Mode deck column (the page hides the site chrome itself). #[serde(default)] pub role: TabRole, } /// What a tab webview is in the shell's layout. #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum TabRole { #[default] Pane, Column, } fn default_profile() -> String { crate::profiles::DEFAULT_PROFILE.into() } /// A tab's address-bar state for the shell (`tab-state`); absent fields are unchanged. #[derive(Clone, Serialize, Default, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct TabState { pub id: String, #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, #[serde(skip_serializing_if = "Option::is_none")] pub loading: Option, #[serde(skip_serializing_if = "Option::is_none")] pub favicon: Option, #[serde(skip_serializing_if = "Option::is_none")] pub focused: Option, /// A deck column's unread count (capped at [`MAX_UNREAD`]); only ever a number about this tab. #[serde(skip_serializing_if = "Option::is_none")] pub unread: Option, /// The language the site's own UI is in (its ``), a validated tag; site tabs only. #[serde(skip_serializing_if = "Option::is_none")] pub lang: Option, /// Site content reached its first meaningful paint (sent only by participating apps). #[serde(skip_serializing_if = "Option::is_none")] pub content_ready: Option, } impl TabState { /// What a tab reported about itself, bounded. The URL is never the page's claim: callers pass /// the webview's own URL (and only when the page reported a change). pub fn reported(id: String, url: Option, title: Option, favicon: Option, focused: Option) -> Self { let clip = |value: Option, max: usize| value.filter(|v| v.len() <= max); Self { id, url, title: clip(title, 512), // An embedded icon (a site's own, or a theme's replacement logo) is bigger than any URL. favicon: favicon.filter(|href| { (href.starts_with("https://") && href.len() <= 2048) || (href.starts_with("data:image/") && href.len() <= 64 * 1024) }), focused, loading: None, unread: None, lang: None, content_ready: None, } } } /// Unread counts shown on deck column headers are capped here ("999+"). pub const MAX_UNREAD: u16 = 999; /// A reported unread count, capped at [`MAX_UNREAD`]. pub fn cap_unread(count: u64) -> u16 { count.min(MAX_UNREAD as u64) as u16 } /// A new-window request from a tab that the shell opens as a tab or a pane (`tab-open`). #[derive(Clone, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct OpenRequest { pub url: String, pub from: Option, pub background: bool, /// Open in a pane beside `from` (the "pane" link setting) instead of a tab in its group. pub beside: bool, } /// Only plain http(s) URLs without credentials open in tabs. pub fn parse_web_url(value: &str) -> Result { let url = Url::parse(value).map_err(|_| "VALIDATION: invalid URL")?; if !matches!(url.scheme(), "http" | "https") || !url.username().is_empty() || url.password().is_some() { return Err("VALIDATION: only plain http(s) URLs open in tabs".into()); } Ok(url) } /// Where links to other sites open: beside the pane, in a tab or in the system browser. pub fn valid_external_mode(mode: &str) -> bool { matches!(mode, "pane" | "tab" | "browser") } /// Where clicked site pages open: "same" keeps the site's own in-place navigation. pub fn valid_site_mode(mode: &str) -> bool { matches!(mode, "same" | "pane" | "tab" | "browser") } /// Where a new-window request (target=_blank, window.open) opens. "same" only concerns ordinary /// clicks, so an explicit new window on the site still gets a tab. pub fn new_window_mode<'a>(on_site: bool, site_mode: &'a str, external_mode: &'a str) -> &'a str { if on_site { match site_mode { "same" => "tab", mode => mode, } } else { external_mode } } /// Browser shortcuts a tab may forward to the shell while it has keyboard focus. The `column-*` and /// `deck-*` names are Power User Mode keys (deck columns forward them; the shell acts on its deck). pub const SHORTCUTS: &[&str] = &[ "command-palette", "new-tab", "close-tab", "reopen-tab", "focus-address", "next-tab", "prev-tab", "reload", "back", "forward", "split-right", "split-down", "open-panel", "find", "hard-reload", "column-prev", "column-next", "column-1", "column-2", "column-3", "column-4", "column-5", "column-6", "column-7", "column-8", "column-9", "column-page-prev", "column-page-next", "column-mark-read", "deck-compose", "deck-toggle", ]; pub fn valid_shortcut(shortcut: &str) -> bool { SHORTCUTS.contains(&shortcut) } /// `tab_link_drag`: `start` carries the dragged link (validated here, bounded), `enter`/`end` none. pub fn link_drag(phase: &str, url: Option) -> Result, String> { if !matches!(phase, "start" | "enter" | "end") { return Err("VALIDATION: unknown drag phase".into()); } match (phase, url) { ("start", Some(url)) if url.len() <= 4096 => Ok(Some(parse_web_url(&url)?.to_string())), ("start", _) => Err("VALIDATION: a link drag needs an http(s) URL".into()), _ => Ok(None), } } /// `back | forward | reload | stop | focus`, the tab actions the shell may request. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TabAction { Back, Forward, Reload, Stop, Focus, } impl TabAction { pub fn parse(action: &str) -> Result { Ok(match action { "back" => Self::Back, "forward" => Self::Forward, "reload" => Self::Reload, "stop" => Self::Stop, "focus" => Self::Focus, _ => return Err("VALIDATION: unknown tab action".into()), }) } } /// WebView2 memory target for a tab webview. A hidden tab (background tab of a group, or a pane /// clamped away) asks the browser to drop caches, decoded images and GPU tiles it can rebuild; /// its page keeps running (timers, sockets, notifications). Shown tabs go back to normal. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MemoryTarget { Normal, Low, } pub fn memory_target(visible: bool, parked: bool) -> MemoryTarget { if !visible && parked { MemoryTarget::Low } else { MemoryTarget::Normal } } /// Power User Mode: what the shell may tell a deck column's page (`column_configure`, shell-only). /// Everything is data for the page runtime's column module (hide site chrome, density, filters, /// unread tracking); nothing here can make the page request or act. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ColumnConfig { /// The column's own page; must be on the site. pub url: String, /// Source kind (site-defined), e.g. "home" or "search". pub kind: String, pub density: String, #[serde(default)] pub filters: ColumnFilters, /// Per-column feature flags over the global ones; names must be allow-listed by the app. #[serde(default)] pub flags: BTreeMap, #[serde(default = "yes")] pub unread: bool, #[serde(default)] pub read_seq: u32, #[serde(default)] pub jump_seq: u32, } fn yes() -> bool { true } #[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ColumnFilters { #[serde(default)] pub terms: Vec, #[serde(default)] pub hide_replies: bool, #[serde(default)] pub hide_reposts: bool, #[serde(default)] pub media_only: bool, } pub const MAX_COLUMN_TERMS: usize = 32; pub const MAX_COLUMN_TERM_CHARS: usize = 200; pub const MAX_COLUMN_FLAGS: usize = 16; impl ColumnConfig { /// Bounds every field: the URL is exactly on the site, at most 32 filter terms of at most 200 /// characters without control characters, known densities, allow-listed flags only. pub fn validated(mut self, site: &crate::site::SiteSpec, allowed_flags: &[&str]) -> Result { if self.url.len() > 2048 { return Err("VALIDATION: column URL too long".into()); } let url = parse_web_url(&self.url)?; if !site.is_origin(&url) { return Err("VALIDATION: a column shows a page of the site".into()); } self.url = url.to_string(); if self.kind.is_empty() || self.kind.len() > 24 || !self.kind.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') { return Err("VALIDATION: invalid column kind".into()); } if !matches!(self.density.as_str(), "compact" | "comfortable") { return Err("VALIDATION: invalid column density".into()); } if self.filters.terms.len() > MAX_COLUMN_TERMS { return Err("VALIDATION: too many column filters".into()); } for term in &self.filters.terms { if term.trim().is_empty() || term.chars().count() > MAX_COLUMN_TERM_CHARS || term.chars().any(char::is_control) { return Err("VALIDATION: invalid column filter".into()); } } if self.flags.len() > MAX_COLUMN_FLAGS || self.flags.keys().any(|key| !allowed_flags.contains(&key.as_str())) { return Err("VALIDATION: column flag not allowed".into()); } Ok(self) } } /// Hands a JSON value to a member of the page runtime: `window...(value)` /// when the runtime exists, else parked in `window.` for the runtime to pick up at start. /// `json` must be serialized JSON (never page-supplied text). pub fn deliver_script(global: &str, member: &str, method: &str, queue: &str, json: &str) -> String { format!("(()=>{{const v={json};const R=window.{global};if(R&&R.{member})R.{member}.{method}(v);else window.{queue}=v;}})();") } #[cfg(test)] mod tests { use super::*; #[test] fn only_plain_web_urls_open() { assert!(parse_web_url("https://example.com/a?b").is_ok()); for bad in ["javascript:alert(1)", "file:///c:/x", "https://u:p@example.com/", "about:blank", "nope"] { assert!(parse_web_url(bad).is_err(), "{bad}"); } } #[test] fn link_modes_route_new_windows() { assert!(valid_external_mode("pane") && !valid_external_mode("same")); assert!(valid_site_mode("same") && !valid_site_mode("popup")); assert_eq!(new_window_mode(true, "same", "browser"), "tab"); assert_eq!(new_window_mode(true, "pane", "browser"), "pane"); assert_eq!(new_window_mode(false, "same", "browser"), "browser"); } #[test] fn tab_chrome_input_is_validated() { assert!(valid_shortcut("new-tab") && !valid_shortcut("eval")); assert_eq!(link_drag("start", Some("https://a.test/x".into())).unwrap().as_deref(), Some("https://a.test/x")); assert!(link_drag("start", None).is_err()); assert!(link_drag("start", Some(format!("https://a.test/{}", "a".repeat(4096)))).is_err()); assert!(link_drag("start", Some("javascript:x".into())).is_err()); assert_eq!(link_drag("end", Some("https://a.test/".into())).unwrap(), None); assert!(link_drag("drop", None).is_err()); assert_eq!(TabAction::parse("reload").unwrap(), TabAction::Reload); assert!(TabAction::parse("close").is_err()); } #[test] fn reports_are_bounded() { let state = TabState::reported( "a".into(), None, Some("t".repeat(513)), Some("javascript:alert(1)".into()), Some(true), ); assert_eq!((state.title, state.favicon, state.focused), (None, None, Some(true))); let state = TabState::reported("a".into(), None, Some("Home".into()), Some("data:image/png;base64,AA".into()), None); assert_eq!(state.favicon.as_deref(), Some("data:image/png;base64,AA")); let big = format!("data:image/png;base64,{}", "A".repeat(30_000)); let state = TabState::reported("tab-1".into(), None, None, Some(big.clone()), None); assert_eq!(state.favicon, Some(big), "a 30 KB embedded logo is kept"); let huge = format!("data:image/png;base64,{}", "A".repeat(70_000)); assert_eq!(TabState::reported("tab-1".into(), None, None, Some(huge), None).favicon, None); let long_url = format!("https://x.com/{}", "a".repeat(3000)); assert_eq!(TabState::reported("tab-1".into(), None, None, Some(long_url), None).favicon, None); } #[test] fn hidden_tabs_target_low_memory() { assert_eq!(memory_target(false, true), MemoryTarget::Low, "a parked background tab"); assert_eq!(memory_target(true, false), MemoryTarget::Normal); assert_eq!(memory_target(false, false), MemoryTarget::Normal, "hidden only behind a shell overlay"); } #[test] fn layouts_default_to_the_default_profile() { let layout: TabLayout = serde_json::from_str(r#"{"id":"a","url":"https://a.test/","x":0,"y":0,"width":1,"height":1,"visible":true}"#).unwrap(); assert_eq!((layout.profile.as_str(), layout.nav_redundant, layout.role), ("default", false, TabRole::Pane)); let column: TabLayout = serde_json::from_str(r#"{"id":"a","url":"https://a.test/","x":0,"y":0,"width":1,"height":1,"visible":true,"role":"column"}"#).unwrap(); assert_eq!(column.role, TabRole::Column); assert!(serde_json::from_str::(r#"{"id":"a","url":"u","x":0,"y":0,"width":1,"height":1,"visible":true,"role":"popup"}"#).is_err()); } #[test] fn deck_shortcuts_are_known_and_unread_is_capped() { for name in [ "column-prev", "column-next", "column-1", "column-9", "column-page-prev", "column-page-next", "column-mark-read", "deck-compose", "deck-toggle", ] { assert!(valid_shortcut(name), "{name}"); } assert!(!valid_shortcut("column-10") && !valid_shortcut("column-0") && !valid_shortcut("deck-post")); assert_eq!((cap_unread(0), cap_unread(12), cap_unread(999), cap_unread(1_000_000)), (0, 12, 999, 999)); let state = TabState { id: "a".into(), unread: Some(3), ..Default::default() }; assert_eq!(serde_json::to_value(state).unwrap(), serde_json::json!({ "id": "a", "unread": 3 })); } fn column(json: serde_json::Value) -> Result { serde_json::from_value::(json) .map_err(|error| error.to_string())? .validated(&crate::site::testing::SITE, &["autoplayOff", "hideAds"]) } #[test] fn column_config_is_bounded() { let base = serde_json::json!({ "url": "https://x.com/home", "kind": "home", "density": "compact" }); let ok = column(base.clone()).unwrap(); assert!(ok.unread && ok.filters.terms.is_empty() && ok.read_seq == 0); let full = serde_json::json!({ "url": "https://x.com/search?q=a&f=live", "kind": "search", "density": "comfortable", "filters": { "terms": ["gm", "/^rt\\b/i"], "hideReplies": true, "hideReposts": false, "mediaOnly": true }, "flags": { "autoplayOff": true, "hideAds": false }, "unread": false, "readSeq": 4, "jumpSeq": 1 }); let full = column(full).unwrap(); assert_eq!((full.filters.terms.len(), full.flags.len(), full.unread, full.read_seq), (2, 2, false, 4)); let with = |key: &str, value: serde_json::Value| { let mut json = base.clone(); json[key] = value; column(json) }; assert!(with("url", "https://evil.test/home".into()).is_err(), "other sites"); assert!(with("url", "javascript:alert(1)".into()).is_err()); assert!(with("url", format!("https://x.com/{}", "a".repeat(2100)).into()).is_err()); assert!(with("kind", "Home!".into()).is_err()); assert!(with("density", "tiny".into()).is_err()); assert!(with("flags", serde_json::json!({ "evalScript": true })).is_err(), "flags are allow-listed"); assert!(with("flags", serde_json::json!({ "autoplayOff": "yes" })).is_err()); assert!(with("filters", serde_json::json!({ "terms": vec!["t"; 33] })).is_err(), "at most 32 terms"); assert!(with("filters", serde_json::json!({ "terms": ["x".repeat(201)] })).is_err(), "at most 200 characters"); assert!(with("filters", serde_json::json!({ "terms": ["ok".repeat(100)] })).is_ok(), "exactly 200 is fine"); assert!(with("filters", serde_json::json!({ "terms": ["a\nb"] })).is_err(), "no control characters"); assert!(with("filters", serde_json::json!({ "terms": [" "] })).is_err()); assert!(with("filters", serde_json::json!({ "script": "x" })).is_err(), "unknown fields"); assert!(with("extra", true.into()).is_err(), "unknown fields"); } #[test] fn delivery_parks_values_until_the_runtime_exists() { assert_eq!( deliver_script("Demo", "pane", "set", "__demoPane", r#"{"redundantNav":true}"#), r#"(()=>{const v={"redundantNav":true};const R=window.Demo;if(R&&R.pane)R.pane.set(v);else window.__demoPane=v;})();"# ); } }