Files
work-app/src-tauri/src/config.rs
T
Vincent ada18d54ae Draw apps with Simple Icons instead of fetched favicons
The favicon service was wrong as often as it was right: a sign-in page's
icon for anything behind a login, nothing at all for a private host, and
both answers cached past any way of asking again. Refreshing could not
fix it, because the staleness was not local. Now every mark is.

Each app is its brand glyph in white on a round tile, coloured from
Tailwind's 500s by hashing the host - you find things by their colour, so
one that moved every launch would be worse than none. A host with no
glyph gets its initial in the same tile.

Matching tries the registrable name first, since a self-hosted tool is
nearly always on a subdomain of its vendor - aputure.odoo.com is Odoo,
not Aputure. A short table covers what a domain cannot answer, which is
most of Google.

The build reduces Simple Icons' 15MB of SVGs to one 4.5MB map in public/,
fetched once rather than parsed into the bundle at every start; the
bundle stays at 233KB. It is generated on every build, so never committed
and never stale.

Traffic lights are offset to sit inside the window margin rather than
crowding its edge.
2026-09-01 14:45:41 +02:00

302 lines
9.3 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>,
/// 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: 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<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(&"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/"));
}
}