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:
parent
7530fb1de7
commit
6d1798038c
21 changed files with 1455 additions and 519 deletions
|
|
@ -751,6 +751,10 @@
|
|||
"plugin.chat-plus.bookmarks.importBad": "This is not an export of starred messages.",
|
||||
"plugin.chat-plus.bookmarks.imported": "{count, plural, =0 {Nothing new to import} one {# starred message imported} other {# starred messages imported}}",
|
||||
"plugin.chat-plus.bookmarks.close": "Close",
|
||||
"plugin.chat-plus.feature.linkPreviews.label": "Link previews",
|
||||
"plugin.chat-plus.feature.linkPreviews.description": "Shows a preview under a link in a chat: the page's title, text and picture, and for YouTube the video, playable in place. To make one, the app asks the link's site for its preview itself: only the link is sent, without cookies, never the message or who sent it. Nothing is saved.",
|
||||
"plugin.chat-plus.linkPreviews.play": "Play the video here",
|
||||
"plugin.chat-plus.linkPreviews.open": "Open the link",
|
||||
"plugin.chat-plus.feature.searchLoaded.label": "Find in conversation",
|
||||
"plugin.chat-plus.feature.searchLoaded.description": "Ctrl+Shift+F in a chat searches the messages X has loaded so far and jumps to each match. It can load older messages while it is open.",
|
||||
"plugin.chat-plus.searchLoaded.title": "Find in conversation",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ar-x-fm",
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"fallback": "ar",
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "bn",
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "eu",
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "en-GB",
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ca",
|
||||
|
|
@ -79,7 +79,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "zh-Hans",
|
||||
|
|
@ -89,7 +89,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "zh-Hant",
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "hr",
|
||||
|
|
@ -109,7 +109,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "cs",
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "da",
|
||||
|
|
@ -129,7 +129,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "nl",
|
||||
|
|
@ -139,7 +139,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "fil",
|
||||
|
|
@ -149,7 +149,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "fi",
|
||||
|
|
@ -159,7 +159,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "fr",
|
||||
|
|
@ -169,7 +169,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "gl",
|
||||
|
|
@ -179,7 +179,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "de",
|
||||
|
|
@ -189,7 +189,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "el",
|
||||
|
|
@ -199,7 +199,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "gu",
|
||||
|
|
@ -209,7 +209,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ha",
|
||||
|
|
@ -219,7 +219,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "he",
|
||||
|
|
@ -229,7 +229,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "hi",
|
||||
|
|
@ -239,7 +239,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "hu",
|
||||
|
|
@ -249,7 +249,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ig",
|
||||
|
|
@ -259,7 +259,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "id",
|
||||
|
|
@ -269,7 +269,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ga",
|
||||
|
|
@ -279,7 +279,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "it",
|
||||
|
|
@ -289,7 +289,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ja",
|
||||
|
|
@ -299,7 +299,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "kn",
|
||||
|
|
@ -309,7 +309,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ko",
|
||||
|
|
@ -319,7 +319,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ms",
|
||||
|
|
@ -329,7 +329,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "mr",
|
||||
|
|
@ -339,7 +339,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "nb",
|
||||
|
|
@ -349,7 +349,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "fa",
|
||||
|
|
@ -359,7 +359,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "pl",
|
||||
|
|
@ -369,7 +369,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "pt",
|
||||
|
|
@ -379,7 +379,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ro",
|
||||
|
|
@ -389,7 +389,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ru",
|
||||
|
|
@ -399,7 +399,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "sr",
|
||||
|
|
@ -409,7 +409,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "sk",
|
||||
|
|
@ -419,7 +419,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "es",
|
||||
|
|
@ -429,7 +429,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "sv",
|
||||
|
|
@ -439,7 +439,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ta",
|
||||
|
|
@ -449,7 +449,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "th",
|
||||
|
|
@ -459,7 +459,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "tr",
|
||||
|
|
@ -479,7 +479,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "ur",
|
||||
|
|
@ -489,7 +489,7 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "vi",
|
||||
|
|
@ -499,7 +499,7 @@
|
|||
"status": "machine",
|
||||
"tier": "supported",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
},
|
||||
{
|
||||
"code": "yo",
|
||||
|
|
@ -509,6 +509,6 @@
|
|||
"status": "machine",
|
||||
"tier": "community",
|
||||
"reviewers": [],
|
||||
"coverage": 1
|
||||
"coverage": 0.996
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -800,6 +800,10 @@
|
|||
"plugin.chat-plus.personFilter.groupOnly": "Önce bir grup sohbeti aç",
|
||||
"plugin.chat-plus.personFilter.nobody": "Henüz seçilecek kimse yok: hiç mesaj yüklenmedi.",
|
||||
"plugin.chat-plus.personFilter.hint": "Şimdiye kadar yüklenen mesajları yazan kişiler. Daha fazlası için yukarı kaydır.",
|
||||
"plugin.chat-plus.feature.linkPreviews.label": "Link önizlemeleri",
|
||||
"plugin.chat-plus.feature.linkPreviews.description": "Sohbetteki bir linkin altında önizlemesini gösterir: sayfanın başlığı, metni ve görseli; YouTube'da ise yerinde oynatılabilen video. Önizleme için uygulama linkin sitesine kendisi sorar: yalnızca link gider, çerezsiz; mesaj ya da gönderen asla gitmez. Hiçbir şey kaydedilmez.",
|
||||
"plugin.chat-plus.linkPreviews.play": "Videoyu burada oynat",
|
||||
"plugin.chat-plus.linkPreviews.open": "Linki aç",
|
||||
"plugin.chat-plus.feature.keywords.label": "Sohbetlerde anahtar kelimeler",
|
||||
"plugin.chat-plus.feature.keywords.description": "Kelimelerini içeren sohbet mesajlarını vurgular, sessize aldığın kelimeleri içeren mesajları katlar. Katlanmış bir mesaj tıklayınca açılır.",
|
||||
"plugin.chat-plus.keywords.highlight": "Vurgulanacak kelimeler",
|
||||
|
|
|
|||
2
apps/desktop/src-tauri/Cargo.lock
generated
2
apps/desktop/src-tauri/Cargo.lock
generated
|
|
@ -296,7 +296,9 @@ dependencies = [
|
|||
"base64 0.22.1",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ desktop = [
|
|||
"waybetter-host/app",
|
||||
"waybetter-host/net",
|
||||
"waybetter-host/macos-private-api",
|
||||
"dep:reqwest",
|
||||
"dep:rustls",
|
||||
]
|
||||
|
||||
[build-dependencies]
|
||||
|
|
@ -52,6 +54,9 @@ url = "2"
|
|||
tauri-plugin-deep-link = { version = "2", optional = true }
|
||||
# Gallery folder picker, driven from Rust only (no JS capability grants the dialog plugin).
|
||||
tauri-plugin-dialog = { version = "2", optional = true }
|
||||
# Chat+ link previews (src/link_preview.rs): the same client the host's store uses, no cookies.
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"], optional = true }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"], optional = true }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
# Encrypted settings sync (a folder the owner picks): Argon2id key from the passphrase,
|
||||
|
|
|
|||
|
|
@ -1,25 +1,26 @@
|
|||
fn main() {
|
||||
#[cfg(feature = "desktop")]
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
|
||||
// X's own commands; `manifest` adds the ones the wayBetter host serves for every app
|
||||
// (`waybetter_host::commands`: tab layout and actions, shell raise, profiles, language,
|
||||
// the plugins folder and custom CSS, the tweak flags, the shell's settings).
|
||||
tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&[
|
||||
"capture_draft",
|
||||
"column_configure",
|
||||
"tab_report",
|
||||
"drafts_picker_open",
|
||||
"tab_insert_text",
|
||||
"drafts_popover_dismiss",
|
||||
"drafts_hint",
|
||||
"popover_state",
|
||||
"popover_drafts",
|
||||
"popover_insert",
|
||||
"popover_close",
|
||||
"popover_open_vault",
|
||||
"keyword_alert",
|
||||
"tab_attach_image",
|
||||
])),
|
||||
))
|
||||
.expect("Tauri build configuration must be valid");
|
||||
}
|
||||
fn main() {
|
||||
#[cfg(feature = "desktop")]
|
||||
tauri_build::try_build(tauri_build::Attributes::new().app_manifest(
|
||||
// X's own commands; `manifest` adds the ones the wayBetter host serves for every app
|
||||
// (`waybetter_host::commands`: tab layout and actions, shell raise, profiles, language,
|
||||
// the plugins folder and custom CSS, the tweak flags, the shell's settings).
|
||||
tauri_build::AppManifest::new().commands(waybetter_host::commands::manifest(&[
|
||||
"capture_draft",
|
||||
"column_configure",
|
||||
"tab_report",
|
||||
"drafts_picker_open",
|
||||
"tab_insert_text",
|
||||
"drafts_popover_dismiss",
|
||||
"drafts_hint",
|
||||
"popover_state",
|
||||
"popover_drafts",
|
||||
"popover_insert",
|
||||
"popover_close",
|
||||
"popover_open_vault",
|
||||
"keyword_alert",
|
||||
"link_preview",
|
||||
"tab_attach_image",
|
||||
])),
|
||||
))
|
||||
.expect("Tauri build configuration must be valid");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "x-capture",
|
||||
"description": "X tabs: append-only draft capture, opening the wayBetter tab, asking for the drafts popover under its composer and closing it, asking whether a captured draft exists for exactly its own post page (yes/no, no text or ids) and reporting keyword matches about itself. No reads of the local vault, no shell, filesystem or generic APIs.",
|
||||
"webviews": ["tab-*"],
|
||||
"local": false,
|
||||
"platforms": ["macOS", "windows", "linux"],
|
||||
"remote": { "urls": ["https://x.com/*"] },
|
||||
"permissions": ["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert"]
|
||||
}
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "x-capture",
|
||||
"description": "X tabs: append-only draft capture, opening the wayBetter tab, asking for the drafts popover under its composer and closing it, asking whether a captured draft exists for exactly its own post page (yes/no, no text or ids), reporting keyword matches about itself and, on its own chat page only, asking for the preview of one link it shows (native fetch: public https hosts only, no cookies, size caps; src/link_preview.rs). No reads of the local vault, no shell, filesystem or generic APIs.",
|
||||
"webviews": ["tab-*"],
|
||||
"local": false,
|
||||
"platforms": ["macOS", "windows", "linux"],
|
||||
"remote": { "urls": ["https://x.com/*"] },
|
||||
"permissions": ["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert", "allow-link-preview"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ pub const FLAGS: &[&str] = &[
|
|||
"chat.searchLoaded",
|
||||
"chat.pins",
|
||||
"chat.personFilter",
|
||||
"chat.linkPreviews",
|
||||
"chat.keywords",
|
||||
"mediaDownload",
|
||||
"rememberVideoVolume",
|
||||
|
|
|
|||
|
|
@ -233,6 +233,19 @@ async fn keyword_alert(webview: Webview, state: State<'_, AppState>, hit: Keywor
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Chat+ link previews (owner-approved network use, 2026-10-10): an X tab on its own chat page asks
|
||||
/// for the preview of one link it shows. Only the link reaches native code; the fetch rules
|
||||
/// (public https hosts, no cookies, pinned addresses, size caps, memory-only cache) are in link_preview.rs.
|
||||
#[tauri::command]
|
||||
async fn link_preview(webview: Webview, url: String) -> Result<crate::link_preview::LinkPreview, String> {
|
||||
policy::require_capture(webview.label(), &webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?)?;
|
||||
let page = webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?;
|
||||
if !crate::link_preview::is_chat_page(page.path()) {
|
||||
return Err("FORBIDDEN: link previews are for X's chat pages only".into());
|
||||
}
|
||||
crate::link_preview::preview(&url).await
|
||||
}
|
||||
|
||||
/// Tells an X tab whether its left navigation repeats one already on screen; the runtime hides it
|
||||
/// while the `singleSplitNav` feature is on. Queued in `__btPane` until the runtime exists.
|
||||
fn pane_script(nav_redundant: bool) -> String {
|
||||
|
|
@ -578,6 +591,7 @@ pub fn run() {
|
|||
desktop_popover::popover_close,
|
||||
desktop_popover::popover_open_vault,
|
||||
keyword_alert,
|
||||
link_preview,
|
||||
crate::desktop_media::tab_attach_image,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub mod devmode;
|
|||
pub mod distribution;
|
||||
pub mod gfonts;
|
||||
pub mod hotfix;
|
||||
pub mod link_preview;
|
||||
pub mod media;
|
||||
pub mod model;
|
||||
pub mod palette_commands;
|
||||
|
|
|
|||
607
apps/desktop/src-tauri/src/link_preview.rs
Normal file
607
apps/desktop/src-tauri/src/link_preview.rs
Normal 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 & 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<b> AB &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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,430 +1,434 @@
|
|||
use url::Url;
|
||||
use better_twttr::{policy::*, site::SITE, theme::{x_default, XTheme, X_THEMES}};
|
||||
|
||||
#[test]
|
||||
fn exact_origin_and_window_scope_is_enforced() {
|
||||
let x = Url::parse("https://x.com/home").unwrap();
|
||||
assert!(require_capture("tab-1", &x).is_ok());
|
||||
assert!(require_capture("shell", &x).is_err());
|
||||
assert!(require_local(
|
||||
"tab-1",
|
||||
&Url::parse("tauri://localhost/index.html").unwrap(),
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
for value in [
|
||||
"https://evil.x.com/home",
|
||||
"http://x.com/home",
|
||||
"https://x.com:8443/home",
|
||||
"https://x.com.evil.test/home",
|
||||
"https://x.com@evil.test/home",
|
||||
"https://evil@x.com/home",
|
||||
"file:///tmp/index.html",
|
||||
"https://twitter.com/home",
|
||||
] {
|
||||
assert!(
|
||||
require_capture("tab-1", &Url::parse(value).unwrap()).is_err(),
|
||||
"accepted {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_origin_has_no_production_localhost_backdoor() {
|
||||
for value in [
|
||||
"tauri://localhost/index.html",
|
||||
"http://tauri.localhost/index.html",
|
||||
"https://tauri.localhost/index.html",
|
||||
] {
|
||||
assert!(require_local("shell", &Url::parse(value).unwrap(), false).is_ok());
|
||||
}
|
||||
assert!(require_local("shell", &Url::parse("http://127.0.0.1:1420/").unwrap(), true).is_ok());
|
||||
for value in [
|
||||
"https://x.com/",
|
||||
"http://127.0.0.1:1420/",
|
||||
"http://localhost:1420/",
|
||||
"https://tauri.localhost.evil.test/",
|
||||
"tauri://evil/",
|
||||
] {
|
||||
assert!(
|
||||
require_local("shell", &Url::parse(value).unwrap(), false).is_err(),
|
||||
"accepted {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_allows_only_x_and_specific_legacy_login_routes() {
|
||||
for value in [
|
||||
"https://x.com/home",
|
||||
"https://twitter.com/login",
|
||||
"https://twitter.com/i/flow/login",
|
||||
"https://twitter.com/i/flow/login/step",
|
||||
] {
|
||||
assert!(x_navigation_allowed(&Url::parse(value).unwrap()));
|
||||
}
|
||||
for value in [
|
||||
"https://twitter.com/home",
|
||||
"https://t.co/test",
|
||||
"https://accounts.google.com/",
|
||||
"javascript:alert(1)",
|
||||
"mailto:test@example.com",
|
||||
"https://twitter.com/login/evil",
|
||||
] {
|
||||
assert!(
|
||||
!x_navigation_allowed(&Url::parse(value).unwrap()),
|
||||
"accepted {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_capabilities_cannot_reach_the_vault() {
|
||||
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
|
||||
let x = read(include_str!("../capabilities/x-capture.json"));
|
||||
// Drafts popover (2026-09-27): X may only open/close it about itself and ask one yes/no hint question.
|
||||
assert_eq!(x["permissions"], serde_json::json!(["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert"]));
|
||||
for vault in ["allow-popover-drafts", "allow-popover-insert", "allow-popover-state", "allow-draft-list"] {
|
||||
assert!(!x["permissions"].as_array().unwrap().iter().any(|p| p == vault), "X tabs have {vault}");
|
||||
}
|
||||
|
||||
// The drafts popover is local-only and gets nothing beyond its own five commands.
|
||||
let popover = read(include_str!("../capabilities/drafts-popover.json"));
|
||||
assert_eq!(popover["webviews"], serde_json::json!(["popover-*"]));
|
||||
assert_eq!(popover["local"], true);
|
||||
assert!(popover.get("remote").is_none());
|
||||
assert_eq!(popover["permissions"], serde_json::json!(["allow-popover-state", "allow-popover-drafts", "allow-popover-insert", "allow-popover-close", "allow-popover-open-vault"]));
|
||||
assert_eq!(x["webviews"], serde_json::json!(["tab-*"]));
|
||||
assert_eq!(x["remote"]["urls"], serde_json::json!(["https://x.com/*"]));
|
||||
assert_eq!(x["local"], false);
|
||||
// Linux joined with the same permissions (owner request, 2026-09-28): platforms only, no new grants.
|
||||
assert_eq!(x["platforms"], serde_json::json!(["macOS", "windows", "linux"]));
|
||||
|
||||
let tabs = read(include_str!("../capabilities/tab-chrome.json"));
|
||||
assert_eq!(tabs["permissions"], serde_json::json!(["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"]));
|
||||
assert_eq!(tabs["webviews"], serde_json::json!(["tab-*"]));
|
||||
assert_eq!(tabs["local"], false);
|
||||
|
||||
let local = read(include_str!("../capabilities/workspace-local.json"));
|
||||
assert!(local.get("remote").is_none());
|
||||
// My Space (profile customization) is removed: its commands do not exist, so no capability
|
||||
// file grants them and neither the command manifest nor the handler names them.
|
||||
let (build, desktop) = (include_str!("../build.rs"), include_str!("../src/desktop.rs"));
|
||||
for gone in [
|
||||
"profile_owner_get", "profile_create", "profile_api", "profile_media_pick", "profile_media_still", "profile_media_upload", "profile_still_to_x", "profile_view_get", "profile_view_set",
|
||||
"bt_profiles_resolve", "bt_media_get", "bt_profile_link_seen",
|
||||
] {
|
||||
let permission = format!("allow-{}", gone.replace('_', "-"));
|
||||
for (name, capability) in [("x-capture", &x), ("drafts-popover", &popover), ("tab-chrome", &tabs), ("workspace-local", &local)] {
|
||||
assert!(!capability["permissions"].as_array().unwrap().iter().any(|p| p == permission.as_str()), "{name} grants {permission}");
|
||||
}
|
||||
assert!(!build.contains(&format!("\"{gone}\"")), "build.rs still lists {gone}");
|
||||
assert!(!desktop.contains(gone), "desktop.rs still names {gone}");
|
||||
}
|
||||
assert_eq!(local["webviews"], serde_json::json!(["shell"]));
|
||||
|
||||
// Update commands are shell-only; no capability exposes the updater/process plugin JS APIs.
|
||||
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
|
||||
// Account profiles (separate X logins) are managed only by the shell.
|
||||
// Drafts are inserted and alert keywords edited only by the shell; X tabs only ask/report.
|
||||
for name in [
|
||||
"allow-update-status", "allow-apply-update", "allow-app-ready", "allow-profiles-get", "allow-profiles-set",
|
||||
"allow-tab-insert-text", "allow-alerts-get", "allow-alerts-set", "allow-draft-list", "allow-draft-revisions",
|
||||
// Google Fonts downloads (network), account/pane themes and the builder preview: shell only.
|
||||
"allow-gfonts-installed", "allow-gfonts-install", "allow-gfonts-remove", "allow-gfonts-faces", "allow-gfonts-preview",
|
||||
"allow-theme-scopes-get", "allow-theme-scopes-set", "allow-theme-preview",
|
||||
// Installing themes/plugins (links, dropped files, the signed store; owner-approved 2026-09-28):
|
||||
// only the shell, after its confirmation screen. X tabs and web pages cannot install anything.
|
||||
"allow-install-pending", "allow-install-stage-link", "allow-install-stage-file", "allow-install-cancel",
|
||||
"allow-install-commit", "allow-install-records", "allow-install-forget", "allow-plugin-remove",
|
||||
"allow-store-catalog", "allow-store-stage",
|
||||
] {
|
||||
assert!(perms(&local).iter().any(|p| p == name), "shell lacks {name}");
|
||||
assert!(!perms(&x).iter().chain(perms(&tabs).iter()).any(|p| p == name), "tab has {name}");
|
||||
}
|
||||
for p in perms(&local).iter().chain(perms(&x).iter()).chain(perms(&tabs).iter()) {
|
||||
assert!(
|
||||
!p.starts_with("updater:") && !p.starts_with("process:") && !p.starts_with("notification:") && !p.starts_with("deep-link:"),
|
||||
"plugin API exposed: {p}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_accepts_only_known_presets_and_hex_accents() {
|
||||
let script = |theme: XTheme| theme.script(&X_THEMES, &SITE);
|
||||
let valid = XTheme { preset: "night-ink".into(), accent: Some("#1d9bf0".into()), ..x_default() };
|
||||
let text = script(valid).unwrap();
|
||||
assert!(text.contains("location.origin!=='https://x.com'"));
|
||||
assert!(text.contains(r#""preset":"night-ink""#));
|
||||
assert!(x_default().is_default(&X_THEMES));
|
||||
for preset in ["x", "classic", "night-ink", "terminal", "paper"] {
|
||||
assert!(script(XTheme { preset: preset.into(), ..x_default() }).is_ok());
|
||||
}
|
||||
for hostile in ["evil", "x;display:none", "</script>", ""] {
|
||||
assert!(script(XTheme { preset: hostile.into(), ..x_default() }).is_err(), "accepted {hostile}");
|
||||
}
|
||||
for hostile in ["red", "#fff", "url(https://evil.test)", "#000000;display:none", "</style><script>", "#00000é"] {
|
||||
assert!(script(XTheme { accent: Some(hostile.into()), ..x_default() }).is_err(), "accepted {hostile}");
|
||||
}
|
||||
assert!(serde_json::from_str::<XTheme>(r#"{"preset":"x","css":"body{}"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_keeps_macos_targets_and_signed_updater() {
|
||||
let conf: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
let targets = conf["bundle"]["targets"].as_array().unwrap();
|
||||
for target in ["nsis", "app", "dmg"] {
|
||||
assert!(targets.iter().any(|t| t == target), "missing bundle target {target}");
|
||||
}
|
||||
assert!(!conf["plugins"]["updater"]["pubkey"].as_str().unwrap_or("").is_empty());
|
||||
for endpoint in conf["plugins"]["updater"]["endpoints"].as_array().unwrap() {
|
||||
assert!(endpoint.as_str().unwrap().starts_with("https://"), "updater endpoint must be https");
|
||||
}
|
||||
// One URL scheme, only for install links; the installer/Info.plist register exactly this.
|
||||
assert_eq!(conf["plugins"]["deep-link"]["desktop"]["schemes"], serde_json::json!(["waybetter"]));
|
||||
assert!(conf["plugins"]["deep-link"].get("mobile").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_sync_workspace_palette_and_dev_commands_are_shell_only() {
|
||||
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
|
||||
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
|
||||
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
|
||||
let x = perms(&read(include_str!("../capabilities/x-capture.json")));
|
||||
let tabs = perms(&read(include_str!("../capabilities/tab-chrome.json")));
|
||||
let popover = perms(&read(include_str!("../capabilities/drafts-popover.json")));
|
||||
// Settings backup/sync (files and the sync key stay native), workspaces, running a plugin's
|
||||
// palette command in a tab, and developer mode (file watcher, hot reload).
|
||||
for name in [
|
||||
"allow-tab-run-command", "allow-workspaces-get", "allow-workspaces-set",
|
||||
"allow-backup-collect", "allow-backup-export-file", "allow-backup-open-file", "allow-backup-apply",
|
||||
"allow-sync-status", "allow-sync-choose-folder", "allow-sync-unlock", "allow-sync-lock", "allow-sync-disable",
|
||||
"allow-sync-configure", "allow-sync-push", "allow-sync-check", "allow-sync-open", "allow-sync-mark-pulled",
|
||||
"allow-dev-get", "allow-dev-set", "allow-dev-pick-theme", "allow-dev-clear-theme", "allow-dev-reload-plugin", "allow-dev-report-error",
|
||||
] {
|
||||
assert!(local.iter().any(|p| p == name), "shell lacks {name}");
|
||||
assert!(!x.iter().chain(tabs.iter()).chain(popover.iter()).any(|p| p == name), "a tab or the popover has {name}");
|
||||
}
|
||||
// X tabs report their plugins' commands only through tab_report (owner-approved 2026-09-28);
|
||||
// the tab capabilities gained nothing for it.
|
||||
assert_eq!(tabs, ["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_commands_are_shell_only() {
|
||||
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
|
||||
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
|
||||
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
|
||||
let others: Vec<String> = [
|
||||
include_str!("../capabilities/x-capture.json"),
|
||||
include_str!("../capabilities/tab-chrome.json"),
|
||||
include_str!("../capabilities/drafts-popover.json"),
|
||||
]
|
||||
.iter()
|
||||
.flat_map(|json| perms(&read(json)))
|
||||
.collect();
|
||||
// The shell picks the language and hands native code and X tabs their strings; tabs and the
|
||||
// popover only ever receive them (X tabs as data through the runtime), never set them.
|
||||
for command in ["allow-i18n-get", "allow-i18n-set"] {
|
||||
assert!(local.iter().any(|p| p == command), "shell lacks {command}");
|
||||
assert!(!others.iter().any(|p| p == command), "a tab or the popover has {command}");
|
||||
}
|
||||
// The commands are the framework's (waybetter-host `app`); each checks shell_only first.
|
||||
for command in ["i18n_get", "i18n_set"] {
|
||||
waybetter_host::testkit::assert_guarded(include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs"), command, "shell_only(&webview)?");
|
||||
}
|
||||
// X tabs get the strings only as a guarded data value, and only the runtime's own prefixes.
|
||||
let source = include_str!("../src/desktop_i18n.rs");
|
||||
assert!(source.contains(r#"&["x.", "kit.", "plugin."]"#), "X tabs receive only x./kit./plugin. strings");
|
||||
assert!(include_str!("../src/desktop.rs").contains("page_string_prefixes: crate::desktop_i18n::PAGE_PREFIXES"), "X's config hands tabs exactly those");
|
||||
let strings = waybetter_host::i18n::NativeStrings {
|
||||
locale: "tr".into(),
|
||||
preference: "site".into(),
|
||||
messages: [("x.fab.title", "wayBetter"), ("kit.ok", "Tamam"), ("native.notify.alert.title", "Uyarı")]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
||||
.collect(),
|
||||
};
|
||||
let script = waybetter_host::i18n::page_script(&better_twttr::site::SITE, &strings.page_payload(&["x.", "kit.", "plugin."]));
|
||||
assert!(script.starts_with("if(location.origin==='https://x.com')("), "page strings must be x.com only: {script}");
|
||||
assert!(script.contains("x.fab.title") && script.contains("kit.ok") && !script.contains("native.notify"), "{script}");
|
||||
// "Follow X": a tab may report X's <html lang>, only as a valid tag and only while on x.com.
|
||||
let desktop = include_str!("../src/desktop.rs");
|
||||
assert!(
|
||||
desktop.contains("report.lang.filter(|lang| waybetter_host::i18n::valid_tag(lang) && webview.url().is_ok_and(|url| policy::x_origin(&url)))"),
|
||||
"tab_report lang must be a validated tag from a webview on x.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_strings_are_plain_text() {
|
||||
use waybetter_host::i18n::NativeStrings;
|
||||
let set = |value: &str| NativeStrings {
|
||||
locale: "ar".into(),
|
||||
preference: "site".into(),
|
||||
messages: [("x.fab.title".to_owned(), value.to_owned())].into_iter().collect(),
|
||||
};
|
||||
assert!(set("wayBetter لـ X").validated().is_ok());
|
||||
for bad in ["<img src=x onerror=alert(1)>", "<script>", "javascript:alert(1)", "a\u{202e}b"] {
|
||||
assert!(set(bad).validated().is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_raise_is_shell_only() {
|
||||
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
|
||||
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
|
||||
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
|
||||
let x = perms(&read(include_str!("../capabilities/x-capture.json")));
|
||||
let tabs = perms(&read(include_str!("../capabilities/tab-chrome.json")));
|
||||
let popover = perms(&read(include_str!("../capabilities/drafts-popover.json")));
|
||||
// Re-orders the shell webview over the tabs for its own overlays: only the shell may.
|
||||
assert!(local.iter().any(|p| p == "allow-shell-raise"), "shell lacks allow-shell-raise");
|
||||
assert!(!x.iter().chain(tabs.iter()).chain(popover.iter()).any(|p| p == "allow-shell-raise"), "a tab or the popover can raise the shell");
|
||||
// The command is the framework's (waybetter-host `app`).
|
||||
waybetter_host::testkit::assert_guarded(include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs"), "shell_raise", "shell_only(&webview)?");
|
||||
waybetter_host::testkit::Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities")).assert_shell_only("shell_raise");
|
||||
}
|
||||
|
||||
/// The wayBetter host's own commands (tab layout and actions, shell raise, external links, window
|
||||
/// menu, profiles, language; tab shortcuts and link drags) keep their callers in X's capability
|
||||
/// files, and every command wrapper checks its caller first. Every capability file is read, so a
|
||||
/// new one cannot quietly widen them.
|
||||
#[test]
|
||||
fn host_commands_keep_their_callers() {
|
||||
use waybetter_host::testkit::{assert_guarded, assert_host_guards, Capabilities};
|
||||
let caps = Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities"));
|
||||
caps.assert_host_commands();
|
||||
assert_host_guards();
|
||||
// Only Rust drives the plugins; no capability exposes their JS APIs.
|
||||
caps.assert_no_plugin_apis(&["updater", "process", "notification", "deep-link", "single-instance", "window-state", "opener", "dialog"]);
|
||||
// X's own: vault, settings, themes, installs, backups, sync, dev mode, updates are the shell's.
|
||||
for command in [
|
||||
"draft_list", "draft_save", "draft_revisions", "draft_status", "observation_list", "data_export",
|
||||
"gfonts_installed", "gfonts_install", "gfonts_remove", "gfonts_faces", "gfonts_preview",
|
||||
"tab_run_command", "workspaces_get", "workspaces_set", "backup_collect", "backup_export_file", "backup_open_file", "backup_apply",
|
||||
"sync_status", "sync_choose_folder", "sync_unlock", "sync_lock", "sync_disable", "sync_configure", "sync_push", "sync_check", "sync_open", "sync_mark_pulled",
|
||||
"dev_get", "dev_set", "dev_pick_theme", "dev_clear_theme", "dev_reload_plugin", "dev_report_error",
|
||||
"plugins_list", "plugin_set_enabled", "plugins_open_folder", "plugin_remove",
|
||||
"install_pending", "install_stage_link", "install_stage_file", "install_stage_opened", "install_cancel", "install_commit", "install_records", "install_forget", "store_catalog", "store_stage",
|
||||
"custom_css_get", "custom_css_set", "features_get", "features_set", "mode_get", "mode_set", "settings_get", "settings_set", "column_configure",
|
||||
"update_status", "update_check", "apply_update", "app_ready", "tab_insert_text", "alerts_get", "alerts_set",
|
||||
"media_get", "media_set", "gallery_add_folder", "gallery_list", "gallery_thumb", "clip_list", "clip_thumb", "clip_remove", "tab_attach_image",
|
||||
] {
|
||||
caps.assert_shell_only(command);
|
||||
}
|
||||
// Tabs, about themselves only; the X-only ones are granted only on https://x.com.
|
||||
for command in ["tab_report", "capture_draft", "open_panel", "drafts_picker_open", "drafts_popover_dismiss", "drafts_hint", "keyword_alert"] {
|
||||
caps.assert_tab_only(command);
|
||||
}
|
||||
let desktop = include_str!("../src/desktop.rs");
|
||||
assert_guarded(desktop, "tab_insert_text", "shell_only(&webview)?");
|
||||
// The vault commands are the host's: shell-only in the capability files and each checking its caller
|
||||
// first; X's own handler no longer has them, and X turns the vault on with its rules.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::VAULT);
|
||||
waybetter_host::testkit::assert_vault_guards();
|
||||
assert!(desktop.contains(".with_vault("), "the app enables the host's vault");
|
||||
for command in waybetter_host::commands::VAULT {
|
||||
assert!(!desktop.contains(&format!("fn {command}(")), "{command} is served by the host only");
|
||||
}
|
||||
// The updater commands are the shell's too, each checking its caller first; `update_check` runs the
|
||||
// timer's own check once (no other endpoint), and tabs have no updater command at all.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::UPDATE);
|
||||
waybetter_host::testkit::assert_update_guards();
|
||||
assert!(desktop.contains(".with_updates("), "the app enables the updater");
|
||||
// Plugins, custom CSS, the tweak flags and the browser settings are served by the host: they
|
||||
// are in its shell-only list (checked against the capability files and for their caller
|
||||
// check above), and X's own handler no longer has them.
|
||||
let host = include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs");
|
||||
for command in ["plugins_list", "plugin_set_enabled", "plugins_open_folder", "custom_css_get", "custom_css_set", "features_get", "features_set", "mode_get", "mode_set", "settings_get", "settings_set"] {
|
||||
assert!(waybetter_host::commands::SHELL.contains(&command), "{command} is a host shell-only command");
|
||||
assert_guarded(host, command, "shell_only(&webview)?");
|
||||
assert!(!desktop.contains(&format!("fn {command}(")), "{command} is served by the host only");
|
||||
}
|
||||
assert_guarded(desktop, "tab_report", "tab_only(&webview)?");
|
||||
// Opening the panel from a page is the host's site command: only X tabs on x.com have it.
|
||||
caps.assert_site_only("open_panel", &better_twttr::site::SITE);
|
||||
assert!(!desktop.contains("fn open_panel("), "open_panel is served by the host only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_store_media_and_font_commands_are_shell_only() {
|
||||
// The store's commands are the framework's (waybetter-host `store_commands`, served because
|
||||
// this app sets `AppConfig::store`): shell-only in the capability files, and each wrapper
|
||||
// checks shell_only before anything else.
|
||||
use waybetter_host::testkit::assert_guarded;
|
||||
let caps = waybetter_host::testkit::Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities"));
|
||||
caps.assert_shell_only_group(waybetter_host::commands::STORE);
|
||||
waybetter_host::testkit::assert_store_guards();
|
||||
// The media panel's commands are the framework's too (AppConfig::with_media): shell-only, each
|
||||
// checking its caller first; only attaching to X's composer is the app's own.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::MEDIA);
|
||||
waybetter_host::testkit::assert_media_guards();
|
||||
// Workspaces and encrypted sync too (AppConfig::with_sync; workspaces are served for every app).
|
||||
caps.assert_shell_only_group(waybetter_host::commands::WORKSPACES);
|
||||
caps.assert_shell_only_group(waybetter_host::commands::SYNC);
|
||||
waybetter_host::testkit::assert_sync_guards();
|
||||
// The settings backup's commands are the framework's too (what it carries is the app's hooks).
|
||||
caps.assert_shell_only_group(waybetter_host::commands::BACKUP);
|
||||
waybetter_host::testkit::assert_backup_guards();
|
||||
// Developer mode (AppConfig::with_dev): the framework's commands; the theme file is the app's hook.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::DEV);
|
||||
waybetter_host::testkit::assert_dev_guards();
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_dev()"), "the app enables developer mode");
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_sync()"), "the app enables sync");
|
||||
// Site themes too (AppConfig::with_themes): the framework's commands; X only names its built-in themes.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::THEME);
|
||||
waybetter_host::testkit::assert_theme_guards();
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_themes(&crate::theme::THEMES)"), "the app configures its themes");
|
||||
// The keyword alert settings (AppConfig::with_alerts): the framework's commands; the hit and its notification are X's.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::ALERTS);
|
||||
waybetter_host::testkit::assert_alerts_guards();
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_alerts(&alerts::ALERTS)"), "the app configures its alerts");
|
||||
// Google Fonts too (AppConfig::with_fonts).
|
||||
caps.assert_shell_only_group(waybetter_host::commands::GFONTS);
|
||||
waybetter_host::testkit::assert_fonts_guards();
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_fonts()"), "the app enables Google Fonts");
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_media()"), "the app enables the media panel");
|
||||
assert_guarded(include_str!("../src/desktop_media.rs"), "tab_attach_image", "shell_only(&webview)?");
|
||||
let source = include_str!("../../../../waybetter/crates/waybetter-host/src/store_commands.rs");
|
||||
// Links only ever propose an install: the deep-link handler queues and emits, nothing more.
|
||||
let received = &source[source.find("fn received(").unwrap()..];
|
||||
let received = &received[..received.find("\n}").unwrap()];
|
||||
assert!(!received.contains("commit") && !received.contains("stage"), "a link must not stage or install by itself");
|
||||
// The app's store settings: X's id and the framework's hosts; the compiled-in key; and it asks
|
||||
// the framework to serve the store.
|
||||
let settings = include_str!("../src/distribution.rs");
|
||||
assert!(settings.contains("app_id: \"x\"") && settings.contains("allowed_hosts: DEFAULT_HOSTS") && settings.contains("include_str!(\"../keys/store.pub\")"));
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_store(&crate::distribution::STORE)"), "the app configures the store");
|
||||
}
|
||||
|
||||
/// The popover's webview and session are the host's; X's commands reached by the popover each check
|
||||
/// their caller first (the popover webview with the open session), and inserting a draft only
|
||||
/// focuses the tab and types (`compose` scripts), never clicks Post.
|
||||
#[test]
|
||||
fn popover_commands_check_their_caller_and_never_post() {
|
||||
use waybetter_host::testkit::assert_guarded;
|
||||
let source = include_str!("../src/desktop_popover.rs");
|
||||
assert_guarded(source, "popover_state", "host::session_of(&webview)");
|
||||
for command in ["popover_drafts", "popover_insert", "popover_open_vault"] {
|
||||
assert_guarded(source, command, "host::current(&webview,");
|
||||
}
|
||||
// popover_close only acts for the current popover, and does nothing otherwise.
|
||||
let close = &source[source.find("fn popover_close(").unwrap()..];
|
||||
let close = &close[..close.find("
|
||||
}").unwrap()];
|
||||
assert!(close.contains("host::current(&webview, seq).is_ok()"));
|
||||
let insert = &source[source.find("fn popover_insert(").unwrap()..];
|
||||
let insert = &insert[..insert.find("
|
||||
}").unwrap()];
|
||||
assert!(insert.contains("compose::insert_script") && insert.contains("compose::append_script"));
|
||||
assert!(!insert.contains("click") && !insert.contains("submit") && !insert.contains("Post"), "popover_insert never posts");
|
||||
assert!(insert.contains("policy::x_origin(&url)"), "only into X tabs");
|
||||
// The host's popover check: only a `popover-<n>` webview on the app's own local origin.
|
||||
let host = include_str!("../../../../waybetter/crates/waybetter-host/src/popover_host.rs");
|
||||
let only = &host[host.find("pub fn popover_only(").unwrap()..];
|
||||
let only = &only[..only.find("
|
||||
}").unwrap()];
|
||||
assert!(only.contains("popover::valid_label(webview.label())") && only.contains("local_navigation_allowed"));
|
||||
}
|
||||
use url::Url;
|
||||
use better_twttr::{policy::*, site::SITE, theme::{x_default, XTheme, X_THEMES}};
|
||||
|
||||
#[test]
|
||||
fn exact_origin_and_window_scope_is_enforced() {
|
||||
let x = Url::parse("https://x.com/home").unwrap();
|
||||
assert!(require_capture("tab-1", &x).is_ok());
|
||||
assert!(require_capture("shell", &x).is_err());
|
||||
assert!(require_local(
|
||||
"tab-1",
|
||||
&Url::parse("tauri://localhost/index.html").unwrap(),
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
for value in [
|
||||
"https://evil.x.com/home",
|
||||
"http://x.com/home",
|
||||
"https://x.com:8443/home",
|
||||
"https://x.com.evil.test/home",
|
||||
"https://x.com@evil.test/home",
|
||||
"https://evil@x.com/home",
|
||||
"file:///tmp/index.html",
|
||||
"https://twitter.com/home",
|
||||
] {
|
||||
assert!(
|
||||
require_capture("tab-1", &Url::parse(value).unwrap()).is_err(),
|
||||
"accepted {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_origin_has_no_production_localhost_backdoor() {
|
||||
for value in [
|
||||
"tauri://localhost/index.html",
|
||||
"http://tauri.localhost/index.html",
|
||||
"https://tauri.localhost/index.html",
|
||||
] {
|
||||
assert!(require_local("shell", &Url::parse(value).unwrap(), false).is_ok());
|
||||
}
|
||||
assert!(require_local("shell", &Url::parse("http://127.0.0.1:1420/").unwrap(), true).is_ok());
|
||||
for value in [
|
||||
"https://x.com/",
|
||||
"http://127.0.0.1:1420/",
|
||||
"http://localhost:1420/",
|
||||
"https://tauri.localhost.evil.test/",
|
||||
"tauri://evil/",
|
||||
] {
|
||||
assert!(
|
||||
require_local("shell", &Url::parse(value).unwrap(), false).is_err(),
|
||||
"accepted {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_allows_only_x_and_specific_legacy_login_routes() {
|
||||
for value in [
|
||||
"https://x.com/home",
|
||||
"https://twitter.com/login",
|
||||
"https://twitter.com/i/flow/login",
|
||||
"https://twitter.com/i/flow/login/step",
|
||||
] {
|
||||
assert!(x_navigation_allowed(&Url::parse(value).unwrap()));
|
||||
}
|
||||
for value in [
|
||||
"https://twitter.com/home",
|
||||
"https://t.co/test",
|
||||
"https://accounts.google.com/",
|
||||
"javascript:alert(1)",
|
||||
"mailto:test@example.com",
|
||||
"https://twitter.com/login/evil",
|
||||
] {
|
||||
assert!(
|
||||
!x_navigation_allowed(&Url::parse(value).unwrap()),
|
||||
"accepted {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_capabilities_cannot_reach_the_vault() {
|
||||
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
|
||||
let x = read(include_str!("../capabilities/x-capture.json"));
|
||||
// Drafts popover (2026-09-27): X may only open/close it about itself and ask one yes/no hint question.
|
||||
// Chat+ link previews (owner-approved, 2026-10-10): one link at a time, from a chat page only.
|
||||
assert_eq!(x["permissions"], serde_json::json!(["allow-capture-draft", "allow-open-panel", "allow-drafts-picker-open", "allow-drafts-popover-dismiss", "allow-drafts-hint", "allow-keyword-alert", "allow-link-preview"]));
|
||||
let desktop = include_str!("../src/desktop.rs");
|
||||
waybetter_host::testkit::assert_guarded(desktop, "link_preview", "policy::require_capture(webview.label(), &webview.url()");
|
||||
assert!(desktop.contains("link_preview::is_chat_page(page.path())"), "link previews answer chat pages only");
|
||||
for vault in ["allow-popover-drafts", "allow-popover-insert", "allow-popover-state", "allow-draft-list"] {
|
||||
assert!(!x["permissions"].as_array().unwrap().iter().any(|p| p == vault), "X tabs have {vault}");
|
||||
}
|
||||
|
||||
// The drafts popover is local-only and gets nothing beyond its own five commands.
|
||||
let popover = read(include_str!("../capabilities/drafts-popover.json"));
|
||||
assert_eq!(popover["webviews"], serde_json::json!(["popover-*"]));
|
||||
assert_eq!(popover["local"], true);
|
||||
assert!(popover.get("remote").is_none());
|
||||
assert_eq!(popover["permissions"], serde_json::json!(["allow-popover-state", "allow-popover-drafts", "allow-popover-insert", "allow-popover-close", "allow-popover-open-vault"]));
|
||||
assert_eq!(x["webviews"], serde_json::json!(["tab-*"]));
|
||||
assert_eq!(x["remote"]["urls"], serde_json::json!(["https://x.com/*"]));
|
||||
assert_eq!(x["local"], false);
|
||||
// Linux joined with the same permissions (owner request, 2026-09-28): platforms only, no new grants.
|
||||
assert_eq!(x["platforms"], serde_json::json!(["macOS", "windows", "linux"]));
|
||||
|
||||
let tabs = read(include_str!("../capabilities/tab-chrome.json"));
|
||||
assert_eq!(tabs["permissions"], serde_json::json!(["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"]));
|
||||
assert_eq!(tabs["webviews"], serde_json::json!(["tab-*"]));
|
||||
assert_eq!(tabs["local"], false);
|
||||
|
||||
let local = read(include_str!("../capabilities/workspace-local.json"));
|
||||
assert!(local.get("remote").is_none());
|
||||
// My Space (profile customization) is removed: its commands do not exist, so no capability
|
||||
// file grants them and neither the command manifest nor the handler names them.
|
||||
let (build, desktop) = (include_str!("../build.rs"), include_str!("../src/desktop.rs"));
|
||||
for gone in [
|
||||
"profile_owner_get", "profile_create", "profile_api", "profile_media_pick", "profile_media_still", "profile_media_upload", "profile_still_to_x", "profile_view_get", "profile_view_set",
|
||||
"bt_profiles_resolve", "bt_media_get", "bt_profile_link_seen",
|
||||
] {
|
||||
let permission = format!("allow-{}", gone.replace('_', "-"));
|
||||
for (name, capability) in [("x-capture", &x), ("drafts-popover", &popover), ("tab-chrome", &tabs), ("workspace-local", &local)] {
|
||||
assert!(!capability["permissions"].as_array().unwrap().iter().any(|p| p == permission.as_str()), "{name} grants {permission}");
|
||||
}
|
||||
assert!(!build.contains(&format!("\"{gone}\"")), "build.rs still lists {gone}");
|
||||
assert!(!desktop.contains(gone), "desktop.rs still names {gone}");
|
||||
}
|
||||
assert_eq!(local["webviews"], serde_json::json!(["shell"]));
|
||||
|
||||
// Update commands are shell-only; no capability exposes the updater/process plugin JS APIs.
|
||||
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
|
||||
// Account profiles (separate X logins) are managed only by the shell.
|
||||
// Drafts are inserted and alert keywords edited only by the shell; X tabs only ask/report.
|
||||
for name in [
|
||||
"allow-update-status", "allow-apply-update", "allow-app-ready", "allow-profiles-get", "allow-profiles-set",
|
||||
"allow-tab-insert-text", "allow-alerts-get", "allow-alerts-set", "allow-draft-list", "allow-draft-revisions",
|
||||
// Google Fonts downloads (network), account/pane themes and the builder preview: shell only.
|
||||
"allow-gfonts-installed", "allow-gfonts-install", "allow-gfonts-remove", "allow-gfonts-faces", "allow-gfonts-preview",
|
||||
"allow-theme-scopes-get", "allow-theme-scopes-set", "allow-theme-preview",
|
||||
// Installing themes/plugins (links, dropped files, the signed store; owner-approved 2026-09-28):
|
||||
// only the shell, after its confirmation screen. X tabs and web pages cannot install anything.
|
||||
"allow-install-pending", "allow-install-stage-link", "allow-install-stage-file", "allow-install-cancel",
|
||||
"allow-install-commit", "allow-install-records", "allow-install-forget", "allow-plugin-remove",
|
||||
"allow-store-catalog", "allow-store-stage",
|
||||
] {
|
||||
assert!(perms(&local).iter().any(|p| p == name), "shell lacks {name}");
|
||||
assert!(!perms(&x).iter().chain(perms(&tabs).iter()).any(|p| p == name), "tab has {name}");
|
||||
}
|
||||
for p in perms(&local).iter().chain(perms(&x).iter()).chain(perms(&tabs).iter()) {
|
||||
assert!(
|
||||
!p.starts_with("updater:") && !p.starts_with("process:") && !p.starts_with("notification:") && !p.starts_with("deep-link:"),
|
||||
"plugin API exposed: {p}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_accepts_only_known_presets_and_hex_accents() {
|
||||
let script = |theme: XTheme| theme.script(&X_THEMES, &SITE);
|
||||
let valid = XTheme { preset: "night-ink".into(), accent: Some("#1d9bf0".into()), ..x_default() };
|
||||
let text = script(valid).unwrap();
|
||||
assert!(text.contains("location.origin!=='https://x.com'"));
|
||||
assert!(text.contains(r#""preset":"night-ink""#));
|
||||
assert!(x_default().is_default(&X_THEMES));
|
||||
for preset in ["x", "classic", "night-ink", "terminal", "paper"] {
|
||||
assert!(script(XTheme { preset: preset.into(), ..x_default() }).is_ok());
|
||||
}
|
||||
for hostile in ["evil", "x;display:none", "</script>", ""] {
|
||||
assert!(script(XTheme { preset: hostile.into(), ..x_default() }).is_err(), "accepted {hostile}");
|
||||
}
|
||||
for hostile in ["red", "#fff", "url(https://evil.test)", "#000000;display:none", "</style><script>", "#00000é"] {
|
||||
assert!(script(XTheme { accent: Some(hostile.into()), ..x_default() }).is_err(), "accepted {hostile}");
|
||||
}
|
||||
assert!(serde_json::from_str::<XTheme>(r#"{"preset":"x","css":"body{}"}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_keeps_macos_targets_and_signed_updater() {
|
||||
let conf: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
let targets = conf["bundle"]["targets"].as_array().unwrap();
|
||||
for target in ["nsis", "app", "dmg"] {
|
||||
assert!(targets.iter().any(|t| t == target), "missing bundle target {target}");
|
||||
}
|
||||
assert!(!conf["plugins"]["updater"]["pubkey"].as_str().unwrap_or("").is_empty());
|
||||
for endpoint in conf["plugins"]["updater"]["endpoints"].as_array().unwrap() {
|
||||
assert!(endpoint.as_str().unwrap().starts_with("https://"), "updater endpoint must be https");
|
||||
}
|
||||
// One URL scheme, only for install links; the installer/Info.plist register exactly this.
|
||||
assert_eq!(conf["plugins"]["deep-link"]["desktop"]["schemes"], serde_json::json!(["waybetter"]));
|
||||
assert!(conf["plugins"]["deep-link"].get("mobile").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_sync_workspace_palette_and_dev_commands_are_shell_only() {
|
||||
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
|
||||
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
|
||||
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
|
||||
let x = perms(&read(include_str!("../capabilities/x-capture.json")));
|
||||
let tabs = perms(&read(include_str!("../capabilities/tab-chrome.json")));
|
||||
let popover = perms(&read(include_str!("../capabilities/drafts-popover.json")));
|
||||
// Settings backup/sync (files and the sync key stay native), workspaces, running a plugin's
|
||||
// palette command in a tab, and developer mode (file watcher, hot reload).
|
||||
for name in [
|
||||
"allow-tab-run-command", "allow-workspaces-get", "allow-workspaces-set",
|
||||
"allow-backup-collect", "allow-backup-export-file", "allow-backup-open-file", "allow-backup-apply",
|
||||
"allow-sync-status", "allow-sync-choose-folder", "allow-sync-unlock", "allow-sync-lock", "allow-sync-disable",
|
||||
"allow-sync-configure", "allow-sync-push", "allow-sync-check", "allow-sync-open", "allow-sync-mark-pulled",
|
||||
"allow-dev-get", "allow-dev-set", "allow-dev-pick-theme", "allow-dev-clear-theme", "allow-dev-reload-plugin", "allow-dev-report-error",
|
||||
] {
|
||||
assert!(local.iter().any(|p| p == name), "shell lacks {name}");
|
||||
assert!(!x.iter().chain(tabs.iter()).chain(popover.iter()).any(|p| p == name), "a tab or the popover has {name}");
|
||||
}
|
||||
// X tabs report their plugins' commands only through tab_report (owner-approved 2026-09-28);
|
||||
// the tab capabilities gained nothing for it.
|
||||
assert_eq!(tabs, ["allow-tab-report", "allow-tab-shortcut", "allow-tab-link-drag"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_commands_are_shell_only() {
|
||||
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
|
||||
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
|
||||
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
|
||||
let others: Vec<String> = [
|
||||
include_str!("../capabilities/x-capture.json"),
|
||||
include_str!("../capabilities/tab-chrome.json"),
|
||||
include_str!("../capabilities/drafts-popover.json"),
|
||||
]
|
||||
.iter()
|
||||
.flat_map(|json| perms(&read(json)))
|
||||
.collect();
|
||||
// The shell picks the language and hands native code and X tabs their strings; tabs and the
|
||||
// popover only ever receive them (X tabs as data through the runtime), never set them.
|
||||
for command in ["allow-i18n-get", "allow-i18n-set"] {
|
||||
assert!(local.iter().any(|p| p == command), "shell lacks {command}");
|
||||
assert!(!others.iter().any(|p| p == command), "a tab or the popover has {command}");
|
||||
}
|
||||
// The commands are the framework's (waybetter-host `app`); each checks shell_only first.
|
||||
for command in ["i18n_get", "i18n_set"] {
|
||||
waybetter_host::testkit::assert_guarded(include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs"), command, "shell_only(&webview)?");
|
||||
}
|
||||
// X tabs get the strings only as a guarded data value, and only the runtime's own prefixes.
|
||||
let source = include_str!("../src/desktop_i18n.rs");
|
||||
assert!(source.contains(r#"&["x.", "kit.", "plugin."]"#), "X tabs receive only x./kit./plugin. strings");
|
||||
assert!(include_str!("../src/desktop.rs").contains("page_string_prefixes: crate::desktop_i18n::PAGE_PREFIXES"), "X's config hands tabs exactly those");
|
||||
let strings = waybetter_host::i18n::NativeStrings {
|
||||
locale: "tr".into(),
|
||||
preference: "site".into(),
|
||||
messages: [("x.fab.title", "wayBetter"), ("kit.ok", "Tamam"), ("native.notify.alert.title", "Uyarı")]
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.to_owned(), value.to_owned()))
|
||||
.collect(),
|
||||
};
|
||||
let script = waybetter_host::i18n::page_script(&better_twttr::site::SITE, &strings.page_payload(&["x.", "kit.", "plugin."]));
|
||||
assert!(script.starts_with("if(location.origin==='https://x.com')("), "page strings must be x.com only: {script}");
|
||||
assert!(script.contains("x.fab.title") && script.contains("kit.ok") && !script.contains("native.notify"), "{script}");
|
||||
// "Follow X": a tab may report X's <html lang>, only as a valid tag and only while on x.com.
|
||||
let desktop = include_str!("../src/desktop.rs");
|
||||
assert!(
|
||||
desktop.contains("report.lang.filter(|lang| waybetter_host::i18n::valid_tag(lang) && webview.url().is_ok_and(|url| policy::x_origin(&url)))"),
|
||||
"tab_report lang must be a validated tag from a webview on x.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_strings_are_plain_text() {
|
||||
use waybetter_host::i18n::NativeStrings;
|
||||
let set = |value: &str| NativeStrings {
|
||||
locale: "ar".into(),
|
||||
preference: "site".into(),
|
||||
messages: [("x.fab.title".to_owned(), value.to_owned())].into_iter().collect(),
|
||||
};
|
||||
assert!(set("wayBetter لـ X").validated().is_ok());
|
||||
for bad in ["<img src=x onerror=alert(1)>", "<script>", "javascript:alert(1)", "a\u{202e}b"] {
|
||||
assert!(set(bad).validated().is_err(), "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_raise_is_shell_only() {
|
||||
let read = |json: &str| serde_json::from_str::<serde_json::Value>(json).unwrap();
|
||||
let perms = |v: &serde_json::Value| v["permissions"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_owned()).collect::<Vec<_>>();
|
||||
let local = perms(&read(include_str!("../capabilities/workspace-local.json")));
|
||||
let x = perms(&read(include_str!("../capabilities/x-capture.json")));
|
||||
let tabs = perms(&read(include_str!("../capabilities/tab-chrome.json")));
|
||||
let popover = perms(&read(include_str!("../capabilities/drafts-popover.json")));
|
||||
// Re-orders the shell webview over the tabs for its own overlays: only the shell may.
|
||||
assert!(local.iter().any(|p| p == "allow-shell-raise"), "shell lacks allow-shell-raise");
|
||||
assert!(!x.iter().chain(tabs.iter()).chain(popover.iter()).any(|p| p == "allow-shell-raise"), "a tab or the popover can raise the shell");
|
||||
// The command is the framework's (waybetter-host `app`).
|
||||
waybetter_host::testkit::assert_guarded(include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs"), "shell_raise", "shell_only(&webview)?");
|
||||
waybetter_host::testkit::Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities")).assert_shell_only("shell_raise");
|
||||
}
|
||||
|
||||
/// The wayBetter host's own commands (tab layout and actions, shell raise, external links, window
|
||||
/// menu, profiles, language; tab shortcuts and link drags) keep their callers in X's capability
|
||||
/// files, and every command wrapper checks its caller first. Every capability file is read, so a
|
||||
/// new one cannot quietly widen them.
|
||||
#[test]
|
||||
fn host_commands_keep_their_callers() {
|
||||
use waybetter_host::testkit::{assert_guarded, assert_host_guards, Capabilities};
|
||||
let caps = Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities"));
|
||||
caps.assert_host_commands();
|
||||
assert_host_guards();
|
||||
// Only Rust drives the plugins; no capability exposes their JS APIs.
|
||||
caps.assert_no_plugin_apis(&["updater", "process", "notification", "deep-link", "single-instance", "window-state", "opener", "dialog"]);
|
||||
// X's own: vault, settings, themes, installs, backups, sync, dev mode, updates are the shell's.
|
||||
for command in [
|
||||
"draft_list", "draft_save", "draft_revisions", "draft_status", "observation_list", "data_export",
|
||||
"gfonts_installed", "gfonts_install", "gfonts_remove", "gfonts_faces", "gfonts_preview",
|
||||
"tab_run_command", "workspaces_get", "workspaces_set", "backup_collect", "backup_export_file", "backup_open_file", "backup_apply",
|
||||
"sync_status", "sync_choose_folder", "sync_unlock", "sync_lock", "sync_disable", "sync_configure", "sync_push", "sync_check", "sync_open", "sync_mark_pulled",
|
||||
"dev_get", "dev_set", "dev_pick_theme", "dev_clear_theme", "dev_reload_plugin", "dev_report_error",
|
||||
"plugins_list", "plugin_set_enabled", "plugins_open_folder", "plugin_remove",
|
||||
"install_pending", "install_stage_link", "install_stage_file", "install_stage_opened", "install_cancel", "install_commit", "install_records", "install_forget", "store_catalog", "store_stage",
|
||||
"custom_css_get", "custom_css_set", "features_get", "features_set", "mode_get", "mode_set", "settings_get", "settings_set", "column_configure",
|
||||
"update_status", "update_check", "apply_update", "app_ready", "tab_insert_text", "alerts_get", "alerts_set",
|
||||
"media_get", "media_set", "gallery_add_folder", "gallery_list", "gallery_thumb", "clip_list", "clip_thumb", "clip_remove", "tab_attach_image",
|
||||
] {
|
||||
caps.assert_shell_only(command);
|
||||
}
|
||||
// Tabs, about themselves only; the X-only ones are granted only on https://x.com.
|
||||
for command in ["tab_report", "capture_draft", "open_panel", "drafts_picker_open", "drafts_popover_dismiss", "drafts_hint", "keyword_alert", "link_preview"] {
|
||||
caps.assert_tab_only(command);
|
||||
}
|
||||
let desktop = include_str!("../src/desktop.rs");
|
||||
assert_guarded(desktop, "tab_insert_text", "shell_only(&webview)?");
|
||||
// The vault commands are the host's: shell-only in the capability files and each checking its caller
|
||||
// first; X's own handler no longer has them, and X turns the vault on with its rules.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::VAULT);
|
||||
waybetter_host::testkit::assert_vault_guards();
|
||||
assert!(desktop.contains(".with_vault("), "the app enables the host's vault");
|
||||
for command in waybetter_host::commands::VAULT {
|
||||
assert!(!desktop.contains(&format!("fn {command}(")), "{command} is served by the host only");
|
||||
}
|
||||
// The updater commands are the shell's too, each checking its caller first; `update_check` runs the
|
||||
// timer's own check once (no other endpoint), and tabs have no updater command at all.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::UPDATE);
|
||||
waybetter_host::testkit::assert_update_guards();
|
||||
assert!(desktop.contains(".with_updates("), "the app enables the updater");
|
||||
// Plugins, custom CSS, the tweak flags and the browser settings are served by the host: they
|
||||
// are in its shell-only list (checked against the capability files and for their caller
|
||||
// check above), and X's own handler no longer has them.
|
||||
let host = include_str!("../../../../waybetter/crates/waybetter-host/src/app.rs");
|
||||
for command in ["plugins_list", "plugin_set_enabled", "plugins_open_folder", "custom_css_get", "custom_css_set", "features_get", "features_set", "mode_get", "mode_set", "settings_get", "settings_set"] {
|
||||
assert!(waybetter_host::commands::SHELL.contains(&command), "{command} is a host shell-only command");
|
||||
assert_guarded(host, command, "shell_only(&webview)?");
|
||||
assert!(!desktop.contains(&format!("fn {command}(")), "{command} is served by the host only");
|
||||
}
|
||||
assert_guarded(desktop, "tab_report", "tab_only(&webview)?");
|
||||
// Opening the panel from a page is the host's site command: only X tabs on x.com have it.
|
||||
caps.assert_site_only("open_panel", &better_twttr::site::SITE);
|
||||
assert!(!desktop.contains("fn open_panel("), "open_panel is served by the host only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_store_media_and_font_commands_are_shell_only() {
|
||||
// The store's commands are the framework's (waybetter-host `store_commands`, served because
|
||||
// this app sets `AppConfig::store`): shell-only in the capability files, and each wrapper
|
||||
// checks shell_only before anything else.
|
||||
use waybetter_host::testkit::assert_guarded;
|
||||
let caps = waybetter_host::testkit::Capabilities::from_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/capabilities"));
|
||||
caps.assert_shell_only_group(waybetter_host::commands::STORE);
|
||||
waybetter_host::testkit::assert_store_guards();
|
||||
// The media panel's commands are the framework's too (AppConfig::with_media): shell-only, each
|
||||
// checking its caller first; only attaching to X's composer is the app's own.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::MEDIA);
|
||||
waybetter_host::testkit::assert_media_guards();
|
||||
// Workspaces and encrypted sync too (AppConfig::with_sync; workspaces are served for every app).
|
||||
caps.assert_shell_only_group(waybetter_host::commands::WORKSPACES);
|
||||
caps.assert_shell_only_group(waybetter_host::commands::SYNC);
|
||||
waybetter_host::testkit::assert_sync_guards();
|
||||
// The settings backup's commands are the framework's too (what it carries is the app's hooks).
|
||||
caps.assert_shell_only_group(waybetter_host::commands::BACKUP);
|
||||
waybetter_host::testkit::assert_backup_guards();
|
||||
// Developer mode (AppConfig::with_dev): the framework's commands; the theme file is the app's hook.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::DEV);
|
||||
waybetter_host::testkit::assert_dev_guards();
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_dev()"), "the app enables developer mode");
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_sync()"), "the app enables sync");
|
||||
// Site themes too (AppConfig::with_themes): the framework's commands; X only names its built-in themes.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::THEME);
|
||||
waybetter_host::testkit::assert_theme_guards();
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_themes(&crate::theme::THEMES)"), "the app configures its themes");
|
||||
// The keyword alert settings (AppConfig::with_alerts): the framework's commands; the hit and its notification are X's.
|
||||
caps.assert_shell_only_group(waybetter_host::commands::ALERTS);
|
||||
waybetter_host::testkit::assert_alerts_guards();
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_alerts(&alerts::ALERTS)"), "the app configures its alerts");
|
||||
// Google Fonts too (AppConfig::with_fonts).
|
||||
caps.assert_shell_only_group(waybetter_host::commands::GFONTS);
|
||||
waybetter_host::testkit::assert_fonts_guards();
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_fonts()"), "the app enables Google Fonts");
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_media()"), "the app enables the media panel");
|
||||
assert_guarded(include_str!("../src/desktop_media.rs"), "tab_attach_image", "shell_only(&webview)?");
|
||||
let source = include_str!("../../../../waybetter/crates/waybetter-host/src/store_commands.rs");
|
||||
// Links only ever propose an install: the deep-link handler queues and emits, nothing more.
|
||||
let received = &source[source.find("fn received(").unwrap()..];
|
||||
let received = &received[..received.find("\n}").unwrap()];
|
||||
assert!(!received.contains("commit") && !received.contains("stage"), "a link must not stage or install by itself");
|
||||
// The app's store settings: X's id and the framework's hosts; the compiled-in key; and it asks
|
||||
// the framework to serve the store.
|
||||
let settings = include_str!("../src/distribution.rs");
|
||||
assert!(settings.contains("app_id: \"x\"") && settings.contains("allowed_hosts: DEFAULT_HOSTS") && settings.contains("include_str!(\"../keys/store.pub\")"));
|
||||
assert!(include_str!("../src/desktop.rs").contains(".with_store(&crate::distribution::STORE)"), "the app configures the store");
|
||||
}
|
||||
|
||||
/// The popover's webview and session are the host's; X's commands reached by the popover each check
|
||||
/// their caller first (the popover webview with the open session), and inserting a draft only
|
||||
/// focuses the tab and types (`compose` scripts), never clicks Post.
|
||||
#[test]
|
||||
fn popover_commands_check_their_caller_and_never_post() {
|
||||
use waybetter_host::testkit::assert_guarded;
|
||||
let source = include_str!("../src/desktop_popover.rs");
|
||||
assert_guarded(source, "popover_state", "host::session_of(&webview)");
|
||||
for command in ["popover_drafts", "popover_insert", "popover_open_vault"] {
|
||||
assert_guarded(source, command, "host::current(&webview,");
|
||||
}
|
||||
// popover_close only acts for the current popover, and does nothing otherwise.
|
||||
let close = &source[source.find("fn popover_close(").unwrap()..];
|
||||
let close = &close[..close.find("
|
||||
}").unwrap()];
|
||||
assert!(close.contains("host::current(&webview, seq).is_ok()"));
|
||||
let insert = &source[source.find("fn popover_insert(").unwrap()..];
|
||||
let insert = &insert[..insert.find("
|
||||
}").unwrap()];
|
||||
assert!(insert.contains("compose::insert_script") && insert.contains("compose::append_script"));
|
||||
assert!(!insert.contains("click") && !insert.contains("submit") && !insert.contains("Post"), "popover_insert never posts");
|
||||
assert!(insert.contains("policy::x_origin(&url)"), "only into X tabs");
|
||||
// The host's popover check: only a `popover-<n>` webview on the app's own local origin.
|
||||
let host = include_str!("../../../../waybetter/crates/waybetter-host/src/popover_host.rs");
|
||||
let only = &host[host.find("pub fn popover_only(").unwrap()..];
|
||||
let only = &only[..only.find("
|
||||
}").unwrap()];
|
||||
assert!(only.contains("popover::valid_label(webview.label())") && only.contains("local_navigation_allowed"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export const FEATURES: readonly FeatureSpec[] = [
|
|||
{ id: 'chat.searchLoaded', surfaces: CHAT, heavy: true, ...words('searchLoaded'), keywords: 'find search in conversation ctrl shift f messages', permissions: ['ui', 'commands'] },
|
||||
{ id: 'chat.pins', surfaces: CHAT, heavy: true, ...words('pins'), keywords: 'pin pinned label color chats conversations strip', permissions: ['storage', 'ui', 'commands'] },
|
||||
{ id: 'chat.personFilter', surfaces: CHAT, heavy: true, ...words('personFilter'), keywords: 'group chat person participant filter only from', permissions: ['storage', 'ui', 'commands'] },
|
||||
// On by default (owner, 2026-10-10): asks native code for the preview of a link on screen (link_preview.rs).
|
||||
{ id: 'chat.linkPreviews', surfaces: CHAT, heavy: true, default: true, ...words('linkPreviews'), keywords: 'link preview embed youtube card unfurl', permissions: ['ui'] },
|
||||
{ id: 'chat.keywords', surfaces: CHAT, heavy: true, settings: true, ...words('keywords'), keywords: 'highlight mute words keywords hide messages', permissions: ['storage', 'ui'] },
|
||||
]),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@
|
|||
// X's chat is the one place the rest of the app stays out of (a heavy page). These modules run
|
||||
// there on purpose and keep it cheap: one shared watch each (child list only, one frame), looking
|
||||
// only at X's message list or its conversation list. ./x.ts is what they know about X's chat.
|
||||
// None of them makes a request, writes or sends a message, reacts, deletes or marks anything read.
|
||||
// None of them writes or sends a message, reacts, deletes or marks anything read. The one request is
|
||||
// Link previews' (owner-approved, 2026-10-10): the link alone, to native code, which fetches its preview.
|
||||
|
||||
import type { FeatureModule } from '../../../../../waybetter/packages/runtime/src/featureModules.ts';
|
||||
import type { PluginApi } from '../../inject/pluginApi.ts';
|
||||
import { bookmarks } from './bookmarks.ts';
|
||||
import { keywords } from './keywords.ts';
|
||||
import { linkPreviews } from './linkPreviews.ts';
|
||||
import { personFilter } from './personFilter.ts';
|
||||
import { pins } from './pins.ts';
|
||||
import { searchLoaded } from './searchLoaded.ts';
|
||||
|
|
@ -21,4 +23,5 @@ export const MODULES: ReadonlyArray<FeatureModule<PluginApi>> = [
|
|||
pins,
|
||||
personFilter,
|
||||
keywords,
|
||||
linkPreviews,
|
||||
];
|
||||
|
|
|
|||
229
apps/desktop/src/areas/chat/linkPreviews.ts
Normal file
229
apps/desktop/src/areas/chat/linkPreviews.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
// flag-controlled parts and the palette command report; panelButton.ts keeps the way into the
|
||||
// panel on the page). This file is X's: which parts exist, X's own members (alerts, fixes)
|
||||
// and how the "wayBetter" item joins X's navigation.
|
||||
import { setLinkPreviewBridge } from '../areas/chat/linkPreviews.ts';
|
||||
import { installFeatures, type FeatureConfig } from './features';
|
||||
import { ALL_MODULES } from '../areas/modules';
|
||||
import { surfacesOf } from '../areas/surfaces';
|
||||
|
|
@ -88,6 +89,8 @@ function install() {
|
|||
// This runtime only ships inside the app's X tabs; automated test browsers have no bridge, and
|
||||
// the features that need it stay off there.
|
||||
const invoke = pageInvoke(window, LABEL);
|
||||
// Chat+ link previews ask native code for one link's preview (the only feature module with a bridge).
|
||||
setLinkPreviewBridge(invoke as Parameters<typeof setLinkPreviewBridge>[0]);
|
||||
|
||||
// Flag-controlled fixes and tweaks: installed on enable, disposed on disable. The first ones wrap
|
||||
// fetch like the mutation gate: installed at document start, before X's bundle runs.
|
||||
|
|
|
|||
|
|
@ -61,11 +61,11 @@ const pace = (chat: FakeChat) => ({ sleep: (ms: number) => new Promise<void>(res
|
|||
const star = (row: El) => row.querySelector('.bt-chat-star')!;
|
||||
const group = (chat: FakeChat) => chat.conversations.get(GROUP)!.history;
|
||||
|
||||
test('Chat+: five features, each its own flag, all off by default, all allowed on X\'s chat pages', () => {
|
||||
assert.deepEqual(FEATURES.map(feature => feature.id), ['chat.bookmarks', 'chat.searchLoaded', 'chat.pins', 'chat.personFilter', 'chat.keywords']);
|
||||
test('Chat+: six features, each its own flag, off by default but link previews (owner, 2026-10-10), all allowed on X\'s chat pages', () => {
|
||||
assert.deepEqual(FEATURES.map(feature => feature.id), ['chat.bookmarks', 'chat.searchLoaded', 'chat.pins', 'chat.personFilter', 'chat.linkPreviews', 'chat.keywords']);
|
||||
assert.deepEqual(MODULES.map(module => module.id).sort(), FEATURES.map(feature => feature.id).sort());
|
||||
for (const feature of FEATURES) {
|
||||
assert.equal(feature.default, false, feature.id);
|
||||
assert.equal(feature.default, feature.id === 'chat.linkPreviews', feature.id);
|
||||
assert.equal(feature.heavy, true, `${feature.id} runs on the heavy chat pages`);
|
||||
assert.ok(feature.permissions && !feature.permissions.includes('network'), `${feature.id}: no network`);
|
||||
}
|
||||
|
|
@ -175,7 +175,7 @@ test('starred messages: stored in full, valid ones only, each once; the list fil
|
|||
});
|
||||
|
||||
test('Starred messages: a star on every rendered row; a click stores exactly that message, in full; off leaves nothing', () => {
|
||||
const { env, chat, stored } = page(`/i/chat/${GROUP}`, { 'chat.bookmarks': true });
|
||||
const { env, chat, stored } = page(`/i/chat/${GROUP}`, { 'chat.bookmarks': true, 'chat.linkPreviews': false });
|
||||
assert.deepEqual(env.registry.active(), ['chat.bookmarks']);
|
||||
const all = group(chat);
|
||||
const rows = chat.rendered();
|
||||
|
|
|
|||
|
|
@ -133,6 +133,8 @@ const DEFAULT_ON = [
|
|||
'logoBrush', 'twitterWording', 'singleSplitNav',
|
||||
'fixLikeFollow', 'fixScrollRestore', 'fixTimelineJump', 'fixHomeTab', 'fixNotificationBadge', 'fixNewPostsPill', 'fixMediaUpload', 'followingChronological', 'fixChatButton', 'composerRestorePrompt',
|
||||
'composerDrafts', 'hidePromotedPosts', 'mediaDownload', 'rememberVideoVolume', 'searchOperators', 'notifications.keywordAlerts',
|
||||
// On by default by the owner's choice (2026-10-10): Chat+ link previews.
|
||||
'chat.linkPreviews',
|
||||
];
|
||||
/** What Advanced adds: the calm features that came with the area plugins (they add something; none hides or changes X). */
|
||||
const ADVANCED_ADDS = [
|
||||
|
|
@ -166,7 +168,7 @@ test('modes: the exact flags of Basic, Advanced and Power User; an existing setu
|
|||
assert.deepEqual(on(defaults), [...DEFAULT_ON].sort());
|
||||
assert.deepEqual(running(null), [...DEFAULT_ON].sort());
|
||||
for (const [flag, value] of Object.entries(FEATURE_DEFAULTS)) assert.equal(defaults[flag], value, flag);
|
||||
for (const feature of X_FEATURES) if (!(feature.id in FEATURE_DEFAULTS) && feature.id !== 'notifications.keywordAlerts' && feature.backing !== 'plugin') assert.equal(defaults[feature.id], false, `${feature.id} is new: off by default`);
|
||||
for (const feature of X_FEATURES) if (!(feature.id in FEATURE_DEFAULTS) && feature.id !== 'notifications.keywordAlerts' && feature.id !== 'chat.linkPreviews' && feature.backing !== 'plugin') assert.equal(defaults[feature.id], false, `${feature.id} is new: off by default`);
|
||||
for (const row of supersededFiles(X_TABLE)) assert.equal(defaults[row.flag], false, `${row.flag}: what came from an opt-in plugin stays off`);
|
||||
|
||||
// Basic: every area off (so everything an area owns is off), no logo or wording; the fixes and split-view navigation stay.
|
||||
|
|
|
|||
48
apps/desktop/tests/chat-link-previews.test.ts
Normal file
48
apps/desktop/tests/chat-link-previews.test.ts
Normal 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'/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue