mobux 0.51.0

A touch-friendly tmux web UI for unhinged people who run terminal sessions from their phone while walking the dog
//! Web Push delivery.
//!
//! See `docs/twa-push-implementation-plan.md` (Phase 6) for the design.
//!
//! ## Trigger source
//!
//! Bell triggers come from tmux's `alert-bell` hook (set up at startup
//! via `tmux::install_bell_hook`). The hook fires *exactly once per
//! actual bell* and includes the originating session and window in its
//! format context — tmux is the source of truth, so there is no
//! repaint-vs-event ambiguity and no need for client-side dedupe.
//!
//! `bell_emoji` and `program_exit` triggers in `NotificationPrefs` are
//! currently dormant: they require content scanning of the PTY stream,
//! which only works correctly against an append-only source like
//! `tmux pipe-pane`. That plumbing is a separate piece of work — until
//! it lands, those prefs are accepted by the settings UI but no event
//! source feeds them. Scripted notifications still work via
//! `POST /api/push/notify`.
//!
//! ## Library choice
//!
//! Uses [`web-push-native`] for VAPID JWT signing and RFC 8188 (`aes128gcm`)
//! payload encryption, plus [`reqwest`] (rustls-only build) to POST to the push
//! service. The previously-considered `web-push` crate was ruled out because it
//! transitively pulls `openssl-sys`, breaking the project's hermetic-rustls
//! build. `web-push-native` keeps the build openssl-free — verified with
//! `cargo tree -i openssl-sys` (empty).
//!
//! ## Best-effort delivery
//!
//! All errors are logged via `eprintln!` and swallowed. Push delivery must
//! never block or error any handler. Dead subscriptions (HTTP 404 / 410)
//! are pruned from the database on the fly.
//!
//! [`web-push-native`]: https://crates.io/crates/web-push-native
//! [`reqwest`]: https://crates.io/crates/reqwest

use std::sync::Arc;

use reqwest::StatusCode;
use serde_json::json;
use web_push_native::{
    jwt_simple::algorithms::ES256KeyPair, p256::PublicKey, Auth, WebPushBuilder,
};

use crate::db::{Db, Subscription, VapidKeys};

/// Build the deep-link URL for a session notification, embedding
/// `?w={window}` so a click can land on the originating tmux window.
///
/// Relative on purpose: the service worker resolves it against its own
/// registration scope (`web/static/sw.js`), so a mobux mounted under a path
/// prefix deep-links inside that prefix. At the empty prefix the scope is the
/// origin root and `s/{session}` resolves exactly where `/s/{session}` did.
///
/// Deliberately node-less: `session` always names a session on the HUB'S OWN
/// local tmux. The only caller is `fire_bell`, driven exclusively by the
/// `alert-bell` hook (`tmux::install_bell_hook`), which is installed once, at
/// startup, on the hub's local tmux server only — nodes are plain SSH targets
/// (see `nodes.rs`/`tmux.rs`), not separate mobux processes, so they have no
/// hook to install and can never be the source of a bell. There is no node to
/// know here, so producing a bare `s/{session}` is already correct, not a
/// gap (contrast with the `/s/{name}` server route in `main.rs`, which is
/// also reachable by a hand-typed/bookmarked link and — unlike this
/// hook-driven one — cannot assume local; see `terminal_page` /
/// `resolve_session_location` there, issue #210).
fn session_url(session: &str, window: Option<&str>) -> String {
    match window {
        Some(w) if !w.is_empty() => format!("s/{session}?w={w}"),
        _ => format!("s/{session}"),
    }
}

/// Fire a "bell" push for `session` (and optional `window`). Spawned
/// fire-and-forget by the tmux-hook callback — tmux already deduped the
/// event, so callers don't need to.
pub fn fire_bell(db: Arc<Db>, contact: String, session: &str, window: Option<&str>) {
    let payload = Payload {
        title: "mobux".to_string(),
        body: format!("session {session}: 🔔"),
        tag: Some(format!("bell-{session}")),
        url: Some(session_url(session, window)),
    };
    tokio::spawn(notify(db, contact, payload));
}

