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,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()
|
||||
}
|
||||
Reference in New Issue
Block a user