Extract the wayBetter framework core from wayBetter for X
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.
This commit is contained in:
commit
43a9e2e1a7
48 changed files with 9768 additions and 0 deletions
227
crates/waybetter-host/src/tabs.rs
Normal file
227
crates/waybetter-host/src/tabs.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
//! 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 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";
|
||||
|
||||
/// 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,
|
||||
/// Another site pane of the same account already shows the site's navigation rail.
|
||||
#[serde(default)]
|
||||
pub nav_redundant: bool,
|
||||
}
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
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),
|
||||
favicon: clip(favicon, 2048).filter(|href| href.starts_with("https://") || href.starts_with("data:image/")),
|
||||
focused,
|
||||
loading: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub const SHORTCUTS: [&str; 15] = [
|
||||
"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",
|
||||
];
|
||||
|
||||
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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"));
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[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;})();"#
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue