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:
2026-09-01 11:37:35 +02:00
commit a6c8e4336b
53 changed files with 15580 additions and 0 deletions
+363
View File
@@ -0,0 +1,363 @@
//! The command surface. The shell owns "which app is active"; Rust owns the
//! webviews and the file on disk.
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{AppHandle, Manager, State};
use url::Url;
use crate::config::{self, App, Config, Group};
use crate::webviews;
/// Stage rect in logical pixels: x, y, width, height.
pub type Stage = (f64, f64, f64, f64);
pub struct AppState {
pub dir: PathBuf,
pub config: Mutex<Config>,
pub active: Mutex<Option<String>>,
pub stage: Mutex<Stage>,
/// Webviews exist. Guards against bootstrapping twice on a hot reload.
pub booted: Mutex<bool>,
}
impl AppState {
pub fn cfg(&self) -> Config {
self.config.lock().unwrap().clone()
}
fn persist(&self) -> Result<(), String> {
config::save(&self.dir, &self.config.lock().unwrap())
}
}
pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
let dir = handle
.path()
.app_config_dir()
.map_err(|e| format!("no config directory: {e}"))?;
let config = config::load(&dir)?;
Ok(AppState {
dir,
config: Mutex::new(config),
active: Mutex::new(None),
stage: Mutex::new((240.0, 38.0, 800.0, 600.0)),
booted: Mutex::new(false),
})
}
#[tauri::command]
pub fn get_config(state: State<'_, AppState>) -> Config {
state.cfg()
}
/// Records the rect the shell has left for an app, and resizes the one showing.
#[tauri::command]
pub fn set_stage(
x: f64,
y: f64,
width: f64,
height: f64,
app: AppHandle,
state: State<'_, AppState>,
) {
let stage = (x, y, width.max(0.0), height.max(0.0));
*state.stage.lock().unwrap() = stage;
let active = state.active.lock().unwrap().clone();
webviews::set_stage(&app, active.as_deref(), stage);
}
/// Creates every app's webview: the active one first, the rest staggered.
///
/// Called by the shell once it has measured the stage, so the views are born
/// at the right size instead of being built against a guess and corrected.
#[tauri::command]
pub fn bootstrap(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
{
let mut booted = state.booted.lock().unwrap();
if *booted {
return Ok(());
}
*booted = true;
}
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
let active = state.active.lock().unwrap().clone();
let first = active
.clone()
.or_else(|| cfg.ordered().first().map(|a| a.id.clone()));
if let Some(id) = &first {
if let Some(a) = cfg.app(id) {
webviews::create(&app, a, &cfg, stage)?;
*state.active.lock().unwrap() = Some(id.clone());
webviews::show_only(&app, Some(id), &cfg, stage);
}
}
// The rest follow with a gap, so launching does not fire a dozen
// simultaneous page loads at the network and the CPU.
let rest: Vec<App> = cfg
.ordered()
.into_iter()
.filter(|a| Some(&a.id) != first.as_ref())
.cloned()
.collect();
tauri::async_runtime::spawn(async move {
for a in rest {
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
let state = app.state::<AppState>();
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
if let Err(e) = webviews::create(&app, &a, &cfg, stage) {
eprintln!("could not create webview for {}: {e}", a.name);
}
}
});
Ok(())
}
#[tauri::command]
pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) {
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
*state.active.lock().unwrap() = Some(app_id.clone());
webviews::show_only(&app, Some(&app_id), &cfg, stage);
}
/// Hides every app, so a dialog is not painted over by a native view.
#[tauri::command]
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
webviews::hide_all(&app, &state.cfg());
}
/// Puts the active app back after a dialog closes.
#[tauri::command]
pub fn show_stage(app: AppHandle, state: State<'_, AppState>) {
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &cfg, stage);
}
#[tauri::command]
pub fn navigate_app(app_id: String, url: String, app: AppHandle) -> Result<(), String> {
let wv = app
.get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?;
let url = Url::parse(&url).map_err(|e| e.to_string())?;
wv.navigate(url).map_err(|e| e.to_string())
}
/// Back, forward and reload, run inside the app's own page.
#[tauri::command]
pub fn history_go(app_id: String, delta: i32, app: AppHandle) -> Result<(), String> {
let wv = app
.get_webview(&webviews::label_for(&app_id))
.ok_or_else(|| format!("{app_id} has no webview"))?;
let script = if delta == 0 {
"location.reload()".to_string()
} else {
format!("history.go({delta})")
};
wv.eval(&script).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn current_url(app_id: String, app: AppHandle) -> Option<String> {
app.get_webview(&webviews::label_for(&app_id))
.and_then(|wv| wv.url().ok())
.map(|u| u.to_string())
}
#[tauri::command]
pub fn open_external(url: String) -> Result<(), String> {
tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------- apps
#[tauri::command]
pub fn add_app(
name: String,
url: String,
group_id: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
let url = config::normalize_url(&url);
let scope = config::default_scope(&url)
.ok_or_else(|| format!("{url} is not a URL this can open"))?;
let new = {
let mut cfg = state.config.lock().unwrap();
let order = cfg
.apps
.iter()
.filter(|a| a.group_id == group_id)
.map(|a| a.order)
.max()
.map_or(0, |m| m + 1);
let new = App {
id: config::new_id(),
name: name.trim().to_string(),
url,
scope: vec![scope],
group_id,
user_agent: None,
order,
};
cfg.apps.push(new.clone());
new
};
state.persist()?;
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
if *state.booted.lock().unwrap() {
webviews::create(&app, &new, &cfg, stage)?;
}
Ok(cfg)
}
/// Applies an edit. A change to url, scope or user agent rebuilds the webview,
/// since none of the three can be altered on a live one.
#[tauri::command]
pub fn update_app(
updated: App,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
let rebuild = {
let mut cfg = state.config.lock().unwrap();
let existing = cfg
.apps
.iter_mut()
.find(|a| a.id == updated.id)
.ok_or_else(|| format!("no app {}", updated.id))?;
let rebuild = existing.url != updated.url
|| existing.scope != updated.scope
|| existing.user_agent != updated.user_agent;
*existing = updated.clone();
rebuild
};
state.persist()?;
let cfg = state.cfg();
let stage = *state.stage.lock().unwrap();
if rebuild && *state.booted.lock().unwrap() {
webviews::destroy(&app, &updated.id);
webviews::create(&app, &updated, &cfg, stage)?;
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &cfg, stage);
}
Ok(cfg)
}
#[tauri::command]
pub fn delete_app(
app_id: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
cfg.apps.retain(|a| a.id != app_id);
}
state.persist()?;
webviews::destroy(&app, &app_id);
let mut active = state.active.lock().unwrap();
if active.as_deref() == Some(app_id.as_str()) {
*active = None;
}
Ok(state.cfg())
}
/// Rewrites group membership and position in one go, for a drag.
#[tauri::command]
pub fn reorder_apps(
ordering: Vec<(String, Option<String>, i32)>,
state: State<'_, AppState>,
) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
for (id, group_id, order) in ordering {
if let Some(a) = cfg.apps.iter_mut().find(|a| a.id == id) {
a.group_id = group_id;
a.order = order;
}
}
}
state.persist()?;
Ok(state.cfg())
}
// -------------------------------------------------------------- groups
#[tauri::command]
pub fn add_group(name: String, state: State<'_, AppState>) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
let order = cfg.groups.iter().map(|g| g.order).max().map_or(0, |m| m + 1);
cfg.groups.push(Group {
id: config::new_id(),
name: name.trim().to_string(),
collapsed: false,
order,
});
}
state.persist()?;
Ok(state.cfg())
}
#[tauri::command]
pub fn update_group(updated: Group, state: State<'_, AppState>) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
let g = cfg
.groups
.iter_mut()
.find(|g| g.id == updated.id)
.ok_or_else(|| format!("no group {}", updated.id))?;
*g = updated;
}
state.persist()?;
Ok(state.cfg())
}
/// Removes a group. Its apps survive, ungrouped — deleting a folder should
/// never be a way to lose the things inside it by accident.
#[tauri::command]
pub fn delete_group(group_id: String, state: State<'_, AppState>) -> Result<Config, String> {
{
let mut cfg = state.config.lock().unwrap();
cfg.groups.retain(|g| g.id != group_id);
for a in cfg.apps.iter_mut() {
if a.group_id.as_deref() == Some(group_id.as_str()) {
a.group_id = None;
}
}
}
state.persist()?;
Ok(state.cfg())
}
// ------------------------------------------------------------ settings
#[tauri::command]
pub fn set_nav_collapsed(collapsed: bool, state: State<'_, AppState>) -> Result<(), String> {
state.config.lock().unwrap().settings.nav_collapsed = collapsed;
state.persist()
}
#[tauri::command]
pub fn set_theme(theme: String, state: State<'_, AppState>) -> Result<(), String> {
state.config.lock().unwrap().settings.theme = theme;
state.persist()
}