//! 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, theme: Option, /// 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, /// Tab webview label -> X's left navigation repeats one already on screen (see `pane_script`). tab_nav_redundant: HashMap, /// Tab webview label -> its deck column config (Power User Mode; `column_configure`). tab_columns: HashMap, /// Tab webview label -> the palette commands its plugins registered (`tab_report.commands`). tab_commands: HashMap>, /// 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); fn lock(state: &AppState) -> Result, 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, 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, String> { lock(&app.state::())?.store.list_drafts() } pub(crate) fn vault_draft(app: &AppHandle, id: &str) -> Result, String> { lock(&app.state::())?.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 { let state = app.state::(); 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 { shell_only(&webview)?; lock(&state)?.store.save_draft(&input) } #[tauri::command] async fn draft_revisions( webview: Webview, state: State<'_, AppState>, draft_id: String, ) -> Result, 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 { shell_only(&webview)?; lock(&state)?.store.set_status(&id, expected_revision, &status) } #[tauri::command] async fn observation_list( webview: Webview, state: State<'_, AppState>, ) -> Result, String> { shell_only(&webview)?; lock(&state)?.store.observations() } #[tauri::command] async fn data_export(webview: Webview, state: State<'_, AppState>) -> Result { 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 { 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, /// Button position in the tab's viewport (CSS px): left and bottom edge. x: Option, y: Option, /// Button size (CSS px), so the popover's caret can point at its center. width: Option, height: Option, } #[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| 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, context_url: String, x: Option, y: Option, } // 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| 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 { shell_only(&webview)?; Ok(lock(&state)?.alerts.clone()) } #[tauri::command] async fn alerts_set( webview: Webview, app: AppHandle, state: State<'_, AppState>, settings: AlertSettings, ) -> Result { 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 { let theme: XTheme = read_json(app, THEME_FILE)?; theme.validate(&X_THEMES).ok()?; Some(theme) } fn load_theme_scopes(app: &AppHandle) -> ThemeScopes { read_json::(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 { 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 { 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::() else { return }; let per_tab: Vec<(Webview, Vec, Option)> = { 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, ) -> 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 { 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) -> 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 { 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, ) -> 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, title: Option, favicon: Option, focused: Option, /// 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, /// X tabs only: X shows its signed-out page here. signed_out: Option, /// Deck columns on x.com only (owner-approved 2026-09-28): this tab's unread count, capped at 999. unread: Option, /// 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>, /// X tabs only (2026-09-29, "Follow X" language): X's own ``, a validated language /// tag; accepted only from a webview on x.com right now, forwarded to the shell as `lang`. lang: Option, /// X mounted page content after its first DOM paint; startup cover release only. painted: Option, } /// 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 { 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 { 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 { 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::()).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 { let state = app.state::(); 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 { 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 { let state = app.state::(); 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::().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::() .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, 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, String> { settings::backup_drafts(app) } fn backup_apply(&self, app: &AppHandle, sections: &serde_json::Map) -> Result { 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::()) { data.theme_preview = Some(theme); } apply_themes(app); Ok(()) } fn dev_theme_end(&self, app: &AppHandle) { if let Ok(mut data) = lock(&app.state::()) { 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::()) .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::().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> { 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::(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, ], ); }