A build gets sent to testers, and this one arrived with the apps it was developed against baked in as defaults - including an internal Odoo hostname and a per-person Google account index in every path. Nothing personal ships inside the bundle, but the seed is compiled into it. The seed is now three generic Google apps that link to each other, which is enough for a tester to see app switching work without learning where anyone works. A test asserts it stays that way: every seeded URL must be a plain google.com host with no account index. It would otherwise drift back the next time the seed is edited for convenience.
329 lines
10 KiB
Rust
329 lines
10 KiB
Rust
//! `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<String>,
|
|
#[serde(default)]
|
|
pub group_id: Option<String>,
|
|
#[serde(default)]
|
|
pub user_agent: Option<String>,
|
|
/// 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<String>,
|
|
/// The icon this app's own page last reported, kept so the nav is right
|
|
/// from the moment it opens rather than once every page has loaded.
|
|
#[serde(default)]
|
|
pub icon: Option<String>,
|
|
/// 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<String> {
|
|
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<Group>,
|
|
#[serde(default)]
|
|
pub apps: Vec<App>,
|
|
#[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<AppScope> {
|
|
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<String>| -> 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<String> {
|
|
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.
|
|
///
|
|
/// Deliberately generic. This is what someone sees the first time they open a
|
|
/// copy of the app, and a build handed to a tester would otherwise arrive
|
|
/// carrying whichever company's tools it was developed against — an internal
|
|
/// hostname is not something to put in a file you send to people.
|
|
///
|
|
/// Enough to try it on: two Google apps that link to each other, so switching
|
|
/// between configured apps can be seen working straight away. No account index
|
|
/// in the paths, since that is per person.
|
|
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(),
|
|
icon: None,
|
|
zoom: 1.0,
|
|
order,
|
|
};
|
|
Config {
|
|
version: 1,
|
|
groups: vec![Group {
|
|
id: "g-google".into(),
|
|
name: "Google".into(),
|
|
collapsed: false,
|
|
order: 0,
|
|
}],
|
|
apps: vec![
|
|
mk("Gmail", "https://mail.google.com", "g-google", 0),
|
|
mk("Drive", "https://drive.google.com", "g-google", 1),
|
|
mk("Calendar", "https://calendar.google.com", "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<Config, String> {
|
|
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<String> = 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(&"calendar.google.com".to_string()));
|
|
assert!(!scopes.contains(&"google.com".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn the_seed_carries_nobody_in_particular() {
|
|
// A build gets sent to testers. Whatever company this was developed
|
|
// against must not travel with it: no internal hostname, and no
|
|
// per-person account index in a path.
|
|
let cfg = seed();
|
|
for app in &cfg.apps {
|
|
assert!(
|
|
app.url.ends_with(".google.com"),
|
|
"{} points somewhere specific: {}",
|
|
app.name,
|
|
app.url
|
|
);
|
|
assert!(!app.url.contains("/u/"), "{} pins an account", app.name);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn seed_apps_all_carry_a_scope() {
|
|
let cfg = seed();
|
|
assert_eq!(cfg.apps.len(), 3);
|
|
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, ["Gmail", "Drive", "Calendar"]);
|
|
}
|
|
|
|
#[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/"));
|
|
}
|
|
}
|