/// A single push payload.
pub struct Payload {
    pub title: String,
    pub body: String,
    /// Notification `tag`. Same tag from the same origin replaces an existing
    /// notification rather than stacking — free OS-side coalescing.
    pub tag: Option<String>,
    /// Path the SW should deep-link to on click, relative to the SW's
    /// registration scope. Defaults to the scope root.
    pub url: Option<String>,
}

/// What a caller that must not fail silently reports when no device would
/// receive a push.
pub const NO_SUBSCRIBED_DEVICE: &str =
    "no subscribed device: open mobux on the phone and turn on notifications in Settings";

/// How one notification fared across the subscribed devices.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Delivery {
    pub sent: usize,
    pub failed: usize,
    pub pruned: usize,
}

/// Deliver `payload` to every subscribed device and wait for the outcome.
/// Fails when there is no device, or when no device took it, so a caller can
/// say nothing arrived.
pub async fn send_to_devices(
    db: Arc<Db>,
    contact: String,
    payload: Payload,
) -> anyhow::Result<Delivery> {
    if db.list_subscriptions()?.is_empty() {
        anyhow::bail!(NO_SUBSCRIBED_DEVICE);
    }
    let delivery = notify(db, contact, payload).await;
    if delivery.sent == 0 {
        anyhow::bail!(
            "no device took the notification: failed={} pruned={}",
            delivery.failed,
            delivery.pruned
        );
    }
    Ok(delivery)
}

/// Send `payload` as a Web Push notification to every subscribed device.
/// `contact` is the resolved `push.vapid_contact` setting — RFC 8292 requires
/// a `mailto:` or `https:` URL.
///
/// Best-effort: errors are logged and swallowed. Dead subscriptions
/// (HTTP 404 / 410) are pruned from the DB on the fly. Returns the counts
/// once all delivery attempts have completed.
pub async fn notify(db: Arc<Db>, contact: String, payload: Payload) -> Delivery {
    let vapid = match db.vapid_keys() {
        Ok(v) => v,
        Err(e) => {
            eprintln!("push: load vapid keys failed: {e:#}");
            return Delivery::default();
        }
    };

    let subs = match db.list_subscriptions() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("push: list subscriptions failed: {e:#}");
            return Delivery::default();
        }
    };

    if subs.is_empty() {
        return Delivery::default();
    }

    let payload_bytes = json!({
        "title": payload.title,
        "body": payload.body,
        "tag": payload.tag,
        "url": payload.url.unwrap_or_else(|| "./".to_string()),
    })
    .to_string()
    .into_bytes();

    eprintln!(
        "push: notify title={:?} subscribers={}",
        payload.title,
        subs.len()
    );

    let client = reqwest::Client::new();
    let mut sent = 0usize;
    let mut failed = 0usize;
    let mut pruned = 0usize;

    for sub in subs {
        match deliver(&client, &vapid, &contact, &sub, payload_bytes.clone()).await {
            DeliveryOutcome::Ok => sent += 1,
            DeliveryOutcome::Gone => {
                if let Err(e) = db.remove_subscription(&sub.endpoint) {
                    eprintln!(
                        "push: failed to prune dead subscription {}: {e:#}",
                        sub.endpoint
                    );
                } else {
                    pruned += 1;
                }
            }
            DeliveryOutcome::Failed => failed += 1,
        }
    }

    eprintln!("push: notify sent={sent} failed={failed} pruned={pruned}");
    Delivery {
        sent,
        failed,
        pruned,
    }
}

enum DeliveryOutcome {
    Ok,
    /// Subscription is dead (404 / 410) — caller should prune it.
    Gone,
    Failed,
}

