app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
//! 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());
}
}