waybetter/crates/waybetter-host/src/tabs.rs

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;})();"#
);
}
}