car-ffi-common 0.34.0

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
//! Local notification helpers exposed through FFI / JSON-RPC.
//!
//! Platform hosts should prefer their native in-process notification
//! services. This module gives daemon clients a structured API for the
//! same user intent instead of forcing callers to tunnel through the
//! generic AppleScript bridge.

use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
pub struct LocalNotificationArgs {
    pub title: String,
    pub body: String,
    #[serde(default)]
    pub subtitle: Option<String>,
    #[serde(default)]
    pub sound: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct LocalNotificationResult {
    pub delivered: bool,
    pub platform: &'static str,
    pub backend: &'static str,
}

/// Deliver a user-visible local notification when the current platform
/// supports it. On macOS this uses the system `display notification`
/// Apple Event through `osascript`; on Windows a WinRT `ToastGeneric` toast
/// (car#522); iOS delivery is owned by the signed Swift host app, not the
/// daemon process.
pub async fn local(args_json: &str) -> Result<String, String> {
    let args: LocalNotificationArgs =
        serde_json::from_str(args_json).map_err(|e| format!("invalid args: {e}"))?;
    validate(&args)?;

    #[cfg(target_os = "macos")]
    {
        deliver_macos(args).await?;
        serde_json::to_string(&LocalNotificationResult {
            delivered: true,
            platform: "macos",
            backend: "user-notification-apple-event",
        })
        .map_err(|e| format!("serialize: {e}"))
    }

    #[cfg(target_os = "ios")]
    {
        let _ = args;
        return Err(
            "local notifications on iOS must be delivered by the signed host app via UserNotifications"
                .to_string(),
        );
    }

    #[cfg(target_os = "windows")]
    {
        deliver_windows(&args)?;
        return serde_json::to_string(&LocalNotificationResult {
            delivered: true,
            platform: "windows",
            backend: "winrt-toast",
        })
        .map_err(|e| format!("serialize: {e}"));
    }

    #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))]
    {
        let _ = args;
        Err("local notifications are not supported on this platform".to_string())
    }
}

fn validate(args: &LocalNotificationArgs) -> Result<(), String> {
    if args.title.trim().is_empty() {
        return Err("title is required".to_string());
    }
    if args.body.trim().is_empty() {
        return Err("body is required".to_string());
    }
    Ok(())
}

#[cfg(target_os = "windows")]
const AUMID: &str = "ai.parslee.car";

/// Deliver a Windows toast via WinRT (car#522). An unpackaged app's toasts only
/// appear once its AppUserModelId is registered in HKCU with a DisplayName, so
/// we register (idempotently) on first use, pin it on the process, then show a
/// `ToastGeneric` notification.
#[cfg(target_os = "windows")]
fn deliver_windows(args: &LocalNotificationArgs) -> Result<(), String> {
    use windows::core::HSTRING;
    use windows::Data::Xml::Dom::XmlDocument;
    use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
    use windows::UI::Notifications::{ToastNotification, ToastNotificationManager};

    register_aumid().map_err(|e| format!("register AppUserModelId: {e}"))?;
    // Best-effort — the notifier below is created with the explicit AUMID anyway.
    unsafe {
        let _ = SetCurrentProcessExplicitAppUserModelID(&HSTRING::from(AUMID));
    }

    let mut lines = format!("<text>{}</text>", xml_escape(&args.title));
    if let Some(sub) = args.subtitle.as_deref() {
        if !sub.trim().is_empty() {
            lines.push_str(&format!("<text>{}</text>", xml_escape(sub)));
        }
    }
    lines.push_str(&format!("<text>{}</text>", xml_escape(&args.body)));
    let xml = format!(
        "<toast><visual><binding template=\"ToastGeneric\">{lines}</binding></visual></toast>"
    );

    let doc = XmlDocument::new().map_err(|e| format!("XmlDocument: {e}"))?;
    doc.LoadXml(&HSTRING::from(xml))
        .map_err(|e| format!("LoadXml: {e}"))?;
    let toast = ToastNotification::CreateToastNotification(&doc)
        .map_err(|e| format!("CreateToastNotification: {e}"))?;
    let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(AUMID))
        .map_err(|e| format!("CreateToastNotifierWithId: {e}"))?;
    notifier.Show(&toast).map_err(|e| format!("Show: {e}"))?;
    Ok(())
}

