wayBetter for X
This commit is contained in:
commit
df5dff619e
720 changed files with 147465 additions and 0 deletions
145
apps/desktop/src-tauri/src/alerts.rs
Normal file
145
apps/desktop/src-tauri/src/alerts.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
//! Local-only notifications: X's keyword hits on the wayBetter host's keyword alerts (settings,
|
||||
//! matching, snippets, rate limiting: `waybetter_host::alerts`). Nothing here touches the network;
|
||||
//! matches come from responses X's own UI already fetched.
|
||||
|
||||
use serde::Deserialize;
|
||||
pub use waybetter_host::alerts::*;
|
||||
|
||||
/// A match reported by an X tab about a tweet it already received.
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KeywordHit {
|
||||
pub keyword: String,
|
||||
pub tweet_id: String,
|
||||
pub author: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl KeywordMatch for KeywordHit {
|
||||
fn item_id(&self) -> &str {
|
||||
&self.tweet_id
|
||||
}
|
||||
fn keyword(&self) -> &str {
|
||||
&self.keyword
|
||||
}
|
||||
}
|
||||
|
||||
/// A verified keyword hit, ready for a notification in the owner's language.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AlertMessage {
|
||||
pub keyword: String,
|
||||
/// The post's author handle (without @), empty when unknown.
|
||||
pub author: String,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
/// Validates a page-reported hit against the owner's settings. The notification text is built from
|
||||
/// the result by the caller, in the owner's language (desktop_i18n.rs).
|
||||
pub fn check_hit(settings: &AlertSettings, hit: &KeywordHit) -> Result<AlertMessage, String> {
|
||||
let keyword = settings.configured(&hit.keyword)?;
|
||||
if hit.tweet_id.is_empty() || hit.tweet_id.len() > 25 || !hit.tweet_id.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err("VALIDATION: invalid tweet id".into());
|
||||
}
|
||||
if hit.author.len() > 32 || !hit.author.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
|
||||
return Err("VALIDATION: invalid author".into());
|
||||
}
|
||||
let snippet = snippet(&hit.text, &keyword)?;
|
||||
Ok(AlertMessage { keyword, author: hit.author.clone(), snippet })
|
||||
}
|
||||
|
||||
/// Only storage-level capture failures (the text was not saved) are worth a desktop notification;
|
||||
/// validation, conflicts and quota are expected page behavior.
|
||||
pub fn capture_failure_notifies(error: &str) -> bool {
|
||||
error.starts_with("DATABASE:") || error.starts_with("STORAGE:")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn settings(words: &[&str]) -> AlertSettings {
|
||||
AlertSettings { enabled: true, keywords: words.iter().map(|w| w.to_string()).collect() }
|
||||
.validated()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn hit(keyword: &str, id: &str, text: &str) -> KeywordHit {
|
||||
KeywordHit { keyword: keyword.into(), tweet_id: id.into(), author: "jack".into(), text: text.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_normalize_and_dedupe() {
|
||||
let s = settings(&[" Rust Lang ", "rust lang", "", "Tauri"]);
|
||||
assert_eq!(s.keywords, vec!["rust lang".to_string(), "tauri".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_reject_bad_keywords() {
|
||||
assert!(AlertSettings { enabled: true, keywords: vec!["a".into()] }.validated().is_err());
|
||||
assert!(AlertSettings { enabled: true, keywords: vec!["x".repeat(65)] }.validated().is_err());
|
||||
let many = AlertSettings { enabled: true, keywords: (0..33).map(|i| format!("kw{i}")).collect() };
|
||||
assert!(many.validated().is_err());
|
||||
assert!(AlertSettings { enabled: true, keywords: vec!["a\u{7}b".into()] }.validated().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_settings_watch_nothing() {
|
||||
let mut s = settings(&["tauri"]);
|
||||
s.enabled = false;
|
||||
assert!(s.active_keywords().is_empty());
|
||||
assert!(check_hit(&s, &hit("tauri", "1", "tauri rocks")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_must_match_configured_keyword_and_text() {
|
||||
let s = settings(&["tauri"]);
|
||||
let message = check_hit(&s, &hit("TAURI", "123", "I love\nTauri 2")).unwrap();
|
||||
assert_eq!(message.keyword, "tauri");
|
||||
assert_eq!(message.author, "jack");
|
||||
assert_eq!(message.snippet, "I love Tauri 2");
|
||||
assert!(check_hit(&s, &hit("electron", "123", "electron")).is_err());
|
||||
assert!(check_hit(&s, &hit("tauri", "123", "nothing here")).is_err());
|
||||
assert!(check_hit(&s, &hit("tauri", "12a", "tauri")).is_err());
|
||||
let mut bad = hit("tauri", "1", "tauri");
|
||||
bad.author = "<script>".into();
|
||||
assert!(check_hit(&s, &bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_is_truncated() {
|
||||
let s = settings(&["tauri"]);
|
||||
let text = format!("tauri {}", "a".repeat(500));
|
||||
let message = check_hit(&s, &hit("tauri", "1", &text)).unwrap();
|
||||
assert_eq!(message.snippet.chars().count(), MAX_SNIPPET_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn throttle_limits_per_key_and_globally() {
|
||||
let mut t = Throttle::new(Duration::from_secs(60), Duration::from_secs(600), 3);
|
||||
let now = Instant::now();
|
||||
assert!(t.allow("a", now));
|
||||
assert!(!t.allow("a", now + Duration::from_secs(30)));
|
||||
assert!(t.allow("a", now + Duration::from_secs(61)));
|
||||
assert!(t.allow("b", now + Duration::from_secs(62)));
|
||||
assert!(!t.allow("c", now + Duration::from_secs(63)));
|
||||
assert!(t.allow("c", now + Duration::from_secs(601)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_notifies_each_tweet_once() {
|
||||
let mut gate = AlertGate::default();
|
||||
let now = Instant::now();
|
||||
assert!(gate.allow(&hit("tauri", "1", "tauri"), now));
|
||||
assert!(!gate.allow(&hit("tauri", "1", "tauri"), now + Duration::from_secs(120)));
|
||||
assert!(gate.allow(&hit("tauri", "2", "tauri"), now + Duration::from_secs(120)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_failures_that_notify() {
|
||||
assert!(capture_failure_notifies("DATABASE: disk full"));
|
||||
assert!(!capture_failure_notifies("VALIDATION: invalid capture payload"));
|
||||
assert!(!capture_failure_notifies("CONFLICT: stale capture sequence"));
|
||||
assert!(!capture_failure_notifies("QUOTA: too many writes"));
|
||||
}
|
||||
}
|
||||
121
apps/desktop/src-tauri/src/areas.rs
Normal file
121
apps/desktop/src-tauri/src/areas.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//! Generated from `apps/desktop/src/areas/table.ts` by `scripts/build-areas.ts`. Do not edit:
|
||||
//! change the table and run the script (tests/areas.test.ts compares them).
|
||||
//!
|
||||
//! What native code needs of the feature table: the bundled plugin files that became features of
|
||||
//! an area plugin ([`SUPERSEDED`]: the one-time migration and the backup import turn their
|
||||
//! enabled state into the flag), and every flag the table defines ([`FLAGS`]).
|
||||
|
||||
use waybetter_host::plugins::migrate::Superseded;
|
||||
|
||||
/// Old first-party plugin files and the feature flag that replaced each.
|
||||
pub const SUPERSEDED: &[Superseded] = &[
|
||||
Superseded { file: "bt-alt-text-reminder.plugin.js", flag: "compose.altTextReminder" },
|
||||
Superseded { file: "bt-reply-guard.plugin.js", flag: "compose.replyGuard" },
|
||||
Superseded { file: "bt-clean-links.plugin.js", flag: "reader.cleanLinks" },
|
||||
Superseded { file: "bt-copy-as-markdown.plugin.js", flag: "reader.copyAsMarkdown" },
|
||||
Superseded { file: "bt-exact-timestamps.plugin.js", flag: "reader.exactTimestamps" },
|
||||
Superseded { file: "bt-hide-blue-checks.plugin.js", flag: "reader.collapseVerified" },
|
||||
Superseded { file: "bt-muted-regex.plugin.js", flag: "reader.mutedRegex" },
|
||||
Superseded { file: "bt-reading-time.plugin.js", flag: "reader.readingTime" },
|
||||
Superseded { file: "bt-thread-reader.plugin.js", flag: "reader.threadReader" },
|
||||
Superseded { file: "bt-user-notes.plugin.js", flag: "profile.userNotes" },
|
||||
Superseded { file: "bt-media-grid-focus.plugin.js", flag: "media.gridFocus" },
|
||||
Superseded { file: "bt-focus-mode.plugin.js", flag: "navigation.focusMode" },
|
||||
Superseded { file: "bt-keyboard-plus.plugin.js", flag: "navigation.keyboardPlus" },
|
||||
];
|
||||
|
||||
/// Every flag of the table: the features and each area's switch (`area.<id>`).
|
||||
pub const FLAGS: &[&str] = &[
|
||||
"logoBrush",
|
||||
"replaceLogo",
|
||||
"twitterWording",
|
||||
"singleSplitNav",
|
||||
"fixLikeFollow",
|
||||
"fixScrollRestore",
|
||||
"fixTimelineJump",
|
||||
"fixHomeTab",
|
||||
"fixNotificationBadge",
|
||||
"fixNewPostsPill",
|
||||
"fixMediaUpload",
|
||||
"followingChronological",
|
||||
"fixChatButton",
|
||||
"composerRestorePrompt",
|
||||
"composerDrafts",
|
||||
"compose.altTextReminder",
|
||||
"compose.replyGuard",
|
||||
"compose.postingAs",
|
||||
"compose.lengthMeter",
|
||||
"compose.snippets",
|
||||
"hidePromotedPosts",
|
||||
"followingTimelineDefault",
|
||||
"hideForYouTab",
|
||||
"reader.readMarker",
|
||||
"reader.languageFilter",
|
||||
"hideGrok",
|
||||
"hideViewCounts",
|
||||
"hideEngagementCounts",
|
||||
"hideBookmarkButton",
|
||||
"disableTweetTextFormatting",
|
||||
"sortRepliesRecent",
|
||||
"fullNumbers",
|
||||
"hideVerifiedBadges",
|
||||
"reader.cleanLinks",
|
||||
"reader.copyAsMarkdown",
|
||||
"reader.exactTimestamps",
|
||||
"reader.collapseVerified",
|
||||
"reader.mutedRegex",
|
||||
"reader.quickMute",
|
||||
"reader.readingTime",
|
||||
"reader.threadReader",
|
||||
"hideProfileWhoToFollow",
|
||||
"hideSubscribeButtons",
|
||||
"profile.userNotes",
|
||||
"profile.privateLabels",
|
||||
"profile.accountAge",
|
||||
"profile.hoverPeek",
|
||||
"chat.bookmarks",
|
||||
"chat.searchLoaded",
|
||||
"chat.pins",
|
||||
"chat.personFilter",
|
||||
"chat.keywords",
|
||||
"mediaDownload",
|
||||
"rememberVideoVolume",
|
||||
"disableVideoAutoplay",
|
||||
"stopVideoLoop",
|
||||
"media.videoSpeed",
|
||||
"media.pictureInPicture",
|
||||
"media.gridFocus",
|
||||
"media.altText",
|
||||
"media.blurMedia",
|
||||
"searchOperators",
|
||||
"hideExploreContent",
|
||||
"search.savedSearches",
|
||||
"search.advancedSearch",
|
||||
"search.latestDefault",
|
||||
"notifications.keywordAlerts",
|
||||
"notifications.kindFilters",
|
||||
"notifications.keywordHighlight",
|
||||
"notifications.openMentions",
|
||||
"hidePremiumNav",
|
||||
"hideChatNav",
|
||||
"hideCommunitiesNav",
|
||||
"hideGrokNav",
|
||||
"hideSidebarTrends",
|
||||
"hideWhoToFollow",
|
||||
"hideLiveOnX",
|
||||
"hideTodaysNews",
|
||||
"hidePremiumUpsells",
|
||||
"hideRelevantPeople",
|
||||
"hideSidebar",
|
||||
"fullWidthTimeline",
|
||||
"navigation.focusMode",
|
||||
"navigation.keyboardPlus",
|
||||
"area.compose",
|
||||
"area.reader",
|
||||
"area.profile",
|
||||
"area.chat",
|
||||
"area.media",
|
||||
"area.search",
|
||||
"area.notifications",
|
||||
"area.navigation",
|
||||
];
|
||||
241
apps/desktop/src-tauri/src/backup.rs
Normal file
241
apps/desktop/src-tauri/src/backup.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
//! Settings backup: everything the owner set up, in one versioned `.waybetter` JSON file, and the
|
||||
//! import side's validation. Pure (an app-data directory in, JSON out), unit-tested headless.
|
||||
//!
|
||||
//! Native sections (read here from app data): `themes` (global theme incl. imported themes, and the
|
||||
//! account/pane theme map), `plugins` (enabled states + the owner's own plugin files: every file
|
||||
//! that is not an untouched copy of a shipped one), `customCss`, `features` (X tweaks), `settings`
|
||||
//! (links, home page, session restore), `alerts` (keyword alerts), `accounts` (profile ids, names
|
||||
//! and colors only) and `workspaces`. The shell adds `decks` (its own storage) and, only when the
|
||||
//! owner ticks "include drafts", the desktop layer adds `drafts` (text, no ids).
|
||||
//!
|
||||
//! Never included: WebView profile folders (cookies, X sessions, local storage), the session
|
||||
//! database, a signing key an earlier version left behind (`profile-owner.json`), the sync key, update/hotfix state,
|
||||
//! Google Font files, gallery/clipboard data. Only the files named in [`NATIVE_FILES`] are read,
|
||||
//! and [`strip_secrets`] removes secret-looking keys from whatever is exported or imported as a
|
||||
//! second line of defence. The envelope, the scrubbing and the plugin/account sections are the
|
||||
//! wayBetter host's (`waybetter_host::backup`); this module registers X's sections and files.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::Path;
|
||||
use waybetter_host::backup::{accounts_section, object_section, plugin_sections, read_json_file, scrubbed};
|
||||
pub use waybetter_host::backup::{
|
||||
plugins_section, strip_secrets, write_atomic, PluginFile, EXTENSION, FORMAT, MAX_BACKUP_BYTES, MAX_PLUGIN_FILES, VERSION,
|
||||
};
|
||||
|
||||
pub const MAX_DRAFTS: usize = 5000;
|
||||
|
||||
// App-data file names (the same constants as in desktop.rs / workspaces.rs / plugins).
|
||||
pub const THEME_FILE: &str = "x-theme.v2.json";
|
||||
pub const THEME_SCOPES_FILE: &str = "x-theme-scopes.json";
|
||||
pub const FEATURES_FILE: &str = "features.json";
|
||||
pub const SETTINGS_FILE: &str = "settings.json";
|
||||
pub const ALERTS_FILE: &str = "alerts.json";
|
||||
pub const PROFILES_FILE: &str = "profiles.json";
|
||||
/// The only app-data files a backup reads (plus `plugins/`: `*.plugin.js`, `enabled.json`, `custom.css`).
|
||||
pub const NATIVE_FILES: [&str; 7] = [THEME_FILE, THEME_SCOPES_FILE, FEATURES_FILE, SETTINGS_FILE, ALERTS_FILE, PROFILES_FILE, crate::workspaces::FILE];
|
||||
|
||||
/// Sections in the order the import preview shows them.
|
||||
pub const SECTIONS: [&str; 11] = ["themes", "plugins", "customCss", "features", "settings", "alerts", "accounts", "workspaces", "decks", "drafts", "shell"];
|
||||
|
||||
/// The native sections of a backup, read from app-data directory `dir`. `shipped` are the plugin
|
||||
/// files the app writes itself (bundled + example): untouched copies of those are left out.
|
||||
pub fn collect_native(dir: &Path, shipped: &[(&str, &str)]) -> Map<String, Value> {
|
||||
let mut sections = Map::new();
|
||||
let theme = read_json_file(&dir.join(THEME_FILE));
|
||||
let scopes = read_json_file(&dir.join(THEME_SCOPES_FILE));
|
||||
sections.insert("themes".into(), json!({ "theme": theme, "scopes": scopes }));
|
||||
let (plugins, css) = plugin_sections(&dir.join("plugins"), shipped);
|
||||
sections.insert("plugins".into(), plugins);
|
||||
sections.insert("customCss".into(), css);
|
||||
sections.insert("features".into(), object_section(&dir.join(FEATURES_FILE), json!({})));
|
||||
sections.insert("settings".into(), object_section(&dir.join(SETTINGS_FILE), json!({})));
|
||||
sections.insert("alerts".into(), object_section(&dir.join(ALERTS_FILE), json!({ "enabled": false, "keywords": [] })));
|
||||
// Account list metadata only: which profiles exist, their names and colors.
|
||||
sections.insert("accounts".into(), accounts_section(&dir.join(PROFILES_FILE)));
|
||||
let workspaces = read_json_file(&dir.join(crate::workspaces::FILE)).unwrap_or_else(|| json!({ "version": 1, "default": null, "items": [] }));
|
||||
sections.insert("workspaces".into(), workspaces);
|
||||
scrubbed(sections)
|
||||
}
|
||||
|
||||
/// A backup written before a bundled plugin became a feature of an area plugin names that plugin's
|
||||
/// file in its enabled list. `superseded` maps it: the name leaves the list and its flag is turned
|
||||
/// on in the flags to apply, unless the backup carries the owner's own copy of that file (then it
|
||||
/// stays a plugin and the built-in feature is left alone). `features` is the backup's own flag
|
||||
/// section when it has one; otherwise the flags are turned on over `current`. Returns the enabled
|
||||
/// list and, when any flag has to be applied, the flag map.
|
||||
pub fn map_superseded(
|
||||
enabled: Vec<String>,
|
||||
files: &[PluginFile],
|
||||
features: Option<Map<String, Value>>,
|
||||
current: &Map<String, Value>,
|
||||
superseded: &[waybetter_host::plugins::migrate::Superseded],
|
||||
) -> (Vec<String>, Option<Map<String, Value>>) {
|
||||
let carried: Vec<String> = files.iter().map(|file| file.file.clone()).collect();
|
||||
let (enabled, flags_on) = waybetter_host::plugins::migrate::map_backup(enabled, &carried, superseded);
|
||||
if flags_on.is_empty() {
|
||||
return (enabled, features);
|
||||
}
|
||||
let mut map = features.unwrap_or_else(|| current.clone());
|
||||
for flag in flags_on {
|
||||
map.insert(flag, Value::Bool(true));
|
||||
}
|
||||
(enabled, Some(map))
|
||||
}
|
||||
|
||||
/// A draft as a backup carries it: text and what it was about, no ids, accounts or history.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct BackupDraft {
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub context_url: String,
|
||||
#[serde(default = "active")]
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
fn active() -> String {
|
||||
"active".into()
|
||||
}
|
||||
|
||||
/// Checks the envelope of a backup file and returns its sections (secret-looking keys removed).
|
||||
pub fn parse_backup(bytes: &[u8]) -> Result<Map<String, Value>, String> {
|
||||
waybetter_host::backup::parse_backup(bytes, &SECTIONS)
|
||||
}
|
||||
|
||||
pub fn drafts_section(value: &Value) -> Result<Vec<BackupDraft>, String> {
|
||||
let drafts: Vec<BackupDraft> = serde_json::from_value(value.get("drafts").cloned().unwrap_or(json!([]))).map_err(|_| "BACKUP: invalid drafts")?;
|
||||
if drafts.len() > MAX_DRAFTS {
|
||||
return Err("BACKUP: too many drafts".into());
|
||||
}
|
||||
for draft in &drafts {
|
||||
if !["post", "reply", "quote", "thread"].contains(&draft.kind.as_str()) || !["active", "archived"].contains(&draft.status.as_str()) {
|
||||
return Err("BACKUP: invalid draft".into());
|
||||
}
|
||||
}
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
const SECRETS: [&str; 6] = ["SESSION-COOKIE-auth_token=abc123", "ct0-CSRF-SECRET", "ED25519-PRIVATE-SEED", "SYNC-KEY-BASE64", "hunter2-password", "Bearer AAAA-TOKEN"];
|
||||
|
||||
/// An app-data folder with everything a real one has, secrets included.
|
||||
fn fixture() -> tempfile::TempDir {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path();
|
||||
let write = |name: &str, text: &str| {
|
||||
let path = dir.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
};
|
||||
write(THEME_FILE, r##"{"preset":"nord","accent":"#88c0d0"}"##);
|
||||
write(THEME_SCOPES_FILE, r#"{"chrome":"theme","profiles":{"work":"paper"},"panes":{}}"#);
|
||||
write(FEATURES_FILE, r#"{"hideViewCounts":true}"#);
|
||||
write(SETTINGS_FILE, r#"{"homeUrl":"https://x.com/home","externalLinks":"pane","xLinks":"same","restoreTabs":true}"#);
|
||||
write(ALERTS_FILE, r#"{"enabled":true,"keywords":["rust"]}"#);
|
||||
write(PROFILES_FILE, r##"{"profiles":[{"id":"default","name":"Main","color":"#1d9bf0","account":{"handle":"otheraccount","name":"P","avatar":null}},{"id":"work","name":"Work","color":"#f91880","authToken":"Bearer AAAA-TOKEN"}],"active":"work"}"##);
|
||||
write(crate::workspaces::FILE, r#"{"version":1,"default":null,"items":[]}"#);
|
||||
write("plugins/enabled.json", r#"["mine.plugin.js","bt-focus-mode.plugin.js"]"#);
|
||||
write("plugins/mine.plugin.js", "/** @name Mine */ module.exports = class { start(){} };");
|
||||
write("plugins/bt-focus-mode.plugin.js", "shipped focus");
|
||||
write("plugins/bt-edited.plugin.js", "edited by the owner");
|
||||
write("plugins/custom.css", "body{color:red}");
|
||||
// Things a backup must never read.
|
||||
write("profiles/work/EBWebView/Default/Network/Cookies", SECRETS[0]);
|
||||
write("profiles/work/EBWebView/Default/Local Storage/leveldb/000003.log", SECRETS[1]);
|
||||
write("profile-owner.json", SECRETS[2]);
|
||||
write("sync-key.json", SECRETS[3]);
|
||||
write("workspace.sqlite3", SECRETS[4]);
|
||||
write("hotfix.json", "{}");
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_every_setting_and_no_secret() {
|
||||
let root = fixture();
|
||||
let shipped = [("bt-focus-mode.plugin.js", "shipped focus"), ("bt-edited.plugin.js", "shipped edited")];
|
||||
let sections = collect_native(root.path(), &shipped);
|
||||
let text = serde_json::to_string(§ions).unwrap();
|
||||
for secret in SECRETS {
|
||||
assert!(!text.contains(secret), "backup leaked {secret}");
|
||||
}
|
||||
for word in ["cookie", "authToken", "EBWebView", "sqlite", "profile-owner"] {
|
||||
assert!(!text.to_lowercase().contains(&word.to_lowercase()), "backup mentions {word}");
|
||||
}
|
||||
assert_eq!(sections["themes"]["theme"]["preset"], "nord");
|
||||
assert_eq!(sections["themes"]["scopes"]["profiles"]["work"], "paper");
|
||||
assert_eq!(sections["features"]["hideViewCounts"], true);
|
||||
assert_eq!(sections["settings"]["externalLinks"], "pane");
|
||||
assert_eq!(sections["alerts"]["keywords"][0], "rust");
|
||||
assert_eq!(sections["customCss"]["css"], "body{color:red}");
|
||||
assert_eq!(sections["accounts"], json!({ "profiles": [
|
||||
{ "id": "default", "name": "Main", "color": "#1d9bf0" }, { "id": "work", "name": "Work", "color": "#f91880" }
|
||||
], "active": "work" }), "names and colors only: no observed account, no token");
|
||||
let files: Vec<&str> = sections["plugins"]["files"].as_array().unwrap().iter().map(|file| file["file"].as_str().unwrap()).collect();
|
||||
assert_eq!(files, ["bt-edited.plugin.js", "mine.plugin.js"], "own and edited plugins; untouched shipped ones stay out");
|
||||
assert_eq!(sections["plugins"]["enabled"], json!(["mine.plugin.js", "bt-focus-mode.plugin.js"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_backup_turns_replaced_plugins_into_their_flags() {
|
||||
use crate::areas::SUPERSEDED;
|
||||
let names = |list: &[&str]| list.iter().map(|name| (*name).to_owned()).collect::<Vec<_>>();
|
||||
let current = json!({ "hideSidebar": true }).as_object().cloned().unwrap();
|
||||
// The backup names Focus Mode (as shipped then: no copy in the backup) and a plugin of the owner's.
|
||||
let (enabled, features) = map_superseded(names(&["bt-focus-mode.plugin.js", "mine.plugin.js"]), &[], None, ¤t, SUPERSEDED);
|
||||
assert_eq!(enabled, ["mine.plugin.js"]);
|
||||
assert_eq!(Value::Object(features.unwrap()), json!({ "hideSidebar": true, "navigation.focusMode": true }), "no flag section in the backup: on top of the current flags");
|
||||
// With a flag section, the flag joins it (the section replaces the flags, as before).
|
||||
let section = json!({ "hideViewCounts": true }).as_object().cloned();
|
||||
let (_, features) = map_superseded(names(&["bt-keyboard-plus.plugin.js"]), &[], section, ¤t, SUPERSEDED);
|
||||
assert_eq!(Value::Object(features.unwrap()), json!({ "hideViewCounts": true, "navigation.keyboardPlus": true }));
|
||||
// The backup carries the owner's edited copy: it stays a plugin, the built-in feature stays off.
|
||||
let copy = [PluginFile { file: "bt-focus-mode.plugin.js".into(), source: "my focus".into() }];
|
||||
let (enabled, features) = map_superseded(names(&["bt-focus-mode.plugin.js"]), ©, None, ¤t, SUPERSEDED);
|
||||
assert_eq!((enabled, features), (names(&["bt-focus-mode.plugin.js"]), None));
|
||||
// Nothing replaced is named: nothing changes.
|
||||
let (enabled, features) = map_superseded(names(&["bt-emoji-picker.plugin.js"]), &[], None, ¤t, SUPERSEDED);
|
||||
assert_eq!((enabled.len(), features), (1, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_through_a_backup_file() {
|
||||
let root = fixture();
|
||||
let sections = collect_native(root.path(), &[]);
|
||||
let file = json!({ "format": FORMAT, "version": VERSION, "createdAt": "2026-09-29T10:00:00Z", "sections": sections });
|
||||
let parsed = parse_backup(&serde_json::to_vec(&file).unwrap()).unwrap();
|
||||
assert_eq!(Value::Object(parsed.clone()), Value::Object(sections));
|
||||
let (enabled, files) = plugins_section(&parsed["plugins"]).unwrap();
|
||||
assert_eq!(enabled.len(), 2);
|
||||
assert_eq!(files.len(), 3);
|
||||
// A theme from the backup validates as the app's own theme state.
|
||||
let theme: crate::theme::XTheme = serde_json::from_value(parsed["themes"]["theme"].clone()).unwrap();
|
||||
assert!(theme.validate(&crate::theme::X_THEMES).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_refuses_foreign_newer_or_hostile_files_and_strips_secrets() {
|
||||
assert!(parse_backup(b"not json").unwrap_err().contains("line 1"));
|
||||
assert!(parse_backup(br#"{"format":"other","version":1,"sections":{}}"#).is_err());
|
||||
assert!(parse_backup(br#"{"format":"waybetter-backup","version":99,"sections":{}}"#).unwrap_err().contains("newer"));
|
||||
let hostile = json!({ "format": FORMAT, "version": 1, "sections": {
|
||||
"settings": { "homeUrl": "https://x.com/home", "sessionId": "s", "nested": { "Cookie": "c", "api_token": "t" } },
|
||||
"unknownSection": { "x": 1 }
|
||||
} });
|
||||
let parsed = parse_backup(&serde_json::to_vec(&hostile).unwrap()).unwrap();
|
||||
assert_eq!(Value::Object(parsed), json!({ "settings": { "homeUrl": "https://x.com/home", "nested": {} } }));
|
||||
assert!(plugins_section(&json!({ "files": [{ "file": "../evil.plugin.js", "source": "" }] })).is_err());
|
||||
assert!(plugins_section(&json!({ "enabled": ["C:\\x.plugin.js"] })).is_err());
|
||||
assert!(plugins_section(&json!({ "files": [{ "file": "a.plugin.js", "source": "", "extra": 1 }] })).is_err());
|
||||
assert!(drafts_section(&json!({ "drafts": [{ "kind": "dm", "text": "x" }] })).is_err(), "no DM drafts");
|
||||
assert!(drafts_section(&json!({ "drafts": [{ "kind": "post", "text": "x", "id": "d1" }] })).is_err(), "no ids");
|
||||
assert_eq!(drafts_section(&json!({ "drafts": [{ "kind": "post", "text": "hello" }] })).unwrap()[0].status, "active");
|
||||
}
|
||||
}
|
||||
127
apps/desktop/src-tauri/src/capture.rs
Normal file
127
apps/desktop/src-tauri/src/capture.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
use crate::{
|
||||
db::Store,
|
||||
model::{CaptureAck, CaptureInput, SaveDraftInput},
|
||||
policy::{valid_identifier, validate_context, MAX_CAPTURE_TEXT_BYTES},
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct Mapping {
|
||||
draft_id: String,
|
||||
last_input: CaptureInput,
|
||||
revision: i64,
|
||||
}
|
||||
|
||||
pub struct CaptureSession {
|
||||
nonce: String,
|
||||
mappings: HashMap<String, Mapping>,
|
||||
recent_writes: VecDeque<Instant>,
|
||||
}
|
||||
|
||||
impl Default for CaptureSession {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nonce: Uuid::new_v4().to_string(),
|
||||
mappings: HashMap::new(),
|
||||
recent_writes: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CaptureSession {
|
||||
pub fn reset(&mut self) {
|
||||
self.nonce = Uuid::new_v4().to_string();
|
||||
self.mappings.clear();
|
||||
// Keep rate limiting across navigations. Navigation is not a quota escape.
|
||||
}
|
||||
|
||||
/// Drafts this tab's live composers are writing right now.
|
||||
pub fn draft_ids(&self) -> impl Iterator<Item = &str> {
|
||||
self.mappings.values().map(|mapping| mapping.draft_id.as_str())
|
||||
}
|
||||
|
||||
pub fn write(&mut self, store: &mut Store, input: CaptureInput) -> Result<CaptureAck, String> {
|
||||
if !valid_identifier(&input.composer_id)
|
||||
|| input.sequence == 0
|
||||
|| input.sequence > 9_007_199_254_740_991
|
||||
|| !["post", "reply", "quote"].contains(&input.kind.as_str())
|
||||
|| input.text.len() > MAX_CAPTURE_TEXT_BYTES
|
||||
{
|
||||
return Err("VALIDATION: invalid capture payload".into());
|
||||
}
|
||||
validate_context(&input.context_url, true)?;
|
||||
let mapping = self.mappings.get(&input.composer_id);
|
||||
if let Some(existing) = mapping {
|
||||
if input.sequence < existing.last_input.sequence {
|
||||
return Err("CONFLICT: stale capture sequence".into());
|
||||
}
|
||||
if input.sequence == existing.last_input.sequence {
|
||||
return if input == existing.last_input {
|
||||
Ok(CaptureAck {
|
||||
sequence: input.sequence,
|
||||
revision: existing.revision,
|
||||
})
|
||||
} else {
|
||||
Err("CONFLICT: capture sequence reused with different content".into())
|
||||
};
|
||||
}
|
||||
} else if self.mappings.len() >= 200 {
|
||||
return Err("QUOTA: too many composers in this page session".into());
|
||||
}
|
||||
let instant = Instant::now();
|
||||
while self
|
||||
.recent_writes
|
||||
.front()
|
||||
.is_some_and(|old| instant.duration_since(*old) >= Duration::from_secs(60))
|
||||
{
|
||||
self.recent_writes.pop_front();
|
||||
}
|
||||
if self.recent_writes.len() >= 240 {
|
||||
return Err("QUOTA: capture write rate exceeded; retry later".into());
|
||||
}
|
||||
// A local edit/archive takes ownership of the old draft. Keep capturing into
|
||||
// a fresh native-owned draft rather than overwriting it or losing future text.
|
||||
let reusable = match mapping {
|
||||
Some(entry) => store
|
||||
.get_draft(&entry.draft_id)?
|
||||
.filter(|draft| {
|
||||
draft.revision == entry.revision
|
||||
&& draft.source == "capture"
|
||||
&& draft.status == "active"
|
||||
})
|
||||
.map(|_| entry),
|
||||
None => None,
|
||||
};
|
||||
let draft_id = reusable.map_or_else(
|
||||
|| format!("capture-{}-{}", self.nonce, Uuid::new_v4()),
|
||||
|entry| entry.draft_id.clone(),
|
||||
);
|
||||
let saved = store.save_capture(&SaveDraftInput {
|
||||
id: draft_id.clone(),
|
||||
account_id: "unassigned-x".into(),
|
||||
kind: input.kind.clone(),
|
||||
title: "Captured from X".into(),
|
||||
text: input.text.clone(),
|
||||
context_url: input.context_url.clone(),
|
||||
expected_revision: reusable.map_or(0, |entry| entry.revision),
|
||||
source: "capture".into(),
|
||||
})?;
|
||||
let ack = CaptureAck {
|
||||
sequence: input.sequence,
|
||||
revision: saved.revision,
|
||||
};
|
||||
self.mappings.insert(
|
||||
input.composer_id.clone(),
|
||||
Mapping {
|
||||
draft_id,
|
||||
last_input: input,
|
||||
revision: saved.revision,
|
||||
},
|
||||
);
|
||||
self.recent_writes.push_back(instant);
|
||||
Ok(ack)
|
||||
}
|
||||
}
|
||||
116
apps/desktop/src-tauri/src/column.rs
Normal file
116
apps/desktop/src-tauri/src/column.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
//! Power User Mode (deck columns), X side: which feature flags a column may override, who may
|
||||
//! configure a column and which unread reports are accepted. Headless, so the rules are tested
|
||||
//! without the desktop feature; `desktop.rs` wires them to the Tauri commands.
|
||||
//!
|
||||
//! Owner-approved (2026-09-28): `tab_report` may carry `unread` (a number about the tab itself,
|
||||
//! capped at 999), accepted only from `tab-*` webviews that are on x.com right now; the shell-only
|
||||
//! `column_configure` hands a column its config (validated here, data only).
|
||||
|
||||
use crate::{policy, site::SITE};
|
||||
use url::Url;
|
||||
use waybetter_host::tabs::{self, ColumnConfig};
|
||||
|
||||
/// Feature flags a column may set over the global ones (see `src/inject/features.ts`). Only
|
||||
/// display and playback flags plus the navigation-only Following tab switch; none of them sends
|
||||
/// anything to X.
|
||||
pub const COLUMN_FLAGS: &[&str] = &[
|
||||
"disableVideoAutoplay",
|
||||
"stopVideoLoop",
|
||||
"hidePromotedPosts",
|
||||
"hideEngagementCounts",
|
||||
"hideViewCounts",
|
||||
"hideVerifiedBadges",
|
||||
"fullNumbers",
|
||||
"fixTimelineJump",
|
||||
"fixNewPostsPill",
|
||||
"followingTimelineDefault",
|
||||
"disableTweetTextFormatting",
|
||||
];
|
||||
|
||||
/// `column_configure`: only the trusted local shell may configure a column, and only with a
|
||||
/// bounded config for a page on x.com. `None` removes the column's config.
|
||||
pub fn configure(caller_label: &str, caller_url: &Url, development: bool, id: &str, config: Option<ColumnConfig>) -> Result<Option<ColumnConfig>, String> {
|
||||
policy::require_local(caller_label, caller_url, development)?;
|
||||
if !policy::valid_tab_id(id) {
|
||||
return Err("VALIDATION: invalid tab id".into());
|
||||
}
|
||||
config.map(|config| config.validated(&SITE, COLUMN_FLAGS)).transpose()
|
||||
}
|
||||
|
||||
/// An unread count reported by a tab: accepted only from a `tab-*` webview on exactly x.com, capped.
|
||||
pub fn accept_unread(label: &str, url: &Url, unread: Option<u64>) -> Option<u16> {
|
||||
let unread = unread?;
|
||||
policy::require_capture(label, url).ok()?;
|
||||
Some(tabs::cap_unread(unread))
|
||||
}
|
||||
|
||||
/// The script that hands `config` (or null) to the column module of the X runtime, parked in
|
||||
/// `__btColumn` until the runtime exists.
|
||||
pub fn column_script(config: Option<&ColumnConfig>) -> String {
|
||||
let json = serde_json::to_string(&config).unwrap_or_else(|_| "null".into());
|
||||
tabs::deliver_script(SITE.runtime_global, "column", "set", "__btColumn", &json)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn url(value: &str) -> Url {
|
||||
Url::parse(value).unwrap()
|
||||
}
|
||||
|
||||
fn config() -> ColumnConfig {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"url": "https://x.com/notifications", "kind": "notifications", "density": "compact",
|
||||
"flags": { "disableVideoAutoplay": true, "hidePromotedPosts": true }
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_configure_is_shell_only() {
|
||||
let shell = url("http://tauri.localhost/");
|
||||
assert!(configure("shell", &shell, false, "col1", Some(config())).unwrap().is_some());
|
||||
assert!(configure("shell", &shell, false, "col1", None).unwrap().is_none(), "null removes");
|
||||
// An X tab (or any other webview) can never configure a column, not even its own.
|
||||
for (label, from) in [("tab-col1", "https://x.com/home"), ("popover-1", "http://tauri.localhost/"), ("shell", "https://x.com/")] {
|
||||
assert!(configure(label, &url(from), false, "col1", Some(config())).is_err(), "{label} {from}");
|
||||
}
|
||||
assert!(configure("shell", &shell, false, "bad id", Some(config())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_flags_are_allow_listed() {
|
||||
let shell = url("http://tauri.localhost/");
|
||||
let mut bad = config();
|
||||
bad.flags.insert("fixLikeFollow".into(), false);
|
||||
assert!(configure("shell", &shell, false, "c", Some(bad)).is_err(), "the like/follow fix is not a column flag");
|
||||
let mut other_site = config();
|
||||
other_site.url = "https://twitter.com/home".into();
|
||||
assert!(configure("shell", &shell, false, "c", Some(other_site)).is_err());
|
||||
for flag in COLUMN_FLAGS {
|
||||
assert!(!flag.contains("Follow") || *flag == "followingTimelineDefault", "no follow actions: {flag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unread_is_accepted_only_from_x_tabs_and_capped() {
|
||||
let x = url("https://x.com/home");
|
||||
assert_eq!(accept_unread("tab-c1", &x, Some(4)), Some(4));
|
||||
assert_eq!(accept_unread("tab-c1", &x, Some(5000)), Some(999));
|
||||
assert_eq!(accept_unread("tab-c1", &x, None), None);
|
||||
assert_eq!(accept_unread("shell", &x, Some(4)), None);
|
||||
assert_eq!(accept_unread("popover-1", &x, Some(4)), None);
|
||||
for bad in ["https://example.com/", "https://twitter.com/home", "http://x.com/", "https://www.x.com/"] {
|
||||
assert_eq!(accept_unread("tab-c1", &url(bad), Some(4)), None, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_script_parks_the_config_until_the_runtime_exists() {
|
||||
let script = column_script(Some(&config()));
|
||||
assert!(script.starts_with("(()=>{const v={\"url\":\"https://x.com/notifications\""));
|
||||
assert!(script.contains("R.column.set(v);else window.__btColumn=v;"));
|
||||
assert!(column_script(None).contains("const v=null;"));
|
||||
}
|
||||
}
|
||||
95
apps/desktop/src-tauri/src/compose.rs
Normal file
95
apps/desktop/src-tauri/src/compose.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! Restoring a vault draft into an X composer. The shell picks the draft; native code builds a
|
||||
//! self-contained script that only focuses the composer and inserts text. It never clicks or
|
||||
//! submits anything: the user reviews and posts the text themselves.
|
||||
|
||||
use crate::policy::MAX_CAPTURE_TEXT_BYTES;
|
||||
|
||||
/// Attribute the injected Drafts button puts on the composer it was clicked from.
|
||||
pub const TARGET_ATTRIBUTE: &str = "data-bt-draft-target";
|
||||
|
||||
const INSERT_TEMPLATE: &str = r#"(()=>{try{const append=__APPEND__;let text=__TEXT__;const attr='__ATTR__';
|
||||
const sel='[data-testid^="tweetTextarea_"][role="textbox"][contenteditable="true"]';
|
||||
if(location.origin!=='https://x.com'||/^\/(?:messages|i\/(?:chat|grok))(?:\/|$)/i.test(location.pathname))return;
|
||||
const all=[...document.querySelectorAll(sel)];
|
||||
const active=document.activeElement;
|
||||
const editor=all.find(e=>e.hasAttribute(attr))||all.find(e=>active&&e.contains(active))||(all.length===1?all[0]:null);
|
||||
for(const e of document.querySelectorAll('['+attr+']'))e.removeAttribute(attr);
|
||||
if(!editor)return;
|
||||
editor.focus();
|
||||
const selection=window.getSelection();const range=document.createRange();range.selectNodeContents(editor);
|
||||
if(append){if(editor.innerText.trim())text='\n\n'+text;range.collapse(false);}
|
||||
selection.removeAllRanges();selection.addRange(range);
|
||||
if(!document.execCommand('insertText',false,text)){const data=new DataTransfer();data.setData('text/plain',text);
|
||||
editor.dispatchEvent(new ClipboardEvent('paste',{clipboardData:data,bubbles:true,cancelable:true}));}
|
||||
}catch(error){console.error('[wayBetter for X] draft insert failed',error);}})();"#;
|
||||
|
||||
/// Builds the insert script that replaces the composer's text. The text is embedded as a JSON
|
||||
/// string literal, which is a valid JavaScript expression, so draft content can never break out
|
||||
/// into code.
|
||||
pub fn insert_script(text: &str) -> Result<String, String> {
|
||||
script(text, false)
|
||||
}
|
||||
|
||||
/// Like [`insert_script`], but keeps the composer's text and adds the draft after a blank line.
|
||||
pub fn append_script(text: &str) -> Result<String, String> {
|
||||
script(text, true)
|
||||
}
|
||||
|
||||
fn script(text: &str, append: bool) -> Result<String, String> {
|
||||
if text.is_empty() || text.len() > MAX_CAPTURE_TEXT_BYTES {
|
||||
return Err("VALIDATION: draft text must be 1-65536 bytes".into());
|
||||
}
|
||||
let literal = serde_json::to_string(text).map_err(|_| "VALIDATION: unencodable text")?;
|
||||
// U+2028/2029 are valid in JSON and ES2019 strings; escape them for older engines anyway.
|
||||
let literal = literal.replace('\u{2028}', "\\u2028").replace('\u{2029}', "\\u2029");
|
||||
Ok(INSERT_TEMPLATE
|
||||
.replace("__ATTR__", TARGET_ATTRIBUTE)
|
||||
.replacen("__APPEND__", if append { "true" } else { "false" }, 1)
|
||||
.replacen("__TEXT__", &literal, 1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn text_is_a_json_literal() {
|
||||
let script = insert_script("hi \"there\" `x` ${y} </script> \u{2028}").unwrap();
|
||||
assert!(script.contains(r#"let text="hi \"there\" `x` ${y} </script> \u2028";"#));
|
||||
assert!(!script.contains("__TEXT__"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_in_text_is_not_expanded() {
|
||||
let script = insert_script("__ATTR__ __TEXT__ __APPEND__").unwrap();
|
||||
assert!(script.contains(r#"let text="__ATTR__ __TEXT__ __APPEND__";"#));
|
||||
assert!(script.contains("const append=false;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_submits() {
|
||||
let script = insert_script("x").unwrap();
|
||||
assert!(!script.contains("tweetButton"));
|
||||
assert!(!script.contains(".click("));
|
||||
assert!(!script.contains("submit"));
|
||||
assert!(script.contains("insertText"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_keeps_the_composer_text() {
|
||||
let replace = insert_script("x").unwrap();
|
||||
let append = append_script("x").unwrap();
|
||||
assert!(replace.contains("const append=false;"));
|
||||
assert!(append.contains("const append=true;"));
|
||||
assert!(append.contains("range.collapse(false)"));
|
||||
assert!(!append.contains(".click("));
|
||||
assert!(append_script("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_and_oversized() {
|
||||
assert!(insert_script("").is_err());
|
||||
assert!(insert_script(&"a".repeat(MAX_CAPTURE_TEXT_BYTES + 1)).is_err());
|
||||
assert!(insert_script(&"a".repeat(MAX_CAPTURE_TEXT_BYTES)).is_ok());
|
||||
}
|
||||
}
|
||||
325
apps/desktop/src-tauri/src/db.rs
Normal file
325
apps/desktop/src-tauri/src/db.rs
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
use crate::model::{Draft, DraftRevision, ExportData, Observation, SaveDraftInput};
|
||||
use crate::policy::{valid_identifier, validate_context, MAX_TEXT_BYTES};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use rusqlite::{params, Connection, OptionalExtension, Row, TransactionBehavior};
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, String>;
|
||||
const SELECT_DRAFT: &str = "SELECT id,account_id,kind,title,text,context_url,revision,status,created_at,updated_at,source FROM drafts";
|
||||
const CAPTURE_BUDGET_BYTES: i64 = 64 * 1024 * 1024;
|
||||
const CAPTURE_BUDGET_REVISIONS: i64 = 50_000;
|
||||
|
||||
pub fn now() -> String {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
}
|
||||
|
||||
fn sql_error(error: rusqlite::Error) -> String {
|
||||
// Database errors never include SQL parameters (draft contents).
|
||||
format!("DATABASE: {error}")
|
||||
}
|
||||
|
||||
fn draft_row(row: &Row<'_>) -> rusqlite::Result<Draft> {
|
||||
Ok(Draft {
|
||||
id: row.get(0)?,
|
||||
account_id: row.get(1)?,
|
||||
kind: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
text: row.get(4)?,
|
||||
context_url: row.get(5)?,
|
||||
revision: row.get(6)?,
|
||||
status: row.get(7)?,
|
||||
created_at: row.get(8)?,
|
||||
updated_at: row.get(9)?,
|
||||
source: row.get(10)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn revision_row(row: &Row<'_>) -> rusqlite::Result<DraftRevision> {
|
||||
Ok(DraftRevision {
|
||||
draft_id: row.get(0)?,
|
||||
revision: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
text: row.get(3)?,
|
||||
context_url: row.get(4)?,
|
||||
source: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_input(input: &SaveDraftInput) -> Result<()> {
|
||||
if !valid_identifier(&input.id) || !valid_identifier(&input.account_id) {
|
||||
return Err("VALIDATION: invalid draft or account identifier".into());
|
||||
}
|
||||
if !["post", "reply", "quote", "thread"].contains(&input.kind.as_str())
|
||||
|| !["local", "capture", "template"].contains(&input.source.as_str())
|
||||
|| !(0..9_007_199_254_740_991).contains(&input.expected_revision)
|
||||
{
|
||||
return Err("VALIDATION: invalid draft kind, source, or revision".into());
|
||||
}
|
||||
if input.title.encode_utf16().count() > 120 || input.text.len() > MAX_TEXT_BYTES {
|
||||
return Err("VALIDATION: draft size limit exceeded".into());
|
||||
}
|
||||
validate_context(&input.context_url, false)
|
||||
}
|
||||
|
||||
pub struct Store {
|
||||
connection: Connection,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let mut connection = Connection::open(path).map_err(sql_error)?;
|
||||
connection
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(sql_error)?;
|
||||
connection
|
||||
.execute_batch(
|
||||
"PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON;",
|
||||
)
|
||||
.map_err(sql_error)?;
|
||||
let version: i64 = connection
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.map_err(sql_error)?;
|
||||
if version > 1 {
|
||||
return Err("DATABASE: newer schema; use a newer application version".into());
|
||||
}
|
||||
if version == 0 {
|
||||
let tx = connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(sql_error)?;
|
||||
tx.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS drafts (
|
||||
id TEXT PRIMARY KEY, account_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('post','reply','quote','thread')),
|
||||
title TEXT NOT NULL, text TEXT NOT NULL, context_url TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK(revision > 0),
|
||||
status TEXT NOT NULL CHECK(status IN ('active','archived')),
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
source TEXT NOT NULL CHECK(source IN ('local','capture','template'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS drafts_updated ON drafts(updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS revisions (
|
||||
draft_id TEXT NOT NULL REFERENCES drafts(id), revision INTEGER NOT NULL,
|
||||
title TEXT NOT NULL, text TEXT NOT NULL, context_url TEXT NOT NULL,
|
||||
source TEXT NOT NULL, created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(draft_id,revision)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS observations (
|
||||
id TEXT PRIMARY KEY, account_id TEXT NOT NULL, user_id TEXT NOT NULL,
|
||||
following INTEGER CHECK(following IN (0,1)),
|
||||
followed_by INTEGER CHECK(followed_by IN (0,1)),
|
||||
observed_at TEXT NOT NULL, source TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS capture_usage (
|
||||
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
||||
stored_bytes INTEGER NOT NULL, revision_count INTEGER NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO capture_usage VALUES(1,0,0);
|
||||
PRAGMA user_version=1;",
|
||||
)
|
||||
.map_err(sql_error)?;
|
||||
tx.commit().map_err(sql_error)?;
|
||||
}
|
||||
Ok(Self { connection })
|
||||
}
|
||||
|
||||
pub fn list_drafts(&self) -> Result<Vec<Draft>> {
|
||||
let mut statement = self
|
||||
.connection
|
||||
.prepare(&format!("{SELECT_DRAFT} ORDER BY updated_at DESC,id"))
|
||||
.map_err(sql_error)?;
|
||||
let rows = statement.query_map([], draft_row).map_err(sql_error)?;
|
||||
rows.collect::<rusqlite::Result<_>>().map_err(sql_error)
|
||||
}
|
||||
|
||||
pub fn get_draft(&self, id: &str) -> Result<Option<Draft>> {
|
||||
self.connection
|
||||
.query_row(&format!("{SELECT_DRAFT} WHERE id=?1"), [id], draft_row)
|
||||
.optional()
|
||||
.map_err(sql_error)
|
||||
}
|
||||
|
||||
pub fn save_draft(&mut self, input: &SaveDraftInput) -> Result<Draft> {
|
||||
self.save(input, false)
|
||||
}
|
||||
|
||||
pub(crate) fn save_capture(&mut self, input: &SaveDraftInput) -> Result<Draft> {
|
||||
if input.account_id != "unassigned-x" || input.source != "capture" {
|
||||
return Err("FORBIDDEN: capture account/source are native-owned".into());
|
||||
}
|
||||
self.save(input, true)
|
||||
}
|
||||
|
||||
fn save(&mut self, input: &SaveDraftInput, capture: bool) -> Result<Draft> {
|
||||
validate_input(input)?;
|
||||
let tx = self
|
||||
.connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(sql_error)?;
|
||||
let old = tx
|
||||
.query_row(
|
||||
&format!("{SELECT_DRAFT} WHERE id=?1"),
|
||||
[&input.id],
|
||||
draft_row,
|
||||
)
|
||||
.optional()
|
||||
.map_err(sql_error)?;
|
||||
match &old {
|
||||
Some(draft) if draft.revision != input.expected_revision => {
|
||||
return Err("CONFLICT: draft changed; reload before saving".into())
|
||||
}
|
||||
Some(draft) if draft.account_id != input.account_id => {
|
||||
return Err("FORBIDDEN: draft account cannot change".into())
|
||||
}
|
||||
Some(draft) if capture && (draft.status != "active" || draft.source != "capture") => {
|
||||
return Err("CONFLICT: captured draft was edited or archived locally".into())
|
||||
}
|
||||
None if input.expected_revision != 0 => {
|
||||
return Err("CONFLICT: draft no longer matches expected revision".into())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if capture {
|
||||
let bytes =
|
||||
(input.text.len() + input.title.len() + input.context_url.len() + 256) as i64;
|
||||
let changed = tx.execute(
|
||||
"UPDATE capture_usage SET stored_bytes=stored_bytes+?1,revision_count=revision_count+1 WHERE singleton=1 AND stored_bytes+?1<=?2 AND revision_count+1<=?3",
|
||||
params![bytes, CAPTURE_BUDGET_BYTES, CAPTURE_BUDGET_REVISIONS],
|
||||
).map_err(sql_error)?;
|
||||
if changed != 1 {
|
||||
return Err(
|
||||
"QUOTA: capture storage limit reached; existing drafts are preserved".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let stamp = now();
|
||||
let draft = Draft {
|
||||
id: input.id.clone(),
|
||||
account_id: input.account_id.clone(),
|
||||
kind: input.kind.clone(),
|
||||
title: input.title.clone(),
|
||||
text: input.text.clone(),
|
||||
context_url: input.context_url.clone(),
|
||||
revision: input.expected_revision + 1,
|
||||
status: old
|
||||
.as_ref()
|
||||
.map_or("active", |draft| draft.status.as_str())
|
||||
.to_string(),
|
||||
created_at: old.map_or_else(|| stamp.clone(), |draft| draft.created_at),
|
||||
updated_at: stamp,
|
||||
source: input.source.clone(),
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT INTO drafts(id,account_id,kind,title,text,context_url,revision,status,created_at,updated_at,source)
|
||||
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)
|
||||
ON CONFLICT(id) DO UPDATE SET kind=excluded.kind,title=excluded.title,text=excluded.text,
|
||||
context_url=excluded.context_url,revision=excluded.revision,updated_at=excluded.updated_at,source=excluded.source",
|
||||
params![draft.id,draft.account_id,draft.kind,draft.title,draft.text,draft.context_url,draft.revision,draft.status,draft.created_at,draft.updated_at,draft.source],
|
||||
).map_err(sql_error)?;
|
||||
Self::append_revision(&tx, &draft)?;
|
||||
tx.commit().map_err(sql_error)?;
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
fn append_revision(connection: &Connection, draft: &Draft) -> Result<()> {
|
||||
connection.execute(
|
||||
"INSERT INTO revisions(draft_id,revision,title,text,context_url,source,created_at) VALUES(?1,?2,?3,?4,?5,?6,?7)",
|
||||
params![draft.id,draft.revision,draft.title,draft.text,draft.context_url,draft.source,draft.updated_at],
|
||||
).map_err(sql_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, id: &str, expected_revision: i64, status: &str) -> Result<Draft> {
|
||||
if !valid_identifier(id)
|
||||
|| !["active", "archived"].contains(&status)
|
||||
|| expected_revision < 1
|
||||
|| expected_revision >= 9_007_199_254_740_991
|
||||
{
|
||||
return Err("VALIDATION: invalid status update".into());
|
||||
}
|
||||
let tx = self
|
||||
.connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(sql_error)?;
|
||||
let mut draft = tx
|
||||
.query_row(&format!("{SELECT_DRAFT} WHERE id=?1"), [id], draft_row)
|
||||
.optional()
|
||||
.map_err(sql_error)?
|
||||
.ok_or("CONFLICT: draft not found")?;
|
||||
if draft.revision != expected_revision {
|
||||
return Err("CONFLICT: draft changed before status update".into());
|
||||
}
|
||||
draft.status = status.into();
|
||||
draft.revision += 1;
|
||||
draft.updated_at = now();
|
||||
tx.execute(
|
||||
"UPDATE drafts SET status=?1,revision=?2,updated_at=?3 WHERE id=?4",
|
||||
params![draft.status, draft.revision, draft.updated_at, id],
|
||||
)
|
||||
.map_err(sql_error)?;
|
||||
Self::append_revision(&tx, &draft)?;
|
||||
tx.commit().map_err(sql_error)?;
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
pub fn revisions(&self, id: &str) -> Result<Vec<DraftRevision>> {
|
||||
if !valid_identifier(id) {
|
||||
return Err("VALIDATION: invalid draft identifier".into());
|
||||
}
|
||||
let mut statement = self.connection.prepare("SELECT draft_id,revision,title,text,context_url,source,created_at FROM revisions WHERE draft_id=?1 ORDER BY revision DESC").map_err(sql_error)?;
|
||||
let rows = statement.query_map([id], revision_row).map_err(sql_error)?;
|
||||
rows.collect::<rusqlite::Result<_>>().map_err(sql_error)
|
||||
}
|
||||
|
||||
pub fn observations(&self) -> Result<Vec<Observation>> {
|
||||
// No remote observation ingestion yet: account identity has not been verified.
|
||||
let mut statement = self.connection.prepare("SELECT id,account_id,user_id,following,followed_by,observed_at,source FROM observations ORDER BY observed_at DESC,id").map_err(sql_error)?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok(Observation {
|
||||
id: row.get(0)?,
|
||||
account_id: row.get(1)?,
|
||||
user_id: row.get(2)?,
|
||||
following: row.get(3)?,
|
||||
followed_by: row.get(4)?,
|
||||
observed_at: row.get(5)?,
|
||||
source: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.map_err(sql_error)?;
|
||||
rows.collect::<rusqlite::Result<_>>().map_err(sql_error)
|
||||
}
|
||||
|
||||
pub fn export(&mut self) -> Result<ExportData> {
|
||||
// A deferred transaction gives all export tables one SQLite read snapshot.
|
||||
self.connection
|
||||
.execute_batch("BEGIN DEFERRED")
|
||||
.map_err(sql_error)?;
|
||||
let result = (|| {
|
||||
let drafts = self.list_drafts()?;
|
||||
let observations = self.observations()?;
|
||||
let mut statement = self.connection.prepare("SELECT draft_id,revision,title,text,context_url,source,created_at FROM revisions ORDER BY draft_id,revision").map_err(sql_error)?;
|
||||
let rows = statement.query_map([], revision_row).map_err(sql_error)?;
|
||||
let revisions = rows
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(sql_error)?;
|
||||
Ok(ExportData {
|
||||
version: 1,
|
||||
exported_at: now(),
|
||||
drafts,
|
||||
revisions,
|
||||
observations,
|
||||
})
|
||||
})();
|
||||
match result {
|
||||
Ok(data) => {
|
||||
self.connection.execute_batch("COMMIT").map_err(sql_error)?;
|
||||
Ok(data)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = self.connection.execute_batch("ROLLBACK");
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
861
apps/desktop/src-tauri/src/desktop.rs
Normal file
861
apps/desktop/src-tauri/src/desktop.rs
Normal file
|
|
@ -0,0 +1,861 @@
|
|||
//! The X app on the wayBetter host: its config, what only X tabs get (drafts capture and the
|
||||
//! drafts popover, themes, alerts, deck columns) and the X commands. The window,
|
||||
//! the shell webview, the tab webviews and the generic commands (tab layout and actions, shell
|
||||
//! raise, external links, profiles, language, the plugins folder and custom CSS, the tweak flags,
|
||||
//! the shell's settings) are the framework's (`waybetter_host::app::run_app`, its `PageState`);
|
||||
//! `XSite` fills in its hooks.
|
||||
|
||||
use crate::{
|
||||
alerts::{self, AlertGate, AlertSettings, KeywordHit, Throttle},
|
||||
capture::CaptureSession,
|
||||
compose,
|
||||
db::Store,
|
||||
desktop_popover,
|
||||
model::*,
|
||||
plugins, profiles,
|
||||
policy,
|
||||
site::SITE,
|
||||
theme::{x_default, ThemeScopes, XTheme, X_THEMES},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use tauri::{AppHandle, Manager, State, Webview};
|
||||
use url::Url;
|
||||
use waybetter_host::app::{self as wb, AppConfig, NewTab, SiteHooks, TabClosed};
|
||||
use waybetter_host::host::emit_tab_state as emit_state;
|
||||
pub(crate) use waybetter_host::host::{data_file, emit_shell, read_json, shell_only, tab_only, tab_webview, write_json};
|
||||
use waybetter_host::tabs::{self, ColumnConfig, TabLayout, TabRole, TabState};
|
||||
|
||||
// Settings backup/sync, workspaces, plugin palette commands and developer mode (shell-only).
|
||||
#[path = "desktop_settings.rs"]
|
||||
mod settings;
|
||||
|
||||
/// Identifier used before the Better Twttr rename; its data folders are migrated once.
|
||||
const LEGACY_IDENTIFIER: &str = "dev.xworkspace.desktop";
|
||||
/// The page for new tabs and the first launch until the owner sets another.
|
||||
const HOME_URL: &str = "https://x.com/home";
|
||||
/// Written only when the owner opts in to theming X ("Apply theme to X").
|
||||
const THEME_FILE: &str = "x-theme.v2.json";
|
||||
/// Pre-opt-in builds wrote this automatically; it must not keep recoloring X.
|
||||
const LEGACY_THEME_FILE: &str = "x-theme.json";
|
||||
/// Account and pane themes and the chrome setting (`theme::ThemeScopes`); absent = defaults.
|
||||
const THEME_SCOPES_FILE: &str = "x-theme-scopes.json";
|
||||
/// The account profiles (the framework keeps them; backups read and import them).
|
||||
const PROFILES_FILE: &str = wb::PROFILES_FILE;
|
||||
const ALERTS_FILE: &str = "alerts.json";
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// State
|
||||
|
||||
struct NativeData {
|
||||
store: Store,
|
||||
/// Composer→draft mappings per tab; navigation in a tab rotates only that tab's mappings.
|
||||
captures: HashMap<String, CaptureSession>,
|
||||
theme: Option<XTheme>,
|
||||
/// Which theme each tab shows (pane, account, global) and whether the chrome follows it.
|
||||
theme_scopes: ThemeScopes,
|
||||
/// The Theme Builder's unsaved draft, shown in every X tab until it is saved or closed.
|
||||
theme_preview: Option<XTheme>,
|
||||
/// Tab webview label -> X's left navigation repeats one already on screen (see `pane_script`).
|
||||
tab_nav_redundant: HashMap<String, bool>,
|
||||
/// Tab webview label -> its deck column config (Power User Mode; `column_configure`).
|
||||
tab_columns: HashMap<String, ColumnConfig>,
|
||||
/// Tab webview label -> the palette commands its plugins registered (`tab_report.commands`).
|
||||
tab_commands: HashMap<String, Vec<crate::palette_commands::PaletteCommand>>,
|
||||
/// Keyword alerts (local only) and their dedupe/rate limit.
|
||||
alerts: AlertSettings,
|
||||
alert_gate: AlertGate,
|
||||
/// Capture-failure notifications: at most one per tab every few minutes.
|
||||
capture_failures: Throttle,
|
||||
}
|
||||
|
||||
/// Shows a native OS notification. Rust-only: no webview is granted the notification plugin.
|
||||
pub(crate) fn notify(app: &AppHandle, title: &str, body: &str) {
|
||||
waybetter_host::alerts::notify(app, SITE.product, title, body);
|
||||
}
|
||||
|
||||
struct AppState(Mutex<NativeData>);
|
||||
|
||||
fn lock(state: &AppState) -> Result<MutexGuard<'_, NativeData>, String> {
|
||||
state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "DATABASE: workspace state unavailable; restart safely".into())
|
||||
}
|
||||
|
||||
// Caller checks (`shell_only`: the local shell webview; `tab_only`: a tab speaking about itself),
|
||||
// shell events, tab lookups and app-data JSON come from the wayBetter host
|
||||
// (`waybetter_host::host`) and are re-exported above for the other desktop modules.
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Workspace commands (shell only)
|
||||
|
||||
#[tauri::command]
|
||||
async fn draft_list(webview: Webview, state: State<'_, AppState>) -> Result<Vec<Draft>, String> {
|
||||
shell_only(&webview)?;
|
||||
lock(&state)?.store.list_drafts()
|
||||
}
|
||||
|
||||
/// Vault reads for the drafts popover (desktop_popover.rs checks the caller first).
|
||||
pub(crate) fn vault_drafts(app: &AppHandle) -> Result<Vec<Draft>, String> {
|
||||
lock(&app.state::<AppState>())?.store.list_drafts()
|
||||
}
|
||||
|
||||
pub(crate) fn vault_draft(app: &AppHandle, id: &str) -> Result<Option<Draft>, String> {
|
||||
lock(&app.state::<AppState>())?.store.get_draft(id)
|
||||
}
|
||||
|
||||
/// Whether tab `label` has a captured draft for exactly this post page that none of its live
|
||||
/// composers is writing (see `popover::has_resumable_draft`).
|
||||
pub(crate) fn resumable_draft(app: &AppHandle, label: &str, url: &Url) -> Result<bool, String> {
|
||||
let state = app.state::<AppState>();
|
||||
let data = lock(&state)?;
|
||||
if !crate::popover::is_reply_context(url) {
|
||||
return Ok(false);
|
||||
}
|
||||
let drafts = data.store.list_drafts()?;
|
||||
let live: HashSet<&str> = data.captures.get(label).map(|session| session.draft_ids().collect()).unwrap_or_default();
|
||||
Ok(crate::popover::has_resumable_draft(&drafts, url, |id| live.contains(id)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn draft_save(
|
||||
webview: Webview,
|
||||
state: State<'_, AppState>,
|
||||
input: SaveDraftInput,
|
||||
) -> Result<Draft, String> {
|
||||
shell_only(&webview)?;
|
||||
lock(&state)?.store.save_draft(&input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn draft_revisions(
|
||||
webview: Webview,
|
||||
state: State<'_, AppState>,
|
||||
draft_id: String,
|
||||
) -> Result<Vec<DraftRevision>, String> {
|
||||
shell_only(&webview)?;
|
||||
lock(&state)?.store.revisions(&draft_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn draft_status(
|
||||
webview: Webview,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
expected_revision: i64,
|
||||
status: String,
|
||||
) -> Result<Draft, String> {
|
||||
shell_only(&webview)?;
|
||||
lock(&state)?.store.set_status(&id, expected_revision, &status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn observation_list(
|
||||
webview: Webview,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<Observation>, String> {
|
||||
shell_only(&webview)?;
|
||||
lock(&state)?.store.observations()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn data_export(webview: Webview, state: State<'_, AppState>) -> Result<ExportData, String> {
|
||||
shell_only(&webview)?;
|
||||
lock(&state)?.store.export()
|
||||
}
|
||||
|
||||
/// The X page may only append composer snapshots. Mappings are owned natively per tab.
|
||||
#[tauri::command]
|
||||
async fn capture_draft(
|
||||
webview: Webview,
|
||||
state: State<'_, AppState>,
|
||||
capture: CaptureInput,
|
||||
) -> Result<CaptureAck, String> {
|
||||
let url = webview
|
||||
.url()
|
||||
.map_err(|_| "FORBIDDEN: cannot verify capture origin")?;
|
||||
policy::require_capture(webview.label(), &url)?;
|
||||
// Capability also restricts this to macOS and Windows: Linux cannot distinguish iframe IPC origins.
|
||||
if !cfg!(any(target_os = "macos", target_os = "windows")) {
|
||||
return Err("UNSUPPORTED: remote capture is enabled only on macOS and Windows".into());
|
||||
}
|
||||
let mut data = lock(&state)?;
|
||||
let NativeData { store, captures, capture_failures, .. } = &mut *data;
|
||||
let result = captures
|
||||
.entry(webview.label().to_owned())
|
||||
.or_default()
|
||||
.write(store, capture);
|
||||
if let Err(error) = &result {
|
||||
if alerts::capture_failure_notifies(error)
|
||||
&& capture_failures.allow(webview.label(), std::time::Instant::now())
|
||||
{
|
||||
let app = webview.app_handle();
|
||||
notify(
|
||||
app,
|
||||
&crate::desktop_i18n::tr(app, "native.notify.captureFailed.title", "Draft capture failed"),
|
||||
&crate::desktop_i18n::tr(
|
||||
app,
|
||||
"native.notify.captureFailed.body",
|
||||
"wayBetter for X could not save what you are typing on X. Copy it somewhere safe before leaving the page.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The Drafts button in an X composer asks for the drafts popover under itself (a local webview
|
||||
/// over this tab; desktop_popover.rs), or the shell's picker when the pane has no room for it.
|
||||
/// Nothing is read from the vault here; the X page only learns that the request was delivered.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PickerRequest {
|
||||
kind: Option<String>,
|
||||
/// Button position in the tab's viewport (CSS px): left and bottom edge.
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
/// Button size (CSS px), so the popover's caret can point at its center.
|
||||
width: Option<f64>,
|
||||
height: Option<f64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn drafts_picker_open(webview: Webview, app: AppHandle, request: PickerRequest) -> Result<(), String> {
|
||||
let url = webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?;
|
||||
policy::require_capture(webview.label(), &url)?;
|
||||
policy::validate_context(url.as_str(), true)?;
|
||||
let id = tab_only(&webview)?;
|
||||
let kind = request.kind.filter(|kind| ["post", "reply", "quote"].contains(&kind.as_str()));
|
||||
let coordinate = |value: Option<f64>| value.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 20_000.0));
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Picker {
|
||||
from: String,
|
||||
kind: Option<String>,
|
||||
context_url: String,
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
}
|
||||
// The context comes from the webview itself, not the page's claim.
|
||||
let mut context = url.clone();
|
||||
context.set_query(None);
|
||||
context.set_fragment(None);
|
||||
if let (Some(x), Some(y)) = (coordinate(request.x), coordinate(request.y)) {
|
||||
let size = |value: Option<f64>| value.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 400.0)).unwrap_or(0.0);
|
||||
let (width, height) = (size(request.width), size(request.height));
|
||||
let anchor = crate::popover::Rect::new(x, y - height, width, height);
|
||||
match desktop_popover::open(&app, &webview, id.clone(), kind.clone(), context.to_string(), anchor) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => eprintln!("wayBetter for X: drafts popover unavailable, using the shell picker: {error}"),
|
||||
}
|
||||
}
|
||||
emit_shell(
|
||||
&app,
|
||||
"drafts-picker",
|
||||
Picker { from: id, kind, context_url: context.to_string(), x: coordinate(request.x), y: coordinate(request.y) },
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shell only: puts the chosen draft's text into that X tab's composer. Focus + insertText, never
|
||||
/// a click on Post; the user reviews and sends it themselves.
|
||||
#[tauri::command]
|
||||
async fn tab_insert_text(webview: Webview, app: AppHandle, id: String, text: String) -> Result<(), String> {
|
||||
shell_only(&webview)?;
|
||||
let script = compose::insert_script(&text)?;
|
||||
let tab = tab_webview(&app, &id).ok_or("TAB: not found")?;
|
||||
let url = tab.url().map_err(|_| "TAB: cannot verify origin")?;
|
||||
if !policy::x_origin(&url) {
|
||||
return Err("FORBIDDEN: drafts can only be inserted into X tabs".into());
|
||||
}
|
||||
policy::validate_context(url.as_str(), true)?;
|
||||
let _ = tab.set_focus();
|
||||
tab.eval(&script).map_err(|error| format!("TAB: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn alerts_get(webview: Webview, state: State<'_, AppState>) -> Result<AlertSettings, String> {
|
||||
shell_only(&webview)?;
|
||||
Ok(lock(&state)?.alerts.clone())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn alerts_set(
|
||||
webview: Webview,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
settings: AlertSettings,
|
||||
) -> Result<AlertSettings, String> {
|
||||
shell_only(&webview)?;
|
||||
let settings = settings.validated()?;
|
||||
let script = alerts_script(&settings);
|
||||
{
|
||||
// Hold the lock across the write so concurrent saves cannot leave disk and memory disagreeing.
|
||||
let mut data = lock(&state)?;
|
||||
write_json(&app, ALERTS_FILE, &settings)?;
|
||||
data.alerts = settings.clone();
|
||||
}
|
||||
eval_in_x_tabs(&app, &script);
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Hands the X runtime the keywords to watch for (empty = alerts off, observer uninstalled).
|
||||
fn alerts_script(settings: &AlertSettings) -> String {
|
||||
let keywords = serde_json::to_string(settings.active_keywords()).unwrap_or_else(|_| "[]".into());
|
||||
tabs::deliver_script(SITE.runtime_global, "alerts", "set", "__btAlerts", &keywords)
|
||||
}
|
||||
|
||||
/// An X tab reports a configured keyword in a tweet it already received. Local notification only,
|
||||
/// deduplicated per tweet and rate limited.
|
||||
#[tauri::command]
|
||||
async fn keyword_alert(webview: Webview, state: State<'_, AppState>, hit: KeywordHit) -> Result<(), String> {
|
||||
let url = webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?;
|
||||
policy::require_capture(webview.label(), &url)?;
|
||||
let message = {
|
||||
let mut data = lock(&state)?;
|
||||
let message = alerts::check_hit(&data.alerts, &hit)?;
|
||||
if !data.alert_gate.allow(&hit, std::time::Instant::now()) {
|
||||
return Ok(());
|
||||
}
|
||||
message
|
||||
};
|
||||
let app = webview.app_handle();
|
||||
let title = crate::desktop_i18n::trf(app, "native.notify.alert.title", "Keyword \u{201c}{keyword}\u{201d} on X", &[("keyword", &message.keyword)]);
|
||||
let body = if message.author.is_empty() {
|
||||
message.snippet.clone()
|
||||
} else {
|
||||
crate::desktop_i18n::trf(app, "native.notify.alert.body", "@{author}: {text}", &[("author", &message.author), ("text", &message.snippet)])
|
||||
};
|
||||
notify(app, &title, &body);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_theme(app: &AppHandle) -> Option<XTheme> {
|
||||
let theme: XTheme = read_json(app, THEME_FILE)?;
|
||||
theme.validate(&X_THEMES).ok()?;
|
||||
Some(theme)
|
||||
}
|
||||
|
||||
fn load_theme_scopes(app: &AppHandle) -> ThemeScopes {
|
||||
read_json::<ThemeScopes>(app, THEME_SCOPES_FILE).filter(|scopes| scopes.validate().is_ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The theme (and the Google font bytes it needs) for the X tab `label`: the Theme Builder's
|
||||
/// preview while one is open, else its pane's, its account's or the global theme.
|
||||
fn tab_theme(app: &AppHandle, state: &NativeData, label: &str) -> XTheme {
|
||||
let global = state.theme.clone().unwrap_or_else(x_default);
|
||||
match &state.theme_preview {
|
||||
Some(preview) => preview.clone(),
|
||||
None => {
|
||||
let tab = policy::tab_id(label).unwrap_or_default();
|
||||
state.theme_scopes.for_tab(&X_THEMES, &global, tab, wb::tab_profile(app, label).as_deref())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The X tab `label`'s native background: its theme's page color (see `XTheme::page_color`).
|
||||
fn tab_background(app: &AppHandle, state: &NativeData, label: &str) -> Option<tauri::window::Color> {
|
||||
tab_theme(app, state, label).page_color(&X_THEMES).map(|(r, g, b)| tauri::window::Color(r, g, b, 255))
|
||||
}
|
||||
|
||||
fn tab_theme_scripts(app: &AppHandle, state: &NativeData, label: &str) -> Vec<String> {
|
||||
let theme = tab_theme(app, state, label);
|
||||
let mut scripts = Vec::new();
|
||||
if let Some(fonts) = waybetter_host::fonts::page_script_for(&SITE, &theme.google_fonts()) {
|
||||
scripts.push(fonts);
|
||||
}
|
||||
if let Ok(script) = theme.script(&X_THEMES, &SITE) {
|
||||
scripts.push(script);
|
||||
}
|
||||
scripts
|
||||
}
|
||||
|
||||
/// Sends every X tab its theme again (after the theme, the scopes, the preview or the fonts changed).
|
||||
fn apply_themes(app: &AppHandle) {
|
||||
// Safe mode: X pages keep X's own look; a theme chosen meanwhile applies when safe mode is left.
|
||||
if waybetter_host::safe::active() {
|
||||
return;
|
||||
}
|
||||
let Some(state) = app.try_state::<AppState>() else { return };
|
||||
let per_tab: Vec<(Webview, Vec<String>, Option<tauri::window::Color>)> = {
|
||||
let Ok(data) = state.0.lock() else { return };
|
||||
app.webviews()
|
||||
.into_iter()
|
||||
.filter(|(label, tab)| policy::tab_id(label).is_some() && tab.url().is_ok_and(|url| policy::x_origin(&url)))
|
||||
.map(|(label, tab)| (tab, tab_theme_scripts(app, &data, &label), tab_background(app, &data, &label)))
|
||||
.collect()
|
||||
};
|
||||
for (tab, scripts, background) in per_tab {
|
||||
let _ = tab.set_background_color(background);
|
||||
for script in scripts {
|
||||
let _ = tab.eval(&script);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a theme to every X tab and persists it. `None` (or the `x` preset without an accent)
|
||||
/// restores X's own look.
|
||||
#[tauri::command]
|
||||
async fn x_set_theme(
|
||||
webview: Webview,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
theme: Option<XTheme>,
|
||||
) -> Result<(), String> {
|
||||
shell_only(&webview)?;
|
||||
let theme = theme.unwrap_or_else(x_default);
|
||||
theme.validate(&X_THEMES)?;
|
||||
if theme.is_empty(&X_THEMES) {
|
||||
if let Some(path) = data_file(&app, THEME_FILE) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
lock(&state)?.theme = None;
|
||||
} else {
|
||||
write_json(&app, THEME_FILE, &theme)?;
|
||||
lock(&state)?.theme = Some(theme);
|
||||
}
|
||||
apply_themes(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Account and pane themes and the chrome setting.
|
||||
#[tauri::command]
|
||||
async fn theme_scopes_get(webview: Webview, state: State<'_, AppState>) -> Result<ThemeScopes, String> {
|
||||
shell_only(&webview)?;
|
||||
Ok(lock(&state)?.theme_scopes.clone())
|
||||
}
|
||||
|
||||
/// Saves account and pane themes (bounded, ids checked) and re-themes every X tab.
|
||||
#[tauri::command]
|
||||
async fn theme_scopes_set(webview: Webview, app: AppHandle, state: State<'_, AppState>, scopes: ThemeScopes) -> Result<(), String> {
|
||||
shell_only(&webview)?;
|
||||
scopes.validate()?;
|
||||
if scopes.is_default() {
|
||||
if let Some(path) = data_file(&app, THEME_SCOPES_FILE) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
} else {
|
||||
write_json(&app, THEME_SCOPES_FILE, &scopes)?;
|
||||
}
|
||||
lock(&state)?.theme_scopes = scopes;
|
||||
apply_themes(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Theme Builder's live preview: an unsaved theme shown in every X tab (not persisted). `None`
|
||||
/// ends the preview and gives every tab its own theme back.
|
||||
#[tauri::command]
|
||||
async fn x_preview_theme(webview: Webview, app: AppHandle, state: State<'_, AppState>, theme: Option<XTheme>) -> Result<(), String> {
|
||||
shell_only(&webview)?;
|
||||
if let Some(theme) = &theme {
|
||||
theme.validate(&X_THEMES)?;
|
||||
}
|
||||
lock(&state)?.theme_preview = theme;
|
||||
apply_themes(&app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The persisted X theme (default when none).
|
||||
#[tauri::command]
|
||||
async fn x_theme_get(webview: Webview, state: State<'_, AppState>) -> Result<XTheme, String> {
|
||||
shell_only(&webview)?;
|
||||
Ok(lock(&state)?.theme.clone().unwrap_or_else(x_default))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let pane = serde_json::json!({ "redundantNav": nav_redundant });
|
||||
tabs::deliver_script(SITE.runtime_global, "pane", "set", "__btPane", &pane.to_string())
|
||||
}
|
||||
|
||||
/// Power User Mode: hands deck column `id` its config (hide X's chrome, density, filters, unread
|
||||
/// tracking), or removes it (`null`). Shell-only; validated in `column.rs`. The config is kept per
|
||||
/// tab so a reload or a later X page load gets it again, and a new webview gets it at creation.
|
||||
#[tauri::command]
|
||||
async fn column_configure(
|
||||
webview: Webview,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
config: Option<ColumnConfig>,
|
||||
) -> Result<(), String> {
|
||||
let url = webview.url().map_err(|_| "FORBIDDEN: cannot verify workspace origin")?;
|
||||
let config = crate::column::configure(webview.label(), &url, cfg!(debug_assertions), &id, config)?;
|
||||
let label = format!("{}{id}", policy::TAB_PREFIX);
|
||||
{
|
||||
let mut data = lock(&state)?;
|
||||
match &config {
|
||||
Some(config) => {
|
||||
// Bounded like the tabs themselves: a stale entry for a closed tab is replaced.
|
||||
if data.tab_columns.len() >= 256 && !data.tab_columns.contains_key(&label) {
|
||||
return Err("VALIDATION: too many columns".into());
|
||||
}
|
||||
data.tab_columns.insert(label.clone(), config.clone());
|
||||
}
|
||||
None => {
|
||||
data.tab_columns.remove(&label);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(tab) = tab_webview(&app, &id) {
|
||||
if tab.url().is_ok_and(|url| policy::x_origin(&url)) {
|
||||
let _ = tab.eval(&crate::column::column_script(config.as_ref()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TabReport {
|
||||
url: Option<String>,
|
||||
title: Option<String>,
|
||||
favicon: Option<String>,
|
||||
focused: Option<bool>,
|
||||
/// X tabs only: the account X's own account button shows in this tab, i.e. the login of this
|
||||
/// tab's profile. Accepted only from a webview that is on x.com right now.
|
||||
account: Option<profiles::XAccount>,
|
||||
/// X tabs only: X shows its signed-out page here.
|
||||
signed_out: Option<bool>,
|
||||
/// Deck columns on x.com only (owner-approved 2026-09-28): this tab's unread count, capped at 999.
|
||||
unread: Option<u64>,
|
||||
/// X tabs only (owner-approved 2026-09-28): the palette commands this tab's plugins registered,
|
||||
/// `{ id, title, keywords }` strings, validated and capped in `palette_commands.rs`.
|
||||
commands: Option<Vec<crate::palette_commands::PaletteCommand>>,
|
||||
/// X tabs only (2026-09-29, "Follow X" language): X's own `<html lang>`, a validated language
|
||||
/// tag; accepted only from a webview on x.com right now, forwarded to the shell as `lang`.
|
||||
lang: Option<String>,
|
||||
/// X mounted page content after its first DOM paint; startup cover release only.
|
||||
painted: Option<bool>,
|
||||
}
|
||||
|
||||
/// Injected into every tab: SPA URL/title/favicon changes and focus, for the address bar.
|
||||
#[tauri::command]
|
||||
async fn tab_report(webview: Webview, app: AppHandle, state: State<'_, AppState>, report: TabReport) -> Result<(), String> {
|
||||
let id = tab_only(&webview)?;
|
||||
let seen = match (report.account, report.signed_out) {
|
||||
(Some(account), _) => Some(profiles::AccountSeen::SignedIn(account)),
|
||||
(None, Some(true)) => Some(profiles::AccountSeen::SignedOut),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(seen) = seen.filter(|_| webview.url().is_ok_and(|url| policy::x_origin(&url))) {
|
||||
// A tab can only speak for the profile its own webview data directory belongs to.
|
||||
wb::account_seen(&app, webview.label(), seen)?;
|
||||
}
|
||||
// Plugin palette commands: strings about this tab, only from a tab on x.com right now.
|
||||
if let Some(list) = report.commands {
|
||||
if webview.url().is_ok_and(|url| policy::x_origin(&url)) {
|
||||
let list = crate::palette_commands::validate(list)?;
|
||||
let mut data = lock(&state)?;
|
||||
settings::tab_commands_reported(&app, &mut data, webview.label(), &id, Some(list));
|
||||
}
|
||||
}
|
||||
// Only a number about this tab, only from a tab on x.com right now (column.rs).
|
||||
let unread = webview.url().ok().and_then(|url| crate::column::accept_unread(webview.label(), &url, report.unread));
|
||||
// The URL comes from the webview itself, not the page's claim.
|
||||
let url = report.url.and(webview.url().ok().map(|url| url.to_string()));
|
||||
if let Some(url) = &url {
|
||||
desktop_popover::tab_navigated(&app, &id, url);
|
||||
}
|
||||
let mut state = TabState::reported(id, url, report.title, report.favicon, report.focused);
|
||||
state.unread = unread;
|
||||
state.lang = report.lang.filter(|lang| waybetter_host::i18n::valid_tag(lang) && webview.url().is_ok_and(|url| policy::x_origin(&url)));
|
||||
state.content_ready = report.painted.filter(|ready| *ready && webview.url().is_ok_and(|url| policy::x_origin(&url)));
|
||||
emit_state(&app, state);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_x_like(url: &Url) -> bool {
|
||||
url.scheme() == "https"
|
||||
&& matches!(
|
||||
url.host_str(),
|
||||
Some("x.com" | "www.x.com" | "mobile.x.com" | "twitter.com" | "www.twitter.com" | "mobile.twitter.com")
|
||||
)
|
||||
}
|
||||
|
||||
/// Legacy twitter.com links (other than login routes) load as the same path on x.com.
|
||||
fn as_x_url(url: &Url) -> Option<Url> {
|
||||
if is_x_like(url) && !policy::x_origin(url) && !policy::x_navigation_allowed(url) {
|
||||
let mut x = url.clone();
|
||||
x.set_host(Some("x.com")).ok()?;
|
||||
return Some(x);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Theme, custom CSS, feature flags and enabled plugins for a freshly loaded X document in tab
|
||||
/// `label`. The flags, the link setting, custom CSS and the plugins are the host's page state
|
||||
/// (`wb::page_scripts`); X's alerts and theme go between them, as they always did.
|
||||
fn x_page_scripts(app: &AppHandle, state: &NativeData, label: &str) -> Vec<String> {
|
||||
let Ok(page) = wb::page_scripts(app) else { return Vec::new() };
|
||||
let mut scripts = vec![page.features, alerts_script(&state.alerts), page.links];
|
||||
if state.theme.is_some() || state.theme_preview.is_some() || !state.theme_scopes.is_default() {
|
||||
scripts.extend(tab_theme_scripts(app, state, label));
|
||||
}
|
||||
scripts.extend(page.css);
|
||||
scripts.extend(page.plugins);
|
||||
scripts
|
||||
}
|
||||
|
||||
pub(crate) fn eval_in_x_tabs(app: &AppHandle, script: &str) {
|
||||
waybetter_host::host::eval_in_site_tabs(app, &SITE, script);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The X site on the wayBetter host
|
||||
|
||||
/// What only X knows about its tabs (`waybetter_host::app::SiteHooks`).
|
||||
struct XSite;
|
||||
|
||||
impl SiteHooks for XSite {
|
||||
fn init_scripts(&self, app: &AppHandle, tab: &NewTab) -> Vec<String> {
|
||||
let mut scripts = Vec::new();
|
||||
// A deck column gets its config before X's first paint, so its chrome never flashes.
|
||||
if let Some(column) = lock(&app.state::<AppState>()).ok().and_then(|data| data.tab_columns.get(tab.label).cloned()) {
|
||||
scripts.push(crate::column::column_script(Some(&column)));
|
||||
}
|
||||
scripts.push(waybetter_host::bridge::SOURCE.to_owned());
|
||||
// Debug builds read these from disk and rebuild the tabs when `bun run dev` rebuilds them.
|
||||
scripts.push(waybetter_host::page_script!(include_str!("../observer.js"), "observer.js"));
|
||||
// The pane role at creation, so a reload does not flash the navigation; later changes
|
||||
// arrive through `pane_script` from tab_placed and on every finished X page load.
|
||||
scripts.push(pane_script(nav_redundant(tab.item)));
|
||||
// The X runtime's words in the owner's language from the first paint (x.com only).
|
||||
scripts.push(crate::desktop_i18n::page_script(app));
|
||||
scripts.push(waybetter_host::page_script!(include_str!("../x-runtime.js"), "x-runtime.js"));
|
||||
scripts
|
||||
}
|
||||
|
||||
fn tab_background(&self, app: &AppHandle, label: &str) -> Option<tauri::window::Color> {
|
||||
let state = app.state::<AppState>();
|
||||
let data = lock(&state).ok()?;
|
||||
tab_background(app, &data, label)
|
||||
}
|
||||
|
||||
/// Legacy twitter.com links (other than login routes) load as the same path on x.com.
|
||||
fn canonical_url(&self, url: &Url) -> Option<Url> {
|
||||
as_x_url(url)
|
||||
}
|
||||
|
||||
/// twitter.com login routes count as X for link routing.
|
||||
fn site_navigation(&self, url: &Url) -> bool {
|
||||
policy::x_navigation_allowed(url)
|
||||
}
|
||||
|
||||
fn navigation_started(&self, app: &AppHandle, label: &str) -> Option<bool> {
|
||||
let state = app.state::<AppState>();
|
||||
let mut data = state.0.lock().ok()?;
|
||||
// Navigation rotates this tab's composer mappings; never reopen another session's draft ID.
|
||||
if let Some(session) = data.captures.get_mut(label) {
|
||||
session.reset();
|
||||
}
|
||||
// Whether links to other sites go to the system browser: the host's settings.
|
||||
Some(wb::page(app).ok()?.settings.external_in_browser())
|
||||
}
|
||||
|
||||
/// A new document: its plugins register their palette commands again.
|
||||
fn page_started(&self, app: &AppHandle, tab: &Webview) {
|
||||
let Some(id) = policy::tab_id(tab.label()) else { return };
|
||||
if let Ok(mut data) = app.state::<AppState>().0.lock() {
|
||||
settings::tab_commands_reported(app, &mut data, tab.label(), id, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn site_page_loaded(&self, app: &AppHandle, tab: &Webview, _url: &Url) {
|
||||
let mut scripts = app
|
||||
.state::<AppState>()
|
||||
.0
|
||||
.lock()
|
||||
.map(|data| {
|
||||
let mut scripts = x_page_scripts(app, &data, tab.label());
|
||||
let redundant = data.tab_nav_redundant.get(tab.label()).copied().unwrap_or(false);
|
||||
scripts.push(pane_script(redundant));
|
||||
if let Some(column) = data.tab_columns.get(tab.label()) {
|
||||
scripts.push(crate::column::column_script(Some(column)));
|
||||
}
|
||||
scripts
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Signed hotfix selectors/flags first, as data, before plugins read them.
|
||||
if let Some(hotfix) = crate::desktop_update::hotfix_script(app) {
|
||||
scripts.insert(0, hotfix);
|
||||
}
|
||||
// The X runtime's words in the owner's language, before plugins start using them.
|
||||
scripts.insert(0, crate::desktop_i18n::page_script(app));
|
||||
for script in scripts {
|
||||
let _ = tab.eval(&script);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Google font was installed or removed: every X tab gets its theme (and font bytes) again.
|
||||
fn fonts_changed(&self, app: &AppHandle) {
|
||||
apply_themes(app);
|
||||
}
|
||||
|
||||
/// What a backup and sync carry from app data (the host adds the shell's own storage).
|
||||
fn backup_sections(&self, app: &AppHandle) -> Result<serde_json::Map<String, serde_json::Value>, String> {
|
||||
settings::backup_native(app)
|
||||
}
|
||||
|
||||
fn backup_section_names(&self) -> &'static [&'static str] {
|
||||
&crate::backup::SECTIONS
|
||||
}
|
||||
|
||||
/// Developer mode: the watched theme file becomes the theme preview in every X tab.
|
||||
fn backup_drafts(&self, app: &AppHandle) -> Result<Option<serde_json::Value>, String> {
|
||||
settings::backup_drafts(app)
|
||||
}
|
||||
|
||||
fn backup_apply(&self, app: &AppHandle, sections: &serde_json::Map<String, serde_json::Value>) -> Result<serde_json::Value, String> {
|
||||
settings::backup_apply(app, sections)
|
||||
}
|
||||
|
||||
fn dev_theme_apply(&self, app: &AppHandle, path: &std::path::Path) -> Result<(), waybetter_host::devmode::DevError> {
|
||||
let theme = crate::devmode::load_theme_file(path)?;
|
||||
if let Ok(mut data) = lock(&app.state::<AppState>()) {
|
||||
data.theme_preview = Some(theme);
|
||||
}
|
||||
apply_themes(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dev_theme_end(&self, app: &AppHandle) {
|
||||
if let Ok(mut data) = lock(&app.state::<AppState>()) {
|
||||
data.theme_preview = None;
|
||||
}
|
||||
apply_themes(app);
|
||||
}
|
||||
|
||||
fn tab_created(&self, _app: &AppHandle) {
|
||||
desktop_popover::tab_created();
|
||||
}
|
||||
|
||||
fn tab_placed(&self, app: &AppHandle, tab: &Webview, item: &TabLayout) {
|
||||
let nav_redundant = nav_redundant(item);
|
||||
let nav_changed = lock(&app.state::<AppState>())
|
||||
.map(|mut data| data.tab_nav_redundant.insert(tab.label().to_owned(), nav_redundant) != Some(nav_redundant))
|
||||
.unwrap_or(false);
|
||||
if nav_changed && tab.url().is_ok_and(|url| policy::x_origin(&url)) {
|
||||
let _ = tab.eval(&pane_script(nav_redundant));
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_changed(&self, app: &AppHandle, layout: &[(String, waybetter_host::layout::Rect, bool)]) {
|
||||
let layout: Vec<_> = layout
|
||||
.iter()
|
||||
.map(|(id, (x, y, width, height), visible)| (id.clone(), crate::popover::Rect::new(*x, *y, *width, *height), *visible))
|
||||
.collect();
|
||||
desktop_popover::layout_changed(app, &layout);
|
||||
}
|
||||
|
||||
/// A closed webview's composer mappings and pane role go with it; a deck column's config only
|
||||
/// when its tab is gone (a new webview of the same tab gets it again).
|
||||
fn tab_closed(&self, app: &AppHandle, label: &str, why: TabClosed) {
|
||||
if let Ok(mut data) = app.state::<AppState>().0.lock() {
|
||||
data.captures.remove(label);
|
||||
data.tab_nav_redundant.remove(label);
|
||||
if why == TabClosed::Removed {
|
||||
data.tab_columns.remove(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_raised(&self, app: &AppHandle, on: bool) {
|
||||
if on {
|
||||
desktop_popover::shell_raised(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deck columns hide X's chrome themselves; the split-view rail rule never applies to them.
|
||||
fn nav_redundant(item: &TabLayout) -> bool {
|
||||
item.nav_redundant && item.role == TabRole::Pane
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// App
|
||||
|
||||
/// The X app's window and plugins (install links, signed updates) on the wayBetter host.
|
||||
fn config() -> AppConfig {
|
||||
AppConfig {
|
||||
legacy_identifier: Some(LEGACY_IDENTIFIER),
|
||||
window_size: (1360.0, 900.0),
|
||||
min_window_size: (900.0, 560.0),
|
||||
// #07080c: no white flash before the shell paints.
|
||||
background: (7, 8, 12),
|
||||
defer_window_show: true,
|
||||
deep_link: true,
|
||||
updater: true,
|
||||
page_string_prefixes: crate::desktop_i18n::PAGE_PREFIXES,
|
||||
home_url: Some(HOME_URL),
|
||||
..AppConfig::new(&SITE, "wayBetter for X", &plugins::HOST).with_store(&crate::distribution::STORE).with_media().with_fonts().with_updates(&crate::desktop_update::CONFIG).with_sync().with_dev()
|
||||
}
|
||||
}
|
||||
|
||||
/// X's own state and services, after the host's (app data folder, profiles, language, the plugin
|
||||
/// store, tweak flags and settings).
|
||||
fn setup(handle: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = handle.path().app_data_dir()?;
|
||||
let _ = std::fs::remove_file(directory.join(LEGACY_THEME_FILE));
|
||||
let store = Store::open(directory.join("workspace.sqlite3")).map_err(std::io::Error::other)?;
|
||||
handle.manage(AppState(Mutex::new(NativeData {
|
||||
store,
|
||||
captures: HashMap::new(),
|
||||
theme: load_theme(handle),
|
||||
theme_scopes: load_theme_scopes(handle),
|
||||
theme_preview: None,
|
||||
tab_nav_redundant: HashMap::new(),
|
||||
tab_columns: HashMap::new(),
|
||||
tab_commands: HashMap::new(),
|
||||
alerts: read_json::<AlertSettings>(handle, ALERTS_FILE)
|
||||
.and_then(|settings| settings.validated().ok())
|
||||
.unwrap_or_default(),
|
||||
alert_gate: AlertGate::default(),
|
||||
capture_failures: Throttle::new(std::time::Duration::from_secs(300), std::time::Duration::from_secs(300), 3),
|
||||
})));
|
||||
desktop_popover::manage(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
// The host serves the generic commands (waybetter_host::commands); these are X's.
|
||||
wb::run_app(
|
||||
config(),
|
||||
XSite,
|
||||
setup,
|
||||
tauri::generate_context!(),
|
||||
tauri::generate_handler![
|
||||
draft_list,
|
||||
draft_save,
|
||||
draft_revisions,
|
||||
draft_status,
|
||||
observation_list,
|
||||
data_export,
|
||||
capture_draft,
|
||||
x_set_theme,
|
||||
x_theme_get,
|
||||
theme_scopes_get,
|
||||
theme_scopes_set,
|
||||
x_preview_theme,
|
||||
settings::tab_run_command,
|
||||
column_configure,
|
||||
tab_report,
|
||||
drafts_picker_open,
|
||||
tab_insert_text,
|
||||
desktop_popover::drafts_popover_dismiss,
|
||||
desktop_popover::drafts_hint,
|
||||
desktop_popover::popover_state,
|
||||
desktop_popover::popover_drafts,
|
||||
desktop_popover::popover_insert,
|
||||
desktop_popover::popover_close,
|
||||
desktop_popover::popover_open_vault,
|
||||
alerts_get,
|
||||
alerts_set,
|
||||
keyword_alert,
|
||||
crate::desktop_media::tab_attach_image,
|
||||
],
|
||||
);
|
||||
}
|
||||
14
apps/desktop/src-tauri/src/desktop_i18n.rs
Normal file
14
apps/desktop/src-tauri/src/desktop_i18n.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//! Language, native side. The shell picks the language (Settings → Language: follow X, follow the
|
||||
//! system, or one of X's languages) and hands native code the strings it needs through the
|
||||
//! shell-only `i18n_set`: native dialog titles and notifications (`native.*`), and the X runtime's,
|
||||
//! the plugin UI kit's and the bundled plugins' own words (`x.*`, `kit.*`, `plugin.*`), which X tabs
|
||||
//! receive as a data value through the runtime (`wayBetter.i18n.set`), like every other setting.
|
||||
//! The commands, the validation (plain text, bounded; waybetter-host `i18n.rs`), the saved set in
|
||||
//! app data (`i18n.json`) and the page script (guarded to https://x.com) are the framework's
|
||||
//! (waybetter-host `app`); X only says which strings its tabs get. X tabs never get a command.
|
||||
|
||||
/// What X tabs receive: the X runtime's, the plugin UI kit's and the bundled plugins' words.
|
||||
pub(crate) const PAGE_PREFIXES: &[&str] = &["x.", "kit.", "plugin."];
|
||||
|
||||
/// The native string in the owner's language (`tr`, `trf`) and the X tab strings script.
|
||||
pub(crate) use waybetter_host::app::{i18n_page_script as page_script, tr, trf};
|
||||
42
apps/desktop/src-tauri/src/desktop_media.rs
Normal file
42
apps/desktop/src-tauri/src/desktop_media.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
//! Attaching an image to an X tab's composer, the one media command that is X's own. The media
|
||||
//! panel (gallery folders, previews, the opt-in clipboard history) is the framework's
|
||||
//! (`waybetter_host::media_commands`, enabled by `AppConfig::with_media`); this command reads the
|
||||
//! bytes through its checked accessors. Shell-only; X tabs get nothing new. Attaching sets the
|
||||
//! composer's own file input (or a synthetic drop) and never clicks Post.
|
||||
|
||||
use crate::desktop::{shell_only, tab_webview};
|
||||
use crate::media;
|
||||
use crate::policy;
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Webview};
|
||||
use waybetter_host::media_commands::{clip_image, gallery_file};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub(crate) enum AttachSource {
|
||||
Gallery { path: String },
|
||||
Clip { id: String },
|
||||
}
|
||||
|
||||
/// Attaches one image to that X tab's composer (file input / drop). Never posts.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn tab_attach_image(webview: Webview, app: AppHandle, id: String, source: AttachSource) -> Result<(), String> {
|
||||
shell_only(&webview)?;
|
||||
let (name, mime, bytes) = match source {
|
||||
AttachSource::Gallery { path } => {
|
||||
let (real, bytes) = gallery_file(&app, &path)?;
|
||||
let name = real.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
(name, media::mime_for(&real), bytes)
|
||||
}
|
||||
AttachSource::Clip { id: clip } => ("clipboard.png".to_string(), "image/png", clip_image(&app, &clip)?),
|
||||
};
|
||||
let script = media::attach_script(&name, mime, &bytes)?;
|
||||
let tab = tab_webview(&app, &id).ok_or("TAB: not found")?;
|
||||
let url = tab.url().map_err(|_| "TAB: cannot verify origin")?;
|
||||
if !policy::x_origin(&url) {
|
||||
return Err("FORBIDDEN: images can only be attached in X tabs".into());
|
||||
}
|
||||
policy::validate_context(url.as_str(), true)?;
|
||||
let _ = tab.set_focus();
|
||||
tab.eval(&script).map_err(|error| format!("TAB: {error}"))
|
||||
}
|
||||
357
apps/desktop/src-tauri/src/desktop_popover.rs
Normal file
357
apps/desktop/src-tauri/src/desktop_popover.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
//! The drafts popover: a small trusted local webview (`popover-<n>`, the app's own index.html with
|
||||
//! `?popover=drafts`) layered over an X tab right under the composer's Drafts button. X stays
|
||||
//! visible and interactive around it.
|
||||
//!
|
||||
//! Boundary: the X page only says "open it here" (`drafts_picker_open` with the button's rect in
|
||||
//! its own viewport), "close it" (`drafts_popover_dismiss`, e.g. on scroll) and asks one yes/no
|
||||
//! question about its own post page (`drafts_hint`). It never receives draft text or ids. The
|
||||
//! popover webview, which is local code, reads active drafts and inserts one into the tab that
|
||||
//! opened it, and nothing else (capability `drafts-popover`, checks below).
|
||||
//!
|
||||
//! Z-order: a new child webview is placed on top of its siblings (wry: HWND_TOP on Windows,
|
||||
//! addSubview on macOS). A tab webview created after the popover would cover it, so the popover
|
||||
//! is recreated whenever a tab webview was created since it was.
|
||||
|
||||
use crate::compose;
|
||||
use crate::desktop::{emit_shell, resumable_draft, tab_only, tab_webview, vault_draft, vault_drafts};
|
||||
use crate::model::Draft;
|
||||
use crate::policy;
|
||||
use crate::popover::{self, Placement, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::webview::NewWindowResponse;
|
||||
use tauri::{AppHandle, LogicalPosition, LogicalSize, Manager, Webview, WebviewBuilder, WebviewUrl};
|
||||
use url::Url;
|
||||
|
||||
/// Bumped whenever a tab webview is created (see the z-order note above).
|
||||
static TAB_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
/// A Drafts click that lands right after the popover closed on blur is the same click that
|
||||
/// blurred it: treat it as "toggle closed" instead of reopening.
|
||||
const TOGGLE_WINDOW: Duration = Duration::from_millis(350);
|
||||
/// `drafts_hint` answers at most this often per tab.
|
||||
const HINT_INTERVAL: Duration = Duration::from_millis(400);
|
||||
|
||||
pub(crate) fn tab_created() {
|
||||
TAB_GENERATION.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// What the popover shows; `seq` identifies one opening so stale clicks from a previous one fail.
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Session {
|
||||
seq: u64,
|
||||
from: String,
|
||||
kind: Option<String>,
|
||||
context_url: String,
|
||||
placement: Placement,
|
||||
#[serde(skip)]
|
||||
tab_rect: Rect,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Popover {
|
||||
label: Option<String>,
|
||||
created_generation: u64,
|
||||
next_label: u64,
|
||||
seq: u64,
|
||||
open: Option<Session>,
|
||||
last_closed: Option<(String, Instant)>,
|
||||
hints: HashMap<String, Instant>,
|
||||
}
|
||||
|
||||
pub(crate) struct PopoverState(Mutex<Popover>);
|
||||
|
||||
pub(crate) fn manage(app: &AppHandle) {
|
||||
app.manage(PopoverState(Mutex::new(Popover::default())));
|
||||
}
|
||||
|
||||
fn state(app: &AppHandle) -> Result<MutexGuard<'_, Popover>, String> {
|
||||
app.state::<PopoverState>().inner().0.lock().map_err(|_| "STATE: popover state poisoned".to_string())
|
||||
}
|
||||
|
||||
/// Only the popover webview, on the app's own local origin.
|
||||
fn popover_only(webview: &Webview) -> Result<(), String> {
|
||||
let url = webview.url().map_err(|_| "FORBIDDEN: cannot verify popover origin")?;
|
||||
if popover::valid_label(webview.label()) && policy::local_navigation_allowed(&url, cfg!(debug_assertions)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("FORBIDDEN: drafts popover required".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The open session, if `seq` names it and the caller is the current popover webview.
|
||||
fn current(app: &AppHandle, webview: &Webview, seq: u64) -> Result<Session, String> {
|
||||
popover_only(webview)?;
|
||||
let data = state(app)?;
|
||||
if data.label.as_deref() != Some(webview.label()) {
|
||||
return Err("FORBIDDEN: stale popover".into());
|
||||
}
|
||||
match &data.open {
|
||||
Some(session) if session.seq == seq => Ok(session.clone()),
|
||||
_ => Err("CLOSED: the popover is not open".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn logical_rect(tab: &Webview) -> Result<Rect, String> {
|
||||
// GTK child webviews report no position of their own; the host knows where it put them.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some((x, y, width, height)) = waybetter_host::host::placed_rect(tab) {
|
||||
return Ok(Rect::new(x, y, width, height));
|
||||
}
|
||||
let scale = tab.window().scale_factor().map_err(|e| format!("POPOVER: {e}"))?;
|
||||
let position = tab.position().map_err(|e| format!("POPOVER: {e}"))?.to_logical::<f64>(scale);
|
||||
let size = tab.size().map_err(|e| format!("POPOVER: {e}"))?.to_logical::<f64>(scale);
|
||||
Ok(Rect::new(position.x, position.y, size.width, size.height))
|
||||
}
|
||||
|
||||
fn builder(label: &str) -> WebviewBuilder<tauri::Wry> {
|
||||
let builder = WebviewBuilder::new(label, WebviewUrl::App("index.html?popover=drafts".into()))
|
||||
.disable_drag_drop_handler()
|
||||
.focused(true)
|
||||
.on_navigation(|url| policy::local_navigation_allowed(url, cfg!(debug_assertions)))
|
||||
.on_new_window(|_, _| NewWindowResponse::Deny);
|
||||
// The card draws its own rounded corners and shadow over X. macOS would need the private API
|
||||
// for transparency; there the margin shows the popover's dark ground instead.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let builder = builder.transparent(true);
|
||||
#[cfg(target_os = "macos")]
|
||||
let builder = builder.background_color(tauri::window::Color(13, 16, 23, 255));
|
||||
builder
|
||||
}
|
||||
|
||||
/// Opens (or moves) the popover for tab `from` under `anchor` (the button, in the tab's viewport).
|
||||
/// An error means the caller should fall back to the shell's picker.
|
||||
pub(crate) fn open(app: &AppHandle, tab: &Webview, from: String, kind: Option<String>, context_url: String, anchor: Rect) -> Result<(), String> {
|
||||
let tab_rect = logical_rect(tab)?;
|
||||
let placement = popover::place(anchor, tab_rect).ok_or("POPOVER: no room in this pane")?;
|
||||
let window = tab.window();
|
||||
let generation = TAB_GENERATION.load(Ordering::SeqCst);
|
||||
|
||||
let (label, fresh, session) = {
|
||||
let mut data = state(app)?;
|
||||
if let Some((closed_from, at)) = &data.last_closed {
|
||||
if *closed_from == from && at.elapsed() < TOGGLE_WINDOW && data.open.is_none() {
|
||||
data.last_closed = None;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Recreate when a tab webview may now sit above it, or when it is gone.
|
||||
let existing = data.label.clone().and_then(|label| app.get_webview(&label));
|
||||
let reuse = existing.filter(|_| data.created_generation == generation);
|
||||
if reuse.is_none() {
|
||||
if let Some(old) = data.label.take().and_then(|label| app.get_webview(&label)) {
|
||||
let _ = old.close();
|
||||
}
|
||||
data.next_label += 1;
|
||||
data.label = Some(format!("{}{}", popover::LABEL_PREFIX, data.next_label));
|
||||
data.created_generation = generation;
|
||||
}
|
||||
data.seq += 1;
|
||||
let session = Session { seq: data.seq, from, kind, context_url, placement, tab_rect };
|
||||
data.open = Some(session.clone());
|
||||
(data.label.clone().unwrap_or_default(), reuse.is_none(), session)
|
||||
};
|
||||
|
||||
let rect = placement.webview;
|
||||
let popover = if fresh {
|
||||
window
|
||||
.add_child(builder(&label), LogicalPosition::new(rect.x, rect.y), LogicalSize::new(rect.width, rect.height))
|
||||
.map_err(|error| {
|
||||
if let Ok(mut data) = state(app) {
|
||||
data.label = None;
|
||||
data.open = None;
|
||||
}
|
||||
format!("POPOVER: {error}")
|
||||
})?
|
||||
} else {
|
||||
let popover = app.get_webview(&label).ok_or("POPOVER: missing webview")?;
|
||||
waybetter_host::host::place_child(&popover, rect.x, rect.y, rect.width, rect.height);
|
||||
// The page re-reads its session (drafts, placement) on this event.
|
||||
let _ = popover.eval(&format!("window.dispatchEvent(new CustomEvent('bt-popover-open',{{detail:{}}}))", session.seq));
|
||||
popover
|
||||
};
|
||||
// Linux: out of the window box, over the tabs (see `host::place_child`).
|
||||
#[cfg(target_os = "linux")]
|
||||
if fresh {
|
||||
waybetter_host::host::place_child(&popover, rect.x, rect.y, rect.width, rect.height);
|
||||
}
|
||||
let _ = popover.show();
|
||||
let _ = popover.set_focus();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hides the popover. `refocus` gives keyboard focus back to the tab (Esc, after inserting).
|
||||
fn close(app: &AppHandle, refocus: bool) -> Option<Session> {
|
||||
let (session, label) = {
|
||||
let mut data = state(app).ok()?;
|
||||
let session = data.open.take()?;
|
||||
data.last_closed = Some((session.from.clone(), Instant::now()));
|
||||
(session, data.label.clone())
|
||||
};
|
||||
if let Some(popover) = label.and_then(|label| app.get_webview(&label)) {
|
||||
let _ = popover.hide();
|
||||
}
|
||||
if refocus {
|
||||
if let Some(tab) = tab_webview(app, &session.from) {
|
||||
let _ = tab.set_focus();
|
||||
}
|
||||
}
|
||||
Some(session)
|
||||
}
|
||||
|
||||
/// Layout changed (tabs_sync): close unless the source tab is still visible at the same place.
|
||||
pub(crate) fn layout_changed(app: &AppHandle, tabs: &[(String, Rect, bool)]) {
|
||||
let stale = match state(app) {
|
||||
Ok(data) => data.open.as_ref().is_some_and(|session| {
|
||||
!tabs.iter().any(|(id, rect, visible)| {
|
||||
*id == session.from
|
||||
&& *visible
|
||||
&& (rect.x - session.tab_rect.x).abs() < 2.0
|
||||
&& (rect.y - session.tab_rect.y).abs() < 2.0
|
||||
&& (rect.width - session.tab_rect.width).abs() < 2.0
|
||||
&& (rect.height - session.tab_rect.height).abs() < 2.0
|
||||
})
|
||||
}),
|
||||
Err(_) => false,
|
||||
};
|
||||
if stale {
|
||||
close(app, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The source tab navigated (SPA or full load): its composer is gone.
|
||||
pub(crate) fn tab_navigated(app: &AppHandle, id: &str, url: &str) {
|
||||
let navigated = state(app)
|
||||
.map(|data| data.open.as_ref().is_some_and(|s| s.from == id && !popover::same_context(&s.context_url, url)))
|
||||
.unwrap_or(false);
|
||||
if navigated {
|
||||
close(app, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// The shell is raised over the tabs for one of its own overlays: the popover would sit under it.
|
||||
pub(crate) fn shell_raised(app: &AppHandle) {
|
||||
close(app, false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// X tab commands (x-capture capability)
|
||||
|
||||
/// The X page scrolled or its composer went away: close the popover if this tab opened it.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn drafts_popover_dismiss(webview: Webview, app: AppHandle) -> Result<(), String> {
|
||||
let url = webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?;
|
||||
policy::require_capture(webview.label(), &url)?;
|
||||
let id = tab_only(&webview)?;
|
||||
let mine = state(&app)?.open.as_ref().is_some_and(|session| session.from == id);
|
||||
if mine {
|
||||
close(&app, false);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a captured draft exists for exactly this post page (the webview's own URL, not the
|
||||
/// page's claim), so the reply composer can offer "Continue your draft?". Yes/no only: the page
|
||||
/// never receives draft text or ids, and X saw that text when it was typed here.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn drafts_hint(webview: Webview, app: AppHandle) -> Result<bool, String> {
|
||||
let url = webview.url().map_err(|_| "FORBIDDEN: cannot verify origin")?;
|
||||
policy::require_capture(webview.label(), &url)?;
|
||||
policy::validate_context(url.as_str(), true)?;
|
||||
let label = webview.label().to_owned();
|
||||
{
|
||||
let mut data = state(&app)?;
|
||||
let now = Instant::now();
|
||||
if data.hints.get(&label).is_some_and(|at| now.duration_since(*at) < HINT_INTERVAL) {
|
||||
return Ok(false);
|
||||
}
|
||||
data.hints.retain(|_, at| now.duration_since(*at) < Duration::from_secs(60));
|
||||
data.hints.insert(label.clone(), now);
|
||||
}
|
||||
resumable_draft(&app, &label, &url)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Popover commands (drafts-popover capability)
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn popover_state(webview: Webview, app: AppHandle) -> Result<Option<Session>, String> {
|
||||
popover_only(&webview)?;
|
||||
let data = state(&app)?;
|
||||
if data.label.as_deref() != Some(webview.label()) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(data.open.clone())
|
||||
}
|
||||
|
||||
/// Active drafts with text, newest first. Only while this popover is open.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn popover_drafts(webview: Webview, app: AppHandle, seq: u64) -> Result<Vec<Draft>, String> {
|
||||
current(&app, &webview, seq)?;
|
||||
let mut drafts = vault_drafts(&app)?;
|
||||
drafts.retain(|draft| draft.status == "active" && !draft.text.trim().is_empty());
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct InsertRequest {
|
||||
seq: u64,
|
||||
draft_id: String,
|
||||
/// "replace" (default) or "append".
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
/// Puts a vault draft into the composer of the tab that opened the popover (focus + insertText,
|
||||
/// never a click on Post), then closes the popover and gives the tab focus.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn popover_insert(webview: Webview, app: AppHandle, request: InsertRequest) -> Result<(), String> {
|
||||
let session = current(&app, &webview, request.seq)?;
|
||||
if !policy::valid_identifier(&request.draft_id) {
|
||||
return Err("VALIDATION: invalid draft id".into());
|
||||
}
|
||||
let draft = vault_draft(&app, &request.draft_id)?
|
||||
.filter(|draft| draft.status == "active")
|
||||
.ok_or("NOT_FOUND: draft")?;
|
||||
let script = match request.mode.as_deref() {
|
||||
None | Some("replace") => compose::insert_script(&draft.text)?,
|
||||
Some("append") => compose::append_script(&draft.text)?,
|
||||
Some(_) => return Err("VALIDATION: unknown insert mode".into()),
|
||||
};
|
||||
let tab = tab_webview(&app, &session.from).ok_or("TAB: not found")?;
|
||||
let url: Url = tab.url().map_err(|_| "TAB: cannot verify origin")?;
|
||||
if !policy::x_origin(&url) {
|
||||
return Err("FORBIDDEN: drafts can only be inserted into X tabs".into());
|
||||
}
|
||||
policy::validate_context(url.as_str(), true)?;
|
||||
close(&app, true);
|
||||
tab.eval(&script).map_err(|error| format!("TAB: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn popover_close(webview: Webview, app: AppHandle, seq: u64, refocus: Option<bool>) -> Result<(), String> {
|
||||
if current(&app, &webview, seq).is_ok() {
|
||||
close(&app, refocus.unwrap_or(false));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Closes the popover and opens the wayBetter panel's drafts vault beside the tab.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn popover_open_vault(webview: Webview, app: AppHandle, seq: u64) -> Result<(), String> {
|
||||
let session = current(&app, &webview, seq)?;
|
||||
close(&app, false);
|
||||
#[derive(Clone, Serialize)]
|
||||
struct Open {
|
||||
from: String,
|
||||
section: Option<String>,
|
||||
}
|
||||
emit_shell(&app, "open-panel", Open { from: session.from, section: Some("drafts".into()) });
|
||||
if let Some(shell) = app.get_webview(policy::SHELL_LABEL) {
|
||||
let _ = shell.set_focus();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
355
apps/desktop/src-tauri/src/desktop_settings.rs
Normal file
355
apps/desktop/src-tauri/src/desktop_settings.rs
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
//! What X adds to the framework's settings backup, workspaces, sync and developer mode (waybetter-host
|
||||
//! `backup_commands`, `sync_commands`, `dev_commands`, answered through `SiteHooks`): the native backup
|
||||
//! sections, the drafts section and the import step that applies each chosen section the way its own
|
||||
//! setter does (themes, plugins, tweaks, settings, alerts, accounts, workspaces, drafts), plus the
|
||||
//! plugin palette commands (`palette_commands.rs`). A child module of `desktop` so it applies imported
|
||||
//! settings through the same state and page scripts as the individual setters.
|
||||
|
||||
use super::{alerts_script, apply_themes, eval_in_x_tabs, lock, AppState, NativeData, ALERTS_FILE, PROFILES_FILE, THEME_FILE, THEME_SCOPES_FILE};
|
||||
use crate::{
|
||||
alerts::AlertSettings,
|
||||
backup,
|
||||
model::SaveDraftInput,
|
||||
palette_commands::{self, PaletteCommand},
|
||||
plugins,
|
||||
profiles::{Profile, ProfileSettings},
|
||||
site::SITE,
|
||||
theme::{x_default, ThemeScopes, XTheme, X_THEMES},
|
||||
workspaces::{self, WorkspaceStore},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager, State, Webview};
|
||||
use waybetter_host::app as wb;
|
||||
use waybetter_host::host::{data_file, emit_shell, shell_only, tab_webview, write_json};
|
||||
use waybetter_host::settings::{ShellSettings, ShellSettingsInput};
|
||||
|
||||
fn app_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
app.path().app_data_dir().map_err(|_| "BACKUP: no app data folder".to_string())
|
||||
}
|
||||
|
||||
/// The native backup sections (what a backup and sync carry from app data), for the host's sync.
|
||||
pub(super) fn backup_native(app: &AppHandle) -> Result<Map<String, Value>, String> {
|
||||
Ok(backup::collect_native(&app_dir(app)?, &shipped_plugins()))
|
||||
}
|
||||
|
||||
fn shipped_plugins() -> Vec<(&'static str, &'static str)> {
|
||||
let mut shipped: Vec<(&str, &str)> = plugins::BUNDLED.to_vec();
|
||||
if let Some(example) = plugins::HOST.example {
|
||||
shipped.push(example);
|
||||
}
|
||||
shipped
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Plugin palette commands
|
||||
|
||||
/// Stores what tab `label` reported (validated) and tells the shell. `None` clears (page load).
|
||||
pub(super) fn tab_commands_reported(app: &AppHandle, data: &mut NativeData, label: &str, id: &str, list: Option<Vec<PaletteCommand>>) {
|
||||
let list = list.unwrap_or_default();
|
||||
let changed = data.tab_commands.get(label).map(Vec::as_slice).unwrap_or(&[]) != list.as_slice();
|
||||
if list.is_empty() {
|
||||
data.tab_commands.remove(label);
|
||||
} else {
|
||||
data.tab_commands.insert(label.to_owned(), list.clone());
|
||||
}
|
||||
if changed {
|
||||
#[derive(Clone, Serialize)]
|
||||
struct Commands {
|
||||
id: String,
|
||||
commands: Vec<PaletteCommand>,
|
||||
}
|
||||
emit_shell(app, palette_commands::EVENT, Commands { id: id.to_owned(), commands: list });
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a plugin's palette command in tab `id`: only a command that tab reported, only while it is
|
||||
/// on x.com. The command's code runs in the page; the tab gets keyboard focus for what it opens.
|
||||
#[tauri::command]
|
||||
pub(super) async fn tab_run_command(webview: Webview, app: AppHandle, state: State<'_, AppState>, id: String, command: String) -> Result<(), String> {
|
||||
shell_only(&webview)?;
|
||||
let label = format!("{}{id}", crate::policy::TAB_PREFIX);
|
||||
let known = lock(&state)?.tab_commands.get(&label).is_some_and(|list| list.iter().any(|item| item.id == command));
|
||||
if !known {
|
||||
return Err("VALIDATION: that tab has no such command".into());
|
||||
}
|
||||
let tab = tab_webview(&app, &id).ok_or("VALIDATION: no such tab")?;
|
||||
if !tab.url().is_ok_and(|url| crate::policy::x_origin(&url)) {
|
||||
return Err("VALIDATION: the tab is not on x.com".into());
|
||||
}
|
||||
let script = palette_commands::run_script(&SITE, &command)?;
|
||||
let _ = tab.set_focus();
|
||||
tab.eval(&script).map_err(|error| format!("TABS: {error}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Backup
|
||||
|
||||
/// The `drafts` backup section (text, no ids), for the host's backup when the owner ticks "include
|
||||
/// drafts".
|
||||
pub(super) fn backup_drafts(app: &AppHandle) -> Result<Option<Value>, String> {
|
||||
let state = app.state::<AppState>();
|
||||
let drafts = lock(&state)?.store.list_drafts()?;
|
||||
let list: Vec<backup::BackupDraft> = drafts
|
||||
.into_iter()
|
||||
.take(backup::MAX_DRAFTS)
|
||||
.map(|draft| backup::BackupDraft { kind: draft.kind, title: draft.title, text: draft.text, context_url: draft.context_url, status: draft.status })
|
||||
.collect();
|
||||
Ok(Some(json!({ "drafts": list })))
|
||||
}
|
||||
|
||||
/// The host's `backup_apply` command hands the chosen sections here.
|
||||
pub(super) fn backup_apply(app: &AppHandle, sections: &Map<String, Value>) -> Result<Value, String> {
|
||||
let state = app.state::<AppState>();
|
||||
serde_json::to_value(apply_sections(app, &state, sections)?).map_err(|_| "BACKUP: serialization failed".to_string())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct Applied {
|
||||
applied: Vec<String>,
|
||||
notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Applies the chosen native sections (the shell applies `decks` itself). Each section is checked
|
||||
/// as its own setter checks it; one bad section fails the import before anything is written.
|
||||
/// Accounts are merged (names/colors; an account is never removed, so no login is ever deleted);
|
||||
/// drafts are added as new drafts (existing ones untouched, exact duplicates skipped).
|
||||
struct Plan {
|
||||
theme: Option<Option<XTheme>>,
|
||||
scopes: Option<ThemeScopes>,
|
||||
plugins: Option<(Vec<String>, Vec<backup::PluginFile>)>,
|
||||
css: Option<String>,
|
||||
features: Option<Map<String, Value>>,
|
||||
settings: Option<ShellSettingsInput>,
|
||||
alerts: Option<AlertSettings>,
|
||||
accounts: Option<(Vec<Profile>, Option<String>)>,
|
||||
workspaces: Option<WorkspaceStore>,
|
||||
drafts: Option<Vec<backup::BackupDraft>>,
|
||||
}
|
||||
|
||||
fn plan(sections: &Map<String, Value>) -> Result<Plan, String> {
|
||||
let bad = |name: &str, error: String| format!("BACKUP: {name}: {}", error.trim_start_matches("VALIDATION: "));
|
||||
let mut plan = Plan { theme: None, scopes: None, plugins: None, css: None, features: None, settings: None, alerts: None, accounts: None, workspaces: None, drafts: None };
|
||||
if let Some(themes) = sections.get("themes") {
|
||||
let theme: Option<XTheme> = serde_json::from_value(themes.get("theme").cloned().unwrap_or(Value::Null)).map_err(|error| bad("themes", error.to_string()))?;
|
||||
if let Some(theme) = &theme {
|
||||
theme.validate(&X_THEMES).map_err(|error| bad("themes", error))?;
|
||||
}
|
||||
plan.theme = Some(theme);
|
||||
let scopes: Option<ThemeScopes> = serde_json::from_value(themes.get("scopes").cloned().unwrap_or(Value::Null)).map_err(|error| bad("themes", error.to_string()))?;
|
||||
let scopes = scopes.unwrap_or_default();
|
||||
scopes.validate().map_err(|error| bad("themes", error))?;
|
||||
plan.scopes = Some(scopes);
|
||||
}
|
||||
if let Some(value) = sections.get("plugins") {
|
||||
plan.plugins = Some(backup::plugins_section(value)?);
|
||||
}
|
||||
if let Some(value) = sections.get("customCss") {
|
||||
let css = value.get("css").and_then(Value::as_str).unwrap_or("").to_owned();
|
||||
if css.len() > plugins::MAX_CSS_BYTES {
|
||||
return Err(bad("custom CSS", "larger than 512 KiB".into()));
|
||||
}
|
||||
plan.css = Some(css);
|
||||
}
|
||||
if let Some(value) = sections.get("features") {
|
||||
let map = value.as_object().ok_or_else(|| bad("tweaks", "not an object".into()))?;
|
||||
waybetter_host::settings::validate_features(map).map_err(|_| bad("tweaks", "must be a small map of on/off values".into()))?;
|
||||
plan.features = Some(map.clone());
|
||||
}
|
||||
if let Some(value) = sections.get("settings") {
|
||||
let settings: ShellSettingsInput = serde_json::from_value(value.clone()).map_err(|error| bad("browser settings", error.to_string()))?;
|
||||
// Checked now (the home page only fills a missing field), applied over the app's defaults later.
|
||||
ShellSettings::from_input(settings.clone(), super::HOME_URL).map_err(|error| bad("browser settings", error))?;
|
||||
plan.settings = Some(settings);
|
||||
}
|
||||
if let Some(value) = sections.get("alerts") {
|
||||
let alerts: AlertSettings = serde_json::from_value(value.clone()).map_err(|error| bad("keyword alerts", error.to_string()))?;
|
||||
plan.alerts = Some(alerts.validated().map_err(|error| bad("keyword alerts", error))?);
|
||||
}
|
||||
if let Some(value) = sections.get("accounts") {
|
||||
#[derive(Deserialize)]
|
||||
struct Meta {
|
||||
id: String,
|
||||
name: String,
|
||||
color: String,
|
||||
}
|
||||
let list: Vec<Meta> = serde_json::from_value(value.get("profiles").cloned().unwrap_or(json!([]))).map_err(|error| bad("accounts", error.to_string()))?;
|
||||
let profiles = list.into_iter().map(|meta| Profile { id: meta.id, name: meta.name, color: meta.color, account: None }).collect();
|
||||
plan.accounts = Some((profiles, value.get("active").and_then(Value::as_str).map(str::to_owned)));
|
||||
}
|
||||
if let Some(value) = sections.get("workspaces") {
|
||||
let store: WorkspaceStore = serde_json::from_value(value.clone()).map_err(|error| bad("workspaces", error.to_string()))?;
|
||||
plan.workspaces = Some(store.validated().map_err(|error| bad("workspaces", error))?);
|
||||
}
|
||||
if let Some(value) = sections.get("drafts") {
|
||||
plan.drafts = Some(backup::drafts_section(value)?);
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
fn apply_sections(app: &AppHandle, state: &AppState, sections: &Map<String, Value>) -> Result<Applied, String> {
|
||||
let mut plan = plan(sections)?;
|
||||
// A backup from before a bundled plugin became a feature of an area plugin names that plugin's
|
||||
// file: its flag is turned on instead (unless the backup carries the owner's own copy of it).
|
||||
if let Some((enabled, files)) = plan.plugins.take() {
|
||||
let current = wb::page(app)?.features.as_object().cloned().unwrap_or_default();
|
||||
let (enabled, features) = backup::map_superseded(enabled, &files, plan.features.take(), ¤t, crate::areas::SUPERSEDED);
|
||||
plan.plugins = Some((enabled, files));
|
||||
plan.features = features;
|
||||
}
|
||||
let mut out = Applied::default();
|
||||
let mut scripts: Vec<String> = Vec::new();
|
||||
let mut themes_changed = false;
|
||||
{
|
||||
let mut data = lock(state)?;
|
||||
if let Some(theme) = plan.theme {
|
||||
let theme = theme.unwrap_or_else(x_default);
|
||||
if theme.is_empty(&X_THEMES) {
|
||||
if let Some(path) = data_file(app, THEME_FILE) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
data.theme = None;
|
||||
} else {
|
||||
write_json(app, THEME_FILE, &theme)?;
|
||||
data.theme = Some(theme);
|
||||
}
|
||||
themes_changed = true;
|
||||
out.applied.push("themes".into());
|
||||
}
|
||||
if let Some(scopes) = plan.scopes {
|
||||
if scopes.is_default() {
|
||||
if let Some(path) = data_file(app, THEME_SCOPES_FILE) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
} else {
|
||||
write_json(app, THEME_SCOPES_FILE, &scopes)?;
|
||||
}
|
||||
data.theme_scopes = scopes;
|
||||
themes_changed = true;
|
||||
}
|
||||
if let Some((enabled, files)) = plan.plugins {
|
||||
// The host keeps the plugin store (`wb::page`), locked after the app state.
|
||||
let page = wb::page(app)?;
|
||||
page.plugins.ensure()?;
|
||||
let dir = page.plugins.dir().to_path_buf();
|
||||
let before: Vec<String> = page.plugins.list()?.into_iter().filter(|plugin| plugin.enabled).map(|plugin| plugin.file).collect();
|
||||
for file in &files {
|
||||
let path = dir.join(&file.file);
|
||||
if std::fs::read_to_string(&path).is_ok_and(|current| current == file.source) {
|
||||
continue;
|
||||
}
|
||||
if before.contains(&file.file) {
|
||||
scripts.push(plugins::stop_script(&file.file)?);
|
||||
}
|
||||
backup::write_atomic(&path, file.source.as_bytes())?;
|
||||
}
|
||||
let present: Vec<String> = page.plugins.list()?.into_iter().map(|plugin| plugin.file).collect();
|
||||
for plugin in &present {
|
||||
let want = enabled.contains(plugin);
|
||||
let was = before.contains(plugin);
|
||||
let rewritten = files.iter().any(|file| &file.file == plugin);
|
||||
if want != was || (want && rewritten) {
|
||||
page.plugins.set_enabled(plugin, want)?;
|
||||
if want {
|
||||
if let Ok(script) = page.plugins.start_script(plugin) {
|
||||
scripts.push(script);
|
||||
}
|
||||
} else {
|
||||
scripts.push(plugins::stop_script(plugin)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
let missing = enabled.iter().filter(|file| !present.contains(file)).count();
|
||||
if missing > 0 {
|
||||
out.notes.push(format!("{missing} enabled plugin(s) are not in this backup or on this computer."));
|
||||
}
|
||||
out.applied.push("plugins".into());
|
||||
}
|
||||
// Custom CSS, the tweak flags and the browser settings are the host's: saved and applied
|
||||
// in every X tab by its setters.
|
||||
if let Some(css) = plan.css {
|
||||
wb::set_custom_css(app, &css)?;
|
||||
out.applied.push("customCss".into());
|
||||
}
|
||||
if let Some(features) = plan.features {
|
||||
wb::set_features(app, features)?;
|
||||
out.applied.push("features".into());
|
||||
}
|
||||
if let Some(settings) = plan.settings {
|
||||
wb::set_settings(app, settings)?;
|
||||
out.applied.push("settings".into());
|
||||
}
|
||||
if let Some(alerts) = plan.alerts {
|
||||
write_json(app, ALERTS_FILE, &alerts)?;
|
||||
scripts.push(alerts_script(&alerts));
|
||||
data.alerts = alerts;
|
||||
out.applied.push("alerts".into());
|
||||
}
|
||||
if let Some((imported, active)) = plan.accounts {
|
||||
// The framework keeps the profiles (waybetter-host `app`); locked after the app state,
|
||||
// like every other caller.
|
||||
let mut profiles = waybetter_host::app::profiles(app)?;
|
||||
let mut next: ProfileSettings = profiles.settings.clone();
|
||||
let mut added = 0;
|
||||
for profile in imported {
|
||||
if let Some(existing) = next.profiles.iter_mut().find(|current| current.id == profile.id) {
|
||||
existing.name = profile.name;
|
||||
existing.color = profile.color;
|
||||
} else {
|
||||
next.profiles.push(profile);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
if let Some(active) = active.filter(|active| next.profiles.iter().any(|profile| &profile.id == active)) {
|
||||
next.active = active;
|
||||
}
|
||||
let next = next.validated(&SITE.accounts)?.keep_accounts_from(&profiles.settings);
|
||||
write_json(app, PROFILES_FILE, &next)?;
|
||||
profiles.settings = next.clone();
|
||||
drop(profiles);
|
||||
emit_shell(app, waybetter_host::tabs::EVENT_PROFILES_CHANGED, next);
|
||||
if added > 0 {
|
||||
out.notes.push(format!("{added} account(s) added. Sign in to X in each of them; logins are never part of a backup."));
|
||||
}
|
||||
out.applied.push("accounts".into());
|
||||
}
|
||||
if let Some(store) = plan.workspaces {
|
||||
write_json(app, workspaces::FILE, &store)?;
|
||||
out.applied.push("workspaces".into());
|
||||
}
|
||||
if let Some(drafts) = plan.drafts {
|
||||
let existing: Vec<(String, String)> = data.store.list_drafts()?.into_iter().map(|draft| (draft.kind, draft.text)).collect();
|
||||
let mut added = 0;
|
||||
for draft in drafts {
|
||||
if existing.iter().any(|(kind, text)| *kind == draft.kind && *text == draft.text) {
|
||||
continue;
|
||||
}
|
||||
let saved = data.store.save_draft(&SaveDraftInput {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
account_id: "local".into(),
|
||||
kind: draft.kind,
|
||||
title: draft.title,
|
||||
text: draft.text,
|
||||
context_url: draft.context_url,
|
||||
expected_revision: 0,
|
||||
source: "local".into(),
|
||||
})?;
|
||||
if draft.status == "archived" {
|
||||
data.store.set_status(&saved.id, saved.revision, "archived")?;
|
||||
}
|
||||
added += 1;
|
||||
}
|
||||
out.notes.push(format!("{added} draft(s) added."));
|
||||
out.applied.push("drafts".into());
|
||||
}
|
||||
}
|
||||
for script in scripts {
|
||||
eval_in_x_tabs(app, &script);
|
||||
}
|
||||
if themes_changed {
|
||||
apply_themes(app);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
39
apps/desktop/src-tauri/src/desktop_update.rs
Normal file
39
apps/desktop/src-tauri/src/desktop_update.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
//! Native updates (tauri-plugin-updater, quiet NSIS) and the signed data-only hotfix channel, run
|
||||
//! by the wayBetter host (`waybetter_host::updater`) with X's channel, key and download page.
|
||||
//!
|
||||
//! Everything runs in Rust. The shell's four narrow commands (`update_status`, `update_check`,
|
||||
//! `apply_update`, `app_ready`) and the loop are the host's (`AppConfig::with_updates`); X tabs get
|
||||
//! no updater IPC at all, only the verified hotfix data injected as `window.__btHotfix`.
|
||||
|
||||
use tauri::AppHandle;
|
||||
use waybetter_host::updater::{self, UpdaterConfig};
|
||||
|
||||
pub(crate) static CONFIG: UpdaterConfig = UpdaterConfig {
|
||||
site: &crate::site::SITE,
|
||||
hotfix_url: crate::hotfix::HOTFIX_URL,
|
||||
hotfix_pubkey: crate::hotfix::HOTFIX_PUBKEY,
|
||||
hotfix_global: "__btHotfix",
|
||||
hotfix_event: "bt-hotfix",
|
||||
manual_download_url: crate::update::MANUAL_DOWNLOAD_URL,
|
||||
user_agent: "BetterTwttr",
|
||||
on_ready,
|
||||
};
|
||||
|
||||
/// A downloaded update: a native notification in the owner's language.
|
||||
fn on_ready(app: &AppHandle, version: &str) {
|
||||
crate::desktop::notify(
|
||||
app,
|
||||
&crate::desktop_i18n::tr(app, "native.notify.update.title", "wayBetter for X update ready"),
|
||||
&crate::desktop_i18n::trf(
|
||||
app,
|
||||
"native.notify.update.body",
|
||||
"Version {version} is downloaded. Restart wayBetter for X to finish updating.",
|
||||
&[("version", version)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Hotfix data for a freshly loaded X document, if one is held.
|
||||
pub fn hotfix_script(app: &AppHandle) -> Option<String> {
|
||||
updater::hotfix_script(app)
|
||||
}
|
||||
45
apps/desktop/src-tauri/src/devmode.rs
Normal file
45
apps/desktop/src-tauri/src/devmode.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//! Live-reload developer mode (Settings → Developer, off by default): the wayBetter host's watcher
|
||||
//! (`waybetter_host::devmode`: plugins folder, `custom.css` and one owner-picked theme file,
|
||||
//! debounced, fixed reload order) with X's theme loading. Shell-only; X tabs gain no command (they
|
||||
//! only receive the same scripts native code already evaluates: plugin start/stop, custom CSS, the
|
||||
//! theme preview).
|
||||
|
||||
use std::path::Path;
|
||||
pub use waybetter_host::devmode::*;
|
||||
|
||||
/// Reads and checks the watched theme file. The document must be one theme (`{ id, name, ... }`,
|
||||
/// like Appearance → Import); the result is the preview state `{ preset: id, themes: [doc] }`.
|
||||
pub fn load_theme_file(path: &Path) -> Result<crate::theme::XTheme, DevError> {
|
||||
waybetter_host::devmode::load_theme_file(path, |mut object| {
|
||||
let id = object.get("id").and_then(serde_json::Value::as_str).unwrap_or("").to_owned();
|
||||
// A theme being developed may reuse a built-in id; it is shown under its own id.
|
||||
let id = if crate::theme::PRESETS.contains(&id.as_str()) { format!("{id}-dev") } else { id };
|
||||
object.insert("id".into(), serde_json::Value::String(id.clone()));
|
||||
let theme = crate::theme::XTheme { preset: id, accent: None, overrides: serde_json::Map::new(), themes: vec![serde_json::Value::Object(object)], ..crate::theme::x_default() };
|
||||
theme.validate(&crate::theme::X_THEMES)?;
|
||||
Ok(theme)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn theme_file_errors_carry_line_and_column() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("mine.json");
|
||||
fs::write(&path, "{\n \"id\": \"mine\",\n \"name\": \"Mine\"\n \"colors\": {}\n}").unwrap();
|
||||
let error = load_theme_file(&path).unwrap_err();
|
||||
assert_eq!((error.file.as_str(), error.line), ("mine.json", Some(4)));
|
||||
assert!(error.column.is_some() && error.message.contains("expected"), "{error:?}");
|
||||
fs::write(&path, r#"{ "$schema": "https://unpkg.com/waybetter/theme.schema.json", "id": "nord", "name": "My Nord" }"#).unwrap();
|
||||
let theme = load_theme_file(&path).unwrap();
|
||||
assert_eq!(theme.preset, "nord-dev", "a built-in id is previewed under its own id");
|
||||
assert!(theme.themes[0].get("$schema").is_none());
|
||||
fs::write(&path, r#"{ "id": "Bad Id", "name": "x" }"#).unwrap();
|
||||
assert!(load_theme_file(&path).is_err());
|
||||
assert!(DevSettings { enabled: true, theme_file: Some("relative.json".into()) }.validated().is_err());
|
||||
}
|
||||
}
|
||||
86
apps/desktop/src-tauri/src/distribution.rs
Normal file
86
apps/desktop/src-tauri/src/distribution.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! wayBetter for X's part of distribution (owner-approved widening, 2026-09-28): installing themes
|
||||
//! and plugins from a `waybetter://install?kind=theme|plugin&url=<https URL>` link, a dropped file,
|
||||
//! or the signed wayBetter Store. The rules, the catalog format and the install state are the
|
||||
//! framework's (waybetter-host `store`, docs in waybetter/docs/STORE.md); this app supplies only its
|
||||
//! settings below, and the framework serves the shell-only Tauri commands (waybetter-host
|
||||
//! `store_commands`, enabled by `AppConfig::with_store`).
|
||||
//!
|
||||
//! Trust model (see waybetter-host `store` for the details):
|
||||
//! - Nothing installs without the shell's confirmation screen (name, author, version, source,
|
||||
//! sha256, permissions, code/CSS preview). Only the local shell webview can call the install
|
||||
//! commands; X tabs and web pages cannot. A web page can at most open a `waybetter://` link,
|
||||
//! which only *proposes* an install.
|
||||
//! - Links may only point at https URLs on [`STORE`]'s hosts (waybetter.fyi, cdn1.crt.fyi, and
|
||||
//! git.crt.fyi only for raw files), default port, no credentials; native code fetches them
|
||||
//! without redirects or cookies and with size caps.
|
||||
//! - The store catalog carries a detached Ed25519 signature made offline with the store key
|
||||
//! (`waybetter store sign`, via `scripts/sign-store.mjs`); the app verifies it with the public key
|
||||
//! compiled in (`keys/store.pub`), keeps only items for app id "x", and then checks every
|
||||
//! downloaded item against the catalog's sha256 and size.
|
||||
|
||||
pub use waybetter_host::store::*;
|
||||
|
||||
/// Store public keys (base64 of the raw 32-byte Ed25519 key). More than one allows rotation.
|
||||
pub const STORE_PUBKEYS: &[&str] = &[include_str!("../keys/store.pub")];
|
||||
|
||||
/// wayBetter for X in the shared store: its id in the catalog and where it may download from.
|
||||
pub static STORE: StoreConfig = StoreConfig {
|
||||
app_id: "x",
|
||||
product: "wayBetter for X",
|
||||
user_agent: concat!("wayBetter-for-X/", env!("CARGO_PKG_VERSION")),
|
||||
scheme: "waybetter",
|
||||
store_base: DEFAULT_STORE_BASE,
|
||||
public_keys: STORE_PUBKEYS,
|
||||
allowed_hosts: DEFAULT_HOSTS,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn production_store_key_parses() {
|
||||
assert!(decode_key(STORE_PUBKEYS[0]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x_links_keep_the_hosts_and_scheme_they_had() {
|
||||
let hosts: Vec<(&str, bool)> = STORE.allowed_hosts.iter().map(|h| (h.host, h.raw_files_only)).collect();
|
||||
assert_eq!(hosts, [("waybetter.fyi", false), ("cdn1.crt.fyi", false), ("git.crt.fyi", true)]);
|
||||
assert_eq!((STORE.app_id, STORE.scheme, STORE.store_base), ("x", "waybetter", "https://waybetter.fyi/store/"));
|
||||
assert!(parse_install_link(&STORE, "waybetter://install?kind=theme&url=https://waybetter.fyi/store/themes/nord.json").is_ok());
|
||||
assert!(parse_install_link(&STORE, "waybetter://install?kind=theme&url=https://evil.example/a.json").is_err());
|
||||
}
|
||||
|
||||
/// `waybetter store sign` (Node crypto) and this verifier agree: the committed store catalog
|
||||
/// verifies with the production key, and every X item file matches its catalog entry.
|
||||
#[test]
|
||||
fn committed_store_catalog_verifies_with_the_production_key() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../web/public/store");
|
||||
let Ok(body) = std::fs::read(dir.join("catalog.json")) else {
|
||||
panic!("apps/web/public/store/catalog.json missing: run node scripts/build-store.mjs && node scripts/sign-store.mjs");
|
||||
};
|
||||
let signature = std::fs::read_to_string(dir.join("catalog.json.sig")).expect("catalog.json.sig");
|
||||
let catalog = verify_catalog(&STORE, &body, &signature).expect("store catalog must verify: rebuild and re-sign it");
|
||||
assert!(catalog.items.iter().any(|item| item.kind == Kind::Plugin) && catalog.items.iter().any(|item| item.kind == Kind::Theme));
|
||||
for item in &catalog.items {
|
||||
assert!(item.is_for("x"));
|
||||
let relative = item.url.strip_prefix(STORE.store_base).expect("store items live under the store base");
|
||||
let bytes = std::fs::read(dir.join(relative)).unwrap_or_else(|_| panic!("{relative} missing"));
|
||||
verify_item(item, &bytes).unwrap_or_else(|e| panic!("{relative}: {e}"));
|
||||
match item.kind {
|
||||
Kind::Plugin => {
|
||||
let validated = validate_plugin(item.file.as_deref().unwrap(), &bytes).unwrap_or_else(|e| panic!("{relative}: {e}"));
|
||||
assert_eq!(validated.meta.version, item.version, "{relative}: catalog version = header version");
|
||||
assert_eq!(validated.meta.permissions, item.permissions, "{relative}: catalog permissions = header");
|
||||
}
|
||||
Kind::Theme => {
|
||||
validate_theme(&bytes).unwrap_or_else(|e| panic!("{relative}: {e}"));
|
||||
}
|
||||
Kind::Strings => {
|
||||
validate_strings(&bytes).unwrap_or_else(|e| panic!("{relative}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apps/desktop/src-tauri/src/gfonts.rs
Normal file
25
apps/desktop/src-tauri/src/gfonts.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! Google Fonts, self-hosted (owner-approved network use, `fonts.googleapis.com` /
|
||||
//! `fonts.gstatic.com` only): CSS parsing, URL/byte validation and the cache format come from the
|
||||
//! wayBetter host (`waybetter_host::gfonts`); X pages get the bytes through `BetterTwttr.fonts`.
|
||||
|
||||
use crate::site::SITE;
|
||||
pub use waybetter_host::gfonts::*;
|
||||
|
||||
/// The script evaluated in every X tab so the page can hand cached font bytes to
|
||||
/// `document.fonts` without X's CSP ever seeing a remote font URL.
|
||||
pub fn page_script(fonts: &[serde_json::Value]) -> Option<String> {
|
||||
waybetter_host::gfonts::page_script(&SITE, fonts)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn page_script_is_for_x_and_the_x_runtime_only() {
|
||||
let script = page_script(&[serde_json::json!({ "family": "Inter", "faces": [] })]).unwrap();
|
||||
assert!(script.starts_with("(()=>{if(window.top!==window||location.origin!=='https://x.com')return;"));
|
||||
assert!(script.contains("const BT=window.BetterTwttr;if(BT&&BT.fonts)BT.fonts.provide(f);"));
|
||||
assert!(page_script(&[]).is_none());
|
||||
}
|
||||
}
|
||||
25
apps/desktop/src-tauri/src/hotfix.rs
Normal file
25
apps/desktop/src-tauri/src/hotfix.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! X's hotfix channel on the wayBetter host's signed, data-only hotfix format: selector and flag
|
||||
//! overrides for x.com markup changes, delivered to X tabs as frozen data (`window.__btHotfix`).
|
||||
|
||||
pub use waybetter_host::hotfix::{accept, validate, verify, version_at_least, Hotfix, Outcome, MAX_BODY_BYTES, MAX_SIG_BYTES};
|
||||
|
||||
/// Hotfix public key (base64 of the minisign `.pub` file, as `tauri signer generate` writes it).
|
||||
pub const HOTFIX_PUBKEY: &str = include_str!("../keys/hotfix.key.pub");
|
||||
/// Default channel URL. `.sig` is appended for the signature.
|
||||
pub const HOTFIX_URL: &str = "https://twttr.crt.fyi/v1/hotfix/stable";
|
||||
|
||||
/// Delivers the hotfix to an X page as data (`window.__btHotfix`, `bt-hotfix` event).
|
||||
pub fn script(hotfix: &Hotfix) -> String {
|
||||
waybetter_host::hotfix::script(hotfix, "__btHotfix", "bt-hotfix")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn production_key_parses() {
|
||||
let text = waybetter_host::hotfix::minisign_text(HOTFIX_PUBKEY).unwrap();
|
||||
assert!(text.contains("minisign public key"));
|
||||
}
|
||||
}
|
||||
39
apps/desktop/src-tauri/src/lib.rs
Normal file
39
apps/desktop/src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
pub mod alerts;
|
||||
pub mod areas;
|
||||
pub mod backup;
|
||||
pub mod capture;
|
||||
pub mod column;
|
||||
pub mod compose;
|
||||
pub mod db;
|
||||
pub mod devmode;
|
||||
pub mod distribution;
|
||||
pub mod gfonts;
|
||||
pub mod hotfix;
|
||||
pub mod media;
|
||||
pub mod model;
|
||||
pub mod palette_commands;
|
||||
pub mod plugins;
|
||||
pub mod profiles;
|
||||
pub mod site;
|
||||
pub mod sync;
|
||||
pub mod policy;
|
||||
pub mod popover;
|
||||
pub mod theme;
|
||||
pub mod update;
|
||||
pub mod workspaces;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
mod desktop;
|
||||
#[cfg(feature = "desktop")]
|
||||
#[cfg(feature = "desktop")]
|
||||
mod desktop_i18n;
|
||||
#[cfg(feature = "desktop")]
|
||||
mod desktop_media;
|
||||
#[cfg(feature = "desktop")]
|
||||
mod desktop_popover;
|
||||
#[cfg(feature = "desktop")]
|
||||
#[cfg(feature = "desktop")]
|
||||
mod desktop_update;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
pub use desktop::run;
|
||||
5
apps/desktop/src-tauri/src/main.rs
Normal file
5
apps/desktop/src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
better_twttr::run();
|
||||
}
|
||||
86
apps/desktop/src-tauri/src/media.rs
Normal file
86
apps/desktop/src-tauri/src/media.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! Gallery folders and the local clipboard history come from the wayBetter host
|
||||
//! (`waybetter_host::media`); this module adds attaching an image to an X composer. Nothing here
|
||||
//! posts anything.
|
||||
|
||||
use base64::Engine;
|
||||
pub use waybetter_host::media::*;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Attaching to the composer
|
||||
|
||||
/// Builds a self-contained script that attaches one image to the targeted X composer by setting
|
||||
/// the composer's own file input through a DataTransfer (the same path as picking a file), with a
|
||||
/// synthetic drop on the editor as fallback. It never clicks anything and never submits.
|
||||
pub fn attach_script(name: &str, mime: &str, bytes: &[u8]) -> Result<String, String> {
|
||||
if bytes.is_empty() || bytes.len() as u64 > MAX_IMAGE_BYTES {
|
||||
return Err("VALIDATION: image must be 1 byte to 16 MB".into());
|
||||
}
|
||||
if !mime.starts_with("image/") {
|
||||
return Err("VALIDATION: not an image".into());
|
||||
}
|
||||
let safe_name: String = name
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && !matches!(c, '/' | '\\'))
|
||||
.take(120)
|
||||
.collect();
|
||||
let safe_name = if safe_name.is_empty() { "image".to_string() } else { safe_name };
|
||||
let name = serde_json::to_string(&safe_name).map_err(|_| "VALIDATION: bad name")?;
|
||||
let mime = serde_json::to_string(mime).map_err(|_| "VALIDATION: bad type")?;
|
||||
let data = serde_json::to_string(&base64::engine::general_purpose::STANDARD.encode(bytes))
|
||||
.map_err(|_| "VALIDATION: bad data")?;
|
||||
let name = name.replace('\u{2028}', "\\u2028").replace('\u{2029}', "\\u2029");
|
||||
Ok(ATTACH_TEMPLATE
|
||||
.replace("__ATTR__", crate::compose::TARGET_ATTRIBUTE)
|
||||
.replacen("__MIME__", &mime, 1)
|
||||
// Data before name, and name last: the file name is untrusted text from disk and must
|
||||
// never be searched for later placeholders (a name containing "__DATA__" broke the script).
|
||||
.replacen("__DATA__", &data, 1)
|
||||
.replacen("__NAME__", &name, 1))
|
||||
}
|
||||
|
||||
const ATTACH_TEMPLATE: &str = r#"(()=>{try{const attr='__ATTR__';const mime=__MIME__;const name=__NAME__;const b64=__DATA__;
|
||||
if(location.origin!=='https://x.com'||/^\/(?:messages|i\/(?:chat|grok))(?:\/|$)/i.test(location.pathname))return;
|
||||
const sel='[data-testid^="tweetTextarea_"][role="textbox"][contenteditable="true"]';
|
||||
const all=[...document.querySelectorAll(sel)];const active=document.activeElement;
|
||||
const editor=all.find(e=>e.hasAttribute(attr))||all.find(e=>active&&e.contains(active))||(all.length===1?all[0]:null);
|
||||
if(!editor)return;
|
||||
const raw=atob(b64);const buf=new Uint8Array(raw.length);for(let i=0;i<raw.length;i++)buf[i]=raw.charCodeAt(i);
|
||||
const file=new File([buf],name,{type:mime,lastModified:Date.now()});
|
||||
const dt=new DataTransfer();dt.items.add(file);
|
||||
let scope=editor.parentElement;let input=null;
|
||||
while(scope&&!input){input=scope.querySelector('input[type="file"][data-testid="fileInput"]');scope=scope.parentElement;}
|
||||
editor.focus();
|
||||
if(input){input.files=dt.files;input.dispatchEvent(new Event('change',{bubbles:true}));return;}
|
||||
for(const type of ['dragenter','dragover','drop'])editor.dispatchEvent(new DragEvent(type,{dataTransfer:dt,bubbles:true,cancelable:true}));
|
||||
}catch(error){console.error('[wayBetter for X] image attach failed',error);}})();"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn attach_script_embeds_literals_and_never_submits() {
|
||||
let script = attach_script("a\"b</script>\u{2028}.png", "image/png", b"\x89PNG").unwrap();
|
||||
assert!(script.contains(r#"const name="a\"b<script>\u2028.png";"#));
|
||||
assert!(script.contains("const mime=\"image/png\""));
|
||||
assert!(!script.contains("__DATA__") && !script.contains("__NAME__"));
|
||||
assert!(!script.contains("tweetButton"));
|
||||
assert!(!script.contains(".click("));
|
||||
assert!(!script.contains("submit"));
|
||||
// A file name that contains a placeholder is embedded verbatim and does not swallow data.
|
||||
let tricky = attach_script("x__DATA__y__MIME__.png", "image/png", b"PNG").unwrap();
|
||||
assert!(tricky.contains(r#"const name="x__DATA__y__MIME__.png";"#));
|
||||
assert!(tricky.contains(r#"const b64="UE5H";"#));
|
||||
assert!(tricky.contains(r#"const mime="image/png";"#));
|
||||
assert!(attach_script("x", "text/html", b"x").is_err());
|
||||
assert!(attach_script("x", "image/png", b"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_and_extension() {
|
||||
assert_eq!(mime_for(Path::new("A.JPG")), "image/jpeg");
|
||||
assert!(is_image_name(Path::new("x.webp")));
|
||||
assert!(!is_image_name(Path::new("x.svg")));
|
||||
}
|
||||
}
|
||||
80
apps/desktop/src-tauri/src/model.rs
Normal file
80
apps/desktop/src-tauri/src/model.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct Draft {
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub text: String,
|
||||
pub context_url: String,
|
||||
pub revision: i64,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DraftRevision {
|
||||
pub draft_id: String,
|
||||
pub revision: i64,
|
||||
pub title: String,
|
||||
pub text: String,
|
||||
pub context_url: String,
|
||||
pub source: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SaveDraftInput {
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub text: String,
|
||||
pub context_url: String,
|
||||
pub expected_revision: i64,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct Observation {
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub user_id: String,
|
||||
pub following: Option<bool>,
|
||||
pub followed_by: Option<bool>,
|
||||
pub observed_at: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportData {
|
||||
pub version: u8,
|
||||
pub exported_at: String,
|
||||
pub drafts: Vec<Draft>,
|
||||
pub revisions: Vec<DraftRevision>,
|
||||
pub observations: Vec<Observation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CaptureInput {
|
||||
pub composer_id: String,
|
||||
pub sequence: u64,
|
||||
pub text: String,
|
||||
pub context_url: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct CaptureAck {
|
||||
pub sequence: u64,
|
||||
pub revision: i64,
|
||||
}
|
||||
11
apps/desktop/src-tauri/src/palette_commands.rs
Normal file
11
apps/desktop/src-tauri/src/palette_commands.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//! Plugin commands in the shell's command palette (owner-approved widening, 2026-09-28).
|
||||
//!
|
||||
//! An X tab may add `commands` to `tab_report`: the list of palette commands its plugins registered
|
||||
//! (`api.commands.register`, waybetter runtime `commands.ts`), as `{ id, title, keywords }` strings
|
||||
//! about itself. Accepted only from `tab-*` webviews that are on x.com right now, validated and
|
||||
//! capped by the wayBetter host (`waybetter_host::plugins::commands`), kept per tab (a new page load
|
||||
//! clears it) and forwarded to the shell. Running one is the shell-only `tab_run_command { id,
|
||||
//! command }`: native code checks the command is one this tab reported and asks that tab's page to
|
||||
//! `run` it; the command's code only ever runs in the page.
|
||||
|
||||
pub use waybetter_host::plugins::commands::*;
|
||||
70
apps/desktop/src-tauri/src/plugins.rs
Normal file
70
apps/desktop/src-tauri/src/plugins.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//! BetterDiscord-style extensions for the X page (wayBetter host plugin store): `*.plugin.js`
|
||||
//! files and custom CSS, the X example plugin and the first-party plugins shipped with the app.
|
||||
//!
|
||||
//! The small bundled plugins are being folded into the eight area plugins (docs/AREA-PLUGINS.md).
|
||||
//! A plugin whose feature now runs from its area (src/areas/, behind a flag) is listed in
|
||||
//! `areas::SUPERSEDED` (generated from the feature table): the host never writes its file again,
|
||||
//! and an existing copy is moved over once (`waybetter_host::plugins::migrate`). It stays in
|
||||
//! [`BUNDLED`] and in `bundled-plugins/` while the Store still offers it, so this file does not
|
||||
//! change when an area ports a plugin.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use waybetter_host::plugins::PluginHost;
|
||||
pub use waybetter_host::plugins::{
|
||||
parse_meta, parse_permissions, source_findings, valid_file_name, validate_plugin, PluginStore, ScriptPlugin, ValidatedPlugin,
|
||||
MAX_CSS_BYTES, MAX_PLUGIN_BYTES, PERMISSIONS, PLUGIN_SUFFIX,
|
||||
};
|
||||
|
||||
/// First-party plugins shipped with the app. Written disabled; see `PluginStore::install_bundled`.
|
||||
/// The ones in `areas::SUPERSEDED` are not written any more (their feature is in an area plugin).
|
||||
pub const BUNDLED: &[(&str, &str)] = &[
|
||||
("bt-alt-text-reminder.plugin.js", include_str!("../bundled-plugins/alt-text-reminder.plugin.js")),
|
||||
("bt-clean-links.plugin.js", include_str!("../bundled-plugins/clean-links.plugin.js")),
|
||||
("bt-copy-as-markdown.plugin.js", include_str!("../bundled-plugins/copy-as-markdown.plugin.js")),
|
||||
("bt-emoji-picker.plugin.js", include_str!("../bundled-plugins/emoji-picker.plugin.js")),
|
||||
("bt-exact-timestamps.plugin.js", include_str!("../bundled-plugins/exact-timestamps.plugin.js")),
|
||||
("bt-focus-mode.plugin.js", include_str!("../bundled-plugins/focus-mode.plugin.js")),
|
||||
("bt-hide-blue-checks.plugin.js", include_str!("../bundled-plugins/hide-blue-checks.plugin.js")),
|
||||
("bt-keyboard-plus.plugin.js", include_str!("../bundled-plugins/keyboard-plus.plugin.js")),
|
||||
("bt-media-grid-focus.plugin.js", include_str!("../bundled-plugins/media-grid-focus.plugin.js")),
|
||||
("bt-muted-regex.plugin.js", include_str!("../bundled-plugins/muted-regex.plugin.js")),
|
||||
("bt-reading-time.plugin.js", include_str!("../bundled-plugins/reading-time.plugin.js")),
|
||||
("bt-reply-guard.plugin.js", include_str!("../bundled-plugins/reply-guard.plugin.js")),
|
||||
("bt-thread-reader.plugin.js", include_str!("../bundled-plugins/thread-reader.plugin.js")),
|
||||
("bt-user-notes.plugin.js", include_str!("../bundled-plugins/user-notes.plugin.js")),
|
||||
];
|
||||
|
||||
pub static HOST: PluginHost = PluginHost {
|
||||
runtime_global: "BetterTwttr",
|
||||
origin: "https://x.com",
|
||||
product: "wayBetter for X",
|
||||
// DOM id kept from before the rename so saved setups keep working.
|
||||
css_element_id: "x-workspace-custom-css",
|
||||
example: Some(("example-hide-trends.plugin.js", include_str!("../example.plugin.js"))),
|
||||
bundled: BUNDLED,
|
||||
superseded: crate::areas::SUPERSEDED,
|
||||
};
|
||||
|
||||
/// The plugin store in `dir` (the app-data `plugins` folder).
|
||||
pub fn store(dir: PathBuf) -> PluginStore {
|
||||
PluginStore::new(dir, &HOST)
|
||||
}
|
||||
|
||||
/// Wraps a CommonJS-style plugin; native code evaluates the result in X tabs.
|
||||
pub fn start_script(file: &str, source: &str) -> Result<String, String> {
|
||||
waybetter_host::plugins::start_script(&HOST, file, source)
|
||||
}
|
||||
|
||||
pub fn stop_script(file: &str) -> Result<String, String> {
|
||||
waybetter_host::plugins::stop_script(&HOST, file)
|
||||
}
|
||||
|
||||
/// Developer mode (live reload): [`start_script`] evaluated as `<source_url_prefix><file>`, so stack
|
||||
/// traces name the file and line, with start failures shown in the page (`BetterTwttr.devError`).
|
||||
pub fn dev_start_script(file: &str, source: &str, source_url_prefix: &str) -> Result<String, String> {
|
||||
waybetter_host::plugins::dev_start_script(&HOST, file, source, source_url_prefix)
|
||||
}
|
||||
|
||||
pub fn css_script(css: &str) -> Result<String, String> {
|
||||
waybetter_host::plugins::css_script(&HOST, css)
|
||||
}
|
||||
81
apps/desktop/src-tauri/src/policy.rs
Normal file
81
apps/desktop/src-tauri/src/policy.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! X's policy on top of the wayBetter host policy (labels, local shell, tab rect clamp, site tabs):
|
||||
//! the exact X origin, legacy twitter.com login routes, capture contexts and text limits.
|
||||
|
||||
use crate::site::SITE;
|
||||
use url::Url;
|
||||
pub use waybetter_host::policy::{
|
||||
clamp_tab_rect, local_navigation_allowed, plain_origin, require_local, tab_id, valid_identifier, valid_tab_id,
|
||||
MAIN_LABEL, RESIZE_BORDER, SHELL_LABEL, TAB_PREFIX, TITLEBAR_H,
|
||||
};
|
||||
|
||||
pub const MAX_TEXT_BYTES: usize = 262_144;
|
||||
pub const MAX_CAPTURE_TEXT_BYTES: usize = 65_536;
|
||||
|
||||
pub fn x_origin(url: &Url) -> bool {
|
||||
SITE.is_origin(url)
|
||||
}
|
||||
|
||||
pub fn x_navigation_allowed(url: &Url) -> bool {
|
||||
x_origin(url)
|
||||
|| (plain_origin(url)
|
||||
&& url.scheme() == "https"
|
||||
&& url.host_str() == Some("twitter.com")
|
||||
&& url.port_or_known_default() == Some(443)
|
||||
&& (url.path() == "/login"
|
||||
|| url.path() == "/i/flow/login"
|
||||
|| url.path().starts_with("/i/flow/login/")))
|
||||
}
|
||||
|
||||
/// X-tab commands: a tab webview on exactly https://x.com.
|
||||
pub fn require_capture(label: &str, url: &Url) -> Result<(), String> {
|
||||
waybetter_host::policy::require_site_tab(&SITE, label, url).map_err(|_| "FORBIDDEN: exact X capture origin required".into())
|
||||
}
|
||||
|
||||
pub fn validate_context(value: &str, capture: bool) -> Result<(), String> {
|
||||
if value.is_empty() && !capture {
|
||||
return Ok(());
|
||||
}
|
||||
if value.len() > 2_048 {
|
||||
return Err("VALIDATION: context URL too long".into());
|
||||
}
|
||||
let url = Url::parse(value).map_err(|_| "VALIDATION: invalid context URL")?;
|
||||
if !plain_origin(&url)
|
||||
|| url.scheme() != "https"
|
||||
|| !matches!(url.host_str(), Some("x.com" | "twitter.com"))
|
||||
|| url.port().is_some()
|
||||
{
|
||||
return Err("VALIDATION: context must be an HTTPS X/Twitter URL".into());
|
||||
}
|
||||
if capture
|
||||
&& (!x_origin(&url)
|
||||
|| url.path() == "/messages"
|
||||
|| url.path().starts_with("/messages/")
|
||||
|| url.path() == "/i/chat"
|
||||
|| url.path().starts_with("/i/chat/"))
|
||||
{
|
||||
return Err("VALIDATION: unsupported capture context".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn x_tab_commands_need_a_tab_on_exactly_x() {
|
||||
let x = Url::parse("https://x.com/home").unwrap();
|
||||
assert!(require_capture("tab-1", &x).is_ok());
|
||||
assert_eq!(require_capture("shell", &x).unwrap_err(), "FORBIDDEN: exact X capture origin required");
|
||||
for bad in ["https://twitter.com/home", "https://www.x.com/", "http://x.com/", "https://x.com:8443/"] {
|
||||
assert!(require_capture("tab-1", &Url::parse(bad).unwrap()).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_layout_constants_are_the_shell_ones() {
|
||||
// The shell's chrome.ts mirrors these; the framework owns both.
|
||||
assert_eq!((TITLEBAR_H, RESIZE_BORDER), (44.0, 5.0));
|
||||
assert_eq!(clamp_tab_rect(0.0, 0.0, 100.0, 100.0, 1000.0, 700.0, true, true).unwrap(), Some((0.0, TITLEBAR_H, 100.0, 100.0 - TITLEBAR_H)));
|
||||
}
|
||||
}
|
||||
297
apps/desktop/src-tauri/src/popover.rs
Normal file
297
apps/desktop/src-tauri/src/popover.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
//! Drafts popover geometry and the "continue your draft?" hint check. Pure and headless-tested;
|
||||
//! `desktop_popover.rs` drives the native webview with it.
|
||||
//!
|
||||
//! The popover is a small trusted local webview layered over an X tab, right under the composer's
|
||||
//! Drafts button, so X stays visible. The X page reports only the button rectangle (in its own
|
||||
//! viewport); native code adds the tab webview's position and keeps the card inside that tab.
|
||||
|
||||
use crate::model::Draft;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Labels of the popover webview are `popover-<n>` (a fresh label whenever it is recreated, so a
|
||||
/// closing webview never collides with the new one).
|
||||
pub const LABEL_PREFIX: &str = "popover-";
|
||||
|
||||
/// Card size in logical px. The card shrinks to the room left in the tab, never below the minimum.
|
||||
pub const CARD_WIDTH: f64 = 408.0;
|
||||
pub const CARD_MAX_HEIGHT: f64 = 472.0;
|
||||
pub const CARD_MIN_WIDTH: f64 = 260.0;
|
||||
pub const CARD_MIN_HEIGHT: f64 = 200.0;
|
||||
/// Prefer flipping above the button when less than this is left below it.
|
||||
pub const FLIP_BELOW: f64 = 300.0;
|
||||
/// Space between the button and the card (the caret lives here).
|
||||
pub const GAP: f64 = 8.0;
|
||||
/// Minimum distance from the tab's edges.
|
||||
pub const EDGE: f64 = 8.0;
|
||||
/// Transparent margin around the card for its shadow (not on the button side).
|
||||
pub const SHADOW: f64 = 22.0;
|
||||
/// The caret never gets closer than this to the card's corners.
|
||||
const CARET_INSET: f64 = 20.0;
|
||||
|
||||
pub fn valid_label(label: &str) -> bool {
|
||||
label
|
||||
.strip_prefix(LABEL_PREFIX)
|
||||
.is_some_and(|n| !n.is_empty() && n.len() <= 12 && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct Rect {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
|
||||
Self { x, y, width, height }
|
||||
}
|
||||
fn finite(&self) -> bool {
|
||||
[self.x, self.y, self.width, self.height].iter().all(|v| v.is_finite())
|
||||
}
|
||||
fn right(&self) -> f64 {
|
||||
self.x + self.width
|
||||
}
|
||||
fn bottom(&self) -> f64 {
|
||||
self.y + self.height
|
||||
}
|
||||
/// Same rectangle within half a px (layout reports round differently on each side).
|
||||
pub fn near(&self, other: &Rect) -> bool {
|
||||
(self.x - other.x).abs() < 0.5
|
||||
&& (self.y - other.y).abs() < 0.5
|
||||
&& (self.width - other.width).abs() < 0.5
|
||||
&& (self.height - other.height).abs() < 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the popover goes. `webview` is in window coordinates (logical px); `card` is relative to
|
||||
/// the webview; `caret_x` is relative to the card.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Placement {
|
||||
pub webview: Rect,
|
||||
pub card: Rect,
|
||||
pub above: bool,
|
||||
pub caret_x: f64,
|
||||
}
|
||||
|
||||
fn clamp(value: f64, min: f64, max: f64) -> f64 {
|
||||
value.max(min).min(max.max(min))
|
||||
}
|
||||
|
||||
/// Places the card under `anchor` (the button, in the tab's viewport), flipping above it when the
|
||||
/// room below is short, always inside `tab` (the tab webview, in window coordinates). `None` when
|
||||
/// the input is not finite or the tab is too small to hold a usable card.
|
||||
pub fn place(anchor: Rect, tab: Rect) -> Option<Placement> {
|
||||
if !anchor.finite() || !tab.finite() || tab.width <= 0.0 || tab.height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
// The page's numbers are only a hint: keep the anchor inside its own viewport.
|
||||
let ax = tab.x + clamp(anchor.x, 0.0, tab.width);
|
||||
let ay = tab.y + clamp(anchor.y, 0.0, tab.height);
|
||||
let aw = clamp(anchor.width, 0.0, (tab.right() - ax).min(400.0));
|
||||
let ah = clamp(anchor.height, 0.0, (tab.bottom() - ay).min(200.0));
|
||||
|
||||
let width = CARD_WIDTH.min(tab.width - 2.0 * EDGE);
|
||||
if width < CARD_MIN_WIDTH {
|
||||
return None;
|
||||
}
|
||||
// Left edge a little left of the button so the caret sits near the card's corner.
|
||||
let left = clamp(ax + aw / 2.0 - 34.0, tab.x + EDGE, tab.right() - EDGE - width);
|
||||
|
||||
let below = tab.bottom() - EDGE - (ay + ah + GAP);
|
||||
let above_room = ay - GAP - (tab.y + EDGE);
|
||||
let above = below < FLIP_BELOW.min(CARD_MAX_HEIGHT) && above_room > below;
|
||||
let room = if above { above_room } else { below };
|
||||
if room < CARD_MIN_HEIGHT.min(tab.height - 2.0 * EDGE).max(120.0) {
|
||||
return None;
|
||||
}
|
||||
let height = CARD_MAX_HEIGHT.min(room);
|
||||
let top = if above { ay - GAP - height } else { ay + ah + GAP };
|
||||
let card = Rect::new(left, top, width, height);
|
||||
|
||||
// Shadow margin on every side except toward the button, clipped to the tab so the popover
|
||||
// never covers the shell's address bar or another pane.
|
||||
let (pad_top, pad_bottom) = if above { (SHADOW, GAP) } else { (GAP, SHADOW) };
|
||||
let wx = (card.x - SHADOW).max(tab.x);
|
||||
let wy = (card.y - pad_top).max(tab.y);
|
||||
let wr = (card.right() + SHADOW).min(tab.right());
|
||||
let wb = (card.bottom() + pad_bottom).min(tab.bottom());
|
||||
let webview = Rect::new(wx, wy, wr - wx, wb - wy);
|
||||
|
||||
let caret_x = clamp(ax + aw / 2.0 - card.x, CARET_INSET, width - CARET_INSET);
|
||||
Some(Placement {
|
||||
webview,
|
||||
card: Rect::new(card.x - wx, card.y - wy, width, height),
|
||||
above,
|
||||
caret_x,
|
||||
})
|
||||
}
|
||||
|
||||
/// `https://x.com/a/status/1/?s=20#x` and `https://x.com/A/status/1` are the same context.
|
||||
pub fn same_context(a: &str, b: &str) -> bool {
|
||||
let strip = |value: &str| {
|
||||
let end = value.find(['?', '#']).unwrap_or(value.len());
|
||||
value[..end].trim_end_matches('/').to_ascii_lowercase()
|
||||
};
|
||||
let (a, b) = (strip(a), strip(b));
|
||||
!a.is_empty() && a == b
|
||||
}
|
||||
|
||||
/// A post page (`/<user>/status/<id>`, optionally deeper), where an inline reply composer lives.
|
||||
/// The hint is offered only there: a context shared by every composer (home, the compose modal)
|
||||
/// would match drafts that belong to other conversations.
|
||||
pub fn is_reply_context(url: &url::Url) -> bool {
|
||||
let mut segments = url.path_segments().into_iter().flatten().filter(|s| !s.is_empty());
|
||||
let (Some(user), Some(status), Some(id)) = (segments.next(), segments.next(), segments.next()) else {
|
||||
return false;
|
||||
};
|
||||
user != "i" && status == "status" && !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Whether a captured draft for exactly this reply context exists, excluding drafts that the tab's
|
||||
/// live composers are writing right now (that text is on screen already). X saw the text when it
|
||||
/// was typed on this page, so the answer tells the page nothing it did not already have.
|
||||
pub fn has_resumable_draft(drafts: &[Draft], context: &url::Url, live: impl Fn(&str) -> bool) -> bool {
|
||||
is_reply_context(context)
|
||||
&& drafts.iter().any(|draft| {
|
||||
draft.status == "active"
|
||||
&& draft.source == "capture"
|
||||
&& !draft.text.trim().is_empty()
|
||||
&& same_context(&draft.context_url, context.as_str())
|
||||
&& !live(&draft.id)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TAB: Rect = Rect::new(100.0, 92.0, 800.0, 700.0);
|
||||
|
||||
#[test]
|
||||
fn labels() {
|
||||
assert!(valid_label("popover-1"));
|
||||
assert!(valid_label("popover-42"));
|
||||
assert!(!valid_label("popover-"));
|
||||
assert!(!valid_label("popover-x"));
|
||||
assert!(!valid_label("tab-1"));
|
||||
assert!(!valid_label("shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_below_the_button_in_window_coordinates() {
|
||||
let p = place(Rect::new(200.0, 150.0, 34.0, 34.0), TAB).unwrap();
|
||||
assert!(!p.above);
|
||||
// Card: left = 100 + 200 + 17 - 34 = 283; top = 92 + 150 + 34 + 8 = 284.
|
||||
let card_x = p.webview.x + p.card.x;
|
||||
let card_y = p.webview.y + p.card.y;
|
||||
assert_eq!((card_x, card_y), (283.0, 284.0));
|
||||
assert_eq!(p.card.width, CARD_WIDTH);
|
||||
assert_eq!(p.card.height, CARD_MAX_HEIGHT);
|
||||
// Only a GAP-high strip above the card (no shadow margin over the button).
|
||||
assert_eq!(p.card.y, GAP);
|
||||
assert_eq!(p.card.x, SHADOW);
|
||||
// The caret points at the button's center.
|
||||
assert_eq!(card_x + p.caret_x, 100.0 + 200.0 + 17.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flips_above_near_the_bottom() {
|
||||
let p = place(Rect::new(200.0, 600.0, 34.0, 34.0), TAB).unwrap();
|
||||
assert!(p.above);
|
||||
let card_bottom = p.webview.y + p.card.y + p.card.height;
|
||||
assert_eq!(card_bottom, 92.0 + 600.0 - GAP);
|
||||
assert_eq!(p.webview.y + p.webview.height, card_bottom + GAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stays_inside_the_tab() {
|
||||
for anchor in [
|
||||
Rect::new(790.0, 10.0, 34.0, 34.0),
|
||||
Rect::new(-50.0, -50.0, 34.0, 34.0),
|
||||
Rect::new(5_000.0, 5_000.0, 34.0, 34.0),
|
||||
Rect::new(400.0, 330.0, 34.0, 34.0),
|
||||
] {
|
||||
let p = place(anchor, TAB).unwrap();
|
||||
let w = p.webview;
|
||||
assert!(w.x >= TAB.x && w.y >= TAB.y && w.right() <= TAB.right() && w.bottom() <= TAB.bottom(), "{anchor:?} -> {w:?}");
|
||||
let (cx, cy) = (w.x + p.card.x, w.y + p.card.y);
|
||||
assert!(cx >= TAB.x + EDGE && cx + p.card.width <= TAB.right() - EDGE, "{anchor:?}");
|
||||
assert!(cy >= TAB.y + EDGE && cy + p.card.height <= TAB.bottom() - EDGE, "{anchor:?}");
|
||||
assert!(p.caret_x >= CARET_INSET && p.caret_x <= p.card.width - CARET_INSET);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_edge_keeps_the_caret_on_the_button() {
|
||||
let p = place(Rect::new(740.0, 100.0, 34.0, 34.0), TAB).unwrap();
|
||||
let card_x = p.webview.x + p.card.x;
|
||||
assert_eq!(card_x + p.card.width, TAB.right() - EDGE);
|
||||
assert_eq!(card_x + p.caret_x, 100.0 + 740.0 + 17.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_panes_shrink_the_card() {
|
||||
let tab = Rect::new(0.0, 44.0, 300.0, 700.0);
|
||||
let p = place(Rect::new(10.0, 100.0, 34.0, 34.0), tab).unwrap();
|
||||
assert_eq!(p.card.width, 300.0 - 2.0 * EDGE);
|
||||
let short = Rect::new(0.0, 44.0, 800.0, 400.0);
|
||||
let p = place(Rect::new(10.0, 60.0, 34.0, 34.0), short).unwrap();
|
||||
assert!(p.card.height < CARD_MAX_HEIGHT && p.card.height >= 120.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unusable_input() {
|
||||
assert!(place(Rect::new(f64::NAN, 0.0, 1.0, 1.0), TAB).is_none());
|
||||
assert!(place(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, f64::INFINITY, 10.0)).is_none());
|
||||
assert!(place(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, 200.0, 700.0)).is_none());
|
||||
assert!(place(Rect::new(0.0, 60.0, 1.0, 1.0), Rect::new(0.0, 0.0, 800.0, 140.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contexts() {
|
||||
assert!(same_context("https://x.com/a/status/1", "https://x.com/A/status/1/?s=20#top"));
|
||||
assert!(!same_context("", ""));
|
||||
assert!(!same_context("https://x.com/a/status/1", "https://x.com/a/status/12"));
|
||||
let url = |s: &str| url::Url::parse(s).unwrap();
|
||||
assert!(is_reply_context(&url("https://x.com/jack/status/20")));
|
||||
assert!(is_reply_context(&url("https://x.com/jack/status/20/photo/1")));
|
||||
assert!(!is_reply_context(&url("https://x.com/home")));
|
||||
assert!(!is_reply_context(&url("https://x.com/compose/post")));
|
||||
assert!(!is_reply_context(&url("https://x.com/i/status/abc")));
|
||||
assert!(!is_reply_context(&url("https://x.com/jack/status/")));
|
||||
}
|
||||
|
||||
fn draft(id: &str, context: &str, source: &str, status: &str, text: &str) -> Draft {
|
||||
Draft {
|
||||
id: id.into(),
|
||||
account_id: "unassigned-x".into(),
|
||||
kind: "reply".into(),
|
||||
title: String::new(),
|
||||
text: text.into(),
|
||||
context_url: context.into(),
|
||||
revision: 1,
|
||||
status: status.into(),
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
source: source.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumable_draft_needs_a_captured_active_draft_for_this_post() {
|
||||
let here = url::Url::parse("https://x.com/jack/status/20?s=1").unwrap();
|
||||
let none = |_: &str| false;
|
||||
assert!(has_resumable_draft(&[draft("a", "https://x.com/jack/status/20", "capture", "active", "hi")], &here, none));
|
||||
// Other post, local draft, archived, blank, or the one being typed right now: no hint.
|
||||
assert!(!has_resumable_draft(&[draft("a", "https://x.com/jack/status/21", "capture", "active", "hi")], &here, none));
|
||||
assert!(!has_resumable_draft(&[draft("a", "https://x.com/jack/status/20", "local", "active", "hi")], &here, none));
|
||||
assert!(!has_resumable_draft(&[draft("a", "https://x.com/jack/status/20", "capture", "archived", "hi")], &here, none));
|
||||
assert!(!has_resumable_draft(&[draft("a", "https://x.com/jack/status/20", "capture", "active", " ")], &here, none));
|
||||
assert!(!has_resumable_draft(&[draft("a", "https://x.com/jack/status/20", "capture", "active", "hi")], &here, |id| id == "a"));
|
||||
// Never on pages every composer shares.
|
||||
let home = url::Url::parse("https://x.com/home").unwrap();
|
||||
assert!(!has_resumable_draft(&[draft("a", "https://x.com/home", "capture", "active", "hi")], &home, none));
|
||||
}
|
||||
}
|
||||
45
apps/desktop/src-tauri/src/profiles.rs
Normal file
45
apps/desktop/src-tauri/src/profiles.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//! Account profiles (wayBetter host) with X's account rules: each X tab runs in one profile, every
|
||||
//! non-default profile has its own WebView data directory, and the X login inside a profile is read
|
||||
//! from X's own account button and validated with `site::SITE.accounts`.
|
||||
|
||||
pub use crate::site::valid_handle;
|
||||
pub use waybetter_host::profiles::{
|
||||
data_dir, orphan_dirs, store_identifier, valid_profile_id, AccountSeen, Profile, ProfileSettings, DEFAULT_PROFILE,
|
||||
PROFILES_DIR,
|
||||
};
|
||||
|
||||
/// The X account signed in inside a profile (`@handle` without the `@`, name, pbs.twimg.com avatar).
|
||||
pub type XAccount = waybetter_host::profiles::Account;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::site::SITE;
|
||||
|
||||
fn account(handle: &str, avatar: &str) -> XAccount {
|
||||
XAccount { handle: handle.into(), name: "Sample".into(), avatar: Some(avatar.into()), signed_in: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x_logins_are_validated_with_x_rules() {
|
||||
let mut settings = ProfileSettings::default();
|
||||
for bad in ["", "has space", "waytoolonghandle_", "a/b", "a.b"] {
|
||||
assert!(!settings.observe("default", AccountSeen::SignedIn(account(bad, "https://pbs.twimg.com/a.jpg")), &SITE.accounts), "{bad:?}");
|
||||
}
|
||||
assert!(settings.observe("default", AccountSeen::SignedIn(account("@other_account", "https://evil.test/x.png")), &SITE.accounts));
|
||||
let seen = settings.profiles[0].account.clone().unwrap();
|
||||
assert_eq!((seen.handle.as_str(), seen.avatar, seen.signed_in), ("other_account", None, true));
|
||||
assert!(settings.observe("default", AccountSeen::SignedIn(account("other_account", "https://pbs.twimg.com/profile_images/1/a_x96.jpg")), &SITE.accounts));
|
||||
assert!(settings.profiles[0].account.as_ref().unwrap().avatar.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_json_shape_is_unchanged() {
|
||||
// profiles.json written by earlier builds must keep loading.
|
||||
let raw = r##"{"profiles":[{"id":"default","name":"Main","color":"#1d9bf0","account":{"handle":"a","name":"A","avatar":"https://pbs.twimg.com/x.jpg","signedIn":true}}],"active":"default"}"##;
|
||||
let settings: ProfileSettings = serde_json::from_str(raw).unwrap();
|
||||
let settings = settings.validated(&SITE.accounts).unwrap();
|
||||
assert_eq!(serde_json::to_string(&settings).unwrap(), raw);
|
||||
assert_eq!(ProfileSettings::default().profiles[0].color, "#1d9bf0");
|
||||
}
|
||||
}
|
||||
22
apps/desktop/src-tauri/src/site.rs
Normal file
22
apps/desktop/src-tauri/src/site.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//! The X site adapter for the wayBetter host (`waybetter-host`): what "a site tab" is, the page
|
||||
//! runtime's global and how the account seen in a profile is validated.
|
||||
|
||||
use waybetter_host::site::{AccountRules, SiteSpec};
|
||||
|
||||
/// An X handle: 1-15 ASCII letters, digits or underscores (no `@`).
|
||||
pub fn valid_handle(handle: &str) -> bool {
|
||||
(1..=15).contains(&handle.len()) && handle.bytes().all(|ch| ch.is_ascii_alphanumeric() || ch == b'_')
|
||||
}
|
||||
|
||||
pub static SITE: SiteSpec = SiteSpec {
|
||||
product: "wayBetter for X",
|
||||
// Exactly https://x.com; twitter.com login routes are allowed for navigation only (policy.rs).
|
||||
hosts: &["x.com"],
|
||||
runtime_global: "BetterTwttr",
|
||||
accounts: AccountRules {
|
||||
valid_handle,
|
||||
avatar_prefixes: &["https://pbs.twimg.com/"],
|
||||
max_name_chars: 50,
|
||||
max_avatar_len: 512,
|
||||
},
|
||||
};
|
||||
4
apps/desktop/src-tauri/src/sync.rs
Normal file
4
apps/desktop/src-tauri/src/sync.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
//! Encrypted settings sync through a folder the owner already syncs: the wayBetter host's
|
||||
//! Argon2id/XChaCha20-Poly1305 snapshots of the settings backup (`backup.rs`).
|
||||
|
||||
pub use waybetter_host::sync::*;
|
||||
54
apps/desktop/src-tauri/src/theme.rs
Normal file
54
apps/desktop/src-tauri/src/theme.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! X's built-in themes for the wayBetter host's theme state (`waybetter_host::theme`: the owner's
|
||||
//! theme choice, account and pane themes, envelope checks, page colors and the delivery script).
|
||||
|
||||
pub use waybetter_host::theme::{ThemeChoice as XTheme, ThemeScopes, ThemeSite};
|
||||
|
||||
/// Built-in theme ids the X runtime knows (`BUILTIN_THEMES` in `src/inject/themeSpec.ts`; the desktop
|
||||
/// test `themePresets.test.ts` keeps both lists equal). `x` keeps X's own look.
|
||||
pub const PRESETS: [&str; 14] = [
|
||||
"x", "classic", "night-ink", "terminal", "paper", "dracula", "nord", "tokyo-night", "solarized-light",
|
||||
"frutiger-aero", "windows-98", "vaporwave", "high-contrast", "midnight-oled",
|
||||
];
|
||||
|
||||
/// The page color (`colors.page`, else `colors.bg`) of every built-in theme with colors of its own
|
||||
/// (`BUILTIN_THEMES` in `src/inject/themeSpec.ts`; `themePresets.test.ts` keeps both equal). `x`
|
||||
/// keeps X's own colors and has none.
|
||||
pub const PRESET_PAGE_COLORS: [(&str, &str); 13] = [
|
||||
("classic", "#c0deed"),
|
||||
("night-ink", "#05060a"),
|
||||
("terminal", "#020402"),
|
||||
("paper", "#e9e2d3"),
|
||||
("dracula", "#1d1e27"),
|
||||
("nord", "#232831"),
|
||||
("tokyo-night", "#13141c"),
|
||||
("solarized-light", "#eee8d5"),
|
||||
("frutiger-aero", "#d8f0fc"),
|
||||
("windows-98", "#c0c0c0"),
|
||||
("vaporwave", "#140726"),
|
||||
("high-contrast", "#000000"),
|
||||
("midnight-oled", "#000000"),
|
||||
];
|
||||
|
||||
/// X's themes: `x` (X's own look) first.
|
||||
pub static X_THEMES: ThemeSite = ThemeSite { presets: &PRESETS, page_colors: &PRESET_PAGE_COLORS };
|
||||
|
||||
/// X's own look, nothing customized.
|
||||
pub fn x_default() -> XTheme {
|
||||
XTheme::default_for(&X_THEMES)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn x_is_the_default_and_every_colored_preset_has_a_page_color() {
|
||||
assert_eq!(X_THEMES.own(), "x");
|
||||
assert!(x_default().is_default(&X_THEMES));
|
||||
for id in PRESETS.iter().skip(1) {
|
||||
let theme = XTheme { preset: (*id).into(), ..x_default() };
|
||||
assert!(theme.page_color(&X_THEMES).is_some(), "{id}");
|
||||
}
|
||||
assert_eq!(x_default().page_color(&X_THEMES), None);
|
||||
}
|
||||
}
|
||||
7
apps/desktop/src-tauri/src/update.rs
Normal file
7
apps/desktop/src-tauri/src/update.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//! Update scheduling and the crash handshake come from the wayBetter host; the download page for
|
||||
//! a broken update is the app's own.
|
||||
|
||||
pub use waybetter_host::update::*;
|
||||
|
||||
/// Shown when an update keeps failing to start.
|
||||
pub const MANUAL_DOWNLOAD_URL: &str = "https://twttr.crt.fyi/v1/download/windows/latest";
|
||||
5
apps/desktop/src-tauri/src/workspaces.rs
Normal file
5
apps/desktop/src-tauri/src/workspaces.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//! Saved workspaces (named tab/pane/deck layouts, `src/shell/workspaceModel.ts`), stored natively in
|
||||
//! app data `workspaces.json` through the shell-only `workspaces_get` / `workspaces_set`; validated
|
||||
//! by the wayBetter host (`waybetter_host::workspaces`).
|
||||
|
||||
pub use waybetter_host::workspaces::*;
|
||||
Loading…
Add table
Add a link
Reference in a new issue