Files
work-app/src-tauri/src/commands.rs
T
Vincent 0567a4b7c7 Offer to save a password when a login is submitted
Submitting a form containing a password now offers to remember it.

It goes into the macOS Keychain, through the Security framework rather
than the `security` binary - a password passed as a command-line argument
is visible in `ps` to anyone on the machine, however briefly. Never
apps.json, never a log.

The value travels as little as it can: the injected script hands it
straight to Rust, which holds it in memory and tells the shell only which
host and which username, since that is all the shell needs to ask the
question. It is written on Save and dropped on anything else.

Autofill is deliberately not built. Reading a password back out and
injecting it into a page is a materially larger surface than offering to
store one, and deserves its own decision.
2026-09-01 15:26:53 +02:00

860 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, Emitter, 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,
/// Corner radius the window's inner frame is currently drawn with.
pub radius: Mutex<f64>,
/// Title bar height: how far a child webview's origin sits above the
/// content the shell measures from.
pub chrome: Mutex<f64>,
/// A login waiting on an answer: host, account, password. Held only until
/// it is saved or declined, and never written anywhere but the Keychain.
pub pending_password: Mutex<Option<(String, String, String)>>,
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 each app's page last reported about itself, keyed by app id.
pub diag: Mutex<std::collections::HashMap<String, String>>,
/// The last notification a page raised, and what macOS did with it.
pub last_notification: Mutex<String>,
/// How much has arrived in each app since you last looked at it.
pub unread: Mutex<std::collections::HashMap<String, u32>>,
/// Size of the last dialog backdrop still, or why there wasn't one.
pub last_snapshot: 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())
}
/// Same, for the sentinel handlers that live outside this module.
pub fn save(&self) -> Result<(), String> {
self.persist()
}
/// 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()
}
}
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,
radius: Mutex::new(0.0),
chrome: Mutex::new(0.0),
pending_password: Mutex::new(None),
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(std::collections::HashMap::new()),
last_notification: Mutex::new(String::new()),
unread: Mutex::new(std::collections::HashMap::new()),
last_snapshot: 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,
radius: f64,
app: AppHandle,
state: State<'_, AppState>,
) {
let stage = (x, y, width.max(0.0), height.max(0.0));
*state.stage.lock().unwrap() = stage;
*state.radius.lock().unwrap() = radius;
let active = state.active.lock().unwrap().clone();
webviews::set_stage(&app, active.as_deref(), stage, radius);
}
/// 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)?;
webviews::set_corner_radius(&app, &a.id, *state.radius.lock().unwrap());
*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);
}
// Each new view is added on top of its siblings, so the app being
// looked at has to be put back in front after every one.
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &cfg, stage);
}
});
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);
// Looking at an app is what clears its count. Nothing else does.
state.unread.lock().unwrap().remove(&app_id);
let _ = app.emit("unread-changed", unread_list(&state));
}
/// Every app's count, in the shape the nav wants.
pub fn unread_list(state: &AppState) -> Vec<(String, u32)> {
state
.unread
.lock()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect()
}
#[tauri::command]
pub fn unread_counts(state: State<'_, AppState>) -> Vec<(String, u32)> {
unread_list(&state)
}
/// Hides every app, so a dialog is not painted over by a native view.
/// A still of the app on screen, taken before a dialog covers it.
///
/// Async, and the wait happens on a blocking worker: the snapshot's completion
/// handler runs on the main thread, so waiting for it *on* the main thread
/// deadlocks until the timeout and returns nothing every time.
#[tauri::command]
pub async fn stage_snapshot(app: AppHandle) -> Option<String> {
let id = {
let state = app.state::<AppState>();
let active = state.active.lock().unwrap();
active.clone()?
};
let handle = app.clone();
let shot = tauri::async_runtime::spawn_blocking(move || webviews::snapshot(&app, &id))
.await
.ok()
.flatten();
// Recorded so the diagnostic can say whether the still was taken at all,
// rather than leaving a flat backdrop to be interpreted by eye.
*handle.state::<AppState>().last_snapshot.lock().unwrap() = match &shot {
Some(d) => format!("{} bytes", d.len()),
None => "none".into(),
};
shot
}
#[tauri::command]
pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) {
let stage = *state.stage.lock().unwrap();
webviews::hide_all(&app, &state.cfg(), stage);
}
/// 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(),
icon: None,
zoom: 1.0,
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)?;
let active = state.active.lock().unwrap().clone();
webviews::show_only(&app, active.as_deref(), &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, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
state.config.lock().unwrap().settings.theme = theme.clone();
apply_window_theme(&app, &theme);
state.persist()
}
/// Puts the window's own title bar in the same light or dark as the app.
///
/// `None` hands it back to the system, which is what "System" means — the bar
/// then follows the OS the way every other window does.
pub fn apply_window_theme(app: &AppHandle, theme: &str) {
let wanted = match theme {
"light" => Some(tauri::Theme::Light),
"dark" => Some(tauri::Theme::Dark),
_ => None,
};
if let Some(w) = app.get_window("main") {
let _ = w.set_theme(wanted);
}
}
// ------------------------------------------------------- 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())
}
/// 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 last = app_state.last_notification.lock().unwrap().clone();
let from_page = if last.is_empty() { "none yet".into() } else { last };
let stage = *app_state.stage.lock().unwrap();
let offset = webviews::chrome_offset(&app);
let shot = app_state.last_snapshot.lock().unwrap().clone();
let shot = if shot.is_empty() { "not taken".into() } else { shot };
format!(
"permission: {state} · direct: {raised} · from page: {from_page} · backdrop: {shot} \
· stage: {:.0},{:.0} {:.0}×{:.0} · chrome offset: {offset:.1}",
stage.0, stage.1, stage.2, stage.3
)
}
/// Asks macOS for notification permission, and claims this app's identity.
///
/// The identity has to be set explicitly. Left alone, mac-notification-sys
/// looks up an application literally named "use_default", fails, and falls back
/// to `com.apple.Finder` — so every notification arrives as Finder, wearing
/// Finder's alert style, which is why they land silently in Notification Centre
/// instead of on screen. It is registered before the first notification because
/// the crate's own lazy fallback runs once and cannot be corrected afterwards.
pub fn ensure_notification_permission(app: &AppHandle) {
use tauri_plugin_notification::NotificationExt;
#[cfg(target_os = "macos")]
{
let bundle = app.config().identifier.clone();
if let Err(e) = mac_notification_sys::set_application(&bundle) {
eprintln!("could not claim {bundle} for notifications: {e}");
}
present_notifications_while_frontmost();
}
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())
}
// ----------------------------------------------------------------- zoom
/// The zoom ladder, so a keystroke lands on a sensible size rather than
/// drifting by a multiplier that never returns to exactly 100%.
const ZOOM_STEPS: [f64; 13] = [
0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0,
];
fn step_zoom(current: f64, direction: i32) -> f64 {
// The nearest rung, so a hand-edited value still moves somewhere sane.
let idx = ZOOM_STEPS
.iter()
.enumerate()
.min_by(|a, b| {
(a.1 - current).abs().partial_cmp(&(b.1 - current).abs()).unwrap()
})
.map(|(i, _)| i as i32)
.unwrap_or(5);
let next = (idx + direction).clamp(0, ZOOM_STEPS.len() as i32 - 1);
ZOOM_STEPS[next as usize]
}
/// Applies a zoom change to whichever app is showing, and remembers it.
///
/// Per app, because a dense ERP and a mail client do not want the same size.
pub fn adjust_zoom(app: &AppHandle, direction: Option<i32>) {
let state = app.state::<AppState>();
let Some(id) = state.active.lock().unwrap().clone() else { return };
let zoom = {
let mut cfg = state.config.lock().unwrap();
let Some(target) = cfg.apps.iter_mut().find(|a| a.id == id) else { return };
target.zoom = match direction {
Some(d) => step_zoom(target.zoom, d),
None => 1.0,
};
target.zoom
};
let _ = state.persist();
if let Some(wv) = app.get_webview(&webviews::label_for(&id)) {
let _ = wv.set_zoom(zoom);
}
let _ = app.emit("zoom-changed", (id, zoom));
}
#[tauri::command]
pub fn set_zoom(app_id: String, zoom: f64, 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.zoom = zoom.clamp(0.25, 5.0);
}
state.persist()?;
if let Some(wv) = app.get_webview(&webviews::label_for(&app_id)) {
let _ = wv.set_zoom(zoom);
}
Ok(state.cfg())
}
/// Makes macOS show a banner even when this app is the frontmost one.
///
/// `NSUserNotificationCenter` suppresses the banner whenever the posting
/// application is in front, delivering it silently to Notification Centre
/// instead, unless the delegate says otherwise. That default is wrong here: an
/// app showing Odoo is still frontmost when Gmail — a hidden webview in the
/// same window — has something to say, and that notification is the entire
/// point of keeping the other apps loaded.
///
/// The delegate belongs to mac-notification-sys and implements only delivery
/// and activation, so the missing method is added to its class at runtime.
#[cfg(target_os = "macos")]
fn present_notifications_while_frontmost() {
use objc2::ffi::class_addMethod;
use objc2::runtime::{AnyClass, AnyObject, Bool, Sel};
extern "C" fn should_present(
_this: *mut AnyObject,
_cmd: Sel,
_center: *mut AnyObject,
_notification: *mut AnyObject,
) -> Bool {
Bool::YES
}
let Some(class) = AnyClass::get(c"NotificationCenterDelegate") else {
eprintln!("notification delegate class is missing; banners will stay silent");
return;
};
let added = unsafe {
class_addMethod(
class as *const AnyClass as *mut AnyClass,
objc2::sel!(userNotificationCenter:shouldPresentNotification:),
std::mem::transmute::<*const (), unsafe extern "C-unwind" fn()>(
should_present as *const (),
),
c"c@:@@".as_ptr(),
)
};
if !added.as_bool() {
eprintln!("could not force banner presentation; notifications may stay silent");
}
}
/// Reloads whichever app is showing, for the menu bar's Reload item.
pub fn reload_active(app: &AppHandle) {
let state = app.state::<AppState>();
let Some(id) = state.active.lock().unwrap().clone() else { return };
if let Some(wv) = app.get_webview(&webviews::label_for(&id)) {
let _ = wv.eval("location.reload()");
}
}
/// Asks every app's page what it currently believes about itself.
///
/// Built because guessing was not working: it separates "the page is asleep"
/// from "the page is awake and thinks it is on screen" from "the page is awake,
/// knows it is hidden, and simply has nothing to say".
#[tauri::command]
pub fn probe_apps(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
state.diag.lock().unwrap().clear();
for a in state.cfg().apps {
let Some(wv) = app.get_webview(&webviews::label_for(&a.id)) else { continue };
let _ = wv.eval(
r#"(function () {
if (!window.__workAppSend) return;
var st = window.__workAppState ? window.__workAppState() : {};
window.__workAppSend('diag', {
title: String(document.title || '').slice(0, 32),
active: String(st.active),
seen: String(st.seen),
// The three ways an app can say something arrived.
notified: String(st.raised),
badged: String(st.badged),
sw: typeof (window.ServiceWorkerRegistration || {}).prototype
});
})();"#,
);
}
Ok(())
}
/// What every app reported, from the newest run only.
#[tauri::command]
pub fn app_reports(state: State<'_, AppState>) -> Vec<(String, String)> {
let cfg = state.cfg();
let diag = state.diag.lock().unwrap();
cfg.apps
.iter()
.map(|a| {
let report = diag
.get(&a.id)
.cloned()
.unwrap_or_else(|| "no reply — page is asleep or gone".into());
(a.name.clone(), report)
})
.collect()
}
// ------------------------------------------------------------ passwords
/// Writes the offered login to the macOS Keychain.
///
/// The Keychain, not `apps.json`: it is encrypted at rest, unlocked with the
/// login session, and the one place on this machine that is actually built to
/// hold a password. The value never touches the config file, the logs, or the
/// shell.
#[tauri::command]
pub fn save_password(state: State<'_, AppState>) -> Result<String, String> {
let offer = state.pending_password.lock().unwrap().take();
let Some((host, account, password)) = offer else {
return Err("nothing waiting to be saved".into());
};
#[cfg(target_os = "macos")]
{
let service = format!("Work — {host}");
security_framework::passwords::set_generic_password(
&service,
&account,
password.as_bytes(),
)
.map_err(|e| format!("the Keychain refused it: {e}"))?;
}
#[cfg(not(target_os = "macos"))]
let _ = password;
Ok(host)
}
/// Drops the offered login without saving it.
#[tauri::command]
pub fn discard_password(state: State<'_, AppState>) {
*state.pending_password.lock().unwrap() = None;
}
// -------------------------------------------------- notification clicks
/// Runs the page's own click handler for a notification it raised.
///
/// This is what makes a click land on the message rather than merely on the
/// app: Gmail's handler knows which thread it was about, and this app does not
/// and should not.
#[tauri::command]
pub fn notification_click(app_id: String, notification_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"))?;
let escaped = notification_id.replace('\\', "\\\\").replace('\'', "\\'");
wv.eval(&format!(
"window.__workAppNotifyClick && window.__workAppNotifyClick('{escaped}')"
))
.map_err(|e| e.to_string())
}
/// Brings the window forward when a notification is clicked.
#[tauri::command]
pub fn focus_window(app: AppHandle) {
if let Some(w) = app.get_window("main") {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zoom_steps_up_and_down_the_ladder() {
assert_eq!(step_zoom(1.0, 1), 1.1);
assert_eq!(step_zoom(1.0, -1), 0.9);
assert_eq!(step_zoom(1.25, 1), 1.5);
}
#[test]
fn zoom_stops_at_the_ends_rather_than_wrapping() {
assert_eq!(step_zoom(3.0, 1), 3.0);
assert_eq!(step_zoom(0.5, -1), 0.5);
}
#[test]
fn an_off_ladder_value_snaps_to_its_nearest_rung() {
// A hand-edited apps.json should still zoom somewhere sensible.
assert_eq!(step_zoom(1.04, 1), 1.1);
assert_eq!(step_zoom(1.04, -1), 0.9);
}
}