/// Build, encrypt, and POST a single push request. All errors are mapped to
/// `Failed` (or `Gone` for 404 / 410) and logged. Never panics.
async fn deliver(
    client: &reqwest::Client,
    vapid: &VapidKeys,
    contact: &str,
    sub: &Subscription,
    payload: Vec<u8>,
) -> DeliveryOutcome {
    let key_pair = match ES256KeyPair::from_bytes(&vapid.private_key) {
        Ok(k) => k,
        Err(e) => {
            eprintln!("push: invalid VAPID private key: {e}");
            return DeliveryOutcome::Failed;
        }
    };

    let endpoint_uri = match sub.endpoint.parse() {
        Ok(u) => u,
        Err(e) => {
            eprintln!("push: bad endpoint {}: {e}", sub.endpoint);
            return DeliveryOutcome::Failed;
        }
    };

    let ua_public = match PublicKey::from_sec1_bytes(&sub.p256dh) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("push: bad p256dh for {}: {e}", sub.endpoint);
            return DeliveryOutcome::Failed;
        }
    };

    if sub.auth.len() != 16 {
        eprintln!(
            "push: bad auth length {} for {} (expected 16)",
            sub.auth.len(),
            sub.endpoint
        );
        return DeliveryOutcome::Failed;
    }
    // `clone_from_slice` is deprecated in generic-array 1.x but is the
    // documented API of `web-push-native 0.4` (which still uses 0.x). Track
    // upstream for an updated constructor.
    #[allow(deprecated)]
    let ua_auth = Auth::clone_from_slice(&sub.auth);

    let builder =
        WebPushBuilder::new(endpoint_uri, ua_public, ua_auth).with_vapid(&key_pair, contact);

    let request = match builder.build(payload) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("push: build request for {} failed: {e}", sub.endpoint);
            return DeliveryOutcome::Failed;
        }
    };

    // Convert http::Request to reqwest::Request.
    let (parts, body) = request.into_parts();
    let url = parts.uri.to_string();
    let mut req = client.post(&url).body(body);
    for (name, value) in parts.headers.iter() {
        req = req.header(name.as_str(), value.as_bytes());
    }

    match req.send().await {
        Ok(resp) => {
            let status = resp.status();
            if status.is_success() {
                DeliveryOutcome::Ok
            } else if status == StatusCode::NOT_FOUND || status == StatusCode::GONE {
                eprintln!("push: subscription gone ({}) for {}", status, sub.endpoint);
                DeliveryOutcome::Gone
            } else {
                let body = resp.text().await.unwrap_or_default();
                eprintln!(
                    "push: delivery failed ({}) for {}: {}",
                    status, sub.endpoint, body
                );
                DeliveryOutcome::Failed
            }
        }
        Err(e) => {
            eprintln!("push: HTTP error for {}: {e}", sub.endpoint);
            DeliveryOutcome::Failed
        }
    }
}

#[cfg(test)]
mod tests {
    use super::session_url;

    /// What the browser's `new URL(relative, scope)` does for a scope ending
    /// in `/` and a relative reference that starts with a path segment.
    fn resolved_against_scope(scope: &str, url: &str) -> String {
        format!("{scope}{url}")
    }

    #[test]
    fn session_url_is_relative() {
        assert_eq!(session_url("work", None), "s/work");
        assert_eq!(session_url("work", Some("3")), "s/work?w=3");
        assert_eq!(session_url("work", Some("")), "s/work");
    }

    #[test]
    fn session_url_lands_where_it_always_did_at_the_empty_prefix() {
        let scope = "https://host/";
        assert_eq!(
            resolved_against_scope(scope, &session_url("work", None)),
            "https://host/s/work"
        );
        assert_eq!(
            resolved_against_scope(scope, &session_url("work", Some("3"))),
            "https://host/s/work?w=3"
        );
    }

    #[test]
    fn session_url_stays_inside_a_path_prefix() {
        let scope = "https://host/mobux/";
        assert_eq!(
            resolved_against_scope(scope, &session_url("work", Some("3"))),
            "https://host/mobux/s/work?w=3"
        );
    }
}