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:
commit
43a9e2e1a7
48 changed files with 9768 additions and 0 deletions
368
crates/waybetter-host/src/profiles.rs
Normal file
368
crates/waybetter-host/src/profiles.rs
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
//! Account profiles: each site tab runs in one profile, and every non-default profile has its own
|
||||
//! WebView data directory (cookies, storage, cache), so several accounts can be signed in side by
|
||||
//! side without sharing a session. The default profile keeps the WebView's default data directory.
|
||||
//!
|
||||
//! The account inside a profile is owned by native code: only a site tab of that very profile can
|
||||
//! report it ([`ProfileSettings::observe`]), validated with the site's [`AccountRules`], and a save
|
||||
//! from the shell never changes it ([`ProfileSettings::keep_accounts_from`]).
|
||||
|
||||
use crate::site::AccountRules;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const DEFAULT_PROFILE: &str = "default";
|
||||
pub const PROFILES_DIR: &str = "profiles";
|
||||
const MAX_PROFILES: usize = 500;
|
||||
const MAX_NAME_CHARS: usize = 40;
|
||||
|
||||
/// The account signed in inside a profile, as the site itself shows it (read by the page runtime
|
||||
/// in that profile's tabs and reported through `tab_report`). Kept after a sign-out
|
||||
/// (`signed_in: false`) so the list still says whose login it was.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Account {
|
||||
/// Without the `@`.
|
||||
pub handle: String,
|
||||
pub name: String,
|
||||
/// On the site's image CDN only (see [`AccountRules::avatar_prefixes`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub avatar: Option<String>,
|
||||
#[serde(default)]
|
||||
pub signed_in: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Profile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// `#rrggbb`, shown as the tab's profile dot.
|
||||
pub color: String,
|
||||
/// Owned by native code (see `ProfileSettings::observe`); a save from the shell never changes it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub account: Option<Account>,
|
||||
}
|
||||
|
||||
/// What a site tab saw about its own profile's login.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AccountSeen {
|
||||
SignedIn(Account),
|
||||
SignedOut,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
/// Validated and normalized, or None when the handle is not one the site allows.
|
||||
pub fn validated(mut self, rules: &AccountRules) -> Option<Self> {
|
||||
let handle = self.handle.trim().trim_start_matches('@').to_owned();
|
||||
if !(rules.valid_handle)(&handle) {
|
||||
return None;
|
||||
}
|
||||
self.handle = handle;
|
||||
let name: String = self.name.chars().filter(|ch| !ch.is_control()).collect();
|
||||
let name: String = name.trim().chars().take(rules.max_name_chars).collect();
|
||||
self.name = if name.is_empty() { self.handle.clone() } else { name };
|
||||
self.avatar = self.avatar.filter(|url| rules.avatar_allowed(url));
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileSettings {
|
||||
pub profiles: Vec<Profile>,
|
||||
/// New tabs open in this profile.
|
||||
pub active: String,
|
||||
}
|
||||
|
||||
/// The first profile's name and color (the shell's first palette color).
|
||||
pub const DEFAULT_NAME: &str = "Main";
|
||||
pub const DEFAULT_COLOR: &str = "#1d9bf0";
|
||||
|
||||
impl Default for ProfileSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
profiles: vec![Profile { id: DEFAULT_PROFILE.into(), name: DEFAULT_NAME.into(), color: DEFAULT_COLOR.into(), account: None }],
|
||||
active: DEFAULT_PROFILE.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `default`, or `p` followed by 1-32 lowercase ASCII letters/digits. Ids become directory names,
|
||||
/// so nothing else (no dots, slashes, separators or reserved names) is accepted.
|
||||
pub fn valid_profile_id(id: &str) -> bool {
|
||||
id == DEFAULT_PROFILE
|
||||
|| (id.len() >= 2
|
||||
&& id.len() <= 33
|
||||
&& id.starts_with('p')
|
||||
&& id.bytes().all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()))
|
||||
}
|
||||
|
||||
fn valid_color(color: &str) -> bool {
|
||||
color.len() == 7 && color.starts_with('#') && color[1..].bytes().all(|ch| ch.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
impl ProfileSettings {
|
||||
pub fn validated(mut self, rules: &AccountRules) -> Result<Self, String> {
|
||||
if self.profiles.is_empty() || self.profiles.len() > MAX_PROFILES {
|
||||
return Err("VALIDATION: invalid profile count".into());
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
for profile in &mut self.profiles {
|
||||
if !valid_profile_id(&profile.id) || !seen.insert(profile.id.clone()) {
|
||||
return Err("VALIDATION: invalid or duplicate profile id".into());
|
||||
}
|
||||
let name = profile.name.trim();
|
||||
if name.is_empty() || name.chars().count() > MAX_NAME_CHARS || name.chars().any(char::is_control) {
|
||||
return Err("VALIDATION: profile name must be 1-40 visible characters".into());
|
||||
}
|
||||
profile.name = name.to_owned();
|
||||
if !valid_color(&profile.color) {
|
||||
return Err("VALIDATION: profile color must be #rrggbb".into());
|
||||
}
|
||||
profile.color = profile.color.to_ascii_lowercase();
|
||||
profile.account = profile.account.take().and_then(|account| account.validated(rules));
|
||||
}
|
||||
if !seen.contains(DEFAULT_PROFILE) {
|
||||
return Err("VALIDATION: the default profile cannot be removed".into());
|
||||
}
|
||||
if !seen.contains(&self.active) {
|
||||
return Err("VALIDATION: active profile does not exist".into());
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Native code owns each profile's account: a save from the shell keeps the one on record.
|
||||
pub fn keep_accounts_from(mut self, current: &ProfileSettings) -> Self {
|
||||
for profile in &mut self.profiles {
|
||||
profile.account = current.profiles.iter().find(|old| old.id == profile.id).and_then(|old| old.account.clone());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Records what a tab of `profile` saw. The profile takes the account's display name. Returns
|
||||
/// whether anything changed, so repeated reports cost no disk write.
|
||||
pub fn observe(&mut self, profile: &str, seen: AccountSeen, rules: &AccountRules) -> bool {
|
||||
let Some(entry) = self.profiles.iter_mut().find(|entry| entry.id == profile) else { return false };
|
||||
let before = entry.clone();
|
||||
match seen {
|
||||
AccountSeen::SignedIn(account) => {
|
||||
// Narrow site layouts may show only the avatar: no name then means "unchanged".
|
||||
let unnamed = account.name.trim().is_empty();
|
||||
let Some(mut account) = account.validated(rules) else { return false };
|
||||
account.signed_in = true;
|
||||
if let Some(old) = entry.account.as_ref().filter(|old| unnamed && old.handle.eq_ignore_ascii_case(&account.handle)) {
|
||||
account.name = old.name.clone();
|
||||
}
|
||||
// Some layouts show no avatar: keep the one seen before for the same login.
|
||||
if account.avatar.is_none() {
|
||||
if let Some(old) = entry.account.as_ref().filter(|old| old.handle.eq_ignore_ascii_case(&account.handle)) {
|
||||
account.avatar = old.avatar.clone();
|
||||
}
|
||||
}
|
||||
entry.name = account.name.chars().take(MAX_NAME_CHARS).collect();
|
||||
entry.account = Some(account);
|
||||
}
|
||||
AccountSeen::SignedOut => {
|
||||
if let Some(account) = entry.account.as_mut() {
|
||||
account.signed_in = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
*entry != before
|
||||
}
|
||||
|
||||
pub fn contains(&self, id: &str) -> bool {
|
||||
self.profiles.iter().any(|profile| profile.id == id)
|
||||
}
|
||||
|
||||
/// Profiles present in `self` but not in `next`.
|
||||
pub fn removed_in(&self, next: &ProfileSettings) -> Vec<String> {
|
||||
self.profiles
|
||||
.iter()
|
||||
.filter(|profile| !next.contains(&profile.id))
|
||||
.map(|profile| profile.id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Removed profiles when switching to `next`, or an error if any of them still has an open tab.
|
||||
pub fn removal<'a>(&self, next: &ProfileSettings, open_tabs: impl IntoIterator<Item = &'a String>) -> Result<Vec<String>, String> {
|
||||
let removed = self.removed_in(next);
|
||||
if open_tabs.into_iter().any(|profile| removed.contains(profile)) {
|
||||
return Err("PROFILE: close this profile's tabs before removing it".into());
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
}
|
||||
|
||||
/// The WebView data directory of a profile under the app-local data dir; `None` for the default
|
||||
/// profile (it keeps the WebView's existing default directory) and for invalid ids.
|
||||
pub fn data_dir(local_data: &Path, id: &str) -> Option<PathBuf> {
|
||||
if id == DEFAULT_PROFILE || !valid_profile_id(id) {
|
||||
return None;
|
||||
}
|
||||
Some(local_data.join(PROFILES_DIR).join(id))
|
||||
}
|
||||
|
||||
/// Folders under `<local_data>/profiles` named like a profile id that `settings` no longer has
|
||||
/// (e.g. a removal failed while the folder was still locked). Nothing else is ever listed.
|
||||
pub fn orphan_dirs(local_data: &Path, settings: &ProfileSettings) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(local_data.join(PROFILES_DIR)) else { return Vec::new() };
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|entry| {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
valid_profile_id(&name) && !settings.contains(&name) && entry.path().is_dir()
|
||||
})
|
||||
.map(|entry| entry.path())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stable 16-byte WKWebView data-store identifier for a profile (macOS ignores data_directory).
|
||||
pub fn store_identifier(id: &str) -> [u8; 16] {
|
||||
let hash = |seed: u64| {
|
||||
id.bytes().fold(seed, |acc, byte| (acc ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3))
|
||||
};
|
||||
let mut out = [0u8; 16];
|
||||
out[..8].copy_from_slice(&hash(0xcbf2_9ce4_8422_2325).to_le_bytes());
|
||||
out[8..].copy_from_slice(&hash(0x6c62_272e_07bb_0142).to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::site::testing::SITE;
|
||||
|
||||
const RULES: &AccountRules = &SITE.accounts;
|
||||
|
||||
fn profile(id: &str) -> Profile {
|
||||
Profile { id: id.into(), name: format!("Account {id}"), color: "#AABBCC".into(), account: None }
|
||||
}
|
||||
|
||||
fn account(handle: &str) -> Account {
|
||||
Account { handle: handle.into(), name: "Sample".into(), avatar: Some("https://pbs.twimg.com/profile_images/1/a_normal.jpg".into()), signed_in: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seen_login_names_the_profile_and_persists_a_sign_out() {
|
||||
let mut settings = ProfileSettings { profiles: vec![profile("default"), profile("p1")], active: "default".into() };
|
||||
assert!(settings.observe("p1", AccountSeen::SignedIn(account("@otheraccount")), RULES));
|
||||
let seen = settings.profiles[1].account.clone().unwrap();
|
||||
assert_eq!((seen.handle.as_str(), seen.signed_in, settings.profiles[1].name.as_str()), ("otheraccount", true, "Sample"));
|
||||
// The same report again changes nothing (no disk write).
|
||||
assert!(!settings.observe("p1", AccountSeen::SignedIn(account("otheraccount")), RULES));
|
||||
assert!(settings.observe("p1", AccountSeen::SignedOut, RULES));
|
||||
assert_eq!(settings.profiles[1].account.as_ref().map(|a| (a.handle.as_str(), a.signed_in)), Some(("otheraccount", false)));
|
||||
assert!(!settings.observe("nope", AccountSeen::SignedOut, RULES));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unnamed_or_avatarless_report_keeps_what_was_seen() {
|
||||
let mut settings = ProfileSettings::default();
|
||||
assert!(settings.observe("default", AccountSeen::SignedIn(account("fish")), RULES));
|
||||
let mut bare = account("FISH");
|
||||
bare.name = " ".into();
|
||||
bare.avatar = None;
|
||||
settings.observe("default", AccountSeen::SignedIn(bare), RULES);
|
||||
let kept = settings.profiles[0].account.clone().unwrap();
|
||||
assert_eq!((kept.name.as_str(), kept.avatar.is_some()), ("Sample", true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_reports_are_validated() {
|
||||
let mut settings = ProfileSettings::default();
|
||||
for bad in ["", "has space", "waytoolonghandle_", "a/b"] {
|
||||
assert!(!settings.observe("default", AccountSeen::SignedIn(account(bad)), RULES), "{bad:?}");
|
||||
}
|
||||
let mut foreign = account("ok");
|
||||
foreign.avatar = Some("https://evil.test/x.png".into());
|
||||
assert!(settings.observe("default", AccountSeen::SignedIn(foreign), RULES));
|
||||
assert_eq!(settings.profiles[0].account.as_ref().unwrap().avatar, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_save_keeps_the_native_accounts() {
|
||||
let mut current = ProfileSettings { profiles: vec![profile("default"), profile("p1")], active: "default".into() };
|
||||
current.observe("p1", AccountSeen::SignedIn(account("otheraccount")), RULES);
|
||||
let mut incoming = ProfileSettings { profiles: vec![profile("default"), profile("p1")], active: "p1".into() };
|
||||
incoming.profiles[0].account = Some(account("forged"));
|
||||
let kept = incoming.keep_accounts_from(¤t);
|
||||
assert_eq!(kept.profiles[0].account, None);
|
||||
assert_eq!(kept.profiles[1].account.as_ref().map(|a| a.handle.as_str()), Some("otheraccount"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_cannot_escape_the_profiles_directory() {
|
||||
for bad in ["", "p", "..", "p/../x", "p\\x", "P1", "p.1", "con", "p-1", "x1", &format!("p{}", "a".repeat(33))] {
|
||||
assert!(!valid_profile_id(bad), "{bad:?}");
|
||||
assert_eq!(data_dir(Path::new("/base"), bad), None);
|
||||
}
|
||||
assert!(valid_profile_id("p1abc"));
|
||||
assert_eq!(data_dir(Path::new("/base"), "p1abc"), Some(Path::new("/base").join("profiles").join("p1abc")));
|
||||
assert_eq!(data_dir(Path::new("/base"), DEFAULT_PROFILE), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphans_are_only_profile_shaped_folders_no_longer_listed() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().join(PROFILES_DIR);
|
||||
for name in ["p1", "p2", "keep.me", "P3"] {
|
||||
std::fs::create_dir_all(dir.join(name)).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("p4"), "a file").unwrap();
|
||||
let settings = ProfileSettings { profiles: vec![profile("default"), profile("p1")], active: "default".into() };
|
||||
assert_eq!(orphan_dirs(root.path(), &settings), vec![dir.join("p2")]);
|
||||
assert!(orphan_dirs(&root.path().join("missing"), &settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_profile_is_required_and_active_must_exist() {
|
||||
let only_other = ProfileSettings { profiles: vec![profile("p1")], active: "p1".into() };
|
||||
assert!(only_other.validated(RULES).is_err());
|
||||
let missing_active = ProfileSettings { profiles: vec![profile("default")], active: "p9".into() };
|
||||
assert!(missing_active.validated(RULES).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_profiles_are_allowed_and_normalized() {
|
||||
let mut profiles = vec![profile("default")];
|
||||
profiles.extend((0..20).map(|n| profile(&format!("p{n}"))));
|
||||
profiles[3].name = " Work ".into();
|
||||
let settings = ProfileSettings { profiles, active: "p7".into() }.validated(RULES).unwrap();
|
||||
assert_eq!(settings.profiles.len(), 21);
|
||||
assert_eq!(settings.profiles[3].name, "Work");
|
||||
assert_eq!(settings.profiles[0].color, "#aabbcc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_bad_names_and_colors_are_rejected() {
|
||||
let dup = ProfileSettings { profiles: vec![profile("default"), profile("p1"), profile("p1")], active: "default".into() };
|
||||
assert!(dup.validated(RULES).is_err());
|
||||
for (name, color) in [(" ", "#000000"), ("a\nb", "#000000"), (&"x".repeat(41)[..], "#000000"), ("ok", "red"), ("ok", "#12345g")] {
|
||||
let mut bad = profile("default");
|
||||
bad.name = name.into();
|
||||
bad.color = color.into();
|
||||
assert!(ProfileSettings { profiles: vec![bad], active: "default".into() }.validated(RULES).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_profiles_and_store_identifiers() {
|
||||
let before = ProfileSettings { profiles: vec![profile("default"), profile("p1"), profile("p2")], active: "p1".into() };
|
||||
let after = ProfileSettings { profiles: vec![profile("default"), profile("p2")], active: "default".into() };
|
||||
assert_eq!(before.removed_in(&after), vec!["p1".to_string()]);
|
||||
assert_eq!(store_identifier("p1"), store_identifier("p1"));
|
||||
assert_ne!(store_identifier("p1"), store_identifier("p2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_with_open_tabs_cannot_be_removed() {
|
||||
let before = ProfileSettings { profiles: vec![profile("default"), profile("p1"), profile("p2")], active: "p1".into() };
|
||||
let after = ProfileSettings { profiles: vec![profile("default"), profile("p2")], active: "default".into() };
|
||||
let open = ["p1".to_string()];
|
||||
assert!(before.removal(&after, &open).is_err());
|
||||
let open = ["p2".to_string(), "default".to_string()];
|
||||
assert_eq!(before.removal(&after, &open).unwrap(), vec!["p1".to_string()]);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue