Chat+ link previews: a link in a chat shows its page's title, text and picture, YouTube playable in place

X's web chat shows a link as a bare card. An X tab on a chat page may now ask native code
(link_preview, x-capture only, chat pages only) for one link's preview. Native code fetches it:
https and public hosts only (addresses checked and pinned), never X's hosts, no cookies or
referrer, redirects re-checked, page head <= 512 KB, images <= 1.5 MB by magic bytes as data:,
30 fetches a minute, memory-only cache. Only the link leaves the page. On by default (owner,
2026-10-10).
This commit is contained in:
CRTkafa 2026-10-10 21:47:31 +08:00
parent 7530fb1de7
commit 6d1798038c
21 changed files with 1455 additions and 519 deletions

View file

@ -751,6 +751,10 @@
"plugin.chat-plus.bookmarks.importBad": "This is not an export of starred messages.",
"plugin.chat-plus.bookmarks.imported": "{count, plural, =0 {Nothing new to import} one {# starred message imported} other {# starred messages imported}}",
"plugin.chat-plus.bookmarks.close": "Close",
"plugin.chat-plus.feature.linkPreviews.label": "Link previews",
"plugin.chat-plus.feature.linkPreviews.description": "Shows a preview under a link in a chat: the page's title, text and picture, and for YouTube the video, playable in place. To make one, the app asks the link's site for its preview itself: only the link is sent, without cookies, never the message or who sent it. Nothing is saved.",
"plugin.chat-plus.linkPreviews.play": "Play the video here",
"plugin.chat-plus.linkPreviews.open": "Open the link",
"plugin.chat-plus.feature.searchLoaded.label": "Find in conversation",
"plugin.chat-plus.feature.searchLoaded.description": "Ctrl+Shift+F in a chat searches the messages X has loaded so far and jumps to each match. It can load older messages while it is open.",
"plugin.chat-plus.searchLoaded.title": "Find in conversation",

View file

@ -17,7 +17,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ar-x-fm",
@ -28,7 +28,7 @@
"tier": "community",
"reviewers": [],
"fallback": "ar",
"coverage": 1
"coverage": 0.996
},
{
"code": "bn",
@ -38,7 +38,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "eu",
@ -48,7 +48,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "en-GB",
@ -69,7 +69,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ca",
@ -79,7 +79,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "zh-Hans",
@ -89,7 +89,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "zh-Hant",
@ -99,7 +99,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "hr",
@ -109,7 +109,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "cs",
@ -119,7 +119,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "da",
@ -129,7 +129,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "nl",
@ -139,7 +139,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "fil",
@ -149,7 +149,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "fi",
@ -159,7 +159,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "fr",
@ -169,7 +169,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "gl",
@ -179,7 +179,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "de",
@ -189,7 +189,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "el",
@ -199,7 +199,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "gu",
@ -209,7 +209,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ha",
@ -219,7 +219,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "he",
@ -229,7 +229,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "hi",
@ -239,7 +239,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "hu",
@ -249,7 +249,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ig",
@ -259,7 +259,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "id",
@ -269,7 +269,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ga",
@ -279,7 +279,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "it",
@ -289,7 +289,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ja",
@ -299,7 +299,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "kn",
@ -309,7 +309,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ko",
@ -319,7 +319,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ms",
@ -329,7 +329,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "mr",
@ -339,7 +339,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "nb",
@ -349,7 +349,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "fa",
@ -359,7 +359,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "pl",
@ -369,7 +369,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "pt",
@ -379,7 +379,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ro",
@ -389,7 +389,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ru",
@ -399,7 +399,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "sr",
@ -409,7 +409,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "sk",
@ -419,7 +419,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "es",
@ -429,7 +429,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "sv",
@ -439,7 +439,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ta",
@ -449,7 +449,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "th",
@ -459,7 +459,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "tr",
@ -479,7 +479,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "ur",
@ -489,7 +489,7 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "vi",
@ -499,7 +499,7 @@
"status": "machine",
"tier": "supported",
"reviewers": [],
"coverage": 1
"coverage": 0.996
},
{
"code": "yo",
@ -509,6 +509,6 @@
"status": "machine",
"tier": "community",
"reviewers": [],
"coverage": 1
"coverage": 0.996
}
]

View file

@ -800,6 +800,10 @@
"plugin.chat-plus.personFilter.groupOnly": "Önce bir grup sohbeti aç",
"plugin.chat-plus.personFilter.nobody": "Henüz seçilecek kimse yok: hiç mesaj yüklenmedi.",
"plugin.chat-plus.personFilter.hint": "Şimdiye kadar yüklenen mesajları yazan kişiler. Daha fazlası için yukarı kaydır.",
"plugin.chat-plus.feature.linkPreviews.label": "Link önizlemeleri",
"plugin.chat-plus.feature.linkPreviews.description": "Sohbetteki bir linkin altında önizlemesini gösterir: sayfanın başlığı, metni ve görseli; YouTube'da ise yerinde oynatılabilen video. Önizleme için uygulama linkin sitesine kendisi sorar: yalnızca link gider, çerezsiz; mesaj ya da gönderen asla gitmez. Hiçbir şey kaydedilmez.",
"plugin.chat-plus.linkPreviews.play": "Videoyu burada oynat",
"plugin.chat-plus.linkPreviews.open": "Linki aç",
"plugin.chat-plus.feature.keywords.label": "Sohbetlerde anahtar kelimeler",
"plugin.chat-plus.feature.keywords.description": "Kelimelerini içeren sohbet mesajlarını vurgular, sessize aldığın kelimeleri içeren mesajları katlar. Katlanmış bir mesaj tıklayınca açılır.",
"plugin.chat-plus.keywords.highlight": "Vurgulanacak kelimeler",

View file

@ -296,7 +296,9 @@ dependencies = [
"base64 0.22.1",
"chacha20poly1305",
"chrono",
"reqwest",
"rusqlite",
"rustls",
"serde",
"serde_json",
"sha2",

View file

@ -25,6 +25,8 @@ desktop = [
"waybetter-host/app",
"waybetter-host/net",
"waybetter-host/macos-private-api",
"dep:reqwest",
"dep:rustls",
]
[build-dependencies]
@ -52,6 +54,9 @@ url = "2"
tauri-plugin-deep-link = { version = "2", optional = true }
# Gallery folder picker, driven from Rust only (no JS capability grants the dialog plugin).
tauri-plugin-dialog = { version = "2", optional = true }
# Chat+ link previews (src/link_preview.rs): the same client the host's store uses, no cookies.
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"], optional = true }
rustls = { version = "0.23", default-features = false, features = ["ring"], optional = true }
sha2 = "0.10"
base64 = "0.22"
# Encrypted settings sync (a folder the owner picks): Argon2id key from the passphrase,

View file

@ -1,25 +1,26 @@
fn main() {
#[cfg(feature = "desktop")]
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
// X's own commands; `manifest` adds the ones the wayBetter host serves for every app
// (`waybetter_host::commands`: tab layout and actions, shell raise, profiles, language,
// the plugins folder and custom CSS, the tweak flags, the shell's settings).
tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&[
"capture_draft",
"column_configure",
"tab_report",
"drafts_picker_open",
"tab_insert_text",
"drafts_popover_dismiss",
"drafts_hint",
"popover_state",
"popover_drafts",
"popover_insert",
"popover_close",
"popover_open_vault",
"keyword_alert",
"tab_attach_image",
])),
))
.expect("Tauri build configuration must be valid");
}
fn main() {
#[cfg(feature = "desktop")]
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
// X's own commands; `manifest` adds the ones the wayBetter host serves for every app
// (`waybetter_host::commands`: tab layout and actions, shell raise, profiles, language,
// the plugins folder and custom CSS, the tweak flags, the shell's settings).
tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&[
"capture_draft",
"column_configure",
"tab_report",
"drafts_picker_open",
"tab_insert_text",
"drafts_popover_dismiss",
"drafts_hint",
"popover_state",
"popover_drafts",
"popover_insert",
"popover_close",
"popover_open_vault",
"keyword_alert",
"link_preview",
"tab_attach_image",
])),
))
.expect("Tauri build configuration must be valid");
}

View file

@ -1,10 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "x-capture",
"description": "X tabs: append-only draft capture, opening the wayBetter tab, asking for the drafts popover under its composer and closing it, asking whether a captured draft exists for exactly its own post page (yes/no, no text or ids) and reporting keyword matches about itself. No reads of the local vault, no shell, filesystem or generic APIs.",
"webviews": ["tab-*"],
"local": false,
"platforms": ["macOS", "windows", "linux"],
"remote": { "urls": ["https://x.com/*"] },
"permissions": ["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert"]
}
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "x-capture",
"description": "X tabs: append-only draft capture, opening the wayBetter tab, asking for the drafts popover under its composer and closing it, asking whether a captured draft exists for exactly its own post page (yes/no, no text or ids), reporting keyword matches about itself and, on its own chat page only, asking for the preview of one link it shows (native fetch: public https hosts only, no cookies, size caps; src/link_preview.rs). No reads of the local vault, no shell, filesystem or generic APIs.",
"webviews": ["tab-*"],
"local": false,
"platforms": ["macOS", "windows", "linux"],
"remote": { "urls": ["https://x.com/*"] },
"permissions": ["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert", "allow-link-preview"]
}

View file

