Extract the wayBetter framework core from wayBetter for X

Site-agnostic parts of the X app, behind a small site adapter:

- crates/waybetter-host: webview-label security policy and tab rect
  clamp, account profiles with native-owned accounts (AccountRules),
  plugin store with bundled-plugin upgrades (PluginHost), signed
  data-only hotfix channel, update scheduling and crash handshake, tab
  payloads/link routing/tab-chrome validation, and (feature "tauri")
  webview helpers and the Windows system menu.
- packages/runtime: DOM hub, request hub, plugin API core, OKLab theme
  color model, split-view rail hiding, link routing and account
  reporting, each configured by a SiteAdapter.
- packages/shell: address bar, pane, link-drop, profile, palette and
  update-chip models, and window controls with the Tauri API injected.

Tests moved/adapted from the app: 45 Rust (headless), 40 TypeScript.
This commit is contained in:
CRTkafa 2026-09-28 03:01:33 +07:00
commit 43a9e2e1a7
48 changed files with 9768 additions and 0 deletions

View file

@ -0,0 +1,114 @@
//! Pure update-scheduling and crash-handshake logic (no Tauri types, so it is unit-tested headless).
//! The app owns the updater endpoint, its public key and the manual download URL.
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// First check after launch.
pub const FIRST_CHECK: Duration = Duration::from_secs(15);
/// Base interval between checks; each wait is jittered by +/- `JITTER`.
pub const CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
pub const JITTER: Duration = Duration::from_secs(5 * 60);
/// After an update, the shell must call `app_ready` within this long.
pub const READY_DEADLINE: Duration = Duration::from_secs(30);
/// Launches of an updated version without `app_ready` before automatic apply is disabled.
pub const MAX_FAILED_BOOTS: u32 = 2;
pub const PENDING_FILE: &str = "update-pending.json";
/// Wait before the next check: 30 min +/- 5 min, from any 64-bit entropy value.
pub fn next_check_delay(entropy: u64) -> Duration {
let span = JITTER.as_secs() * 2 + 1;
CHECK_INTERVAL - JITTER + Duration::from_secs(entropy % span)
}
/// What the shell's titlebar chip reflects. Only `Ready` is shown prominently.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum UpdateStatus {
Idle,
Checking,
UpToDate,
Downloading { version: String },
#[serde(rename_all = "camelCase")]
Ready { version: String, notes: String },
Installing { version: String },
Error { message: String },
/// The last update failed to start repeatedly; automatic apply is off.
#[serde(rename_all = "camelCase")]
Problem { download_url: String },
}
/// Written just before installing, cleared by `app_ready` in the new version.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PendingUpdate {
pub version: String,
/// Launches of `version` that have not (yet) confirmed `app_ready`.
pub boots: u32,
}
#[derive(Debug, PartialEq)]
pub struct BootDecision {
/// Marker to persist (`None` deletes it).
pub marker: Option<PendingUpdate>,
/// Automatic apply is disabled and the shell is told to offer a manual download.
pub problem: bool,
}
/// Called at every launch with the marker left on disk.
pub fn on_boot(pending: Option<PendingUpdate>, current_version: &str) -> BootDecision {
match pending {
// The installer never replaced the binary (or it was a different release): forget it.
Some(p) if p.version != current_version => BootDecision { marker: None, problem: false },
Some(p) => {
let boots = p.boots.saturating_add(1);
BootDecision {
// `boots - 1` earlier launches of this version never reached `app_ready`.
problem: boots > MAX_FAILED_BOOTS,
marker: Some(PendingUpdate { version: p.version, boots }),
}
}
None => BootDecision { marker: None, problem: false },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delay_stays_within_jitter() {
for entropy in [0, 1, 300, 600, 601, u64::MAX] {
let delay = next_check_delay(entropy);
assert!(delay >= Duration::from_secs(25 * 60) && delay <= Duration::from_secs(35 * 60));
}
assert_eq!(next_check_delay(0), Duration::from_secs(25 * 60));
assert_eq!(next_check_delay(600), Duration::from_secs(35 * 60));
}
#[test]
fn handshake_disables_apply_after_two_failed_boots() {
let pending = |boots| Some(PendingUpdate { version: "0.3.0".into(), boots });
// First launch of the new version: not a problem yet.
assert_eq!(on_boot(pending(0), "0.3.0"), BootDecision { marker: pending(1), problem: false });
// Second launch without app_ready: one failure.
assert!(!on_boot(pending(1), "0.3.0").problem);
// Third launch: two failures in a row.
assert!(on_boot(pending(2), "0.3.0").problem);
}
#[test]
fn handshake_forgets_marker_for_other_versions() {
let pending = Some(PendingUpdate { version: "0.3.0".into(), boots: 5 });
assert_eq!(on_boot(pending, "0.2.0"), BootDecision { marker: None, problem: false });
assert_eq!(on_boot(None, "0.2.0"), BootDecision { marker: None, problem: false });
}
#[test]
fn status_serializes_for_the_shell() {
let json = serde_json::to_value(UpdateStatus::Ready { version: "0.3.0".into(), notes: String::new() }).unwrap();
assert_eq!(json, serde_json::json!({ "state": "ready", "version": "0.3.0", "notes": "" }));
let json = serde_json::to_value(UpdateStatus::Problem { download_url: "https://a.test/".into() }).unwrap();
assert_eq!(json, serde_json::json!({ "state": "problem", "downloadUrl": "https://a.test/" }));
}
}