waybetter-for-x/apps/desktop/src-tauri/src/desktop.rs
2026-10-01 06:01:13 +07:00

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,
],
);
}