Work App: a dedicated browser for work tools
A Tauri 2 shell with one child webview per configured tool. Nav on the left with groups and a collapsible icon rail; links between configured apps switch tabs, everything else leaves for the real browser. Design spec in docs/superpowers/specs/2026-09-01-work-app-design.md.
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
//! `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>,
|
||||
#[serde(default)]
|
||||
pub order: i32,
|
||||
}
|
||||
|
||||
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,
|
||||
#[serde(default)]
|
||||
pub paired_browser: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_paired_at: Option<String>,
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
"system".into()
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
nav_collapsed: false,
|
||||
theme: default_theme(),
|
||||
paired_browser: None,
|
||||
last_paired_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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: enough apps, across enough groups, that grouping and
|
||||
/// cross-app link routing can both be seen working without typing anything.
|
||||
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,
|
||||
order,
|
||||
};
|
||||
Config {
|
||||
version: 1,
|
||||
groups: vec![
|
||||
Group { id: "g-google".into(), name: "Google".into(), collapsed: false, order: 0 },
|
||||
Group { id: "g-dev".into(), name: "Dev".into(), collapsed: false, order: 1 },
|
||||
Group { id: "g-ref".into(), name: "Reference".into(), collapsed: false, order: 2 },
|
||||
],
|
||||
apps: vec![
|
||||
mk("Google", "https://www.google.com", "g-google", 0),
|
||||
mk("YouTube", "https://www.youtube.com", "g-google", 1),
|
||||
mk("GitHub", "https://github.com", "g-dev", 0),
|
||||
mk("Hacker News", "https://news.ycombinator.com", "g-dev", 1),
|
||||
mk("Wikipedia", "https://en.wikipedia.org", "g-ref", 0),
|
||||
mk("MDN", "https://developer.mozilla.org", "g-ref", 1),
|
||||
],
|
||||
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 seed_apps_all_carry_a_scope() {
|
||||
let cfg = seed();
|
||||
assert_eq!(cfg.apps.len(), 6);
|
||||
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, ["Google", "YouTube", "GitHub", "Hacker News", "Wikipedia", "MDN"]);
|
||||
}
|
||||
|
||||
#[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()]);
|
||||
assert!(cfg.apps[0].ua().contains("Chrome/"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user