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

@ -1,5 +1,10 @@
# Changelog # Changelog
## Unreleased
### New
- **Link previews in chats (Chat+).** X's web chat shows a link as a bare card with only the site's name. wayBetter shows the page's title, text and picture under the message, and for YouTube the video, playable right in the chat. Only the link is sent to its site (no cookies, never the message or who sent it), and nothing is saved. On by default; Chat+ → Link previews turns it off.
## 1.0.2 ## 1.0.2
### New ### New

View file

@ -751,6 +751,10 @@
"plugin.chat-plus.bookmarks.importBad": "This is not an export of starred messages.", "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.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.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.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.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", "plugin.chat-plus.searchLoaded.title": "Find in conversation",

View file

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

View file

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

View file

@ -25,6 +25,8 @@ desktop = [
"waybetter-host/app", "waybetter-host/app",
"waybetter-host/net", "waybetter-host/net",
"waybetter-host/macos-private-api", "waybetter-host/macos-private-api",
"dep:reqwest",
"dep:rustls",
] ]
[build-dependencies] [build-dependencies]
@ -52,6 +54,9 @@ url = "2"
tauri-plugin-deep-link = { version = "2", optional = true } tauri-plugin-deep-link = { version = "2", optional = true }
# Gallery folder picker, driven from Rust only (no JS capability grants the dialog plugin). # Gallery folder picker, driven from Rust only (no JS capability grants the dialog plugin).
tauri-plugin-dialog = { version = "2", optional = true } 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" sha2 = "0.10"
base64 = "0.22" base64 = "0.22"
# Encrypted settings sync (a folder the owner picks): Argon2id key from the passphrase, # Encrypted settings sync (a folder the owner picks): Argon2id key from the passphrase,

View file

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

View file

@ -1,10 +1,10 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "x-capture", "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.", "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-*"], "webviews": ["tab-*"],
"local": false, "local": false,
"platforms": ["macOS", "windows", "linux"], "platforms": ["macOS", "windows", "linux"],
"remote": { "urls": ["https://x.com/*"] }, "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"] "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.searchLoaded",
"chat.pins", "chat.pins",
"chat.personFilter", "chat.personFilter",
"chat.linkPreviews",
"chat.keywords", "chat.keywords",
"mediaDownload", "mediaDownload",
"rememberVideoVolume", "rememberVideoVolume",

View file

@ -233,6 +233,19 @@ async fn keyword_alert(webview: Webview, state: State<'_, AppState>, hit: Keywor
Ok(()) 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 /// 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. /// while the `singleSplitNav` feature is on. Queued in `__btPane` until the runtime exists.
fn pane_script(nav_redundant: bool) -> String { fn pane_script(nav_redundant: bool) -> String {
@ -578,6 +591,7 @@ pub fn run() {
desktop_popover::popover_close, desktop_popover::popover_close,
desktop_popover::popover_open_vault, desktop_popover::popover_open_vault,
keyword_alert, keyword_alert,
link_preview,
crate::desktop_media::tab_attach_image, crate::desktop_media::tab_attach_image,
], ],
); );

View file

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

View file

@ -133,6 +133,8 @@ const DEFAULT_ON = [
'logoBrush', 'twitterWording', 'singleSplitNav', 'logoBrush', 'twitterWording', 'singleSplitNav',
'fixLikeFollow', 'fixScrollRestore', 'fixTimelineJump', 'fixHomeTab', 'fixNotificationBadge', 'fixNewPostsPill', 'fixMediaUpload', 'followingChronological', 'fixChatButton', 'composerRestorePrompt', 'fixLikeFollow', 'fixScrollRestore', 'fixTimelineJump', 'fixHomeTab', 'fixNotificationBadge', 'fixNewPostsPill', 'fixMediaUpload', 'followingChronological', 'fixChatButton', 'composerRestorePrompt',
'composerDrafts', 'hidePromotedPosts', 'mediaDownload', 'rememberVideoVolume', 'searchOperators', 'notifications.keywordAlerts', '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). */ /** What Advanced adds: the calm features that came with the area plugins (they add something; none hides or changes X). */
const ADVANCED_ADDS = [ 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(on(defaults), [...DEFAULT_ON].sort());
assert.deepEqual(running(null), [...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 [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`); 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. // 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'/);
});

View file

@ -180,6 +180,7 @@ Commit path-limited to your own files. `src-tauri/src/areas.rs` is committed tog
Owner decisions that bind the areas: Owner decisions that bind the areas:
- **Hide Blue Checks (Reader+ `reader.collapseVerified`, Profile+)**: never collapse a post by the owner of the profile page being visited (every profile sub-route, handle compared case-insensitively), nor the focal post of an opened post page. A post they reposted from someone else follows the normal rule. An author X has not described yet is never collapsed. Leaving the profile re-checks the posts on screen. The `hideVerifiedBadges` tweak keeps the badge in the visited profile's header. - **Hide Blue Checks (Reader+ `reader.collapseVerified`, Profile+)**: never collapse a post by the owner of the profile page being visited (every profile sub-route, handle compared case-insensitively), nor the focal post of an opened post page. A post they reposted from someone else follows the normal rule. An author X has not described yet is never collapsed. Leaving the profile re-checks the posts on screen. The `hideVerifiedBadges` tweak keeps the badge in the visited profile's header.
- **Link previews (Chat+, owner-approved 2026-10-10, on by default)**: for a link in a chat message on screen, the page asks native code (`link_preview`, X tabs on X's chat pages only) for that one link's preview. Native code fetches it itself (`src-tauri/src/link_preview.rs`): https only, a public host name (every resolved address public, the connection pinned to them), never X's own hosts, no cookies or referrer, redirects re-checked (at most 4), a page head of at most 512 KB, an image of at most 1.5 MB by its magic bytes (PNG/JPEG/GIF/WebP) returned as a `data:` URL, at most 30 fresh fetches a minute, a memory-only cache. Only the link leaves the page, never the message, its sender or the conversation. YouTube's player (a www.youtube.com frame, which X's CSP allows) loads only when the user presses play.
- **Starred DMs (Chat+)**: a message the user explicitly stars may be stored locally in full (message id, conversation id, sender, text, time, media references). Never in sync. In a backup only when the owner ticks it (not built yet: Export/Import as JSON stands in). No passive collection of any kind. - **Starred DMs (Chat+)**: a message the user explicitly stars may be stored locally in full (message id, conversation id, sender, text, time, media references). Never in sync. In a backup only when the owner ticks it (not built yet: Export/Import as JSON stands in). No passive collection of any kind.
- **Launch scope**: the ports of what exists and the 29 "must" items of the plan. The 35 "nice" and 7 "later" items come after launch. - **Launch scope**: the ports of what exists and the 29 "must" items of the plan. The 35 "nice" and 7 "later" items come after launch.
- **Modes** (first run; an existing setup keeps what it has): - **Modes** (first run; an existing setup keeps what it has):