@ -77,6 +77,7 @@ pub const FLAGS: &[&str] = &[
"chat.searchLoaded",
"chat.pins",
"chat.personFilter",
"chat.linkPreviews",
"chat.keywords",
"mediaDownload",
"rememberVideoVolume",

View file

@ -233,6 +233,19 @@ async fn keyword_alert(webview: Webview, state: State<'_, AppState>, hit: Keywor
Ok(())
}
/// Chat+ link previews (owner-approved network use, 2026-10-10): an X tab on its own chat page asks
/// for the preview of one link it shows. Only the link reaches native code; the fetch rules
/// (public https hosts, no cookies, pinned addresses, size caps, memory-only cache) are in link_preview.rs.
#[tauri::command]
async fn link_preview(webview: Webview, url: String) -> Result<crate::link_preview::LinkPreview, String> {
policy::require_capture(webview.label(), &webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?)?;
let page = webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?;
if !crate::link_preview::is_chat_page(page.path()) {
return Err("FORBIDDEN: link previews are for X's chat pages only".into());
}
crate::link_preview::preview(&url).await
}
/// Tells an X tab whether its left navigation repeats one already on screen; the runtime hides it
/// while the `singleSplitNav` feature is on. Queued in `__btPane` until the runtime exists.
fn pane_script(nav_redundant: bool) -> String {
@ -578,6 +591,7 @@ pub fn run() {
desktop_popover::popover_close,
desktop_popover::popover_open_vault,
keyword_alert,
link_preview,
crate::desktop_media::tab_attach_image,
],
);

View file

@ -9,6 +9,7 @@ pub mod devmode;
pub mod distribution;
pub mod gfonts;
pub mod hotfix;
pub mod link_preview;
pub mod media;
pub mod model;
pub mod palette_commands;

View file

@ -0,0 +1,607 @@
//! Chat+ link previews (owner-approved network use, 2026-10-10): X's web chat shows a link as a bare
//! card (an icon and the domain), so an X tab on a chat page may ask native code for the preview of
//! ONE link it shows. Native code fetches that link's page head (or YouTube's oEmbed) itself:
//!
//! - https only, no user info, default port, a domain name (no IP literal), not X's own hosts;
//! - every address the name resolves to must be public (no loopback, private, link-local, CGNAT,
//! multicast, documentation or unique-local ranges), and the connection is pinned to those
//! addresses, so a DNS answer cannot point the request into the owner's network;
//! - no cookies (no cookie store), no referrer, a plain user agent naming the app;
//! - redirects followed by hand, at most [`MAX_REDIRECTS`], each hop checked again;
//! - only an HTML head is read, at most [`MAX_PAGE_BYTES`]; an image at most [`MAX_IMAGE_BYTES`],
//! PNG/JPEG/GIF/WebP by its magic bytes only, handed back as a `data:` URL;
//! - the request carries the link and nothing else: no conversation, sender or message text.
//!
//! Results live in memory only (a bounded cache) and are never written anywhere.
use serde::Serialize;
use std::net::IpAddr;
use url::Url;
pub const MAX_URL_LEN: usize = 2_048;
pub const MAX_REDIRECTS: usize = 4;
pub const MAX_PAGE_BYTES: usize = 512 * 1024;
pub const MAX_IMAGE_BYTES: usize = 1_536 * 1024;
pub const MAX_TITLE: usize = 200;
pub const MAX_DESCRIPTION: usize = 300;
pub const MAX_SITE: usize = 80;
pub const USER_AGENT: &str = "Mozilla/5.0 (compatible; wayBetter-LinkPreview/1.0; +https://waybetter.fyi/x/)";
/// What a chat tab gets back: plain text and, for a page, an image as a `data:` URL. A YouTube link
/// also carries its video id (the page offers YouTube's own player, a www.youtube.com frame X allows).
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LinkPreview {
pub url: String,
pub site: String,
pub title: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub youtube: Option<String>,
}
/// X's chat pages (`/i/chat`, `/messages`): the only pages that may ask for a preview.
pub fn is_chat_page(path: &str) -> bool {
["/i/chat", "/messages"].iter().any(|root| path == *root || path.starts_with(&format!("{root}/")))
}
/// X's own hosts: their links are X's to show, never fetched from here.
fn is_x_host(host: &str) -> bool {
["x.com", "twitter.com", "t.co", "twimg.com", "x.co"].iter().any(|own| host == *own || host.ends_with(&format!(".{own}")))
}
/// A link a chat tab may ask about: https, a domain name, default port, no user info, not X.
pub fn check_link(value: &str) -> Result<Url, String> {
if value.is_empty() || value.len() > MAX_URL_LEN || value.chars().any(|c| c.is_control() || c.is_whitespace()) {
return Err("VALIDATION: invalid link".into());
}
let url = Url::parse(value).map_err(|_| "VALIDATION: invalid link")?;
check_url(&url)?;
Ok(url)
}
fn check_url(url: &Url) -> Result<(), String> {
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
return Err("VALIDATION: only plain https links".into());
}
if url.port().is_some_and(|port| port != 443) {
return Err("VALIDATION: only the default port".into());
}
let host = match url.host() {
Some(url::Host::Domain(host)) => host.to_ascii_lowercase(),
_ => return Err("VALIDATION: the link must name a host".into()),
};
if !host.contains('.') || host.ends_with(".local") || host.ends_with(".localhost") || host.ends_with(".internal") || host == "localhost" {
return Err("VALIDATION: not a public host".into());
}
if is_x_host(&host) {
return Err("UNSUPPORTED: X's own links".into());
}
Ok(())
}
/// Public unicast only: anything that could reach the owner's machine or network is refused.
pub fn is_public(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let [a, b, c, _] = v4.octets();
!(v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_broadcast()
|| v4.is_multicast()
|| v4.is_unspecified()
|| v4.is_documentation()
|| a == 0
|| (a == 100 && (64..=127).contains(&b))
|| (a == 192 && b == 0 && c == 0)
|| (a == 198 && (18..=19).contains(&b))
|| a >= 240)
}
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return is_public(IpAddr::V4(v4));
}
let first = v6.segments()[0];
!(v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| (first & 0xfe00) == 0xfc00
|| (first & 0xffc0) == 0xfe80
|| first == 0x2001 && v6.segments()[1] == 0x0db8
|| first == 0x0064 && v6.segments()[1] == 0xff9b)
}
}
}
/// The video id of a YouTube watch, short, shorts or embed link.
pub fn youtube_id(url: &Url) -> Option<String> {
let host = url.host_str()?.trim_start_matches("www.").trim_start_matches("m.").to_ascii_lowercase();
let id = match host.as_str() {
"youtu.be" => url.path_segments()?.next()?.to_string(),
"youtube.com" | "music.youtube.com" => {
let mut segments = url.path_segments()?;
match segments.next()? {
"watch" => url.query_pairs().find(|(k, _)| k == "v")?.1.into_owned(),
"shorts" | "embed" | "live" => segments.next()?.to_string(),
_ => return None,
}
}
_ => return None,
};
(id.len() == 11 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')).then_some(id)
}
/// Plain text: entities decoded, control characters and runs of white space collapsed, capped.
pub fn clean(value: &str, max: usize) -> String {
let decoded = decode_entities(value);
let mut out = String::new();
let mut space = false;
for c in decoded.chars() {
if c.is_whitespace() || c.is_control() {
space = !out.is_empty();
continue;
}
if matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') {
continue;
}
if space {
out.push(' ');
space = false;
}
out.push(c);
}
if out.chars().count() > max {
out = out.chars().take(max.saturating_sub(1)).collect::<String>().trim_end().to_string() + "\u{2026}";
}
out
}
fn decode_entities(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at]);
rest = &rest[at..];
let end = rest[..rest.len().min(12)].find(';');
let decoded = end.and_then(|end| {
let name = &rest[1..end];
let c = match name {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" | "#39" => Some('\''),
"nbsp" => Some(' '),
_ if name.starts_with("#x") || name.starts_with("#X") => u32::from_str_radix(&name[2..], 16).ok().and_then(char::from_u32),
_ if name.starts_with('#') => name[1..].parse().ok().and_then(char::from_u32),
_ => None,
}?;
Some((c, end + 1))
});
match decoded {
Some((c, len)) => {
out.push(c);
rest = &rest[len..];
}
None => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
/// The attributes of one tag (`<meta a="b" c='d' e=f>`), names lowercased.
fn attributes(tag: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let bytes = tag.as_bytes();
let mut i = 0;
while i < bytes.len() {
while i < bytes.len() && (bytes[i].is_ascii_whitespace() || bytes[i] == b'/') {
i += 1;
}
let start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'=' && bytes[i] != b'>' {
i += 1;
}
if start == i {
i += 1;
continue;
}
let name = tag[start..i].to_ascii_lowercase();
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
let mut value = String::new();
if i < bytes.len() && bytes[i] == b'=' {
i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
let quote = bytes[i];
i += 1;
let from = i;
while i < bytes.len() && bytes[i] != quote {
i += 1;
}
value = tag[from..i].to_string();
i += 1;
} else {
let from = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
i += 1;
}
value = tag[from..i].to_string();
}
}
out.push((name, value));
}
out
}
/// What a page's head says about itself: Open Graph first, then Twitter cards, then `<title>` and
/// the description meta. `image` is resolved against `base` and checked like a link.
#[derive(Debug, Default, PartialEq)]
pub struct PageMeta {
pub title: String,
pub description: String,
pub site: String,
pub image: Option<Url>,
}
pub fn parse_head(html: &str, base: &Url) -> PageMeta {
let lower = html.to_ascii_lowercase();
let head_end = lower.find("</head").unwrap_or(lower.len());
let (html, lower) = (&html[..head_end], &lower[..head_end]);
let mut found: Vec<(String, String)> = Vec::new();
let mut at = 0;
while let Some(start) = lower[at..].find("<meta").map(|i| at + i) {
let Some(end) = lower[start..].find('>').map(|i| start + i) else { break };
let attrs = attributes(&html[start + 5..end]);
let key = attrs.iter().find(|(n, _)| n == "property" || n == "name").map(|(_, v)| v.to_ascii_lowercase());
let content = attrs.iter().find(|(n, _)| n == "content").map(|(_, v)| v.clone());
if let (Some(key), Some(content)) = (key, content) {
found.push((key, content));
}
at = end;
}
let get = |keys: &[&str]| keys.iter().find_map(|key| found.iter().find(|(k, v)| k == key && !v.trim().is_empty()).map(|(_, v)| v.clone()));
let title_tag = lower.find("<title").and_then(|start| {
let open = start + lower[start..].find('>')? + 1;
let close = open + lower[open..].find("</title")?;
Some(html[open..close].to_string())
});
let title = get(&["og:title", "twitter:title"]).or(title_tag).unwrap_or_default();
let description = get(&["og:description", "twitter:description", "description"]).unwrap_or_default();
let site = get(&["og:site_name", "application-name"]).unwrap_or_default();
let image = get(&["og:image:secure_url", "og:image", "og:image:url", "twitter:image", "twitter:image:src"])
.and_then(|value| base.join(decode_entities(value.trim()).as_str()).ok())
.filter(|url| check_url(url).is_ok());
PageMeta {
title: clean(&title, MAX_TITLE),
description: clean(&description, MAX_DESCRIPTION),
site: clean(&site, MAX_SITE),
image,
}
}
/// The image type by its first bytes; anything else (SVG included) is refused.
pub fn image_mime(bytes: &[u8]) -> Option<&'static str> {
match bytes {
[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, ..] => Some("image/png"),
[0xff, 0xd8, 0xff, ..] => Some("image/jpeg"),
[b'G', b'I', b'F', b'8', b'7' | b'9', b'a', ..] => Some("image/gif"),
[b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => Some("image/webp"),
_ => None,
}
}
/// The host as shown under a preview when the page names no site (`www.` dropped).
pub fn display_host(url: &Url) -> String {
clean(url.host_str().unwrap_or_default().trim_start_matches("www."), MAX_SITE)
}
#[cfg(feature = "desktop")]
pub use fetch::preview;
#[cfg(feature = "desktop")]
mod fetch {
use super::*;
use base64::Engine;
use std::collections::HashMap;
use std::net::{SocketAddr, ToSocketAddrs};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
const CACHE_ENTRIES: usize = 256;
const CACHE_TTL: Duration = Duration::from_secs(30 * 60);
/// At most this many fresh fetches per minute for the whole app (cached answers are free).
const PER_MINUTE: usize = 30;
type Cached = (Instant, Result<LinkPreview, String>);
fn cache() -> &'static Mutex<HashMap<String, Cached>> {
static CACHE: OnceLock<Mutex<HashMap<String, Cached>>> = OnceLock::new();
CACHE.get_or_init(Default::default)
}
fn allow_fetch() -> bool {
static RECENT: OnceLock<Mutex<Vec<Instant>>> = OnceLock::new();
let mut recent = RECENT.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
recent.retain(|at| now.duration_since(*at) < Duration::from_secs(60));
if recent.len() >= PER_MINUTE {
return false;
}
recent.push(now);
true
}
/// The preview of one link: from the cache, else fetched under the rules above.
pub async fn preview(link: &str) -> Result<LinkPreview, String> {
let url = check_link(link)?;
let key = url.as_str().to_string();
if let Some((at, result)) = cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if at.elapsed() < CACHE_TTL {
return result.clone();
}
}
if !allow_fetch() {
return Err("BUSY: too many previews, try again shortly".into());
}
let result = match youtube_id(&url) {
Some(id) => youtube(&url, id).await,
None => page(&url).await,
};
let mut cache = cache().lock().unwrap_or_else(|e| e.into_inner());
if cache.len() >= CACHE_ENTRIES {
cache.retain(|_, (at, _)| at.elapsed() < CACHE_TTL);
if cache.len() >= CACHE_ENTRIES {
cache.clear();
}
}
cache.insert(key, (Instant::now(), result.clone()));
result
}
/// Resolves the host off the async runtime and keeps only public addresses (all must be public).
async fn public_addrs(url: &Url) -> Result<(String, Vec<SocketAddr>), String> {
let host = url.host_str().ok_or("VALIDATION: no host")?.to_string();
let lookup = host.clone();
let addrs: Vec<SocketAddr> = tauri::async_runtime::spawn_blocking(move || (lookup.as_str(), 443).to_socket_addrs().map(|a| a.collect::<Vec<_>>()))
.await
.map_err(|_| "NETWORK: lookup failed")?
.map_err(|_| "NETWORK: the host does not resolve")?;
if addrs.is_empty() || !addrs.iter().all(|addr| is_public(addr.ip())) {
return Err("FORBIDDEN: the host is not on the public internet".into());
}
Ok((host, addrs))
}
fn client(host: &str, addrs: &[SocketAddr]) -> Result<reqwest::Client, String> {
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
// No cookie store: nothing is sent or kept. Redirects are followed by hand (each hop checked).
reqwest::Client::builder()
.user_agent(USER_AGENT)
.redirect(reqwest::redirect::Policy::none())
.referer(false)
.https_only(true)
.connect_timeout(Duration::from_secs(6))
.timeout(Duration::from_secs(12))
.resolve_to_addrs(host, addrs)
.build()
.map_err(|e| format!("NETWORK: {e}"))
}
/// GET with hand-followed redirects; returns the final URL and up to `max` bytes of the body
/// (`stop` may end the read early, e.g. once the page head is complete).
async fn get(start: &Url, accept: &str, max: usize, stop: fn(&[u8]) -> bool) -> Result<(Url, String, Vec<u8>), String> {
let mut url = start.clone();
for _ in 0..=MAX_REDIRECTS {
check_url(&url)?;
let (host, addrs) = public_addrs(&url).await?;
let mut response = client(&host, &addrs)?
.get(url.clone())
.header(reqwest::header::ACCEPT, accept)
.header(reqwest::header::ACCEPT_LANGUAGE, "en;q=0.9, *;q=0.5")
.send()
.await
.map_err(|e| format!("NETWORK: {}", e.without_url()))?;
let status = response.status();
if status.is_redirection() {
let location = response.headers().get(reqwest::header::LOCATION).and_then(|v| v.to_str().ok()).ok_or("NETWORK: redirect without a location")?;
url = url.join(location).map_err(|_| "NETWORK: invalid redirect")?;
continue;
}
if !status.is_success() {
return Err(format!("NETWORK: the site answered {}", status.as_u16()));
}
let mime = response.headers().get(reqwest::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_ascii_lowercase();
if response.content_length().is_some_and(|len| len as usize > max) && !accept.starts_with("text/html") {
return Err("VALIDATION: too large".into());
}
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|e| format!("NETWORK: {}", e.without_url()))? {
let room = max.saturating_sub(body.len());
body.extend_from_slice(&chunk[..chunk.len().min(room)]);
if body.len() >= max || stop(&body) {
break;
}
}
return Ok((url, mime, body));
}
Err("NETWORK: too many redirects".into())
}
fn head_complete(body: &[u8]) -> bool {
let tail = &body[body.len().saturating_sub(16 * 1024)..];
tail.windows(7).any(|w| w.eq_ignore_ascii_case(b"</head>"))
}
fn never(_: &[u8]) -> bool {
false
}
async fn page(url: &Url) -> Result<LinkPreview, String> {
let (final_url, mime, body) = get(url, "text/html,application/xhtml+xml;q=0.9", MAX_PAGE_BYTES, head_complete).await?;
if !(mime.starts_with("text/html") || mime.starts_with("application/xhtml")) {
return Err("UNSUPPORTED: not a web page".into());
}
let html = String::from_utf8_lossy(&body);
let meta = parse_head(&html, &final_url);
if meta.title.is_empty() && meta.image.is_none() {
return Err("UNSUPPORTED: the page has no preview".into());
}
let image = match &meta.image {
Some(image) => fetch_image(image).await.ok(),
None => None,
};
Ok(LinkPreview {
url: url.to_string(),
site: if meta.site.is_empty() { display_host(&final_url) } else { meta.site },
title: meta.title,
description: meta.description,
image,
youtube: None,
})
}
async fn fetch_image(url: &Url) -> Result<String, String> {
let (_, _, bytes) = get(url, "image/avif,image/webp,image/png,image/jpeg,image/gif;q=0.8", MAX_IMAGE_BYTES, never).await?;
if bytes.len() >= MAX_IMAGE_BYTES {
return Err("VALIDATION: image too large".into());
}
let mime = image_mime(&bytes).ok_or("UNSUPPORTED: not an image")?;
Ok(format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(&bytes)))
}
/// YouTube: its oEmbed answer (title, channel) and the video's thumbnail; the player is YouTube's
/// own www.youtube.com, which X's page allows as a frame, loaded only when the user presses play.
async fn youtube(url: &Url, id: String) -> Result<LinkPreview, String> {
let mut oembed = Url::parse("https://www.youtube.com/oembed?format=json").expect("static URL");
oembed.query_pairs_mut().append_pair("url", &format!("https://www.youtube.com/watch?v={id}"));
let (_, _, body) = get(&oembed, "application/json", 64 * 1024, never).await?;
let value: serde_json::Value = serde_json::from_slice(&body).map_err(|_| "UNSUPPORTED: no video data")?;
let text = |key: &str, max: usize| clean(value.get(key).and_then(|v| v.as_str()).unwrap_or(""), max);
// The thumbnail comes as data too: X's chat page does not reliably load it from i.ytimg.com.
let thumbnail = Url::parse(&format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg")).expect("checked id");
Ok(LinkPreview {
url: url.to_string(),
site: "YouTube".into(),
title: text("title", MAX_TITLE),
description: text("author_name", MAX_SITE),
image: fetch_image(&thumbnail).await.ok(),
youtube: Some(id),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_plain_public_https_links() {
for ok in ["https://example.com/a?b=c", "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "https://sub.example.co.uk:443/"] {
assert!(check_link(ok).is_ok(), "{ok}");
}
for bad in [
"http://example.com/",
"https://user:pw@example.com/",
"https://example.com:8443/",
"https://127.0.0.1/",
"https://[::1]/",
"https://192.168.0.1/",
"https://localhost/",
"https://printer.local/",
"https://intranet/",
"https://x.com/someone/status/1",
"https://pbs.twimg.com/media/a.jpg",
"https://t.co/abc",
"file:///etc/passwd",
"javascript:alert(1)",
"https://exa mple.com/",
"",
] {
assert!(check_link(bad).is_err(), "{bad}");
}
assert!(check_link(&format!("https://example.com/{}", "a".repeat(MAX_URL_LEN))).is_err());
}
#[test]
fn private_and_special_addresses_are_not_public() {
for ip in ["127.0.0.1", "10.1.2.3", "172.16.0.1", "192.168.0.192", "169.254.1.1", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1", "::1", "fe80::1", "fd00::1", "::ffff:192.168.0.1", "2001:db8::1", "64:ff9b::a00:1"] {
assert!(!is_public(ip.parse().unwrap()), "{ip}");
}
for ip in ["1.1.1.1", "142.250.185.78", "2606:4700:4700::1111"] {
assert!(is_public(ip.parse().unwrap()), "{ip}");
}
}
#[test]
fn chat_pages_only() {
for ok in ["/i/chat", "/i/chat/1-2", "/messages", "/messages/1-2"] {
assert!(is_chat_page(ok), "{ok}");
}
for bad in ["/home", "/i/chatter", "/messagesx", "/someone/status/1", "/"] {
assert!(!is_chat_page(bad), "{bad}");
}
}
#[test]
fn youtube_ids() {
let id = |s: &str| youtube_id(&Url::parse(s).unwrap());
assert_eq!(id("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1"), Some("dQw4w9WgXcQ".into()));
assert_eq!(id("https://youtu.be/dQw4w9WgXcQ?si=x"), Some("dQw4w9WgXcQ".into()));
assert_eq!(id("https://m.youtube.com/shorts/dQw4w9WgXcQ"), Some("dQw4w9WgXcQ".into()));
assert_eq!(id("https://www.youtube.com/embed/dQw4w9WgXcQ"), Some("dQw4w9WgXcQ".into()));
assert_eq!(id("https://www.youtube.com/watch?v=bad\"id"), None);
assert_eq!(id("https://www.youtube.com/@channel"), None);
assert_eq!(id("https://example.com/watch?v=dQw4w9WgXcQ"), None);
}
#[test]
fn reads_a_page_head() {
let base = Url::parse("https://fishies.lol/play/").unwrap();
let html = r#"<!doctype html><html><head><title>Fallback</title>
<meta property="og:title" content="Fishies &amp; friends">
<meta name=description content='The social aquarium'>
<meta property="og:site_name" content="Fishies">
<meta property="og:image" content="/img/card.png">
</head><body><meta property="og:title" content="not this"></body></html>"#;
let meta = parse_head(html, &base);
assert_eq!(meta.title, "Fishies & friends");
assert_eq!(meta.description, "The social aquarium");
assert_eq!(meta.site, "Fishies");
assert_eq!(meta.image.unwrap().as_str(), "https://fishies.lol/img/card.png");
let bare = parse_head("<head><title>\n Only a title \u{202e}</title><meta property='og:image' content='http://insecure.example/a.png'></head>", &base);
assert_eq!(bare.title, "Only a title");
assert_eq!(bare.image, None, "an image off the link rules is dropped");
assert_eq!(parse_head("<head><meta property='og:image' content='https://10.0.0.1/a.png'></head>", &base).image, None);
}
#[test]
fn text_is_plain_and_capped() {
assert_eq!(clean("a&lt;b&gt; &#x41;&#66; &unknown; &", 50), "a<b> AB &unknown; &");
assert_eq!(clean(&"x".repeat(500), 10).chars().count(), 10);
assert_eq!(clean("\u{0}\u{7}one\ttwo\n", 50), "one two");
}
#[test]
fn images_by_magic_bytes_only() {
assert_eq!(image_mime(b"\x89PNG\r\n\x1a\nrest"), Some("image/png"));
assert_eq!(image_mime(b"\xff\xd8\xff\xe0"), Some("image/jpeg"));
assert_eq!(image_mime(b"GIF89a"), Some("image/gif"));
assert_eq!(image_mime(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp"));
assert_eq!(image_mime(b"<svg xmlns="), None);
assert_eq!(image_mime(b"<html>"), None);
}
}

View file

@ -1,430 +1,434 @@
use url::Url;
use better_twttr::{policy::*, site::SITE, theme::{x_default, XTheme, X_THEMES}};
#[test]
fn exact_origin_and_window_scope_is_enforced() {
let x = Url::parse("https://x.com/home").unwrap();
assert!(require_capture("tab-1", &x).is_ok());
assert!(require_capture("shell", &x).is_err());
assert!(require_local(
"tab-1",
&Url::parse("tauri://localhost/index.html").unwrap(),
false
)
.is_err());
for value in [
"https://evil.x.com/home",
"http://x.com/home",
"https://x.com:8443/home",
"https://x.com.evil.test/home",
"https://x.com@evil.test/home",
"https://evil@x.com/home",
"file:///tmp/index.html",
"https://twitter.com/home",
] {
assert!(
require_capture("tab-1", &Url::parse(value).unwrap()).is_err(),
"accepted {value}"
);
}
}
#[test]
fn local_origin_has_no_production_localhost_backdoor() {
for value in [
"tauri://localhost/index.html",
"http://tauri.localhost/index.html",
"https://tauri.localhost/index.html",
] {
assert!(require_local("shell", &Url::parse(value).unwrap(), false).is_ok());
}
assert!(require_local("shell", &Url::parse("http://127.0.0.1:1420/").unwrap(), true).is_ok());
for value in [
"https://x.com/",
"http://127.0.0.1:1420/",
"http://localhost:1420/",
"https://tauri.localhost.evil.test/",
"tauri://evil/",
] {
assert!(
require_local("shell", &Url::parse(value).unwrap(), false).is_err(),
"accepted {value}"
);
}
}
#[test]
fn navigation_allows_only_x_and_specific_legacy_login_routes() {
for value in [
"https://x.com/home",
"https://twitter.com/login",
"https://twitter.com/i/flow/login",
"https://twitter.com/i/flow/login/step",
] {
assert!(x_navigation_allowed(&Url::parse(value).unwrap()));
}
for value in [
"https://twitter.com/home",
"https://t.co/test",
"https://accounts.google.com/",
"javascript:alert(1)",
"mailto:test@example.com",
"https://twitter.com/login/evil",
] {
assert!(
!x_navigation_allowed(&Url::parse(value).unwrap()),
"accepted {value}"
);
}
}
#[test]
fn remote_capabilities_cannot_reach_the_vault() {
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
let x = read(include_str!("../capabilities/x-capture.json"));
// Drafts popover (2026-09-27): X may only open/close it about itself and ask one yes/no hint question.
assert_eq!(x["permissions"], serde_json::json!(["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert"]));
for vault in ["allow-popover-drafts", "allow-popover-insert", "allow-popover-state", "allow-draft-list"] {
assert!(!x["permissions"].as_array().unwrap().iter().any(|p| p == vault), "X tabs have {vault}");
}
// The drafts popover is local-only and gets nothing beyond its own five commands.
let popover = read(include_str!("../capabilities/drafts-popover.json"));
assert_eq!(popover["webviews"], serde_json::json!(["popover-*"]));
assert_eq!(popover["local"], true);
assert!(popover.get("remote").is_none());
assert_eq!(popover["permissions"], serde_json::json!(["allow-popover-state", "allow-popover-drafts", "allow-popover-insert", "allow-popover-close", "allow-popover-open-vault"]));
assert_eq!(x["webviews"], serde_json::json!(["tab-*"]));
assert_eq!(x["remote"]["urls"], serde_json::json!(["https://x.com/*"]));
assert_eq!(x["local"], false);
// Linux joined with the same permissions (owner request, 2026-09-28): platforms only, no new grants.
assert_eq!(x["platforms"], serde_json::json!(["macOS", "windows", "linux"]));
let tabs = read(include_str!("../capabilities/tab-chrome.json"));
assert_eq!(tabs["permissions"], serde_json::json!(["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"]));
assert_eq!(tabs["webviews"], serde_json::json!(["tab-*"]));
assert_eq!(tabs["local"], false);
let local = read(include_str!("../capabilities/workspace-local.json"));
assert!(local.get("remote").is_none());
// My Space (profile customization) is removed: its commands do not exist, so no capability
// file grants them and neither the command manifest nor the handler names them.
let (build, desktop) = (include_str!("../build.rs"), include_str!("../src/desktop.rs"));
for gone in [
"profile_owner_get", "profile_create", "profile_api", "profile_media_pick", "profile_media_still", "profile_media_upload", "profile_still_to_x", "profile_view_get", "profile_view_set",
"bt_profiles_resolve", "bt_media_get", "bt_profile_link_seen",
] {
let permission = format!("allow-{}", gone.replace('_', "-"));
for (name, capability) in [("x-capture", &x), ("drafts-popover", &popover), ("tab-chrome", &tabs), ("workspace-local", &local)] {
assert!(!capability["permissions"].as_array().unwrap().iter().any(|p| p == permission.as_str()), "{name} grants {permission}");
}
assert!(!build.contains(&format!("\"{gone}\"")), "build.rs still lists {gone}");
assert!(!desktop.contains(gone), "desktop.rs still names {gone}");
}
assert_eq!(local["webviews"], serde_json::json!(["shell"]));
// Update commands are shell-only; no capability exposes the updater/process plugin JS APIs.
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
// Account profiles (separate X logins) are managed only by the shell.
// Drafts are inserted and alert keywords edited only by the shell; X tabs only ask/report.
for name in [
"allow-update-status", "allow-apply-update", "allow-app-ready", "allow-profiles-get", "allow-profiles-set",
"allow-tab-insert-text", "allow-alerts-get", "allow-alerts-set", "allow-draft-list", "allow-draft-revisions",
// Google Fonts downloads (network), account/pane themes and the builder preview: shell only.
"allow-gfonts-installed", "allow-gfonts-install", "allow-gfonts-remove", "allow-gfonts-faces", "allow-gfonts-preview",
"allow-theme-scopes-get", "allow-theme-scopes-set", "allow-theme-preview",
// Installing themes/plugins (links, dropped files, the signed store; owner-approved 2026-09-28):
// only the shell, after its confirmation screen. X tabs and web pages cannot install anything.
"allow-install-pending", "allow-install-stage-link", "allow-install-stage-file", "allow-install-cancel",
"allow-install-commit", "allow-install-records", "allow-install-forget", "allow-plugin-remove",
"allow-store-catalog", "allow-store-stage",
] {
assert!(perms(&local).iter().any(|p| p == name), "shell lacks {name}");
assert!(!perms(&x).iter().chain(perms(&tabs).iter()).any(|p| p == name), "tab has {name}");
}
for p in perms(&local).iter().chain(perms(&x).iter()).chain(perms(&tabs).iter()) {
assert!(
!p.starts_with("updater:") && !p.starts_with("process:") && !p.starts_with("notification:") && !p.starts_with("deep-link:"),
"plugin API exposed: {p}"
);
}
}
#[test]
fn theme_accepts_only_known_presets_and_hex_accents() {
let script = |theme: XTheme| theme.script(&X_THEMES, &SITE);
let valid = XTheme { preset: "night-ink".into(), accent: Some("#1d9bf0".into()), ..x_default() };
let text = script(valid).unwrap();
assert!(text.contains("location.origin!=='https://x.com'"));
assert!(text.contains(r#""preset":"night-ink""#));
assert!(x_default().is_default(&X_THEMES));
for preset in ["x", "classic", "night-ink", "terminal", "paper"] {
assert!(script(XTheme { preset: preset.into(), ..x_default() }).is_ok());
}
for hostile in ["evil", "x;display:none", "</script>", ""] {
assert!(script(XTheme { preset: hostile.into(), ..x_default() }).is_err(), "accepted {hostile}");
}
for hostile in ["red", "#fff", "url(https://evil.test)", "#000000;display:none", "</style><script>", "#00000é"] {
assert!(script(XTheme { accent: Some(hostile.into()), ..x_default() }).is_err(), "accepted {hostile}");
}
assert!(serde_json::from_str::<XTheme>(r#"{"preset":"x","css":"body{}"}"#).is_err());
}
#[test]
fn bundle_keeps_macos_targets_and_signed_updater() {
let conf: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
let targets = conf["bundle"]["targets"].as_array().unwrap();
for target in ["nsis", "app", "dmg"] {
assert!(targets.iter().any(|t| t == target), "missing bundle target {target}");
}
assert!(!conf["plugins"]["updater"]["pubkey"].as_str().unwrap_or("").is_empty());
for endpoint in conf["plugins"]["updater"]["endpoints"].as_array().unwrap() {
assert!(endpoint.as_str().unwrap().starts_with("https://"), "updater endpoint must be https");
}
// One URL scheme, only for install links; the installer/Info.plist register exactly this.
assert_eq!(conf["plugins"]["deep-link"]["desktop"]["schemes"], serde_json::json!(["waybetter"]));
assert!(conf["plugins"]["deep-link"].get("mobile").is_none());
}
#[test]
fn backup_sync_workspace_palette_and_dev_commands_are_shell_only() {
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
let x = perms(&read(include_str!("../capabilities/x-capture.json")));
let tabs = perms(&read(include_str!("../capabilities/tab-chrome.json")));
let popover = perms(&read(include_str!("../capabilities/drafts-popover.json")));
// Settings backup/sync (files and the sync key stay native), workspaces, running a plugin's
// palette command in a tab, and developer mode (file watcher, hot reload).
for name in [
"allow-tab-run-command", "allow-workspaces-get", "allow-workspaces-set",
"allow-backup-collect", "allow-backup-export-file", "allow-backup-open-file", "allow-backup-apply",
"allow-sync-status", "allow-sync-choose-folder", "allow-sync-unlock", "allow-sync-lock", "allow-sync-disable",
"allow-sync-configure", "allow-sync-push", "allow-sync-check", "allow-sync-open", "allow-sync-mark-pulled",
"allow-dev-get", "allow-dev-set", "allow-dev-pick-theme", "allow-dev-clear-theme", "allow-dev-reload-plugin", "allow-dev-report-error",
] {
assert!(local.iter().any(|p| p == name), "shell lacks {name}");
assert!(!x.iter().chain(tabs.iter()).chain(popover.iter()).any(|p| p == name), "a tab or the popover has {name}");
}
// X tabs report their plugins' commands only through tab_report (owner-approved 2026-09-28);
// the tab capabilities gained nothing for it.
assert_eq!(tabs, ["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"]);
}
#[test]
fn language_commands_are_shell_only() {
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
let others: Vec<String> = [
include_str!("../capabilities/x-capture.json"),
include_str!("../capabilities/tab-chrome.json"),
include_str!("../capabilities/drafts-popover.json"),
]
.iter()
.flat_map(|json| perms(&read(json)))
.collect();
// The shell picks the language and hands native code and X tabs their strings; tabs and the
// popover only ever receive them (X tabs as data through the runtime), never set them.
for command in ["allow-i18n-get", "allow-i18n-set"] {
assert!(local.iter().any(|p| p == command), "shell lacks {command}");
assert!(!others.iter().any(|p| p == command), "a tab or the popover has {command}");
}
// The commands are the framework's (waybetter-host `app`); each checks shell_only first.
for command in ["i18n_get", "i18n_set"] {
waybetter_host::testkit::assert_guarded(include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs"), command, "shell_only(&webview)?");
}
// X tabs get the strings only as a guarded data value, and only the runtime's own prefixes.
let source = include_str!("../src/desktop_i18n.rs");
assert!(source.contains(r#"&["x.", "kit.", "plugin."]"#), "X tabs receive only x./kit./plugin. strings");
assert!(include_str!("../src/desktop.rs").contains("page_string_prefixes: crate::desktop_i18n::PAGE_PREFIXES"), "X's config hands tabs exactly those");
let strings = waybetter_host::i18n::NativeStrings {
locale: "tr".into(),
preference: "site".into(),
messages: [("x.fab.title", "wayBetter"), ("kit.ok", "Tamam"), ("native.notify.alert.title", "Uyarı")]
.into_iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect(),
};
let script = waybetter_host::i18n::page_script(&better_twttr::site::SITE, &strings.page_payload(&["x.", "kit.", "plugin."]));
assert!(script.starts_with("if(location.origin==='https://x.com')("), "page strings must be x.com only: {script}");
assert!(script.contains("x.fab.title") && script.contains("kit.ok") && !script.contains("native.notify"), "{script}");
// "Follow X": a tab may report X's <html lang>, only as a valid tag and only while on x.com.
let desktop = include_str!("../src/desktop.rs");
assert!(
desktop.contains("report.lang.filter(|lang| waybetter_host::i18n::valid_tag(lang) && webview.url().is_ok_and(|url| policy::x_origin(&url)))"),
"tab_report lang must be a validated tag from a webview on x.com"
);
}
#[test]
fn language_strings_are_plain_text() {
use waybetter_host::i18n::NativeStrings;
let set = |value: &str| NativeStrings {
locale: "ar".into(),
preference: "site".into(),
messages: [("x.fab.title".to_owned(), value.to_owned())].into_iter().collect(),
};
assert!(set("wayBetter لـ X").validated().is_ok());
for bad in ["<img src=x onerror=alert(1)>", "&lt;script&gt;", "javascript:alert(1)", "a\u{202e}b"] {
assert!(set(bad).validated().is_err(), "{bad:?}");
}
}
#[test]
fn shell_raise_is_shell_only() {
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
let x = perms(&read(include_str!("../capabilities/x-capture.json")));
let tabs = perms(&read(include_str!("../capabilities/tab-chrome.json")));
let popover = perms(&read(include_str!("../capabilities/drafts-popover.json")));
// Re-orders the shell webview over the tabs for its own overlays: only the shell may.
assert!(local.iter().any(|p| p == "allow-shell-raise"), "shell lacks allow-shell-raise");
assert!(!x.iter().chain(tabs.iter()).chain(popover.iter()).any(|p| p == "allow-shell-raise"), "a tab or the popover can raise the shell");
// The command is the framework's (waybetter-host `app`).
waybetter_host::testkit::assert_guarded(include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs"), "shell_raise", "shell_only(&webview)?");
waybetter_host::testkit::Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities")).assert_shell_only("shell_raise");
}
/// The wayBetter host's own commands (tab layout and actions, shell raise, external links, window
/// menu, profiles, language; tab shortcuts and link drags) keep their callers in X's capability
/// files, and every command wrapper checks its caller first. Every capability file is read, so a
/// new one cannot quietly widen them.
#[test]
fn host_commands_keep_their_callers() {
use waybetter_host::testkit::{assert_guarded, assert_host_guards, Capabilities};
let caps = Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities"));
caps.assert_host_commands();
assert_host_guards();
// Only Rust drives the plugins; no capability exposes their JS APIs.
caps.assert_no_plugin_apis(&["updater", "process", "notification", "deep-link", "single-instance", "window-state", "opener", "dialog"]);
// X's own: vault, settings, themes, installs, backups, sync, dev mode, updates are the shell's.
for command in [
"draft_list", "draft_save", "draft_revisions", "draft_status", "observation_list", "data_export",
"gfonts_installed", "gfonts_install", "gfonts_remove", "gfonts_faces", "gfonts_preview",
"tab_run_command", "workspaces_get", "workspaces_set", "backup_collect", "backup_export_file", "backup_open_file", "backup_apply",
"sync_status", "sync_choose_folder", "sync_unlock", "sync_lock", "sync_disable", "sync_configure", "sync_push", "sync_check", "sync_open", "sync_mark_pulled",
"dev_get", "dev_set", "dev_pick_theme", "dev_clear_theme", "dev_reload_plugin", "dev_report_error",
"plugins_list", "plugin_set_enabled", "plugins_open_folder", "plugin_remove",
"install_pending", "install_stage_link", "install_stage_file", "install_stage_opened", "install_cancel", "install_commit", "install_records", "install_forget", "store_catalog", "store_stage",
"custom_css_get", "custom_css_set", "features_get", "features_set", "mode_get", "mode_set", "settings_get", "settings_set", "column_configure",
"update_status", "update_check", "apply_update", "app_ready", "tab_insert_text", "alerts_get", "alerts_set",
"media_get", "media_set", "gallery_add_folder", "gallery_list", "gallery_thumb", "clip_list", "clip_thumb", "clip_remove", "tab_attach_image",
] {
caps.assert_shell_only(command);
}
// Tabs, about themselves only; the X-only ones are granted only on https://x.com.
for command in ["tab_report", "capture_draft", "open_panel", "drafts_picker_open", "drafts_popover_dismiss", "drafts_hint", "keyword_alert"] {
caps.assert_tab_only(command);
}
let desktop = include_str!("../src/desktop.rs");
assert_guarded(desktop, "tab_insert_text", "shell_only(&webview)?");
// The vault commands are the host's: shell-only in the capability files and each checking its caller
// first; X's own handler no longer has them, and X turns the vault on with its rules.
caps.assert_shell_only_group(waybetter_host::commands::VAULT);
waybetter_host::testkit::assert_vault_guards();
assert!(desktop.contains(".with_vault("), "the app enables the host's vault");
for command in waybetter_host::commands::VAULT {
assert!(!desktop.contains(&format!("fn {command}(")), "{command} is served by the host only");
}
// The updater commands are the shell's too, each checking its caller first; `update_check` runs the
// timer's own check once (no other endpoint), and tabs have no updater command at all.
caps.assert_shell_only_group(waybetter_host::commands::UPDATE);
waybetter_host::testkit::assert_update_guards();
assert!(desktop.contains(".with_updates("), "the app enables the updater");
// Plugins, custom CSS, the tweak flags and the browser settings are served by the host: they
// are in its shell-only list (checked against the capability files and for their caller
// check above), and X's own handler no longer has them.
let host = include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs");
for command in ["plugins_list", "plugin_set_enabled", "plugins_open_folder", "custom_css_get", "custom_css_set", "features_get", "features_set", "mode_get", "mode_set", "settings_get", "settings_set"] {
assert!(waybetter_host::commands::SHELL.contains(&command), "{command} is a host shell-only command");
assert_guarded(host, command, "shell_only(&webview)?");
assert!(!desktop.contains(&format!("fn {command}(")), "{command} is served by the host only");
}
assert_guarded(desktop, "tab_report", "tab_only(&webview)?");
// Opening the panel from a page is the host's site command: only X tabs on x.com have it.
caps.assert_site_only("open_panel", &better_twttr::site::SITE);
assert!(!desktop.contains("fn open_panel("), "open_panel is served by the host only");
}
#[test]
fn install_store_media_and_font_commands_are_shell_only() {
// The store's commands are the framework's (waybetter-host `store_commands`, served because
// this app sets `AppConfig::store`): shell-only in the capability files, and each wrapper
// checks shell_only before anything else.
use waybetter_host::testkit::assert_guarded;
let caps = waybetter_host::testkit::Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities"));
caps.assert_shell_only_group(waybetter_host::commands::STORE);
waybetter_host::testkit::assert_store_guards();
// The media panel's commands are the framework's too (AppConfig::with_media): shell-only, each
// checking its caller first; only attaching to X's composer is the app's own.
caps.assert_shell_only_group(waybetter_host::commands::MEDIA);
waybetter_host::testkit::assert_media_guards();
// Workspaces and encrypted sync too (AppConfig::with_sync; workspaces are served for every app).
caps.assert_shell_only_group(waybetter_host::commands::WORKSPACES);
caps.assert_shell_only_group(waybetter_host::commands::SYNC);
waybetter_host::testkit::assert_sync_guards();
// The settings backup's commands are the framework's too (what it carries is the app's hooks).
caps.assert_shell_only_group(waybetter_host::commands::BACKUP);
waybetter_host::testkit::assert_backup_guards();
// Developer mode (AppConfig::with_dev): the framework's commands; the theme file is the app's hook.
caps.assert_shell_only_group(waybetter_host::commands::DEV);
waybetter_host::testkit::assert_dev_guards();
assert!(include_str!("../src/desktop.rs").contains(".with_dev()"), "the app enables developer mode");
assert!(include_str!("../src/desktop.rs").contains(".with_sync()"), "the app enables sync");
// Site themes too (AppConfig::with_themes): the framework's commands; X only names its built-in themes.
caps.assert_shell_only_group(waybetter_host::commands::THEME);
waybetter_host::testkit::assert_theme_guards();
assert!(include_str!("../src/desktop.rs").contains(".with_themes(&crate::theme::THEMES)"), "the app configures its themes");
// The keyword alert settings (AppConfig::with_alerts): the framework's commands; the hit and its notification are X's.
caps.assert_shell_only_group(waybetter_host::commands::ALERTS);
waybetter_host::testkit::assert_alerts_guards();
assert!(include_str!("../src/desktop.rs").contains(".with_alerts(&alerts::ALERTS)"), "the app configures its alerts");
// Google Fonts too (AppConfig::with_fonts).
caps.assert_shell_only_group(waybetter_host::commands::GFONTS);
waybetter_host::testkit::assert_fonts_guards();
assert!(include_str!("../src/desktop.rs").contains(".with_fonts()"), "the app enables Google Fonts");
assert!(include_str!("../src/desktop.rs").contains(".with_media()"), "the app enables the media panel");
assert_guarded(include_str!("../src/desktop_media.rs"), "tab_attach_image", "shell_only(&webview)?");
let source = include_str!("../../../../waybetter/crates/waybetter-host/src/store_commands.rs");
// Links only ever propose an install: the deep-link handler queues and emits, nothing more.
let received = &source[source.find("fn received(").unwrap()..];
let received = &received[..received.find("\n}").unwrap()];
assert!(!received.contains("commit") && !received.contains("stage"), "a link must not stage or install by itself");
// The app's store settings: X's id and the framework's hosts; the compiled-in key; and it asks
// the framework to serve the store.
let settings = include_str!("../src/distribution.rs");
assert!(settings.contains("app_id: \"x\"") && settings.contains("allowed_hosts: DEFAULT_HOSTS") && settings.contains("include_str!(\"../keys/store.pub\")"));
assert!(include_str!("../src/desktop.rs").contains(".with_store(&crate::distribution::STORE)"), "the app configures the store");
}
/// The popover's webview and session are the host's; X's commands reached by the popover each check
/// their caller first (the popover webview with the open session), and inserting a draft only
/// focuses the tab and types (`compose` scripts), never clicks Post.
#[test]
fn popover_commands_check_their_caller_and_never_post() {
use waybetter_host::testkit::assert_guarded;
let source = include_str!("../src/desktop_popover.rs");
assert_guarded(source, "popover_state", "host::session_of(&webview)");
for command in ["popover_drafts", "popover_insert", "popover_open_vault"] {
assert_guarded(source, command, "host::current(&webview,");
}
// popover_close only acts for the current popover, and does nothing otherwise.
let close = &source[source.find("fn popover_close(").unwrap()..];
let close = &close[..close.find("
}").unwrap()];
assert!(close.contains("host::current(&webview, seq).is_ok()"));
let insert = &source[source.find("fn popover_insert(").unwrap()..];
let insert = &insert[..insert.find("
}").unwrap()];
assert!(insert.contains("compose::insert_script") && insert.contains("compose::append_script"));
assert!(!insert.contains("click") && !insert.contains("submit") && !insert.contains("Post"), "popover_insert never posts");
assert!(insert.contains("policy::x_origin(&url)"), "only into X tabs");
// The host's popover check: only a `popover-<n>` webview on the app's own local origin.
let host = include_str!("../../../../waybetter/crates/waybetter-host/src/popover_host.rs");
let only = &host[host.find("pub fn popover_only(").unwrap()..];
let only = &only[..only.find("
}").unwrap()];
assert!(only.contains("popover::valid_label(webview.label())") && only.contains("local_navigation_allowed"));
}
use url::Url;
use better_twttr::{policy::*, site::SITE, theme::{x_default, XTheme, X_THEMES}};
#[test]
fn exact_origin_and_window_scope_is_enforced() {
let x = Url::parse("https://x.com/home").unwrap();
assert!(require_capture("tab-1", &x).is_ok());
assert!(require_capture("shell", &x).is_err());
assert!(require_local(
"tab-1",
&Url::parse("tauri://localhost/index.html").unwrap(),
false
)
.is_err());
for value in [
"https://evil.x.com/home",
"http://x.com/home",
"https://x.com:8443/home",
"https://x.com.evil.test/home",
"https://x.com@evil.test/home",
"https://evil@x.com/home",
"file:///tmp/index.html",
"https://twitter.com/home",
] {
assert!(
require_capture("tab-1", &Url::parse(value).unwrap()).is_err(),
"accepted {value}"
);
}
}
#[test]
fn local_origin_has_no_production_localhost_backdoor() {
for value in [
"tauri://localhost/index.html",
"http://tauri.localhost/index.html",
"https://tauri.localhost/index.html",
] {
assert!(require_local("shell", &Url::parse(value).unwrap(), false).is_ok());
}
assert!(require_local("shell", &Url::parse("http://127.0.0.1:1420/").unwrap(), true).is_ok());
for value in [
"https://x.com/",
"http://127.0.0.1:1420/",
"http://localhost:1420/",
"https://tauri.localhost.evil.test/",
"tauri://evil/",
] {
assert!(
require_local("shell", &Url::parse(value).unwrap(), false).is_err(),
"accepted {value}"
);
}
}
#[test]
fn navigation_allows_only_x_and_specific_legacy_login_routes() {
for value in [
"https://x.com/home",
"https://twitter.com/login",
"https://twitter.com/i/flow/login",
"https://twitter.com/i/flow/login/step",
] {
assert!(x_navigation_allowed(&Url::parse(value).unwrap()));
}
for value in [
"https://twitter.com/home",
"https://t.co/test",
"https://accounts.google.com/",
"javascript:alert(1)",
"mailto:test@example.com",
"https://twitter.com/login/evil",
] {
assert!(
!x_navigation_allowed(&Url::parse(value).unwrap()),
"accepted {value}"
);
}
}
#[test]
fn remote_capabilities_cannot_reach_the_vault() {
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
let x = read(include_str!("../capabilities/x-capture.json"));
// Drafts popover (2026-09-27): X may only open/close it about itself and ask one yes/no hint question.
// Chat+ link previews (owner-approved, 2026-10-10): one link at a time, from a chat page only.
assert_eq!(x["permissions"], serde_json::json!(["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert", "allow-link-preview"]));
let desktop = include_str!("../src/desktop.rs");
waybetter_host::testkit::assert_guarded(desktop, "link_preview", "policy::require_capture(webview.label(), &webview.url()");
assert!(desktop.contains("link_preview::is_chat_page(page.path())"), "link previews answer chat pages only");
for vault in ["allow-popover-drafts", "allow-popover-insert", "allow-popover-state", "allow-draft-list"] {
assert!(!x["permissions"].as_array().unwrap().iter().any(|p| p == vault), "X tabs have {vault}");
}
// The drafts popover is local-only and gets nothing beyond its own five commands.
let popover = read(include_str!("../capabilities/drafts-popover.json"));
assert_eq!(popover["webviews"], serde_json::json!(["popover-*"]));
assert_eq!(popover["local"], true);
assert!(popover.get("remote").is_none());
assert_eq!(popover["permissions"], serde_json::json!(["allow-popover-state", "allow-popover-drafts", "allow-popover-insert", "allow-popover-close", "allow-popover-open-vault"]));
assert_eq!(x["webviews"], serde_json::json!(["tab-*"]));
assert_eq!(x["remote"]["urls"], serde_json::json!(["https://x.com/*"]));
assert_eq!(x["local"], false);
// Linux joined with the same permissions (owner request, 2026-09-28): platforms only, no new grants.
assert_eq!(x["platforms"], serde_json::json!(["macOS", "windows", "linux"]));
let tabs = read(include_str!("../capabilities/tab-chrome.json"));
assert_eq!(tabs["permissions"], serde_json::json!(["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"]));
assert_eq!(tabs["webviews"], serde_json::json!(["tab-*"]));
assert_eq!(tabs["local"], false);
let local = read(include_str!("../capabilities/workspace-local.json"));
assert!(local.get("remote").is_none());
// My Space (profile customization) is removed: its commands do not exist, so no capability
// file grants them and neither the command manifest nor the handler names them.
let (build, desktop) = (include_str!("../build.rs"), include_str!("../src/desktop.rs"));
for gone in [
"profile_owner_get", "profile_create", "profile_api", "profile_media_pick", "profile_media_still", "profile_media_upload", "profile_still_to_x", "profile_view_get", "profile_view_set",
"bt_profiles_resolve", "bt_media_get", "bt_profile_link_seen",
] {
let permission = format!("allow-{}", gone.replace('_', "-"));
for (name, capability) in [("x-capture", &x), ("drafts-popover", &popover), ("tab-chrome", &tabs), ("workspace-local", &local)] {
assert!(!capability["permissions"].as_array().unwrap().iter().any(|p| p == permission.as_str()), "{name} grants {permission}");
}
assert!(!build.contains(&format!("\"{gone}\"")), "build.rs still lists {gone}");
assert!(!desktop.contains(gone), "desktop.rs still names {gone}");
}
assert_eq!(local["webviews"], serde_json::json!(["shell"]));
// Update commands are shell-only; no capability exposes the updater/process plugin JS APIs.
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
// Account profiles (separate X logins) are managed only by the shell.
// Drafts are inserted and alert keywords edited only by the shell; X tabs only ask/report.
for name in [
"allow-update-status", "allow-apply-update", "allow-app-ready", "allow-profiles-get", "allow-profiles-set",
"allow-tab-insert-text", "allow-alerts-get", "allow-alerts-set", "allow-draft-list", "allow-draft-revisions",
// Google Fonts downloads (network), account/pane themes and the builder preview: shell only.
"allow-gfonts-installed", "allow-gfonts-install", "allow-gfonts-remove", "allow-gfonts-faces", "allow-gfonts-preview",
"allow-theme-scopes-get", "allow-theme-scopes-set", "allow-theme-preview",
// Installing themes/plugins (links, dropped files, the signed store; owner-approved 2026-09-28):
// only the shell, after its confirmation screen. X tabs and web pages cannot install anything.
"allow-install-pending", "allow-install-stage-link", "allow-install-stage-file", "allow-install-cancel",
"allow-install-commit", "allow-install-records", "allow-install-forget", "allow-plugin-remove",
"allow-store-catalog", "allow-store-stage",
] {
assert!(perms(&local).iter().any(|p| p == name), "shell lacks {name}");
assert!(!perms(&x).iter().chain(perms(&tabs).iter()).any(|p| p == name), "tab has {name}");
}
for p in perms(&local).iter().chain(perms(&x).iter()).chain(perms(&tabs).iter()) {
assert!(
!p.starts_with("updater:") && !p.starts_with("process:") && !p.starts_with("notification:") && !p.starts_with("deep-link:"),
"plugin API exposed: {p}"
);
}
}
#[test]
fn theme_accepts_only_known_presets_and_hex_accents() {
let script = |theme: XTheme| theme.script(&X_THEMES, &SITE);
let valid = XTheme { preset: "night-ink".into(), accent: Some("#1d9bf0".into()), ..x_default() };
let text = script(valid).unwrap();
assert!(text.contains("location.origin!=='https://x.com'"));
assert!(text.contains(r#""preset":"night-ink""#));
assert!(x_default().is_default(&X_THEMES));
for preset in ["x", "classic", "night-ink", "terminal", "paper"] {
assert!(script(XTheme { preset: preset.into(), ..x_default() }).is_ok());
}
for hostile in ["evil", "x;display:none", "</script>", ""] {
assert!(script(XTheme { preset: hostile.into(), ..x_default() }).is_err(), "accepted {hostile}");
}
for hostile in ["red", "#fff", "url(https://evil.test)", "#000000;display:none", "</style><script>", "#00000é"] {
assert!(script(XTheme { accent: Some(hostile.into()), ..x_default() }).is_err(), "accepted {hostile}");
}
assert!(serde_json::from_str::<XTheme>(r#"{"preset":"x","css":"body{}"}"#).is_err());
}
#[test]
fn bundle_keeps_macos_targets_and_signed_updater() {
let conf: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
let targets = conf["bundle"]["targets"].as_array().unwrap();
for target in ["nsis", "app", "dmg"] {
assert!(targets.iter().any(|t| t == target), "missing bundle target {target}");
}
assert!(!conf["plugins"]["updater"]["pubkey"].as_str().unwrap_or("").is_empty());
for endpoint in conf["plugins"]["updater"]["endpoints"].as_array().unwrap() {
assert!(endpoint.as_str().unwrap().starts_with("https://"), "updater endpoint must be https");
}
// One URL scheme, only for install links; the installer/Info.plist register exactly this.
assert_eq!(conf["plugins"]["deep-link"]["desktop"]["schemes"], serde_json::json!(["waybetter"]));
assert!(conf["plugins"]["deep-link"].get("mobile").is_none());
}
#[test]
fn backup_sync_workspace_palette_and_dev_commands_are_shell_only() {
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
let x = perms(&read(include_str!("../capabilities/x-capture.json")));
let tabs = perms(&read(include_str!("../capabilities/tab-chrome.json")));
let popover = perms(&read(include_str!("../capabilities/drafts-popover.json")));
// Settings backup/sync (files and the sync key stay native), workspaces, running a plugin's
// palette command in a tab, and developer mode (file watcher, hot reload).
for name in [
"allow-tab-run-command", "allow-workspaces-get", "allow-workspaces-set",
"allow-backup-collect", "allow-backup-export-file", "allow-backup-open-file", "allow-backup-apply",
"allow-sync-status", "allow-sync-choose-folder", "allow-sync-unlock", "allow-sync-lock", "allow-sync-disable",
"allow-sync-configure", "allow-sync-push", "allow-sync-check", "allow-sync-open", "allow-sync-mark-pulled",
"allow-dev-get", "allow-dev-set", "allow-dev-pick-theme", "allow-dev-clear-theme", "allow-dev-reload-plugin", "allow-dev-report-error",
] {
assert!(local.iter().any(|p| p == name), "shell lacks {name}");
assert!(!x.iter().chain(tabs.iter()).chain(popover.iter()).any(|p| p == name), "a tab or the popover has {name}");
}
// X tabs report their plugins' commands only through tab_report (owner-approved 2026-09-28);
// the tab capabilities gained nothing for it.
assert_eq!(tabs, ["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"]);
}
#[test]
fn language_commands_are_shell_only() {
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
let others: Vec<String> = [
include_str!("../capabilities/x-capture.json"),
include_str!("../capabilities/tab-chrome.json"),
include_str!("../capabilities/drafts-popover.json"),
]
.iter()
.flat_map(|json| perms(&read(json)))
.collect();
// The shell picks the language and hands native code and X tabs their strings; tabs and the
// popover only ever receive them (X tabs as data through the runtime), never set them.
for command in ["allow-i18n-get", "allow-i18n-set"] {
assert!(local.iter().any(|p| p == command), "shell lacks {command}");
assert!(!others.iter().any(|p| p == command), "a tab or the popover has {command}");
}
// The commands are the framework's (waybetter-host `app`); each checks shell_only first.
for command in ["i18n_get", "i18n_set"] {
waybetter_host::testkit::assert_guarded(include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs"), command, "shell_only(&webview)?");
}
// X tabs get the strings only as a guarded data value, and only the runtime's own prefixes.
let source = include_str!("../src/desktop_i18n.rs");
assert!(source.contains(r#"&["x.", "kit.", "plugin."]"#), "X tabs receive only x./kit./plugin. strings");
assert!(include_str!("../src/desktop.rs").contains("page_string_prefixes: crate::desktop_i18n::PAGE_PREFIXES"), "X's config hands tabs exactly those");
let strings = waybetter_host::i18n::NativeStrings {
locale: "tr".into(),
preference: "site".into(),
messages: [("x.fab.title", "wayBetter"), ("kit.ok", "Tamam"), ("native.notify.alert.title", "Uyarı")]
.into_iter()
.map(|(key, value)| (key.to_owned(), value.to_owned()))
.collect(),
};
let script = waybetter_host::i18n::page_script(&better_twttr::site::SITE, &strings.page_payload(&["x.", "kit.", "plugin."]));
assert!(script.starts_with("if(location.origin==='https://x.com')("), "page strings must be x.com only: {script}");
assert!(script.contains("x.fab.title") && script.contains("kit.ok") && !script.contains("native.notify"), "{script}");
// "Follow X": a tab may report X's <html lang>, only as a valid tag and only while on x.com.
let desktop = include_str!("../src/desktop.rs");
assert!(
desktop.contains("report.lang.filter(|lang| waybetter_host::i18n::valid_tag(lang) && webview.url().is_ok_and(|url| policy::x_origin(&url)))"),
"tab_report lang must be a validated tag from a webview on x.com"
);
}
#[test]
fn language_strings_are_plain_text() {
use waybetter_host::i18n::NativeStrings;
let set = |value: &str| NativeStrings {
locale: "ar".into(),
preference: "site".into(),
messages: [("x.fab.title".to_owned(), value.to_owned())].into_iter().collect(),
};
assert!(set("wayBetter لـ X").validated().is_ok());
for bad in ["<img src=x onerror=alert(1)>", "&lt;script&gt;", "javascript:alert(1)", "a\u{202e}b"] {
assert!(set(bad).validated().is_err(), "{bad:?}");
}
}
#[test]
fn shell_raise_is_shell_only() {
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
let x = perms(&read(include_str!("../capabilities/x-capture.json")));
let tabs = perms(&read(include_str!("../capabilities/tab-chrome.json")));
let popover = perms(&read(include_str!("../capabilities/drafts-popover.json")));
// Re-orders the shell webview over the tabs for its own overlays: only the shell may.
assert!(local.iter().any(|p| p == "allow-shell-raise"), "shell lacks allow-shell-raise");
assert!(!x.iter().chain(tabs.iter()).chain(popover.iter()).any(|p| p == "allow-shell-raise"), "a tab or the popover can raise the shell");
// The command is the framework's (waybetter-host `app`).
waybetter_host::testkit::assert_guarded(include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs"), "shell_raise", "shell_only(&webview)?");
waybetter_host::testkit::Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities")).assert_shell_only("shell_raise");
}
/// The wayBetter host's own commands (tab layout and actions, shell raise, external links, window
/// menu, profiles, language; tab shortcuts and link drags) keep their callers in X's capability
/// files, and every command wrapper checks its caller first. Every capability file is read, so a
/// new one cannot quietly widen them.
#[test]
fn host_commands_keep_their_callers() {
use waybetter_host::testkit::{assert_guarded, assert_host_guards, Capabilities};
let caps = Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities"));
caps.assert_host_commands();
assert_host_guards();
// Only Rust drives the plugins; no capability exposes their JS APIs.
caps.assert_no_plugin_apis(&["updater", "process", "notification", "deep-link", "single-instance", "window-state", "opener", "dialog"]);
// X's own: vault, settings, themes, installs, backups, sync, dev mode, updates are the shell's.
for command in [
"draft_list", "draft_save", "draft_revisions", "draft_status", "observation_list", "data_export",
"gfonts_installed", "gfonts_install", "gfonts_remove", "gfonts_faces", "gfonts_preview",
"tab_run_command", "workspaces_get", "workspaces_set", "backup_collect", "backup_export_file", "backup_open_file", "backup_apply",
"sync_status", "sync_choose_folder", "sync_unlock", "sync_lock", "sync_disable", "sync_configure", "sync_push", "sync_check", "sync_open", "sync_mark_pulled",
"dev_get", "dev_set", "dev_pick_theme", "dev_clear_theme", "dev_reload_plugin", "dev_report_error",
"plugins_list", "plugin_set_enabled", "plugins_open_folder", "plugin_remove",
"install_pending", "install_stage_link", "install_stage_file", "install_stage_opened", "install_cancel", "install_commit", "install_records", "install_forget", "store_catalog", "store_stage",
"custom_css_get", "custom_css_set", "features_get", "features_set", "mode_get", "mode_set", "settings_get", "settings_set", "column_configure",
"update_status", "update_check", "apply_update", "app_ready", "tab_insert_text", "alerts_get", "alerts_set",
"media_get", "media_set", "gallery_add_folder", "gallery_list", "gallery_thumb", "clip_list", "clip_thumb", "clip_remove", "tab_attach_image",
] {
caps.assert_shell_only(command);
}
// Tabs, about themselves only; the X-only ones are granted only on https://x.com.
for command in ["tab_report", "capture_draft", "open_panel", "drafts_picker_open", "drafts_popover_dismiss", "drafts_hint", "keyword_alert", "link_preview"] {
caps.assert_tab_only(command);
}
let desktop = include_str!("../src/desktop.rs");
assert_guarded(desktop, "tab_insert_text", "shell_only(&webview)?");
// The vault commands are the host's: shell-only in the capability files and each checking its caller
// first; X's own handler no longer has them, and X turns the vault on with its rules.
caps.assert_shell_only_group(waybetter_host::commands::VAULT);
waybetter_host::testkit::assert_vault_guards();
assert!(desktop.contains(".with_vault("), "the app enables the host's vault");
for command in waybetter_host::commands::VAULT {
assert!(!desktop.contains(&format!("fn {command}(")), "{command} is served by the host only");
}
// The updater commands are the shell's too, each checking its caller first; `update_check` runs the
// timer's own check once (no other endpoint), and tabs have no updater command at all.
caps.assert_shell_only_group(waybetter_host::commands::UPDATE);
waybetter_host::testkit::assert_update_guards();
assert!(desktop.contains(".with_updates("), "the app enables the updater");
// Plugins, custom CSS, the tweak flags and the browser settings are served by the host: they
// are in its shell-only list (checked against the capability files and for their caller
// check above), and X's own handler no longer has them.
let host = include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs");
for command in ["plugins_list", "plugin_set_enabled", "plugins_open_folder", "custom_css_get", "custom_css_set", "features_get", "features_set", "mode_get", "mode_set", "settings_get", "settings_set"] {
assert!(waybetter_host::commands::SHELL.contains(&command), "{command} is a host shell-only command");
assert_guarded(host, command, "shell_only(&webview)?");
assert!(!desktop.contains(&format!("fn {command}(")), "{command} is served by the host only");
}
assert_guarded(desktop, "tab_report", "tab_only(&webview)?");
// Opening the panel from a page is the host's site command: only X tabs on x.com have it.
caps.assert_site_only("open_panel", &better_twttr::site::SITE);
assert!(!desktop.contains("fn open_panel("), "open_panel is served by the host only");
}
#[test]
fn install_store_media_and_font_commands_are_shell_only() {
// The store's commands are the framework's (waybetter-host `store_commands`, served because
// this app sets `AppConfig::store`): shell-only in the capability files, and each wrapper
// checks shell_only before anything else.
use waybetter_host::testkit::assert_guarded;
let caps = waybetter_host::testkit::Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities"));
caps.assert_shell_only_group(waybetter_host::commands::STORE);
waybetter_host::testkit::assert_store_guards();
// The media panel's commands are the framework's too (AppConfig::with_media): shell-only, each
// checking its caller first; only attaching to X's composer is the app's own.
caps.assert_shell_only_group(waybetter_host::commands::MEDIA);
waybetter_host::testkit::assert_media_guards();
// Workspaces and encrypted sync too (AppConfig::with_sync; workspaces are served for every app).
caps.assert_shell_only_group(waybetter_host::commands::WORKSPACES);
caps.assert_shell_only_group(waybetter_host::commands::SYNC);
waybetter_host::testkit::assert_sync_guards();
// The settings backup's commands are the framework's too (what it carries is the app's hooks).
caps.assert_shell_only_group(waybetter_host::commands::BACKUP);
waybetter_host::testkit::assert_backup_guards();
// Developer mode (AppConfig::with_dev): the framework's commands; the theme file is the app's hook.
caps.assert_shell_only_group(waybetter_host::commands::DEV);
waybetter_host::testkit::assert_dev_guards();
assert!(include_str!("../src/desktop.rs").contains(".with_dev()"), "the app enables developer mode");
assert!(include_str!("../src/desktop.rs").contains(".with_sync()"), "the app enables sync");
// Site themes too (AppConfig::with_themes): the framework's commands; X only names its built-in themes.
caps.assert_shell_only_group(waybetter_host::commands::THEME);
waybetter_host::testkit::assert_theme_guards();
assert!(include_str!("../src/desktop.rs").contains(".with_themes(&crate::theme::THEMES)"), "the app configures its themes");
// The keyword alert settings (AppConfig::with_alerts): the framework's commands; the hit and its notification are X's.
caps.assert_shell_only_group(waybetter_host::commands::ALERTS);
waybetter_host::testkit::assert_alerts_guards();
assert!(include_str!("../src/desktop.rs").contains(".with_alerts(&alerts::ALERTS)"), "the app configures its alerts");
// Google Fonts too (AppConfig::with_fonts).
caps.assert_shell_only_group(waybetter_host::commands::GFONTS);
waybetter_host::testkit::assert_fonts_guards();
assert!(include_str!("../src/desktop.rs").contains(".with_fonts()"), "the app enables Google Fonts");
assert!(include_str!("../src/desktop.rs").contains(".with_media()"), "the app enables the media panel");
assert_guarded(include_str!("../src/desktop_media.rs"), "tab_attach_image", "shell_only(&webview)?");
let source = include_str!("../../../../waybetter/crates/waybetter-host/src/store_commands.rs");
// Links only ever propose an install: the deep-link handler queues and emits, nothing more.
let received = &source[source.find("fn received(").unwrap()..];
let received = &received[..received.find("\n}").unwrap()];
assert!(!received.contains("commit") && !received.contains("stage"), "a link must not stage or install by itself");
// The app's store settings: X's id and the framework's hosts; the compiled-in key; and it asks
// the framework to serve the store.
let settings = include_str!("../src/distribution.rs");
assert!(settings.contains("app_id: \"x\"") && settings.contains("allowed_hosts: DEFAULT_HOSTS") && settings.contains("include_str!(\"../keys/store.pub\")"));
assert!(include_str!("../src/desktop.rs").contains(".with_store(&crate::distribution::STORE)"), "the app configures the store");
}
/// The popover's webview and session are the host's; X's commands reached by the popover each check
/// their caller first (the popover webview with the open session), and inserting a draft only
/// focuses the tab and types (`compose` scripts), never clicks Post.
#[test]
fn popover_commands_check_their_caller_and_never_post() {
use waybetter_host::testkit::assert_guarded;
let source = include_str!("../src/desktop_popover.rs");
assert_guarded(source, "popover_state", "host::session_of(&webview)");
for command in ["popover_drafts", "popover_insert", "popover_open_vault"] {
assert_guarded(source, command, "host::current(&webview,");
}
// popover_close only acts for the current popover, and does nothing otherwise.
let close = &source[source.find("fn popover_close(").unwrap()..];
let close = &close[..close.find("
}").unwrap()];
assert!(close.contains("host::current(&webview, seq).is_ok()"));
let insert = &source[source.find("fn popover_insert(").unwrap()..];
let insert = &insert[..insert.find("
}").unwrap()];
assert!(insert.contains("compose::insert_script") && insert.contains("compose::append_script"));
assert!(!insert.contains("click") && !insert.contains("submit") && !insert.contains("Post"), "popover_insert never posts");
assert!(insert.contains("policy::x_origin(&url)"), "only into X tabs");
// The host's popover check: only a `popover-<n>` webview on the app's own local origin.
let host = include_str!("../../../../waybetter/crates/waybetter-host/src/popover_host.rs");
let only = &host[host.find("pub fn popover_only(").unwrap()..];
let only = &only[..only.find("
}").unwrap()];
assert!(only.contains("popover::valid_label(webview.label())") && only.contains("local_navigation_allowed"));
}

View file

@ -18,6 +18,8 @@ export const FEATURES: readonly FeatureSpec[] = [
{ id: 'chat.searchLoaded', surfaces: CHAT, heavy: true, ...words('searchLoaded'), keywords: 'find search in conversation ctrl shift f messages', permissions: ['ui', 'commands'] },
{ id: 'chat.pins', surfaces: CHAT, heavy: true, ...words('pins'), keywords: 'pin pinned label color chats conversations strip', permissions: ['storage', 'ui', 'commands'] },
{ id: 'chat.personFilter', surfaces: CHAT, heavy: true, ...words('personFilter'), keywords: 'group chat person participant filter only from', permissions: ['storage', 'ui', 'commands'] },
// On by default (owner, 2026-10-10): asks native code for the preview of a link on screen (link_preview.rs).
{ id: 'chat.linkPreviews', surfaces: CHAT, heavy: true, default: true, ...words('linkPreviews'), keywords: 'link preview embed youtube card unfurl', permissions: ['ui'] },
{ id: 'chat.keywords', surfaces: CHAT, heavy: true, settings: true, ...words('keywords'), keywords: 'highlight mute words keywords hide messages', permissions: ['storage', 'ui'] },
]),
];

View file

@ -5,12 +5,14 @@
// X's chat is the one place the rest of the app stays out of (a heavy page). These modules run
// there on purpose and keep it cheap: one shared watch each (child list only, one frame), looking
// only at X's message list or its conversation list. ./x.ts is what they know about X's chat.
// None of them makes a request, writes or sends a message, reacts, deletes or marks anything read.
// None of them writes or sends a message, reacts, deletes or marks anything read. The one request is
// Link previews' (owner-approved, 2026-10-10): the link alone, to native code, which fetches its preview.
import type { FeatureModule } from '../../../../../waybetter/packages/runtime/src/featureModules.ts';
import type { PluginApi } from '../../inject/pluginApi.ts';
import { bookmarks } from './bookmarks.ts';
import { keywords } from './keywords.ts';
import { linkPreviews } from './linkPreviews.ts';
import { personFilter } from './personFilter.ts';
import { pins } from './pins.ts';
import { searchLoaded } from './searchLoaded.ts';
@ -21,4 +23,5 @@ export const MODULES: ReadonlyArray<FeatureModule<PluginApi>> = [
pins,
personFilter,
keywords,
linkPreviews,
];

View file

@ -0,0 +1,229 @@
// Chat+ · Link previews. X's web chat shows a link as a bare card (an icon and the domain). For a
// link in a message that is on screen, this asks native code (`link_preview`, one link at a time;
// src-tauri/src/link_preview.rs) for its preview and shows it under X's card: the page's title,
// description and image, or for YouTube the video's thumbnail with a play button that opens
// YouTube's own player in place (www.youtube.com is in X's own frame-src).
//
// Owner-approved network use (2026-10-10). Only the link leaves the page: never the message text,
// the sender or the conversation. Nothing is stored: answers are kept in memory for this page only.
// X's card is hidden while a preview stands in for it and comes back when the feature stops.
import type { FeatureContext, FeatureModule } from '../../../../../waybetter/packages/runtime/src/featureModules.ts';
import type { PluginApi } from '../../inject/pluginApi.ts';
import { clearAttribute } from './ui.ts';
import { CONVERSATION_PROBES, CHAT_PAGE_PROBE, chatRoot, chatSelectors, renderedRows } from './x.ts';
export type LinkPreview = { url: string; site: string; title: string; description: string; image?: string; youtube?: string };
type Invoke = (command: 'link_preview', args: { url: string }) => Promise<unknown>;
let bridge: Invoke | null = null;
/** The runtime hands over its native bridge (src/inject/entry.tsx); without one the feature shows nothing. */
export function setLinkPreviewBridge(invoke: Invoke | null | undefined): void { bridge = invoke ?? null; }
const X_HOSTS = /(^|\.)(x\.com|twitter\.com|t\.co|twimg\.com|x\.co)$/i;
/** A link worth a preview: plain https, a public-looking host name, not X's own. */
export function previewable(href: string | null | undefined): string | null {
if (!href) return null;
let url: URL;
try { url = new URL(href); } catch { return null; }
if (url.protocol !== 'https:' || url.username || url.password || (url.port && url.port !== '443')) return null;
const host = url.hostname.toLowerCase();
if (!host.includes('.') || /^[\d.]+$/.test(host) || host.startsWith('[') || X_HOSTS.test(host)) return null;
return url.href;
}
/** A native answer as a preview, or null: every field is checked again (it becomes page text). */
export function asPreview(value: unknown): LinkPreview | null {
if (!value || typeof value !== 'object') return null;
const v = value as Record<string, unknown>;
const str = (key: string, max: number) => (typeof v[key] === 'string' ? (v[key] as string).slice(0, max) : '');
const url = previewable(str('url', 2048));
if (!url) return null;
const image = str('image', 3_000_000);
const youtube = str('youtube', 11);
const preview: LinkPreview = { url, site: str('site', 80), title: str('title', 200), description: str('description', 300) };
if (/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(image)) preview.image = image;
if (/^[A-Za-z0-9_-]{11}$/.test(youtube)) preview.youtube = youtube;
return preview.title || preview.image || preview.youtube ? preview : null;
}
const CARD = 'bt-chat-lp';
const HIDE = 'data-bt-chat-lp-hidden';
const FOR = 'data-bt-chat-lp-for';
const ACCENT = 'var(--wb-accent, rgb(29, 155, 240))';
const CSS = `
[${HIDE}] { display: none !important; }
.${CARD} { display: block; box-sizing: border-box; width: 340px; max-width: 100%; margin: 6px 0 2px; overflow: hidden; border-radius: 16px; border: 1px solid rgba(127,127,127,.28); background: rgba(127,127,127,.10); color: inherit; text-decoration: none; font: 400 14px/1.35 var(--wb-font, system-ui, sans-serif); cursor: pointer; }
.${CARD}:hover { background: rgba(127,127,127,.16); }
.${CARD}:focus-visible { outline: 2px solid ${ACCENT}; outline-offset: 2px; }
.${CARD}-media { position: relative; display: block; width: 100%; aspect-ratio: 1.91 / 1; background: rgba(0,0,0,.35); }
.${CARD}-media.is-video { aspect-ratio: 16 / 9; }
.${CARD}-media img, .${CARD}-media iframe { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border: 0; display: block; }
.${CARD}-play { position: absolute; left: 50%; top: 50%; width: 68px; height: 48px; transform: translate(-50%, -50%); border: 0; border-radius: 14px; background: rgba(0,0,0,.72); color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; }
.${CARD}-play:hover { background: #f00; }
.${CARD}-play svg { width: 26px; height: 26px; fill: currentColor; }
.${CARD}-body { display: block; padding: 10px 14px 12px; }
.${CARD}-site { display: block; font-size: 12.5px; opacity: .65; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.${CARD}-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 2px; font-weight: 700; }
.${CARD}-desc { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-top: 2px; font-size: 13.5px; opacity: .78; }
`;
/** The links of one message: its own anchors to the outside, first one only per address. */
export function rowLinks(row: Element): HTMLAnchorElement[] {
const seen = new Set<string>();
const out: HTMLAnchorElement[] = [];
for (const anchor of Array.from(row.querySelectorAll<HTMLAnchorElement>('a[href]'))) {
if (anchor.closest(`.${CARD}`)) continue;
const url = previewable(anchor.href || anchor.getAttribute('href'));
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(anchor);
}
return out.slice(0, 2);
}
/**
* X's bare link card (a message that is only a link: an icon and the host name, no picture). A link
* inside a message's text shows its path too and is never hidden.
*/
export function isBareCard(anchor: Element): boolean {
if (anchor.querySelector('img, video')) return false;
const text = (anchor.textContent ?? '').trim().replace(/^www\./i, '');
return /^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/i.test(text);
}
/**
* Where a link's preview goes: after the message's bubble (X's `[role="article"]`), or after X's own
* link card when the link is one; null when the anchor is in neither (nothing is placed).
*/
export function cardPlace(anchor: Element): Element | null {
const bubble = anchor.closest('[role="article"]') ?? anchor.closest('[data-testid^="message-text-"]');
return bubble?.parentElement ? bubble : null;
}
const PLAY = 'M8 5.5v13l11-6.5z';
const SVG = 'http://www.w3.org/2000/svg';
function el<K extends keyof HTMLElementTagNameMap>(doc: Document, tag: K, className: string, text?: string): HTMLElementTagNameMap[K] {
const node = doc.createElement(tag);
node.className = className;
if (text) node.textContent = text;
return node;
}
/** The preview card for `preview` (text only through textContent; URLs re-checked). */
export function renderCard(doc: Document, preview: LinkPreview, words: { play: string; open: string }): HTMLAnchorElement {
const card = el(doc, 'a', CARD);
card.href = preview.url;
card.target = '_blank';
card.rel = 'noopener noreferrer';
card.setAttribute(FOR, preview.url);
card.title = words.open;
if (preview.youtube) {
const media = el(doc, 'span', `${CARD}-media is-video`);
const thumb = el(doc, 'img', '');
thumb.src = preview.image ?? `https://i.ytimg.com/vi/${preview.youtube}/hqdefault.jpg`;
thumb.alt = '';
thumb.referrerPolicy = 'no-referrer';
const play = el(doc, 'button', `${CARD}-play`);
play.type = 'button';
play.setAttribute('aria-label', words.play);
const svg = doc.createElementNS(SVG, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
const path = doc.createElementNS(SVG, 'path');
path.setAttribute('d', PLAY);
svg.append(path);
play.append(svg);
play.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const frame = el(doc, 'iframe', '');
frame.src = `https://www.youtube.com/embed/${preview.youtube}?autoplay=1&rel=0`;
frame.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
frame.allowFullscreen = true;
frame.referrerPolicy = 'strict-origin-when-cross-origin';
frame.title = preview.title || 'YouTube';
media.replaceChildren(frame);
card.removeAttribute('href');
card.style.cursor = 'default';
});
media.append(thumb, play);
card.append(media);
} else if (preview.image) {
const media = el(doc, 'span', `${CARD}-media`);
const image = el(doc, 'img', '');
image.src = preview.image;
image.alt = '';
image.decoding = 'async';
media.append(image);
card.append(media);
}
const body = el(doc, 'span', `${CARD}-body`);
body.append(el(doc, 'span', `${CARD}-site`, preview.site));
if (preview.title) body.append(el(doc, 'span', `${CARD}-title`, preview.title));
if (preview.description) body.append(el(doc, 'span', `${CARD}-desc`, preview.description));
card.append(body);
return card;
}
type Ctx = FeatureContext<PluginApi>;
export const linkPreviews: FeatureModule<PluginApi> = {
id: 'chat.linkPreviews',
probes: { ...CONVERSATION_PROBES, ...CHAT_PAGE_PROBE },
start(ctx: Ctx) {
const { win } = ctx;
const doc = win.document;
const sel = chatSelectors(ctx, CONVERSATION_PROBES);
/** Answers for this page only (null: no preview for that link). */
const answers = new Map<string, LinkPreview | null>();
const pending = new Set<string>();
let stopped = false;
const words = () => ({ play: ctx.text('play'), open: ctx.text('open') });
const ask = (url: string) => {
if (!bridge || pending.has(url) || answers.has(url)) return;
pending.add(url);
bridge('link_preview', { url })
.then(value => answers.set(url, asPreview(value)), () => answers.set(url, null))
.finally(() => { pending.delete(url); if (!stopped) watch.schedule(); });
};
const scan = () => {
for (const row of renderedRows(chatRoot(doc), sel)) {
let after: Element | null = null;
for (const anchor of rowLinks(row)) {
const url = previewable(anchor.href || anchor.getAttribute('href'))!;
const answer = answers.get(url);
if (answer === undefined) { ask(url); continue; }
if (!answer) { if (anchor.hasAttribute(HIDE)) anchor.removeAttribute(HIDE); continue; }
// Under the message's bubble (live 2026-10-10: X keeps a link in the text as a plain anchor),
// in the bubble's own column, so it lines up with the message on either side.
const bubble = cardPlace(anchor);
if (!bubble) continue;
const column = bubble.parentElement!;
const existing = Array.from(column.children).find(node => node.getAttribute(FOR) === url);
const card = existing ?? renderCard(doc, answer, words());
const anchorAfter: Element = after ?? bubble;
if (anchorAfter.nextElementSibling !== card) anchorAfter.after(card);
after = card;
if (isBareCard(anchor) && !anchor.hasAttribute(HIDE)) anchor.setAttribute(HIDE, '');
}
}
};
ctx.css(CSS);
const watch = ctx.watch(scan);
ctx.onRouteChange(() => watch.schedule());
ctx.defer(() => {
stopped = true;
for (const place of new Set<ParentNode>([doc, chatRoot(doc)])) {
for (const card of Array.from(place.querySelectorAll(`.${CARD}`))) card.remove();
clearAttribute(place, HIDE);
}
});
watch.schedule();
},
};

View file

@ -7,6 +7,7 @@
// flag-controlled parts and the palette command report; panelButton.ts keeps the way into the
// panel on the page). This file is X's: which parts exist, X's own members (alerts, fixes)
// and how the "wayBetter" item joins X's navigation.
import { setLinkPreviewBridge } from '../areas/chat/linkPreviews.ts';
import { installFeatures, type FeatureConfig } from './features';
import { ALL_MODULES } from '../areas/modules';
import { surfacesOf } from '../areas/surfaces';
@ -88,6 +89,8 @@ function install() {
// This runtime only ships inside the app's X tabs; automated test browsers have no bridge, and
// the features that need it stay off there.
const invoke = pageInvoke(window, LABEL);
// Chat+ link previews ask native code for one link's preview (the only feature module with a bridge).
setLinkPreviewBridge(invoke as Parameters<typeof setLinkPreviewBridge>[0]);
// Flag-controlled fixes and tweaks: installed on enable, disposed on disable. The first ones wrap
// fetch like the mutation gate: installed at document start, before X's bundle runs.

View file

@ -61,11 +61,11 @@ const pace = (chat: FakeChat) => ({ sleep: (ms: number) => new Promise<void>(res
const star = (row: El) => row.querySelector('.bt-chat-star')!;
const group = (chat: FakeChat) => chat.conversations.get(GROUP)!.history;
test('Chat+: five features, each its own flag, all off by default, all allowed on X\'s chat pages', () => {
assert.deepEqual(FEATURES.map(feature => feature.id), ['chat.bookmarks', 'chat.searchLoaded', 'chat.pins', 'chat.personFilter', 'chat.keywords']);
test('Chat+: six features, each its own flag, off by default but link previews (owner, 2026-10-10), all allowed on X\'s chat pages', () => {
assert.deepEqual(FEATURES.map(feature => feature.id), ['chat.bookmarks', 'chat.searchLoaded', 'chat.pins', 'chat.personFilter', 'chat.linkPreviews', 'chat.keywords']);
assert.deepEqual(MODULES.map(module => module.id).sort(), FEATURES.map(feature => feature.id).sort());
for (const feature of FEATURES) {
assert.equal(feature.default, false, feature.id);
assert.equal(feature.default, feature.id === 'chat.linkPreviews', feature.id);
assert.equal(feature.heavy, true, `${feature.id} runs on the heavy chat pages`);
assert.ok(feature.permissions && !feature.permissions.includes('network'), `${feature.id}: no network`);
}
@ -175,7 +175,7 @@ test('starred messages: stored in full, valid ones only, each once; the list fil
});
test('Starred messages: a star on every rendered row; a click stores exactly that message, in full; off leaves nothing', () => {
const { env, chat, stored } = page(`/i/chat/${GROUP}`, { 'chat.bookmarks': true });
const { env, chat, stored } = page(`/i/chat/${GROUP}`, { 'chat.bookmarks': true, 'chat.linkPreviews': false });
assert.deepEqual(env.registry.active(), ['chat.bookmarks']);
const all = group(chat);
const rows = chat.rendered();

View file

@ -133,6 +133,8 @@ const DEFAULT_ON = [
'logoBrush', 'twitterWording', 'singleSplitNav',
'fixLikeFollow', 'fixScrollRestore', 'fixTimelineJump', 'fixHomeTab', 'fixNotificationBadge', 'fixNewPostsPill', 'fixMediaUpload', 'followingChronological', 'fixChatButton', 'composerRestorePrompt',
'composerDrafts', 'hidePromotedPosts', 'mediaDownload', 'rememberVideoVolume', 'searchOperators', 'notifications.keywordAlerts',
// On by default by the owner's choice (2026-10-10): Chat+ link previews.
'chat.linkPreviews',
];
/** What Advanced adds: the calm features that came with the area plugins (they add something; none hides or changes X). */
const ADVANCED_ADDS = [
@ -166,7 +168,7 @@ test('modes: the exact flags of Basic, Advanced and Power User; an existing setu
assert.deepEqual(on(defaults), [...DEFAULT_ON].sort());
assert.deepEqual(running(null), [...DEFAULT_ON].sort());
for (const [flag, value] of Object.entries(FEATURE_DEFAULTS)) assert.equal(defaults[flag], value, flag);
for (const feature of X_FEATURES) if (!(feature.id in FEATURE_DEFAULTS) && feature.id !== 'notifications.keywordAlerts' && feature.backing !== 'plugin') assert.equal(defaults[feature.id], false, `${feature.id} is new: off by default`);
for (const feature of X_FEATURES) if (!(feature.id in FEATURE_DEFAULTS) && feature.id !== 'notifications.keywordAlerts' && feature.id !== 'chat.linkPreviews' && feature.backing !== 'plugin') assert.equal(defaults[feature.id], false, `${feature.id} is new: off by default`);
for (const row of supersededFiles(X_TABLE)) assert.equal(defaults[row.flag], false, `${row.flag}: what came from an opt-in plugin stays off`);
// Basic: every area off (so everything an area owns is off), no logo or wording; the fixes and split-view navigation stay.

View file

@ -0,0 +1,48 @@
// Chat+ link previews (src/areas/chat/linkPreviews.ts; native side src-tauri/src/link_preview.rs):
// which links are asked about, how a native answer is checked again before it becomes page text,
// and which of X's cards a preview stands in for. Nothing here touches the network or x.com.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { asPreview, isBareCard, previewable } from '../src/areas/chat/linkPreviews.ts';
test('link previews: only plain https links to hosts that are not X\'s own', () => {
assert.equal(previewable('https://www.youtube.com/watch?v=dQw4w9WgXcQ'), 'https://www.youtube.com/watch?v=dQw4w9WgXcQ');
assert.equal(previewable('https://fishies.lol/'), 'https://fishies.lol/');
for (const bad of [
'http://example.com/', 'https://user:pw@example.com/', 'https://example.com:8443/', 'https://192.168.0.1/', 'https://[::1]/',
'https://intranet/', 'https://x.com/a/status/1', 'https://t.co/abc', 'https://pbs.twimg.com/media/a.jpg', 'javascript:alert(1)', '/i/chat/1-2', '', null,
]) assert.equal(previewable(bad), null, String(bad));
});
test('link previews: a native answer is checked again: plain fields, capped, data: images of four types, an 11-character video id', () => {
const good = asPreview({ url: 'https://fishies.lol/', site: 'Fishies', title: 'The social aquarium', description: 'x'.repeat(400), image: 'data:image/png;base64,iVBORw0KGgo=' });
assert.ok(good);
assert.equal(good.description.length, 300);
assert.equal(good.image, 'data:image/png;base64,iVBORw0KGgo=');
assert.equal(asPreview({ url: 'https://a.example/', title: 't', image: 'data:image/svg+xml;base64,PHN2Zz4=' })?.image, undefined, 'no SVG');
assert.equal(asPreview({ url: 'https://a.example/', title: 't', image: 'https://tracker.example/p.gif' })?.image, undefined, 'no remote image');
assert.equal(asPreview({ url: 'https://youtu.be/x', title: 't', youtube: 'dQw4w9WgXcQ' })?.youtube, 'dQw4w9WgXcQ');
assert.equal(asPreview({ url: 'https://youtu.be/x', title: 't', youtube: 'a"onload="x' })?.youtube, undefined);
assert.equal(asPreview({ url: 'javascript:alert(1)', title: 't' }), null, 'the link itself is re-checked');
assert.equal(asPreview({ url: 'https://a.example/', title: '' }), null, 'nothing to show, no card');
assert.equal(asPreview('nope'), null);
});
test('link previews: X\'s bare card (a host name and an icon) is replaced; a card with its own picture or a sentence is not', () => {
const anchor = (text: string, media = false) => ({ textContent: text, querySelector: () => (media ? {} : null) }) as unknown as Element;
assert.equal(isBareCard(anchor('www.youtube.com')), true);
assert.equal(isBareCard(anchor('fishies.lol')), true);
assert.equal(isBareCard(anchor('www.youtube.com', true)), false);
assert.equal(isBareCard(anchor('watch this video')), false);
assert.equal(isBareCard(anchor('www.youtube.com/watch?v=jNQXAC9IVRw')), false, 'a link in the text (live 2026-10-10) stays');
assert.equal(isBareCard(anchor('')), false);
});
test('link previews: page text only through textContent, the player only on YouTube\'s host X allows, no request from the page', () => {
const source = readFileSync(new URL('../src/areas/chat/linkPreviews.ts', import.meta.url), 'utf8');
assert.doesNotMatch(source, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/);
assert.doesNotMatch(source, /\bfetch\(|XMLHttpRequest|WebSocket|sendBeacon/);
for (const match of source.matchAll(/https:\/\/([a-z0-9.-]+)\//g)) assert.ok(['i.ytimg.com', 'www.youtube.com'].includes(match[1]!), match[1]);
assert.match(source, /rel = 'noopener noreferrer'/);
});