master
John Lauer tour fixes from the first native run: stop verbs bypass the ab backoff and timeouts do not start it; per-gesture foregr… ef5a17a 2mo ago
//! Real input for the demo tour only (phase 5 of docs/rust-port-plan.md), and the
//! narration audio. This is the ONE module in the bridge that calls SendInput; the rules
//! from win/mod.rs (no SendInput, no SetCursorPos) hold everywhere else.
//!
//! Every input call first measures `GetForegroundWindow()` and refuses unless it is the
//! window the tour foregrounded (with a reason, through the sanctioned `bring_to_front`).
//! Synthetic input goes to whatever is in front; if the user switched windows mid-beat,
//! the tour must stop rather than type into their browser.
//!
//! Audio goes through MCI (`mciSendStringW`, winmm), which plays an mp3 or wav with no
//! window and no PowerShell (the native build has none). Volume is per alias, 0..1000.

use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};

use windows::core::PCWSTR;
use windows::Win32::Media::Multimedia::{mciGetErrorStringW, mciSendStringW};
use windows::Win32::UI::Input::KeyboardAndMouse::{
    SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY, KEYEVENTF_KEYUP,
    MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE,
    MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_WHEEL, MOUSEINPUT, MOUSE_EVENT_FLAGS,
    VIRTUAL_KEY, VK_APPS, VK_DELETE, VK_DIVIDE, VK_DOWN, VK_END, VK_HOME, VK_INSERT, VK_LEFT, VK_LWIN, VK_NEXT, VK_NUMLOCK,
    VK_PRIOR, VK_RCONTROL, VK_RIGHT, VK_RMENU, VK_RWIN, VK_SNAPSHOT, VK_UP,
};
use windows::Win32::UI::WindowsAndMessaging::{
    GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, WHEEL_DELTA,
};

use super::{focus, from_wide, wide};

/// The refusal every caller sees when the beat's window is not in front.
pub const NOT_FOREGROUND: &str = "window is not foreground: refusing synthetic input";

fn require_foreground(h: u64) -> Result<(), String> {
    if h == 0 {
        return Err(NOT_FOREGROUND.into());
    }
    // A caption or the demo panel can hold the foreground for a frame or two right when a
    // gesture starts; give the OS up to 600 ms to agree before refusing (the Python gate
    // waited 1.5 s once per beat; this is the per-gesture version).
    let deadline = std::time::Instant::now() + std::time::Duration::from_millis(600);
    while focus::foreground() != h {
        if std::time::Instant::now() >= deadline {
            return Err(NOT_FOREGROUND.into());
        }
        std::thread::sleep(std::time::Duration::from_millis(40));
    }
    Ok(())
}

fn send(inputs: &[INPUT]) -> Result<(), String> {
    // SAFETY: `inputs` is a slice of fully initialised INPUT records; SendInput copies them.
    let n = unsafe { SendInput(inputs, std::mem::size_of::<INPUT>() as i32) };
    if n as usize != inputs.len() {
        return Err(format!("SendInput injected {n} of {} events (input blocked by UIPI or a higher-integrity window in front)", inputs.len()));
    }
    Ok(())
}

