//! 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, pub active: Mutex>, pub stage: Mutex, /// Webviews exist. Guards against bootstrapping twice on a hot reload. pub booted: Mutex, /// What the last page probe reported, for the notification diagnostic. pub diag: Mutex, /// The last notification a page raised, and what macOS did with it. pub last_notification: Mutex, } 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()) } /// Records a selector chosen by right-clicking it in the page. pub fn add_hidden(&self, app_id: &str, selector: &str) -> Result<(), String> { { let mut cfg = self.config.lock().unwrap(); let app = cfg .apps .iter_mut() .find(|a| a.id == app_id) .ok_or_else(|| format!("no app {app_id}"))?; if app.hidden.iter().any(|s| s == selector) { return Ok(()); } app.hidden.push(selector.to_string()); } self.persist() } /// Every host the import is allowed to bring cookies across for: the /// configured apps, plus the sign-in hosts that vouch for them. fn importable_hosts(&self) -> Vec { let cfg = self.config.lock().unwrap(); let mut hosts: Vec = cfg.apps.iter().flat_map(|a| a.scopes()).collect(); hosts.extend(crate::routing::identity_providers().iter().map(|s| s.to_string())); hosts } } pub fn build_state(handle: &AppHandle) -> Result { 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), diag: Mutex::new(String::new()), last_notification: Mutex::new(String::new()), }) } #[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 = 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::(); 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 { 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, app: AppHandle, state: State<'_, AppState>, ) -> Result { 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, hidden: Vec::new(), 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 { 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 { { 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, i32)>, state: State<'_, AppState>, ) -> Result { { 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 { { 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 { { 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 { { 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() } // ------------------------------------------------------- hidden elements /// Replaces an app's hidden selectors and re-applies them without a reload. #[tauri::command] pub fn set_hidden( app_id: String, hidden: Vec, app: AppHandle, state: State<'_, AppState>, ) -> Result { { let mut cfg = state.config.lock().unwrap(); let target = cfg .apps .iter_mut() .find(|a| a.id == app_id) .ok_or_else(|| format!("no app {app_id}"))?; target.hidden = hidden.clone(); } state.persist()?; webviews::push_hidden(&app, &app_id, &hidden); Ok(state.cfg()) } /// Starts the in-page element picker, for reaching something a right-click /// cannot land on cleanly. #[tauri::command] pub fn pick_hidden(app_id: String, app: AppHandle) -> Result<(), String> { let wv = app .get_webview(&webviews::label_for(&app_id)) .ok_or_else(|| format!("{app_id} has no webview"))?; wv.eval("window.__workAppPick && window.__workAppPick()") .map_err(|e| e.to_string()) } /// Permission state, and what a notification raised straight from Rust does. /// /// Splits the chain in two: if this succeeds and the page test does not, the /// shim is at fault; if this fails, macOS never granted permission. #[tauri::command] pub fn notification_status(app: AppHandle) -> String { use tauri_plugin_notification::NotificationExt; let state = match app.notification().permission_state() { Ok(s) => format!("{s:?}"), Err(e) => format!("unknown ({e})"), }; let raised = match app .notification() .builder() .title("Work") .body("Notifications are working.") .show() { Ok(()) => "raised".to_string(), Err(e) => format!("failed: {e}"), }; let app_state = app.state::(); let last = app_state.last_notification.lock().unwrap().clone(); let from_page = if last.is_empty() { "none yet".into() } else { last }; format!("permission: {state} · direct: {raised} · from page: {from_page}") } /// Asks macOS for notification permission, once, at startup. pub fn ensure_notification_permission(app: &AppHandle) { use tauri_plugin_notification::NotificationExt; let granted = matches!( app.notification().permission_state(), Ok(tauri_plugin_notification::PermissionState::Granted) ); if !granted { if let Err(e) = app.notification().request_permission() { eprintln!("notification permission was refused: {e}"); } } } /// Fires a notification the way a page would. /// /// Deliberately routed through the injected shim rather than raised directly: /// the thing worth testing is the whole chain — page API, sentinel, Rust, and /// macOS — not whether this process can show a notification. #[tauri::command] pub fn test_notification(app_id: String, app: AppHandle) -> Result<(), String> { let wv = app .get_webview(&webviews::label_for(&app_id)) .ok_or_else(|| format!("{app_id} has no webview"))?; // The probe reports what it found before raising anything, so a // notification that never appears still says why. wv.eval( r#"(function () { var kind = typeof window.Notification; var shim = !!(window.Notification && window.Notification.__work); var err = ''; try { new Notification('Test notification', { body: 'If you can see this, pages can reach you.' }); } catch (e) { err = String(e); } if (window.__workAppSend) { window.__workAppSend('diag', { api: kind, shim: shim ? 'yes' : 'no', err: err }); } })();"#, ) .map_err(|e| e.to_string()) } /// Asks the page which cookies it can actually see. /// /// `setCookie` is fire-and-forget, so the import's count is what was handed to /// WebKit, not what WebKit kept. This reads the other end. HttpOnly cookies are /// invisible to script by design, so the answer is a floor, not a total — but a /// zero here means the injection never landed at all. #[tauri::command] pub fn probe_cookies(app_id: String, app: AppHandle) -> Result<(), String> { let wv = app .get_webview(&webviews::label_for(&app_id)) .ok_or_else(|| format!("{app_id} has no webview"))?; wv.eval( r#"(function () { var names = document.cookie ? document.cookie.split(';').map(function (c) { return c.split('=')[0].trim(); }) : []; if (window.__workAppSend) { window.__workAppSend('diag', { host: location.hostname, visible: String(names.length), names: names.slice(0, 12).join(',') }); } })();"#, ) .map_err(|e| e.to_string()) } /// What the last cookie probe saw. #[tauri::command] pub fn cookie_probe(app: AppHandle) -> String { let diag = app.state::().diag.lock().unwrap().clone(); if diag.is_empty() { "no answer from the page".into() } else { diag } } // ------------------------------------------------------- browser pairing #[tauri::command] pub fn list_browsers() -> Vec { crate::cookies::list() } /// Imports the paired browser's cookies for the configured hosts. /// /// Everything outside those hosts is dropped before anything is written: this /// reaches into a browser's whole cookie store, and it must come back with the /// sessions for the tools on the list and nothing else. #[tauri::command] pub fn pair_browser( browser: String, app: AppHandle, state: State<'_, AppState>, ) -> Result { let all = crate::cookies::read_all(&browser)?; let scanned = all.len(); let wanted = crate::cookies::filter_to_scopes(all, &state.importable_hosts()); let mut domains: Vec = wanted.iter().map(|c| c.domain.clone()).collect(); domains.sort(); domains.dedup(); let mut warnings = Vec::new(); if wanted.is_empty() { warnings.push(format!( "Read {scanned} cookies, none for the apps on your list. \ Sign in to them in that browser first." )); } let imported = crate::cookies::inject::install(&app, wanted)?; { let mut cfg = state.config.lock().unwrap(); cfg.settings.paired_browser = Some(browser); cfg.settings.last_paired_at = Some(now_iso()); } state.persist()?; Ok(crate::cookies::PairResult { imported, domains: domains.len(), domain_names: domains, warnings, }) } /// A timestamp for "last paired", without pulling in a date library for it. fn now_iso() -> String { let secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let days = secs / 86_400; let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); let (y, mo, d) = civil_from_days(days as i64); format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC") } /// Howard Hinnant's days-to-civil-date algorithm. fn civil_from_days(z: i64) -> (i64, u32, u32) { let z = z + 719_468; let era = z.div_euclid(146_097); let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d) } #[cfg(test)] mod tests { use super::*; #[test] fn civil_from_days_matches_known_dates() { // Cross-checked against Python: // date(1970,1,1) + timedelta(days=n) assert_eq!(civil_from_days(0), (1970, 1, 1)); assert_eq!(civil_from_days(19_723), (2024, 1, 1)); assert_eq!(civil_from_days(20_697), (2026, 9, 1)); assert_eq!(civil_from_days(20_698), (2026, 9, 2)); } }