Files
work-app/src-tauri/src/commands.rs
T
Vincent ff4a0c6bc4 Browser pairing, notifications, element hiding; drop the top bar
Pairing decrypts a Chromium browser's cookie store (PBKDF2-HMAC-SHA1
against its Keychain key, then AES-128-CBC) and injects the result into
WKHTTPCookieStore. Only the configured apps' hosts and their sign-in
hosts survive the filter. Browsers are offered most-recently-used first,
since the first entry becomes the default and someone with four
Chromium browsers installed wants the one they actually browse in.

WKWebView defines window.Notification but it does nothing: constructing
one throws no error and shows no banner, so a page believes it notified
you. Measured on the machine as `api=function shim=no` before the shim
was made unconditional; `from page: Odoo - Test notification -> raised`
after.

Anything on a page can be right-clicked away. The rule is re-asserted on
every navigation, because the injected script only carries a snapshot
from when the view was built and a selector added since would otherwise
come back on reload.

The top bar is gone. Navigation lives beside the cog, the nav carries
the traffic lights, and two-finger swipe goes back and forward.

The seed is now the real app list, scoped to exact hosts so a Drive link
inside Gmail switches rather than being swallowed.
2026-09-01 12:17:31 +02:00

597 lines
19 KiB
Rust

//! 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>,
/// What the last page probe reported, for the notification diagnostic.
pub diag: Mutex<String>,
/// The last notification a page raised, and what macOS did with it.
pub last_notification: Mutex<String>,
}
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<String> {
let cfg = self.config.lock().unwrap();
let mut hosts: Vec<String> = 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<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),
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<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,
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<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()
}
// ------------------------------------------------------- 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<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Config, String> {
{
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::<AppState>();
let diag = app_state.diag.lock().unwrap().clone();
let last = app_state.last_notification.lock().unwrap().clone();
let page = if diag.is_empty() { "not run yet".into() } else { diag };
let from_page = if last.is_empty() { "none yet".into() } else { last };
format!("permission: {state} · direct: {raised} · page: {page} · 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())
}
// ------------------------------------------------------- browser pairing
#[tauri::command]
pub fn list_browsers() -> Vec<crate::cookies::Browser> {
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<crate::cookies::PairResult, String> {
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<String> = 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));
}
}