443 lines
19 KiB
Rust
443 lines
19 KiB
Rust
//! 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(Clone, 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<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub title: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub loading: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub favicon: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub focused: Option<bool>,
|
|
/// 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<u16>,
|
|
/// The language the site's own UI is in (its `<html lang>`), a validated tag; site tabs only.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub lang: Option<String>,
|
|
/// Site content reached its first meaningful paint (sent only by participating apps).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub content_ready: Option<bool>,
|
|
}
|
|
|
|
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<String>, title: Option<String>, favicon: Option<String>, focused: Option<bool>) -> Self {
|
|
let clip = |value: Option<String>, 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<String>,
|
|
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<Url, String> {
|
|
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",
|
|
"app-menu", "zoom-in", "zoom-out", "zoom-reset",
|
|
"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<String>) -> Result<Option<String>, 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<Self, String> {
|
|
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<String, bool>,
|
|
#[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<String>,
|
|
#[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<Self, String> {
|
|
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.<global>.<member>.<method>(value)`
|
|
/// when the runtime exists, else parked in `window.<queue>` 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::<TabLayout>(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<ColumnConfig, String> {
|
|
serde_json::from_value::<ColumnConfig>(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;})();"#
|
|
);
|
|
}
|
|
}
|