/// Register the AUMID under `HKCU\Software\Classes\AppUserModelId\<aumid>` with
/// a `DisplayName` so an unpackaged app's toasts show a proper name and appear.
/// Idempotent.
#[cfg(target_os = "windows")]
fn register_aumid() -> Result<(), String> {
    use windows::core::PCWSTR;
    use windows::Win32::Foundation::ERROR_SUCCESS;
    use windows::Win32::System::Registry::{
        RegCloseKey, RegCreateKeyExW, RegSetValueExW, HKEY, HKEY_CURRENT_USER, KEY_WRITE,
        REG_OPTION_NON_VOLATILE, REG_SZ,
    };

    let subkey = wide(&format!("Software\\Classes\\AppUserModelId\\{AUMID}"));
    let mut hkey = HKEY::default();
    // SAFETY: `subkey` outlives the call; `hkey` is written on success.
    let rc = unsafe {
        RegCreateKeyExW(
            HKEY_CURRENT_USER,
            PCWSTR(subkey.as_ptr()),
            0,
            PCWSTR::null(),
            REG_OPTION_NON_VOLATILE,
            KEY_WRITE,
            None,
            &mut hkey,
            None,
        )
    };
    if rc != ERROR_SUCCESS {
        return Err(format!("RegCreateKeyExW ({})", rc.0));
    }
    let name = wide("DisplayName");
    let data = wide("CAR");
    // REG_SZ data is the UTF-16 bytes including the terminating NUL.
    let data_bytes: &[u8] =
        unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, data.len() * 2) };
    let rc = unsafe { RegSetValueExW(hkey, PCWSTR(name.as_ptr()), 0, REG_SZ, Some(data_bytes)) };
    unsafe {
        let _ = RegCloseKey(hkey);
    }
    if rc != ERROR_SUCCESS {
        return Err(format!("RegSetValueExW ({})", rc.0));
    }
    Ok(())
}

/// A NUL-terminated UTF-16 buffer for a Win32 wide-string argument.
#[cfg(target_os = "windows")]
fn wide(s: &str) -> Vec<u16> {
    s.encode_utf16().chain(std::iter::once(0)).collect()
}

/// Minimal XML text escaping for the toast payload.
#[cfg(target_os = "windows")]
fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

#[cfg(all(test, target_os = "windows"))]
mod windows_tests {
    use super::*;

    #[test]
    fn xml_escape_escapes_entities() {
        assert_eq!(xml_escape("a & b <c>"), "a &amp; b &lt;c&gt;");
    }

    /// Actually pops a toast (needs an interactive session). `--ignored`.
    #[tokio::test]
    #[ignore = "shows a real toast; run explicitly on an interactive Windows session"]
    async fn shows_a_real_toast() {
        let json = serde_json::json!({
            "title": "CAR",
            "body": "Toast notifications work on Windows (car#522).",
            "subtitle": "self-test",
        })
        .to_string();
        let out = local(&json).await.expect("toast should deliver");
        assert!(out.contains("winrt-toast"), "{out}");
    }
}

#[cfg(target_os = "macos")]
async fn deliver_macos(args: LocalNotificationArgs) -> Result<(), String> {
    let script = r#"
on run argv
  set notificationTitle to item 1 of argv
  set notificationBody to item 2 of argv
  set notificationSubtitle to item 3 of argv
  set notificationSound to item 4 of argv

  if notificationSubtitle is "" and notificationSound is "" then
    display notification notificationBody with title notificationTitle
  else if notificationSubtitle is "" then
    display notification notificationBody with title notificationTitle sound name notificationSound
  else if notificationSound is "" then
    display notification notificationBody with title notificationTitle subtitle notificationSubtitle
  else
    display notification notificationBody with title notificationTitle subtitle notificationSubtitle sound name notificationSound
  end if
end run
"#;

    let subtitle = args.subtitle.unwrap_or_default();
    let sound = args.sound.unwrap_or_default();
    let argv = [
        args.title.as_str(),
        args.body.as_str(),
        subtitle.as_str(),
        sound.as_str(),
    ];

    car_automation::applescript::run_with_args(
        script,
        car_automation::applescript::Language::AppleScript,
        &argv,
        Some(std::time::Duration::from_secs(10)),
    )
    .await
    .map(|_| ())
    .map_err(|e| format!("{e}"))
}