861 lines
35 KiB
Rust
861 lines
35 KiB
Rust
//! 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,
|
|
],
|
|
);
|
|
}
|