//! 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, #[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, } /// 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 { 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, /// 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 { 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 { 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) -> Result, 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 { if id == DEFAULT_PROFILE || !valid_profile_id(id) { return None; } Some(local_data.join(PROFILES_DIR).join(id)) } /// Folders under `/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 { 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()]); } }