fn mouse(flags: MOUSE_EVENT_FLAGS, dx: i32, dy: i32, data: u32) -> INPUT {
    INPUT { r#type: INPUT_MOUSE, Anonymous: INPUT_0 { mi: MOUSEINPUT { dx, dy, mouseData: data, dwFlags: flags, time: 0, dwExtraInfo: 0 } } }
}

fn key(vk: u16, flags: KEYBD_EVENT_FLAGS) -> INPUT {
    INPUT { r#type: INPUT_KEYBOARD, Anonymous: INPUT_0 { ki: KEYBDINPUT { wVk: VIRTUAL_KEY(vk), wScan: 0, dwFlags: flags, time: 0, dwExtraInfo: 0 } } }
}

/// Screen pixel to the 0..65535 grid SendInput uses across the whole virtual desktop, so a
/// second monitor at a negative x lands where it should.
pub fn normalise(x: i32, y: i32, vx: i32, vy: i32, vw: i32, vh: i32) -> (i32, i32) {
    let nx = ((x - vx) as i64 * 65536 / vw.max(1) as i64).clamp(0, 65535) as i32;
    let ny = ((y - vy) as i64 * 65536 / vh.max(1) as i64).clamp(0, 65535) as i32;
    (nx, ny)
}

fn virtual_desktop() -> (i32, i32, i32, i32) {
    // SAFETY: metric reads.
    unsafe { (GetSystemMetrics(SM_XVIRTUALSCREEN), GetSystemMetrics(SM_YVIRTUALSCREEN), GetSystemMetrics(SM_CXVIRTUALSCREEN), GetSystemMetrics(SM_CYVIRTUALSCREEN)) }
}

pub fn mouse_move(h: u64, x: i32, y: i32) -> Result<(), String> {
    require_foreground(h)?;
    let (vx, vy, vw, vh) = virtual_desktop();
    let (nx, ny) = normalise(x, y, vx, vy, vw, vh);
    send(&[mouse(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, nx, ny, 0)])
}

pub fn mouse_button(h: u64, button: &str, down: bool) -> Result<(), String> {
    require_foreground(h)?;
    let flag = match (button.to_ascii_lowercase().as_str(), down) {
        ("left", true) => MOUSEEVENTF_LEFTDOWN,
        ("left", false) => MOUSEEVENTF_LEFTUP,
        ("right", true) => MOUSEEVENTF_RIGHTDOWN,
        ("right", false) => MOUSEEVENTF_RIGHTUP,
        ("middle", true) => MOUSEEVENTF_MIDDLEDOWN,
        ("middle", false) => MOUSEEVENTF_MIDDLEUP,
        _ => return Err(format!("unknown mouse button {button:?} (left | middle | right)")),
    };
    send(&[mouse(flag, 0, 0, 0)])
}

/// Wheel clicks, positive = up (away from the user), one WHEEL_DELTA each.
pub fn wheel(h: u64, clicks: i32) -> Result<(), String> {
    require_foreground(h)?;
    if clicks == 0 {
        return Ok(());
    }
    send(&[mouse(MOUSEEVENTF_WHEEL, 0, 0, (clicks * WHEEL_DELTA as i32) as u32)])
}

/// Keys whose scan codes carry the extended prefix; SendInput needs the flag or the
/// navigation cluster arrives as the numpad.
fn is_extended(vk: u16) -> bool {
    [VK_LEFT, VK_RIGHT, VK_UP, VK_DOWN, VK_HOME, VK_END, VK_PRIOR, VK_NEXT, VK_INSERT, VK_DELETE, VK_RCONTROL, VK_RMENU, VK_DIVIDE, VK_NUMLOCK, VK_SNAPSHOT, VK_LWIN, VK_RWIN, VK_APPS]
        .iter()
        .any(|k| k.0 == vk)
}

/// Press and release one virtual key.
pub fn key_press(h: u64, vk: u16) -> Result<(), String> {
    require_foreground(h)?;
    let ext = if is_extended(vk) { KEYEVENTF_EXTENDEDKEY } else { KEYBD_EVENT_FLAGS(0) };
    send(&[key(vk, ext), key(vk, ext | KEYEVENTF_KEYUP)])
}

// ── Audio (MCI) ───────────────────────────────────────────────────────────────

static NEXT_AUDIO: AtomicU64 = AtomicU64::new(1);

fn mci(command: &str) -> Result<String, String> {
    let cmd = wide(command);
    let mut ret = [0u16; 256];
    // SAFETY: NUL-terminated command; the return buffer outlives the call; no callback window.
    let err = unsafe { mciSendStringW(PCWSTR(cmd.as_ptr()), Some(&mut ret), None) };
    if err != 0 {
        let mut text = [0u16; 256];
        // SAFETY: buffer outlives the call.
        let ok = unsafe { mciGetErrorStringW(err, &mut text) }.as_bool();
        let why = if ok { from_wide(&text) } else { format!("MCI error {err}") };
        return Err(format!("mci {:?}: {why}", command.split(' ').next().unwrap_or(command)));
    }
    Ok(from_wide(&ret))
}

/// Open and play an audio file with no window. mp3 (and anything DirectShow decodes) goes
/// through the mpegvideo device, wav through waveaudio. Returns the handle `audio_stop`
/// closes; a file that ends on its own is closed by the next stop or by process exit.
pub fn audio_play(path: &Path, volume: f32) -> Result<u64, String> {
    if !path.is_file() {
        return Err(format!("{} does not exist", path.display()));
    }
    let id = NEXT_AUDIO.fetch_add(1, Ordering::Relaxed);
    let alias = format!("kb_tour_{id}");
    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
    let device = if ext == "wav" { "waveaudio" } else { "mpegvideo" };
    let p = path.to_string_lossy().replace('/', "\\");
    mci(&format!("open \"{p}\" type {device} alias {alias}"))?;
    let vol = (volume.clamp(0.0, 1.0) * 1000.0).round() as u32;
    // waveaudio has no volume command; mpegvideo does. Not fatal either way.
    let _ = mci(&format!("setaudio {alias} volume to {vol}"));
    if let Err(e) = mci(&format!("play {alias}")) {
        let _ = mci(&format!("close {alias}"));
        return Err(e);
    }
    Ok(id)
}

pub fn audio_stop(handle: u64) -> Result<(), String> {
    let alias = format!("kb_tour_{handle}");
    let _ = mci(&format!("stop {alias}"));
    mci(&format!("close {alias}")).map(|_| ())
}

#[cfg(all(test, windows))]
mod tests {
    use super::*;

    #[test]
    fn normalisation_spans_the_virtual_desktop() {
        assert_eq!(normalise(0, 0, 0, 0, 1920, 1080), (0, 0));
        let (nx, ny) = normalise(1919, 1079, 0, 0, 1920, 1080);
        assert!(nx > 65400 && ny > 65400);
        // A second monitor to the LEFT of the primary: negative x maps to the low end.
        assert_eq!(normalise(-1920, 0, -1920, 0, 3840, 1080).0, 0);
        assert_eq!(normalise(0, 0, -1920, 0, 3840, 1080).0, 32768);
        assert_eq!(normalise(99999, 99999, 0, 0, 1920, 1080), (65535, 65535));
    }

    #[test]
    fn refuses_when_not_foreground() {
        // hwnd 0 is never the foreground.
        assert_eq!(mouse_move(0, 10, 10).unwrap_err(), NOT_FOREGROUND);
        assert_eq!(mouse_button(0, "left", true).unwrap_err(), NOT_FOREGROUND);
        assert_eq!(wheel(0, 1).unwrap_err(), NOT_FOREGROUND);
        assert_eq!(key_press(0, 0x1B).unwrap_err(), NOT_FOREGROUND);
        // A handle that is not the foreground either (the foreground is never 0xDEADBEEF).
        assert_eq!(key_press(0xDEAD_BEEF, 0x1B).unwrap_err(), NOT_FOREGROUND);
    }

    #[test]
    fn extended_keys() {
        assert!(is_extended(VK_LEFT.0));
        assert!(is_extended(VK_DELETE.0));
        assert!(!is_extended(0x41)); // 'A'
        assert!(!is_extended(0x1B)); // Escape
    }

    #[test]
    fn audio_refuses_a_missing_file() {
        assert!(audio_play(Path::new(r"C:\definitely\not\here.mp3"), 0.8).is_err());
    }
}