Deck columns (Power User Mode): shell deckModel (bounded decks, paging layout, focus, unread totals), runtime column mode (hide site chrome, fill width, filters, unread tracking, no requests), host ColumnConfig bounds, TabRole, deck shortcuts, capped unread in TabState; column boxes skip rail rules
This commit is contained in:
parent
f6d52512a0
commit
376e74884e
10 changed files with 1170 additions and 7 deletions
|
|
@ -2,6 +2,7 @@
|
|||
//! 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.
|
||||
|
|
@ -34,6 +35,18 @@ pub struct TabLayout {
|
|||
/// 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 {
|
||||
|
|
@ -55,6 +68,9 @@ pub struct TabState {
|
|||
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>,
|
||||
}
|
||||
|
||||
impl TabState {
|
||||
|
|
@ -72,10 +88,19 @@ impl TabState {
|
|||
}),
|
||||
focused,
|
||||
loading: None,
|
||||
unread: 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")]
|
||||
|
|
@ -119,10 +144,13 @@ pub fn new_window_mode<'a>(on_site: bool, site_mode: &'a str, external_mode: &'a
|
|||
}
|
||||
}
|
||||
|
||||
/// Browser shortcuts a tab may forward to the shell while it has keyboard focus.
|
||||
pub const SHORTCUTS: [&str; 15] = [
|
||||
/// 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 {
|
||||
|
|
@ -181,6 +209,87 @@ pub fn memory_target(visible: bool, parked: bool) -> MemoryTarget {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
|
@ -253,7 +362,64 @@ mod tests {
|
|||
#[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), ("default", false));
|
||||
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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue