//! `apps.json`: the list of tools, their groups, and the window's preferences. //! //! Apps are flat and carry a `group_id` rather than being nested inside their //! group, so dragging one between groups is a field change instead of a tree //! rewrite. use serde::{Deserialize, Serialize}; use std::path::PathBuf; use url::Url; use crate::routing::AppScope; /// What the app claims to be. WebKit's own user agent gets a Google login /// refused as "not a secure browser", which would make half the list unusable. pub const CHROME_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct App { pub id: String, pub name: String, pub url: String, /// Hosts this app owns. Defaults to the URL's exact host — `mail.google.com` /// rather than `google.com`, or Gmail and Drive each swallow the other. #[serde(default)] pub scope: Vec, #[serde(default)] pub group_id: Option, #[serde(default)] pub user_agent: Option, /// CSS selectors this app hides on every page. Chosen by right-clicking /// the thing you never want to see again. #[serde(default)] pub hidden: Vec, /// Page zoom, remembered per app: a dense ERP and a mail client do not /// want the same size. #[serde(default = "default_zoom")] pub zoom: f64, #[serde(default)] pub order: i32, } fn default_zoom() -> f64 { 1.0 } impl App { pub fn scopes(&self) -> Vec { if self.scope.is_empty() { default_scope(&self.url).into_iter().collect() } else { self.scope.clone() } } pub fn ua(&self) -> String { self.user_agent.clone().unwrap_or_else(|| CHROME_UA.to_string()) } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Group { pub id: String, pub name: String, #[serde(default)] pub collapsed: bool, #[serde(default)] pub order: i32, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Settings { #[serde(default)] pub nav_collapsed: bool, #[serde(default = "default_theme")] pub theme: String, } fn default_theme() -> String { "system".into() } impl Default for Settings { fn default() -> Self { Self { nav_collapsed: false, theme: default_theme(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Config { #[serde(default = "default_version")] pub version: u32, #[serde(default)] pub groups: Vec, #[serde(default)] pub apps: Vec, #[serde(default)] pub settings: Settings, } fn default_version() -> u32 { 1 } impl Default for Config { fn default() -> Self { Self { version: 1, groups: Vec::new(), apps: Vec::new(), settings: Settings::default(), } } } impl Config { /// Scopes in the shape routing wants. pub fn scopes(&self) -> Vec { self.apps .iter() .map(|a| AppScope { id: a.id.clone(), scope: a.scopes() }) .collect() } pub fn app(&self, id: &str) -> Option<&App> { self.apps.iter().find(|a| a.id == id) } /// Apps in the order the nav shows them: by group, then by position. pub fn ordered(&self) -> Vec<&App> { let group_rank = |id: &Option| -> i32 { match id { None => -1, Some(g) => self .groups .iter() .find(|x| &x.id == g) .map(|x| x.order) .unwrap_or(i32::MAX), } }; let mut apps: Vec<&App> = self.apps.iter().collect(); apps.sort_by_key(|a| (group_rank(&a.group_id), a.order)); apps } } /// The host of a URL, which is the app's scope until the user widens it. pub fn default_scope(url: &str) -> Option { Url::parse(url) .ok() .and_then(|u| u.host_str().map(|h| h.trim_start_matches("www.").to_string())) } /// Normalises what someone types into an app's URL field. pub fn normalize_url(input: &str) -> String { let t = input.trim(); if t.starts_with("http://") || t.starts_with("https://") { t.to_string() } else { format!("https://{t}") } } pub fn new_id() -> String { uuid::Uuid::new_v4().to_string() } /// First-run contents: the tools this was built for. /// /// Each scope is the exact host, so a link from Gmail to Drive switches rather /// than being swallowed by whichever Google app happens to be showing. The /// `/u/N/` paths pin the second Google account, which is the one that matters. pub fn seed() -> Config { let mk = |name: &str, url: &str, group: &str, order: i32| App { id: new_id(), name: name.into(), url: url.into(), scope: default_scope(url).into_iter().collect(), group_id: Some(group.into()), user_agent: None, hidden: Vec::new(), zoom: 1.0, order, }; Config { version: 1, groups: vec![ Group { id: "g-work".into(), name: "Work".into(), collapsed: false, order: 0 }, Group { id: "g-google".into(), name: "Google".into(), collapsed: false, order: 1 }, ], apps: vec![ mk("Odoo", "https://example.odoo.com/web", "g-work", 0), mk("Gmail", "https://mail.google.com/mail/u/N/", "g-google", 0), mk("Drive", "https://drive.google.com/drive/u/N/my-drive", "g-google", 1), mk("Chat", "https://chat.google.com/u/N/app/home", "g-google", 2), ], settings: Settings::default(), } } pub fn config_path(dir: &PathBuf) -> PathBuf { dir.join("apps.json") } /// Reads `apps.json`, seeding it on first run. /// /// A file that fails to parse is kept, not replaced: losing someone's app list /// to a bad write is worse than starting with the seed and telling them. pub fn load(dir: &PathBuf) -> Result { let path = config_path(dir); if !path.exists() { let cfg = seed(); save(dir, &cfg)?; return Ok(cfg); } let raw = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; serde_json::from_str(&raw).map_err(|e| format!("{} is not readable: {e}", path.display())) } pub fn save(dir: &PathBuf, cfg: &Config) -> Result<(), String> { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; let json = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?; std::fs::write(config_path(dir), json).map_err(|e| e.to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn default_scope_is_the_host_without_www() { assert_eq!(default_scope("https://www.google.com/x"), Some("google.com".into())); assert_eq!(default_scope("https://mail.google.com"), Some("mail.google.com".into())); assert_eq!(default_scope("nonsense"), None); } #[test] fn normalize_url_adds_a_scheme_but_keeps_an_explicit_one() { assert_eq!(normalize_url(" github.com "), "https://github.com"); assert_eq!(normalize_url("http://intranet.local"), "http://intranet.local"); } #[test] fn seeded_scopes_are_exact_hosts_that_cannot_swallow_each_other() { // The whole point of exact hosts: a Drive link inside Gmail must match // Drive, not Gmail. A shared `google.com` scope would break that. let cfg = seed(); let scopes: Vec = cfg.apps.iter().flat_map(|a| a.scopes()).collect(); assert!(scopes.contains(&"mail.google.com".to_string())); assert!(scopes.contains(&"drive.google.com".to_string())); assert!(scopes.contains(&"chat.google.com".to_string())); assert!(scopes.contains(&"example.odoo.com".to_string())); assert!(!scopes.contains(&"google.com".to_string())); } #[test] fn seed_apps_all_carry_a_scope() { let cfg = seed(); assert_eq!(cfg.apps.len(), 4); assert!(cfg.apps.iter().all(|a| !a.scopes().is_empty())); } #[test] fn ordering_follows_group_then_position() { let cfg = seed(); let names: Vec<&str> = cfg.ordered().iter().map(|a| a.name.as_str()).collect(); assert_eq!(names, ["Odoo", "Gmail", "Drive", "Chat"]); } #[test] fn config_survives_a_serde_round_trip() { let cfg = seed(); let json = serde_json::to_string(&cfg).unwrap(); let back: Config = serde_json::from_str(&json).unwrap(); assert_eq!(back.apps.len(), cfg.apps.len()); assert_eq!(back.groups.len(), cfg.groups.len()); } #[test] fn a_sparse_file_fills_in_its_defaults() { // Hand-edited config files are a supported way to add an app. let cfg: Config = serde_json::from_str( r#"{"apps":[{"id":"a","name":"X","url":"https://x.com"}]}"#, ) .unwrap(); assert_eq!(cfg.version, 1); assert_eq!(cfg.settings.theme, "system"); assert_eq!(cfg.apps[0].scopes(), vec!["x.com".to_string()]); // A file written before zoom existed must not open every app at 0%. assert_eq!(cfg.apps[0].zoom, 1.0); assert!(cfg.apps[0].ua().contains("Chrome/")); } }