app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
rust: phase 4 etiquette loop (sentinel, WinEventHook, once ledger, owned pids, heals) wired into boot, window verbs, bring-to-user and kicad_state; tour input and audio primitives
7 files changed
+1581−5
rust/crates/kicad-bridge/src/main.rs+1rust/crates/kicad-bridge/src/verbs_windows.rs+22−3rust/crates/kicad-platform/Cargo.toml+1rust/crates/kicad-platform/src/win/etiquette.rs+1298rust/crates/kicad-platform/src/win/mod.rs+8−1rust/crates/kicad-platform/src/win/tour_input.rs+202rust/crates/kicad-platform/src/windows.rs+49−1rust/crates/kicad-bridge/src/main.rs+1@@ -105,6 +105,7 @@ fn main() { eprintln!("[kicad-bridge] v{VERSION} listening on {host}:{actual_port} ({WORKERS} workers)"); kicad_platform::native().init_process();+ kicad_platform::native().etiquette_start(); let server = Arc::new(server); let mut handles = Vec::new(); for _ in 0..WORKERS {
rust/crates/kicad-bridge/src/verbs_windows.rs+22−3@@ -269,6 +269,10 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> ab::set_caller_from_args(args); let gui = !NO_GUI.contains(&command); let fg_before = if gui { native().foreground().ok().filter(|h| *h != 0) } else { None };+ if gui {+ // Phase 4: arm the guard and snapshot the park baseline for the etiquette loop.+ native().etiquette_begin_verb(command, SPAWNS_WINDOW.contains(&command));+ } let mut out = match command { "kicad_launch" => launch(state, args), "kicad_rescan_libraries" => rescan_libraries(state, args),@@ -296,6 +300,9 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> if gui && out.get("errorCode") != Some(&json!("not_supported_on_platform")) { post_verb(state, command, args, &mut out, fg_before); }+ if gui {+ native().etiquette_end_verb(command);+ } ab::clear_caller(); Some(out) }@@ -350,7 +357,10 @@ pub(crate) fn post_verb(state: &mut State, command: &str, args: &Value, out: &mu // foreground from a user window during this verb? Push it back once, and say so. let mut events = Vec::new(); if !(wants_fg && out["_broughtToUser"] == json!(true)) {- if let Some(ev) = one_shot_focus_check(fg_before) {+ // With the etiquette loop running (Windows) the one-shot push would be a second action+ // on a window the loop already handled once; keep it only where no loop exists.+ let loop_running = native().etiquette_debug() != Value::Null;+ if let Some(ev) = if loop_running { None } else { one_shot_focus_check(fg_before) } { events.push(ev); } }@@ -419,6 +429,7 @@ fn spawn_background(exe: &Path, args: &[String], tag: &str, trace_masks: Option< let log = bridge_log::log_dir().and_then(|d| bridge_log::new_log_path(&d, &utc_stamp(), tag).ok()); let env = bridge_log::trace_env(trace_masks); let pid = plat("spawn_background", native().spawn_background(exe, args, &env, log.as_deref()))?;+ native().etiquette_register_pid(pid, None); invalidate(); Ok((pid, log)) }@@ -637,6 +648,7 @@ fn bring_to_user(hwnd: u64, reason: &str) -> bool { let _ = ab::notify_user("Taking the foreground briefly", &msg, "warning"); let _ = ab::desktop_caption("kicad-foreground", reason, 3000, "Announce the foreground the calling AI asked for before the KiCad window moves"); sleep_ms(1200); // let the caption render BEFORE the window moves+ native().etiquette_sanction(hwnd, 20.0); // the loop must not bounce a foreground the AI asked for let ok = match native().bring_to_front(hwnd) { Ok(b) => b, Err(e) if is_not_impl(&e) => ab::desktop_bring_to_front(hwnd, reason).is_ok(),@@ -2287,13 +2299,20 @@ fn kicad_state(state: &mut State, args: &Value) -> Value { } let editors = open_editors(); let modal: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})).collect();- let events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().rev().take(40).rev().cloned().collect()).unwrap_or_default();+ let loop_events = native().etiquette_events();+ let mut events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();+ events.extend(loop_events);+ let events: Vec<Value> = events.into_iter().rev().take(40).rev().collect();+ let focus_debug = match native().etiquette_debug() {+ Value::Null => json!({"guardian": "no etiquette loop on this OS; one-shot check per verb only", "oneShotCheck": true}),+ v => v,+ }; let mut out = json!({ "success": true, "running": !wins.is_empty(), "focusEvents": events, // PRESENCE GATE (John, 2026-08-17): automation that spawns windows checks this // first and defers while the user is active (< 600 = touched in the last 10 min). "userIdleSeconds": user_idle_seconds(),- "focusDebug": {"guardian": "not built in the native bridge (phase 4); one-shot check per verb only", "oneShotCheck": true},+ "focusDebug": focus_debug, "activeVersion": info.version, "windows": wins.iter().map(|w| json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "kind": w.kind().as_str()})).collect::<Vec<_>>(), "windowSource": source,
rust/crates/kicad-platform/Cargo.toml+1@@ -31,6 +31,7 @@ windows = { version = "0.61", features = [ "Win32_Security", # SECURITY_ATTRIBUTES in the CreateProcessW signature (spawn_background) "Win32_Storage_Xps", # PrintWindow lives here in the windows crate (capture_window) "Win32_System_SystemInformation", # GetTickCount (seconds_since_input)+ "Win32_Media_Multimedia", # mciSendStringW: tour narration audio without a window (tour_input) ] } uiautomation = "0.24"
rust/crates/kicad-platform/src/win/etiquette.rsadded+1298@@ -0,0 +1,1298 @@+//! The foreground etiquette loop (phase 4 of docs/rust-port-plan.md), ported from+//! handlers/win_focus.py 0.9.185. The rules below are John's, with their dates, and the+//! code keeps them in the order the Python learned them:+//!+//! - "ALWAYS open in the background" (2026-08-14). A KiCad window the bridge opened may+//! never sit in the user's foreground.+//! - "never fucking minimize it, cuz then you can't take screenshots of the window when+//! it's in the background" (0.9.181). Nothing here minimizes.+//! - "ONLY background the window once IF IT APPEARS IN THE FOREGROUND. then do nothing+//! else" (0.9.181). One `push_to_background` per (hwnd, pid), recorded in a ledger that+//! expires when the window dies (hwnd values are recycled: ConfRoomROG, 2026-09-08).+//! Repetition is what strobed (wiki #33, Colby: "epileptic" flicker at 10 Hz).+//! - "if you are waiting 2 seconds to let the user activate the window that's rude as+//! fuck" (0.9.181). No WS_EX_NOACTIVATE is ever SET by this loop: it blocks the user's+//! own click. The 3 s bounce hold exists so a stale style is cleared 3 s after a bounce,+//! by inspection, and `BOUNCE_HOLD_NOACTIVATE` documents where a hold would set it.+//! - Off-screen parking (0.9.138) was removed in 0.9.181: every move is a visible frame.+//! Nothing here moves a window except the heal that drags one back ON screen.+//! - "always let the user choose to fg a window" (2026-08-16): a click landing on the+//! window or on the shell (taskbar, Start: 0.9.155, John bounced 8 times launching his+//! own KiCad) or an alt-tab in progress outranks every policy.+//! - THE IDLE RULE (0.9.172, 2026-08-19: "when I click the taskbar icon to bring the app+//! to the fg, you don't let me"): with no verb in flight and no guard armed the bridge+//! is not opening anything, so a KiCad window reaching the foreground is the user,+//! whether or not the click was observed. Leave it alone, always.+//! - The 60 s spawn watch (0.9.185: "you opened the kicad main menu and it came to the+//! foreground - you did nothing to even attempt once to put it in the background"). A+//! spawned app shows its first window seconds after the verb returned; for 60 s after+//! a spawn a window that appears in front is ours to put behind, once.+//! - NO SYSTEM-WIDE HOOKS. EVER. (0.9.184). No SetWindowsHookEx, no global input+//! listener. A WinEventHook (out of context, skip own process) is a notification, not+//! an input hook, and is the one thing the Python kept.+//! - The ownership ledger (2026-08-17: "what happens if the user opens their own kicad?+//! i don't want us ruining that"). Ownership is by PROCESS: pids the bridge spawned+//! plus their children (Toolhelp parent walk), persisted to+//! `%USERPROFILE%/.adom/kicad-bridge-owned.json` in the Python's format so a restart+//! neither orphans nor mis-adopts. Unknown windows default to user-owned.+//! - The 0.9.165 boot deadlock: SetWindowLongPtr sends WM_STYLECHANGING cross-process and+//! never returns on a hung KiCad. Every style write and every SetWindowPos here is gated+//! on the WM_NULL probe, and desktop maintenance runs on the loop thread 2 s after+//! boot, never on the boot path.+//! - Wiki #48 (multi-monitor): the on-screen heal measures against ANY monitor, and only+//! moves a window when no part of it is on one.+//!+//! Threads: the sentinel (`sentinel_loop`, 120 ms) and the WinEvent pump (`hook_pump`).+//! Both funnel into `decide`, the pure decision function, which is what the tests cover.++use std::collections::{HashMap, HashSet};+use std::path::{Path, PathBuf};+use std::sync::{Mutex, OnceLock};+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};++use serde_json::{json, Value};+use windows::Win32::Foundation::{HWND, POINT};+use windows::Win32::UI::Accessibility::{SetWinEventHook, HWINEVENTHOOK};+use windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON, VK_MENU, VK_RBUTTON};+use windows::Win32::UI::WindowsAndMessaging::{+ DispatchMessageW, GetAncestor, GetCursorPos, GetMessageW, GetWindowLongPtrW, SetWindowLongPtrW, SetWindowPos,+ SystemParametersInfoW, TranslateMessage, WindowFromPoint, EVENT_OBJECT_SHOW, EVENT_SYSTEM_FOREGROUND, GA_ROOT,+ GWL_EXSTYLE, MSG, OBJID_WINDOW, SPI_GETFOREGROUNDLOCKTIMEOUT, SWP_NOACTIVATE, SWP_NOZORDER,+ SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, WINDOW_EX_STYLE, WINEVENT_OUTOFCONTEXT, WINEVENT_SKIPOWNPROCESS, WS_EX_APPWINDOW,+ WS_EX_NOACTIVATE,+};++use super::enumerate::{class_name, minimized, on_a_monitor, pid_of, rect, title_nohang, top_level_windows, visible, ExeCache};+use super::{focus, hwnd, hwnd_u64, is_kicad_exe, is_window, process, responsive};++// ── Tunables, every one of them a number the Python carried ──────────────────++/// Sentinel poll period.+const POLL: Duration = Duration::from_millis(120);+/// A click counts as "the user brought this up" for this long after it landed.+const CLICK_WINDOW: Duration = Duration::from_millis(1200);+/// An alt-tab in progress counts for this long.+const ALT_WINDOW: Duration = Duration::from_secs(2);+/// Guard after a window-spawning verb, and the long one for the slow flows.+const GUARD_SHORT: Duration = Duration::from_secs(20);+const GUARD_LONG: Duration = Duration::from_secs(120);+/// For this long after a spawn, a new window in front is ours to put behind (0.9.185).+const SPAWN_WATCH: Duration = Duration::from_secs(60);+/// After a bounce, the window sits in the hold table this long, then any stale+/// WS_EX_NOACTIVATE on it is cleared.+const BOUNCE_HOLD: Duration = Duration::from_secs(3);+/// Idle heals: usability and on-screen every 20 s, session sanitizing every 60 s.+const HEAL_EVERY: Duration = Duration::from_secs(20);+const SANITIZE_EVERY: Duration = Duration::from_secs(60);+/// Desktop maintenance never runs on the boot path (0.9.165).+const BOOT_DELAY: Duration = Duration::from_secs(2);+/// Focus events kept for kicad_state (Python: deque(maxlen=200)).+const EVENT_RING: usize = 200;+/// WM_NULL probe deadline before a style write.+const PROBE_MS: u32 = 150;+/// Parent-chain walk depth when deciding ownership.+const PARENT_DEPTH: usize = 6;+/// Whether a bounce SETS WS_EX_NOACTIVATE for the hold. John, 0.9.181: "NEVER set+/// WS_EX_NOACTIVATE. It blocks the user's own click, and a user may want the window in+/// front the very next second. Their click must work immediately, with no hold-off+/// window." The Python's sentinel kept the hold table but nothing set the style after+/// 0.9.181; this build does the same. Flip to true to reinstate the 10.0.5 experiment.+const BOUNCE_HOLD_NOACTIVATE: bool = false;++/// Verbs whose guard is 120 s (server.py, 2026-08-15 background audit: a cold show_3d_*+/// is board-open plus 3D open plus a render self-raise 30 s out).+const SLOW_VERBS: &[&str] = &["open_3d_viewer", "place_footprint", "demo", "launch", "show_3d_chip", "show_3d_board"];++/// Shell classes a "bring this window up" click lands on (0.9.155).+const SHELL_CLASSES: &[&str] = &[+ "Shell_TrayWnd",+ "Shell_SecondaryTrayWnd",+ "Windows.UI.Core.CoreWindow",+ "XamlExplorerHostIslandWindow",+ "TopLevelWindowForOverflowXamlIsland",+ "Progman",+ "WorkerW",+];++/// Exe prefixes that count as the KiCad family for the ownership ledger.+const OWNED_EXE_PREFIXES: &[&str] = &["kicad", "eeschema", "pcbnew", "gerbview", "pl_editor", "pcb_calculator", "bitmap2component"];++// ── The pure decision ─────────────────────────────────────────────────────────++/// Everything `decide` needs to classify one foreground transition. Built by the+/// sentinel and by the hook from live Win32 reads; built by hand in the tests.+#[derive(Clone, Debug, Default)]+pub struct Transition {+ /// The new foreground window belongs to a KiCad exe.+ pub is_kicad: bool,+ /// Inside a `bring_to_front` window the bridge announced (expect_foreground).+ pub sanctioned: bool,+ /// A mouse button went down within CLICK_WINDOW and the cursor is over the window+ /// or over the shell (taskbar, Start).+ pub clicked_here: bool,+ /// Alt was down within ALT_WINDOW.+ pub alt_tabbed: bool,+ /// In the bridge's spawn tree (pid ledger).+ pub owned: bool,+ /// Was the foreground window when the current verb began: the user was already in+ /// it, so the verb stole nothing.+ pub foreground_at_verb_start: bool,+ /// Already pushed once (the once-per-(hwnd, pid) ledger).+ pub already_bounced: bool,+ pub guards: usize,+ pub inflight: usize,+ pub spawn_watch: bool,+ /// The user has been idle longer than the foreground lock timeout, so Windows+ /// granted the activation instead of refusing it.+ pub user_idle_past_lock: bool,+}++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Verdict {+ /// Not a KiCad window: not logged, not touched.+ Ignore,+ /// Logged with this classification, never touched.+ Leave(&'static str),+ /// Logged with this classification and pushed to the bottom of the z-order once.+ Bounce(&'static str),+}++/// The decision table. The order is the Python sentinel's, with the spec's two additions+/// (baseline foreground, once-ledger) as explicit rows so the log says why nothing+/// happened instead of pretending a bounce that the ledger then skipped.+///+/// `user_idle_past_lock` sits BELOW the guard and spawn-watch rows on purpose: the+/// idle-grant rule says "when the user is idle, Windows lets KiCad take the foreground",+/// but the runs John measures (the verb runner from his phone, ConfRoomROG sitting in a+/// conference room) are exactly the ones where nobody has touched the box for minutes,+/// and the Python bounced there regardless. Putting it above the guards would turn every+/// unattended run into a stream of steals. It only decorates the bridge-idle verdict.+pub fn decide(t: &Transition) -> Verdict {+ if !t.is_kicad {+ return Verdict::Ignore;+ }+ if t.sanctioned {+ return Verdict::Leave("sanctioned-foreground (allowed)");+ }+ if t.clicked_here || t.alt_tabbed {+ return Verdict::Leave("user-foregrounded (left alone)");+ }+ if !t.owned {+ return Verdict::Leave("user-owned window (left alone)");+ }+ if t.foreground_at_verb_start {+ return Verdict::Leave("baseline foreground (user was already in it, left alone)");+ }+ if t.already_bounced {+ return Verdict::Leave("already backgrounded once (hands off)");+ }+ if t.guards > 0 {+ return Verdict::Bounce("steal-during-guard (bounced)");+ }+ if t.inflight > 0 {+ return Verdict::Bounce("self-raise (bounced to background)");+ }+ if t.spawn_watch {+ return Verdict::Bounce("our freshly spawned window took the foreground (bounced)");+ }+ if t.user_idle_past_lock {+ return Verdict::Leave("foreground while bridge idle (user idle past the lock timeout, Windows granted it, left alone)");+ }+ Verdict::Leave("foreground while bridge idle (left alone)")+}++/// Guard length for a verb name (with or without the `kicad_` prefix).+pub fn guard_for(verb: &str) -> Duration {+ let bare = verb.strip_prefix("kicad_").unwrap_or(verb);+ if SLOW_VERBS.contains(&bare) {+ GUARD_LONG+ } else {+ GUARD_SHORT+ }+}++/// Is this a path the bridge itself put into KiCad's session (session sanitizing)?+/// Either an entry of the owned-project ledger (case-insensitive, slash-normalised) or the+/// legacy demo naming that predates the ledger: a DIRECTORY component `adom-<slug>` or+/// `in-<part>-demo` (the Python regex `\\(adom-[a-z0-9-]+|in-[a-z0-9]+-demo)\\`).+pub fn is_bridge_project_path(p: &str, owned_projects: &HashSet<String>) -> bool {+ let q = p.replace('/', "\\").to_lowercase();+ if owned_projects.iter().any(|o| o.replace('/', "\\").to_lowercase() == q) {+ return true;+ }+ let parts: Vec<&str> = q.split('\\').collect();+ // A directory component: something follows it.+ parts.iter().take(parts.len().saturating_sub(1)).any(|seg| {+ if let Some(rest) = seg.strip_prefix("adom-") {+ return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');+ }+ if let Some(rest) = seg.strip_prefix("in-").and_then(|r| r.strip_suffix("-demo")) {+ return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit());+ }+ false+ })+}++// ── Shared state ──────────────────────────────────────────────────────────────++#[derive(Clone, Debug)]+struct Guard {+ verb: String,+ deadline: Instant,+}++#[derive(Default)]+struct Inner {+ started: bool,+ sentinel_started: bool,+ hook_started: bool,+ hook_fg_armed: bool,+ hook_show_armed: bool,+ /// Window verbs in flight (begin/end pairs).+ inflight: usize,+ guards: Vec<Guard>,+ /// KiCad windows that existed when the current run of verbs began.+ baseline: HashSet<u64>,+ /// The foreground window when the current run of verbs began, if KiCad.+ baseline_fg: Option<u64>,+ /// Windows the bridge created (persisted; survives verbs).+ bridge_windows: HashSet<u64>,+ /// (hwnd, pid) -> title at the push. One push per key, ever.+ backgrounded_once: HashMap<(u64, u32), String>,+ /// hwnd -> when the hold ends.+ bounce_holds: HashMap<u64, Instant>,+ /// Windows this process set WS_EX_NOACTIVATE on (cleared when the bridge goes idle).+ noactivated: HashSet<u64>,+ owned_pids: HashSet<u32>,+ owned_projects: HashSet<String>,+ /// pid -> (owned?, decided at). Negative answers expire; positives are in owned_pids.+ owned_cache: HashMap<u32, (bool, Instant)>,+ spawn_watch_until: Option<Instant>,+ expected_fg_until: Option<Instant>,+ sanctioned_hwnd: u64,+ last_click: Option<Instant>,+ last_alt: Option<Instant>,+ events: Vec<Value>,+ sweep_ticks: u64,+ last_heal: Option<Instant>,+ last_sanitize: Option<Instant>,+ heals: u64,+ sanitized: u64,+ bounces: u64,+ last_error: String,+}++static STATE: OnceLock<Mutex<Inner>> = OnceLock::new();++fn state() -> &'static Mutex<Inner> {+ STATE.get_or_init(|| Mutex::new(Inner::default()))+}++fn lock() -> std::sync::MutexGuard<'static, Inner> {+ state().lock().unwrap_or_else(|e| e.into_inner())+}++fn log(line: &str) {+ eprintln!("{line}");+}++fn epoch_secs() -> f64 {+ SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0)+}++fn within(since: Option<Instant>, window: Duration, now: Instant) -> bool {+ since.map(|t| now.saturating_duration_since(t) <= window).unwrap_or(false)+}++fn active_until(until: Option<Instant>, now: Instant) -> bool {+ until.map(|u| now < u).unwrap_or(false)+}++fn seconds_left(until: Option<Instant>, now: Instant) -> f64 {+ until.map(|u| u.saturating_duration_since(now).as_secs_f64()).unwrap_or(0.0)+}++// ── Ownership ledger ──────────────────────────────────────────────────────────++fn ledger_path() -> PathBuf {+ let base = std::env::var("USERPROFILE").ok().filter(|s| !s.is_empty()).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));+ let dir = base.join(".adom");+ let _ = std::fs::create_dir_all(&dir);+ dir.join("kicad-bridge-owned.json")+}++fn pid_is_kicad(pid: u32) -> bool {+ let base = process::exe_base_name(pid);+ !base.is_empty() && OWNED_EXE_PREFIXES.iter().any(|p| base.starts_with(p))+}++/// Write the ledger in the Python's format: live KiCad pids, live bridge windows with+/// their pids, owned projects. Called with the lock NOT held (it opens processes).+fn save_ledger(pids: &HashSet<u32>, windows: &HashSet<u64>, projects: &HashSet<String>) {+ let mut live: Vec<u32> = pids.iter().copied().filter(|p| pid_is_kicad(*p)).collect();+ live.sort_unstable();+ let wins: Vec<Value> = windows+ .iter()+ .copied()+ .filter(|h| is_window(hwnd(*h)))+ .map(|h| json!({"hwnd": h, "pid": pid_of(hwnd(h))}))+ .collect();+ let mut projects: Vec<&String> = projects.iter().collect();+ projects.sort();+ let doc = json!({"pids": live, "windows": wins, "projects": projects});+ if let Err(e) = std::fs::write(ledger_path(), doc.to_string()) {+ lock().last_error = format!("ledger write: {e}");+ }+}++fn snapshot_and_save() {+ let (pids, wins, projects) = {+ let g = lock();+ (g.owned_pids.clone(), g.bridge_windows.clone(), g.owned_projects.clone())+ };+ save_ledger(&pids, &wins, &projects);+}++/// Boot: re-own only what provably survived (a ledger hwnd whose pid still matches and+/// is KiCad, ledger pids still running a KiCad exe). Anything the user launched while+/// the bridge was down stays user-owned.+fn load_ledger() {+ let Ok(text) = std::fs::read_to_string(ledger_path()) else { return };+ let Ok(doc) = serde_json::from_str::<Value>(&text) else { return };+ let mut wins = HashSet::new();+ let mut pids = HashSet::new();+ let mut projects = HashSet::new();+ for e in doc["windows"].as_array().into_iter().flatten() {+ let h = e["hwnd"].as_u64().unwrap_or(0);+ let p = e["pid"].as_u64().unwrap_or(0) as u32;+ if h != 0 && is_window(hwnd(h)) && pid_of(hwnd(h)) == p && pid_is_kicad(p) {+ wins.insert(h);+ pids.insert(p);+ }+ }+ for p in doc["pids"].as_array().into_iter().flatten() {+ let p = p.as_u64().unwrap_or(0) as u32;+ if p != 0 && pid_is_kicad(p) {+ pids.insert(p);+ }+ }+ for p in doc["projects"].as_array().into_iter().flatten() {+ if let Some(s) = p.as_str() {+ projects.insert(s.to_string());+ }+ }+ let mut g = lock();+ g.bridge_windows.extend(wins);+ g.owned_pids.extend(pids);+ g.owned_projects.extend(projects);+}++/// Is this window the bridge's (spawned by us, or a child of our spawn)? Cheap when the+/// pid is already known; otherwise one Toolhelp snapshot and a bounded parent walk. A+/// child of our spawn is adopted into the pid set (and the ledger) on first sight.+fn is_owned_window(h: u64) -> bool {+ if h == 0 {+ return false;+ }+ let pid = pid_of(hwnd(h));+ if pid == 0 {+ return false;+ }+ {+ let g = lock();+ if g.bridge_windows.contains(&h) || g.owned_pids.contains(&pid) {+ return true;+ }+ if let Some((owned, at)) = g.owned_cache.get(&pid) {+ if at.elapsed() < Duration::from_secs(2) {+ return *owned;+ }+ }+ }+ let parents: HashMap<u32, u32> = process::snapshot().into_iter().map(|(p, pp, _)| (p, pp)).collect();+ let owned = {+ let g = lock();+ let mut p = pid;+ let mut found = false;+ for _ in 0..PARENT_DEPTH {+ let Some(&pp) = parents.get(&p) else { break };+ if pp == 0 || pp == p {+ break;+ }+ p = pp;+ if g.owned_pids.contains(&p) {+ found = true;+ break;+ }+ }+ found+ };+ let mut g = lock();+ g.owned_cache.insert(pid, (owned, Instant::now()));+ if owned {+ g.owned_pids.insert(pid);+ drop(g);+ snapshot_and_save();+ }+ owned+}++pub fn owned(h: u64) -> bool {+ is_owned_window(h)+}++// ── Win32 reads the loop needs ────────────────────────────────────────────────++fn cursor_root() -> HWND {+ let mut pt = POINT::default();+ // SAFETY: `pt` outlives the call.+ if unsafe { GetCursorPos(&mut pt) }.is_err() {+ return HWND::default();+ }+ // SAFETY: plain hit test, no cross-thread message.+ let under = unsafe { WindowFromPoint(pt) };+ if under.is_invalid() {+ return HWND::default();+ }+ // SAFETY: table read.+ unsafe { GetAncestor(under, GA_ROOT) }+}++fn cursor_over(h: u64) -> bool {+ let root = cursor_root();+ !root.is_invalid() && hwnd_u64(root) == h+}++fn cursor_over_shell() -> bool {+ let root = cursor_root();+ !root.is_invalid() && SHELL_CLASSES.contains(&class_name(root).as_str())+}++/// Sample the mouse buttons and Alt. `0x8001`: down now, or pressed since the last call+/// (both threads sample, both write the shared timestamps, so it does not matter which+/// one consumes the latched bit).+fn sample_input(g: &mut Inner, now: Instant) {+ // SAFETY: GetAsyncKeyState reads the async key table only.+ let (l, r, alt) = unsafe { (GetAsyncKeyState(VK_LBUTTON.0 as i32), GetAsyncKeyState(VK_RBUTTON.0 as i32), GetAsyncKeyState(VK_MENU.0 as i32)) };+ if (l as u16 & 0x8001) != 0 || (r as u16 & 0x8001) != 0 {+ g.last_click = Some(now);+ }+ if (alt as u16 & 0x8000) != 0 {+ g.last_alt = Some(now);+ }+}++fn exe_of(h: u64, cache: &mut ExeCache) -> String {+ cache.exe(pid_of(hwnd(h)))+}++/// Windows' foreground lock timeout in ms (SPI_GETFOREGROUNDLOCKTIMEOUT). 0 means the+/// lock is off (some boxes are tweaked that way), in which case the idle-grant rule+/// carries no information and is not applied.+fn foreground_lock_timeout_ms() -> u32 {+ let mut ms: u32 = 0;+ // SAFETY: pvParam points at a u32 that outlives the call, as the action requires.+ let ok = unsafe { SystemParametersInfoW(SPI_GETFOREGROUNDLOCKTIMEOUT, 0, Some(&mut ms as *mut u32 as *mut _), SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0)) };+ if ok.is_err() {+ return 0;+ }+ ms+}++fn user_idle_secs() -> f64 {+ super::messages::seconds_since_input().unwrap_or(1e9)+}++fn user_idle_past_lock() -> bool {+ let ms = foreground_lock_timeout_ms();+ ms > 0 && user_idle_secs() * 1000.0 > ms as f64+}++fn exstyle(h: HWND) -> WINDOW_EX_STYLE {+ // SAFETY: GetWindowLongPtr reads the window's own record; no message is sent.+ WINDOW_EX_STYLE(unsafe { GetWindowLongPtrW(h, GWL_EXSTYLE) } as u32)+}++/// The one style write in this module. Refused on a window that fails the WM_NULL probe+/// (0.9.165: SetWindowLongPtr on a hung window never returns).+fn set_exstyle(h: HWND, want: WINDOW_EX_STYLE) -> Result<(), String> {+ super::require_responsive(h, "set_exstyle", PROBE_MS)?;+ // SAFETY: responsive window; the style value is a plain integer.+ unsafe { SetWindowLongPtrW(h, GWL_EXSTYLE, want.0 as isize) };+ Ok(())+}++/// Suppress activation (WS_EX_NOACTIVATE paired with WS_EX_APPWINDOW so the taskbar+/// button survives: 0.9.160, "why do i see no windows taskbar icons"). Only reachable when+/// `BOUNCE_HOLD_NOACTIVATE` is true.+fn suppress_activation(h: u64) {+ let w = hwnd(h);+ if !is_window(w) {+ return;+ }+ let cur = exstyle(w);+ let want = cur | WS_EX_NOACTIVATE | WS_EX_APPWINDOW;+ if want != cur {+ match set_exstyle(w, want) {+ Ok(()) => {+ lock().noactivated.insert(h);+ }+ Err(e) => lock().last_error = e,+ }+ } else {+ lock().noactivated.insert(h);+ }+}++/// Clear WS_EX_NOACTIVATE from one window if it carries it (inspection, so a style left+/// by an older bridge is healed too: 0.9.164). APPWINDOW stays: re-toggling it makes the+/// shell rebuild the button.+fn allow_activation(h: u64) -> bool {+ let w = hwnd(h);+ let mut cleared = false;+ if is_window(w) {+ let cur = exstyle(w);+ if cur.contains(WS_EX_NOACTIVATE) {+ let want = WINDOW_EX_STYLE(cur.0 & !WS_EX_NOACTIVATE.0) | WS_EX_APPWINDOW;+ match set_exstyle(w, want) {+ Ok(()) => cleared = true,+ Err(e) => {+ lock().last_error = e;+ return false;+ }+ }+ }+ }+ lock().noactivated.remove(&h);+ cleared+}++fn allow_activation_all() -> usize {+ let list: Vec<u64> = lock().noactivated.iter().copied().collect();+ let mut n = 0;+ for h in list {+ if allow_activation(h) {+ n += 1;+ }+ }+ n+}++/// Every visible KiCad top-level window.+fn kicad_hwnds(cache: &mut ExeCache) -> Vec<u64> {+ top_level_windows()+ .into_iter()+ .filter(|&h| visible(h) && is_kicad_exe(&cache.exe(pid_of(h))))+ .map(hwnd_u64)+ .collect()+}++// ── The action ────────────────────────────────────────────────────────────────++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+enum Source {+ Sentinel,+ Hook,+}++/// Build the transition for `h`, decide, act, log. Shared by both threads. The decision+/// and the once-ledger claim happen under the lock; the push happens outside it, so a+/// 300 ms probe never stalls the other thread.+fn handle_foreground(h: u64, source: Source, cache: &mut ExeCache) {+ if h == 0 {+ return;+ }+ let exe = exe_of(h, cache);+ if !is_kicad_exe(&exe) {+ return;+ }+ let now = Instant::now();+ let pid = pid_of(hwnd(h));+ // Reads that touch other processes' state (cursor hit test, Toolhelp) before the lock.+ let owned = is_owned_window(h);+ let idle_past_lock = user_idle_past_lock();+ let ago = user_idle_secs();+ let (verdict, t) = {+ let mut g = lock();+ sample_input(&mut g, now);+ expire_guards(&mut g, now);+ let clicked = within(g.last_click, CLICK_WINDOW, now);+ // Only pay for the hit tests when a click is recent.+ let clicked_here = clicked && (cursor_over(h) || cursor_over_shell());+ let t = Transition {+ is_kicad: true,+ sanctioned: active_until(g.expected_fg_until, now),+ clicked_here,+ alt_tabbed: within(g.last_alt, ALT_WINDOW, now),+ owned,+ foreground_at_verb_start: (g.inflight > 0 || !g.guards.is_empty()) && g.baseline_fg == Some(h),+ already_bounced: g.backgrounded_once.contains_key(&(h, pid)),+ guards: g.guards.len(),+ inflight: g.inflight,+ spawn_watch: active_until(g.spawn_watch_until, now),+ user_idle_past_lock: idle_past_lock,+ };+ let v = decide(&t);+ if let Verdict::Bounce(_) = v {+ // Claim the once-per-window slot before acting so the other thread cannot+ // push the same window a second time.+ let title = title_nohang(hwnd(h));+ g.backgrounded_once.insert((h, pid), title);+ g.bounces += 1;+ }+ (v, t)+ };+ let title = title_nohang(hwnd(h));+ let action: String = match verdict {+ Verdict::Ignore => return,+ Verdict::Leave(label) => label.to_string(),+ Verdict::Bounce(label) => {+ let result = match focus::push_to_background(hwnd(h)) {+ Ok(()) => "pushed",+ Err(e) => {+ lock().last_error = e;+ "push refused (window hung)"+ }+ };+ if BOUNCE_HOLD_NOACTIVATE {+ suppress_activation(h);+ }+ lock().bounce_holds.insert(h, now + BOUNCE_HOLD);+ log(&format!("[bg] {h}: sent behind once (z-order only, {result}). Hands off from here."));+ match source {+ Source::Hook => format!("guarded-steal (hook-bounced, ms; {result})"),+ Source::Sentinel => {+ if result == "pushed" {+ label.to_string()+ } else {+ format!("{label}; {result}")+ }+ }+ }+ }+ };+ let ev = json!({+ "t": epoch_secs(),+ "action": action,+ "title": title,+ "secondsSinceUserInput": (ago.min(9999.0) * 100.0).round() / 100.0,+ "hwnd": h,+ "pid": pid,+ "exe": exe,+ "source": match source { Source::Hook => "hook", Source::Sentinel => "sentinel" },+ "owned": t.owned,+ "guards": t.guards,+ "inflight": t.inflight,+ "spawnWatch": t.spawn_watch,+ "userIdlePastLockTimeout": t.user_idle_past_lock,+ });+ let tag = match source {+ Source::Hook => "[focus-hook]",+ Source::Sentinel => "[focus-sentinel]",+ };+ log(&format!("{tag} {}: '{}' (user input {:.2}s ago)", ev["action"].as_str().unwrap_or(""), ev["title"].as_str().unwrap_or(""), ago.min(9999.0)));+ push_event(ev);+}++fn push_event(ev: Value) {+ let mut g = lock();+ g.events.push(ev);+ if g.events.len() > EVENT_RING {+ let n = g.events.len() - EVENT_RING;+ g.events.drain(..n);+ }+}++/// Drop guards past their deadline. A guard is a deadline, not a thread, so the 0.9.160+/// watchdog (a leaked guard kept a window parked for hours) has nothing left to catch.+/// Returns true when the last guard left with no verb in flight (the bridge went idle).+fn expire_guards(g: &mut Inner, now: Instant) -> bool {+ let before = g.guards.len();+ g.guards.retain(|gd| now < gd.deadline);+ let dropped = before - g.guards.len();+ dropped > 0 && g.guards.is_empty() && g.inflight == 0+}++// ── Idle heals ────────────────────────────────────────────────────────────────++/// Every 20 s while idle (0.9.164, "why is it that when i try to click to see one of+/// your kicad windows it doesn't work?"): by INSPECTION, clear WS_EX_NOACTIVATE from every+/// responsive KiCad window, and drag a window back on screen when no part of it is on any+/// monitor (wiki #48: a second monitor counts exactly like the primary). Size preserved,+/// no activation, z-order untouched. A hung window is skipped entirely.+fn heal_windows(cache: &mut ExeCache) -> usize {+ let mut healed = 0;+ for h in kicad_hwnds(cache) {+ let w = hwnd(h);+ if minimized(w) {+ continue; // not ours to restore, and its rect is meaningless+ }+ if !responsive(w, PROBE_MS) {+ continue; // hung: touching its style or position would block us too+ }+ let cur = exstyle(w);+ if cur.contains(WS_EX_NOACTIVATE) && allow_activation(h) {+ healed += 1;+ log(&format!("[focus] usability heal: cleared stale suppression on {h}"));+ }+ let r = rect(w);+ if r.2 > 0 && r.3 > 0 && !on_a_monitor(r) {+ let (x, y) = (120, 90);+ let (wd, ht) = (r.2.max(640), r.3.max(480));+ // SAFETY: responsive window; flags forbid activation and z-order change.+ match unsafe { SetWindowPos(w, None, x, y, wd, ht, SWP_NOACTIVATE | SWP_NOZORDER) } {+ Ok(()) => {+ healed += 1;+ log(&format!("[focus] healed off-screen window {h} -> ({x},{y})"));+ }+ Err(e) => lock().last_error = format!("SetWindowPos({h}) heal: {e}"),+ }+ }+ }+ healed+}++fn any_kicad_running() -> bool {+ process::snapshot().into_iter().any(|(pid, _, exe)| pid != 0 && OWNED_EXE_PREFIXES.iter().any(|p| exe.starts_with(p)))+}++/// Remove BRIDGE projects from KiCad's restore-on-launch list (2026-08-17: every launch of+/// John's KiCad auto-opened our demo and hit the project lock warning). `kicad.json`+/// `system.open_projects` is rewritten by KiCad at exit, so edits only stick while NO+/// KiCad runs; the loop retries every 60 s until that window opens. file_history is left+/// alone (honest recents).+fn sanitize_session() -> usize {+ if any_kicad_running() {+ return 0;+ }+ let owned = lock().owned_projects.clone();+ let Some(root) = std::env::var("APPDATA").ok().filter(|s| !s.is_empty()).map(|a| PathBuf::from(a).join("kicad")) else { return 0 };+ let Ok(rd) = std::fs::read_dir(&root) else { return 0 };+ let mut removed = 0;+ for e in rd.flatten() {+ let cfg = e.path().join("kicad.json");+ if !cfg.is_file() {+ continue;+ }+ removed += sanitize_file(&cfg, &owned);+ }+ removed+}++fn sanitize_file(cfg: &Path, owned: &HashSet<String>) -> usize {+ let Ok(text) = std::fs::read_to_string(cfg) else { return 0 };+ let Ok(mut data) = serde_json::from_str::<Value>(&text) else { return 0 };+ let Some(op) = data.get("system").and_then(|s| s.get("open_projects")).and_then(Value::as_array).cloned() else { return 0 };+ let keep: Vec<Value> = op.iter().filter(|p| !p.as_str().map(|s| is_bridge_project_path(s, owned)).unwrap_or(false)).cloned().collect();+ let n = op.len() - keep.len();+ if n == 0 {+ return 0;+ }+ if !data["system"].is_object() {+ data["system"] = json!({});+ }+ data["system"]["open_projects"] = Value::Array(keep);+ match serde_json::to_string_pretty(&data).map_err(|e| e.to_string()).and_then(|s| std::fs::write(cfg, s).map_err(|e| e.to_string())) {+ Ok(()) => {+ log(&format!("[session] scrubbed {n} bridge project(s) from {}", cfg.display()));+ n+ }+ Err(e) => {+ lock().last_error = format!("sanitize {}: {e}", cfg.display());+ 0+ }+ }+}++// ── The sentinel thread ───────────────────────────────────────────────────────++fn sentinel_loop() {+ let boot = Instant::now();+ let mut last_fg: u64 = focus::foreground();+ let mut cache = ExeCache::default();+ let mut cache_born = Instant::now();+ let mut booted = false;+ loop {+ let now = Instant::now();+ if cache_born.elapsed() > Duration::from_secs(5) {+ cache = ExeCache::default(); // pids are recycled; do not trust a stale exe name+ cache_born = now;+ }+ // Boot maintenance, off the boot path (0.9.165): ledger, stale styles, off-screen+ // windows, session, in that order, once the port has been answering for 2 s.+ if !booted && now.saturating_duration_since(boot) >= BOOT_DELAY {+ booted = true;+ load_ledger();+ let n = heal_windows(&mut cache);+ if n > 0 {+ log(&format!("[boot] healed {n} window(s)"));+ }+ let s = sanitize_session();+ let mut g = lock();+ g.last_heal = Some(now);+ g.last_sanitize = Some(now);+ g.heals += n as u64;+ g.sanitized += s as u64;+ }+ // 1. Input sample, guard expiry, hold expiry.+ let (went_idle, expired_holds, active) = {+ let mut g = lock();+ sample_input(&mut g, now);+ let went_idle = expire_guards(&mut g, now);+ let expired: Vec<u64> = g.bounce_holds.iter().filter(|(_, until)| now >= **until).map(|(h, _)| *h).collect();+ for h in &expired {+ g.bounce_holds.remove(h);+ }+ let active = g.inflight > 0 || !g.guards.is_empty();+ if active {+ g.sweep_ticks += 1;+ }+ (went_idle, expired, active)+ };+ for h in expired_holds {+ allow_activation(h);+ }+ if went_idle {+ let n = allow_activation_all();+ if n > 0 {+ log(&format!("[focus] bridge idle: cleared activation block on {n} window(s)"));+ }+ }+ // 2. The transition.+ let fg = focus::foreground();+ if fg != last_fg {+ handle_foreground(fg, Source::Sentinel, &mut cache);+ last_fg = fg;+ }+ // 3. While the bridge works: adopt owned windows that appeared since the baseline+ // into the bridge-window set (the ledger), so a restart still knows them. The+ // transition above already decided about the foreground; nothing else is touched.+ if active {+ let baseline = lock().baseline.clone();+ let mut adopted = false;+ for h in kicad_hwnds(&mut cache) {+ if baseline.contains(&h) || lock().bridge_windows.contains(&h) {+ continue;+ }+ if is_owned_window(h) {+ lock().bridge_windows.insert(h);+ adopted = true;+ }+ }+ if adopted {+ snapshot_and_save();+ }+ } else if booted {+ // 4. Idle heals.+ let (heal_due, sanitize_due) = {+ let g = lock();+ (+ g.last_heal.map(|t| now.saturating_duration_since(t) >= HEAL_EVERY).unwrap_or(true),+ g.last_sanitize.map(|t| now.saturating_duration_since(t) >= SANITIZE_EVERY).unwrap_or(true),+ )+ };+ if heal_due {+ let n = heal_windows(&mut cache);+ let mut g = lock();+ g.last_heal = Some(now);+ g.heals += n as u64;+ // The once-ledger expires with the windows (hwnd values are recycled).+ g.backgrounded_once.retain(|(h, _), _| is_window(hwnd(*h)));+ g.bridge_windows.retain(|h| is_window(hwnd(*h)));+ }+ if sanitize_due {+ let s = sanitize_session();+ let mut g = lock();+ g.last_sanitize = Some(now);+ g.sanitized += s as u64;+ }+ }+ std::thread::sleep(POLL);+ }+}++// ── The WinEvent hook thread ──────────────────────────────────────────────────++/// Windows delivers EVENT_SYSTEM_FOREGROUND and EVENT_OBJECT_SHOW here, on the pump+/// thread, synchronously with the event (0.9.137: the 120 ms poll latency IS the visible+/// flash on 10.0.5). Only the foreground matters: a SHOW is acted on only when the shown+/// window is the foreground (John: "ONLY background the window once IF IT APPEARS IN THE+/// FOREGROUND"). The same decision function as the sentinel, so a user click still wins.+unsafe extern "system" fn on_event(_hook: HWINEVENTHOOK, event: u32, h: HWND, id_object: i32, id_child: i32, _tid: u32, _time: u32) {+ if id_object != OBJID_WINDOW.0 || id_child != 0 || h.is_invalid() {+ return;+ }+ let hu = hwnd_u64(h);+ let fg = focus::foreground();+ match event {+ EVENT_SYSTEM_FOREGROUND => {}+ EVENT_OBJECT_SHOW if fg == hu => {}+ _ => return,+ }+ // Fast reject: while the bridge is idle and no spawn is being watched, the polling+ // sentinel owns classification (and it will log the transition).+ {+ let g = lock();+ let now = Instant::now();+ if g.inflight == 0 && g.guards.is_empty() && !active_until(g.spawn_watch_until, now) {+ return;+ }+ if active_until(g.expected_fg_until, now) {+ return;+ }+ }+ let mut cache = ExeCache::default();+ handle_foreground(hu, Source::Hook, &mut cache);+}++fn hook_pump() {+ // SAFETY: out-of-context hook with a 'static callback; no module handle needed.+ let (h1, h2) = unsafe {+ (+ SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, None, Some(on_event), 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS),+ SetWinEventHook(EVENT_OBJECT_SHOW, EVENT_OBJECT_SHOW, None, Some(on_event), 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS),+ )+ };+ {+ let mut g = lock();+ g.hook_fg_armed = !h1.is_invalid();+ g.hook_show_armed = !h2.is_invalid();+ }+ log(&format!("[focus-hook] armed fg={} show={}", !h1.is_invalid(), !h2.is_invalid()));+ if h1.is_invalid() && h2.is_invalid() {+ return;+ }+ let mut msg = MSG::default();+ // SAFETY: a plain message pump on the thread that installed the hooks; `msg` outlives+ // every call. GetMessageW returns 0 on WM_QUIT and -1 on error; both end the pump.+ unsafe {+ while GetMessageW(&mut msg, None, 0, 0).0 > 0 {+ let _ = TranslateMessage(&msg);+ DispatchMessageW(&msg);+ }+ }+}++// ── Trait entry points ────────────────────────────────────────────────────────++/// Start both threads once. The ledger load and the boot heals run on the sentinel+/// thread after BOOT_DELAY, never here.+pub fn start() {+ {+ let mut g = lock();+ if g.started {+ return;+ }+ g.started = true;+ }+ match std::thread::Builder::new().name("kicad-focus-sentinel".into()).spawn(sentinel_loop) {+ Ok(_) => lock().sentinel_started = true,+ Err(e) => lock().last_error = format!("sentinel thread: {e}"),+ }+ match std::thread::Builder::new().name("kicad-focus-event-hook".into()).spawn(hook_pump) {+ Ok(_) => lock().hook_started = true,+ Err(e) => lock().last_error = format!("hook thread: {e}"),+ }+}++/// A window verb begins. The baseline (every KiCad window up right now, and the foreground+/// if it is one) is taken only when the bridge was idle: a window that finished its own+/// verb is settled and belongs in the baseline like the user's own (0.9.161). A spawning+/// verb also arms its guard (20 s, or 120 s for the slow flows); the deadline is re-armed+/// at end_verb so the guard covers the late self-raise AFTER the verb returned, which is+/// what restore_foreground_persistent did.+pub fn begin_verb(verb: &str, spawns: bool) {+ let now = Instant::now();+ let mut cache = ExeCache::default();+ let fg = focus::foreground();+ let fg_is_kicad = fg != 0 && is_kicad_exe(&exe_of(fg, &mut cache));+ let mut g = lock();+ expire_guards(&mut g, now);+ if g.inflight == 0 && g.guards.is_empty() {+ g.bridge_windows.retain(|h| is_window(hwnd(*h)));+ g.baseline = kicad_hwnds(&mut cache).into_iter().collect();+ g.baseline_fg = if fg_is_kicad { Some(fg) } else { None };+ }+ g.inflight += 1;+ if spawns {+ g.guards.push(Guard { verb: verb.to_string(), deadline: now + guard_for(verb) });+ }+}++/// A window verb ends. Its guard (if any) restarts its clock now, so the post-verb window+/// is the full 20 s or 120 s. When nothing is in flight and no guard is armed the bridge+/// hands every window back (0.9.180: a stale WS_EX_NOACTIVATE must never outlive the work+/// that justified it).+pub fn end_verb(verb: &str) {+ let now = Instant::now();+ let idle = {+ let mut g = lock();+ g.inflight = g.inflight.saturating_sub(1);+ if let Some(gd) = g.guards.iter_mut().rev().find(|gd| gd.verb == verb) {+ gd.deadline = now + guard_for(verb);+ }+ g.inflight == 0 && g.guards.is_empty()+ };+ if idle {+ allow_activation_all();+ }+}++/// Every KiCad-family process the bridge starts, and every project it creates.+pub fn register_pid(pid: u32, project: Option<&Path>) {+ {+ let mut g = lock();+ if pid != 0 {+ g.owned_pids.insert(pid);+ g.owned_cache.remove(&pid);+ g.spawn_watch_until = Some(Instant::now() + SPAWN_WATCH);+ }+ if let Some(p) = project {+ g.owned_projects.insert(p.to_string_lossy().to_string());+ }+ }+ snapshot_and_save();+}++/// The bridge is about to foreground a window legitimately (bring_to_front with a+/// reason, always announced first): exempt bounces for `seconds`.+pub fn sanction(h: u64, seconds: f64) {+ let until = Instant::now() + Duration::from_secs_f64(seconds.max(0.0));+ let mut g = lock();+ g.expected_fg_until = Some(match g.expected_fg_until {+ Some(cur) if cur > until => cur,+ _ => until,+ });+ g.sanctioned_hwnd = h;+}++pub fn events() -> Vec<Value> {+ lock().events.clone()+}++/// The Python's focusDebug block, same field names, plus what this build adds.+pub fn debug() -> Value {+ let now = Instant::now();+ let g = lock();+ let mut owned: Vec<u32> = g.owned_pids.iter().copied().collect();+ owned.sort_unstable();+ let mut projects: Vec<&String> = g.owned_projects.iter().collect();+ projects.sort();+ let guard_deadline = g.guards.iter().map(|gd| gd.deadline).max();+ // Two objects merged: a single json! of this size trips the macro recursion limit.+ let mut out = json!({+ "sweepStarted": g.sentinel_started,+ "hookStarted": g.hook_started,+ "sentinelStarted": g.sentinel_started,+ "verbInflight": g.inflight,+ "guardCount": g.guards.len(),+ "parkedCount": 0,+ "baselineCount": g.baseline.len(),+ "bridgeWindows": g.bridge_windows.len(),+ "parkAttempts": 0,+ "parkLastError": g.last_error,+ "sweepTicks": g.sweep_ticks,+ "ownedPids": owned,+ "parkFights": {},+ "parkGaveUp": 0,+ "guardianDemotes": {},+ "guardianDemoteLimit": 1,+ "backgroundedOnce": g.backgrounded_once.len(),+ "rule": "each window is backgrounded EXACTLY once, then never touched again",+ "parkFightLog": [],+ "parkFightLimit": 0,+ });+ // This build's additions.+ let extra = json!({+ "guards": g.guards.iter().map(|gd| json!({"verb": gd.verb, "secondsLeft": seconds_left(Some(gd.deadline), now)})).collect::<Vec<_>>(),+ "guardSecondsLeft": seconds_left(guard_deadline, now),+ "spawnWatchSecondsLeft": seconds_left(g.spawn_watch_until, now),+ "sanctionedSecondsLeft": seconds_left(g.expected_fg_until, now),+ "sanctionedHwnd": g.sanctioned_hwnd,+ "baselineForeground": g.baseline_fg,+ "hookArmed": {"foreground": g.hook_fg_armed, "show": g.hook_show_armed},+ "bounceHolds": g.bounce_holds.len(),+ "bounceHoldSetsNoActivate": BOUNCE_HOLD_NOACTIVATE,+ "noActivateHeld": g.noactivated.len(),+ "bounces": g.bounces,+ "heals": g.heals,+ "sessionEntriesScrubbed": g.sanitized,+ "secondsSinceHeal": g.last_heal.map(|t| now.saturating_duration_since(t).as_secs_f64()),+ "secondsSinceSanitize": g.last_sanitize.map(|t| now.saturating_duration_since(t).as_secs_f64()),+ "ownedProjects": projects,+ "ledgerPath": ledger_path().to_string_lossy().replace('\\', "/"),+ "foregroundLockTimeoutMs": foreground_lock_timeout_ms(),+ "userIdleSeconds": user_idle_secs().min(1e6),+ "lastClickSecondsAgo": g.last_click.map(|t| now.saturating_duration_since(t).as_secs_f64()),+ "lastAltSecondsAgo": g.last_alt.map(|t| now.saturating_duration_since(t).as_secs_f64()),+ "pollMs": POLL.as_millis() as u64,+ });+ if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {+ for (k, v) in e {+ o.insert(k.clone(), v.clone());+ }+ }+ out+}++#[cfg(all(test, windows))]+mod tests {+ use super::*;++ fn steal() -> Transition {+ Transition { is_kicad: true, owned: true, ..Default::default() }+ }++ #[test]+ fn non_kicad_is_ignored() {+ let mut t = steal();+ t.is_kicad = false;+ t.guards = 1;+ assert_eq!(decide(&t), Verdict::Ignore);+ }++ #[test]+ fn sanctioned_outranks_everything() {+ let mut t = steal();+ t.sanctioned = true;+ t.guards = 2;+ t.inflight = 1;+ assert_eq!(decide(&t), Verdict::Leave("sanctioned-foreground (allowed)"));+ }++ #[test]+ fn user_click_and_alt_tab_win_even_during_a_guard() {+ let mut t = steal();+ t.guards = 1;+ t.clicked_here = true;+ assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("user-foregrounded")));+ let mut t = steal();+ t.inflight = 1;+ t.alt_tabbed = true;+ assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("user-foregrounded")));+ }++ #[test]+ fn the_users_own_kicad_is_never_touched() {+ let mut t = steal();+ t.owned = false;+ t.guards = 1;+ t.inflight = 1;+ t.spawn_watch = true;+ assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("user-owned")));+ }++ #[test]+ fn baseline_foreground_is_never_bounced() {+ let mut t = steal();+ t.guards = 1;+ t.foreground_at_verb_start = true;+ assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("baseline")));+ }++ #[test]+ fn once_per_window() {+ let mut t = steal();+ t.guards = 1;+ assert!(matches!(decide(&t), Verdict::Bounce(_)));+ t.already_bounced = true;+ assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("already backgrounded once")));+ }++ #[test]+ fn guard_then_inflight_then_spawn_watch_bounce() {+ let mut t = steal();+ t.guards = 1;+ t.inflight = 1;+ assert_eq!(decide(&t), Verdict::Bounce("steal-during-guard (bounced)"));+ t.guards = 0;+ assert_eq!(decide(&t), Verdict::Bounce("self-raise (bounced to background)"));+ t.inflight = 0;+ t.spawn_watch = true;+ assert_eq!(decide(&t), Verdict::Bounce("our freshly spawned window took the foreground (bounced)"));+ }++ #[test]+ fn the_idle_rule_leaves_the_user_alone() {+ let t = steal();+ assert_eq!(decide(&t), Verdict::Leave("foreground while bridge idle (left alone)"));+ let mut t = steal();+ t.user_idle_past_lock = true;+ assert!(matches!(decide(&t), Verdict::Leave(l) if l.contains("lock timeout")));+ }++ #[test]+ fn user_idle_past_lock_does_not_outrank_a_guard() {+ // Unattended runs (the verb runner, ConfRoomROG from a phone) are idle past the+ // lock timeout by definition; the Python bounced there and so does this.+ let mut t = steal();+ t.guards = 1;+ t.user_idle_past_lock = true;+ assert!(matches!(decide(&t), Verdict::Bounce(_)));+ t.guards = 0;+ t.spawn_watch = true;+ assert!(matches!(decide(&t), Verdict::Bounce(_)));+ }++ #[test]+ fn guard_lengths_match_server_py() {+ assert_eq!(guard_for("kicad_open_3d_viewer"), GUARD_LONG);+ assert_eq!(guard_for("open_3d_viewer"), GUARD_LONG);+ assert_eq!(guard_for("kicad_launch"), GUARD_LONG);+ assert_eq!(guard_for("kicad_show_3d_chip"), GUARD_LONG);+ assert_eq!(guard_for("kicad_place_footprint"), GUARD_LONG);+ assert_eq!(guard_for("kicad_demo"), GUARD_LONG);+ assert_eq!(guard_for("kicad_open_board"), GUARD_SHORT);+ assert_eq!(guard_for("kicad_open_symbol_editor"), GUARD_SHORT);+ }++ #[test]+ fn bridge_project_paths() {+ let mut owned = HashSet::new();+ owned.insert("C:/Users/john/Documents/KiCad/parts/lm317.kicad_pro".to_string());+ assert!(is_bridge_project_path("c:\\users\\john\\documents\\kicad\\parts\\LM317.kicad_pro", &owned));+ assert!(is_bridge_project_path("C:\\Users\\john\\Documents\\adom-demo\\adom-demo.kicad_pro", &owned));+ assert!(is_bridge_project_path("C:\\Users\\john\\Documents\\in-lm317-demo\\in-lm317-demo.kicad_pro", &owned));+ assert!(!is_bridge_project_path("C:\\Users\\john\\Documents\\MyBoard\\MyBoard.kicad_pro", &owned));+ // The legacy match is on a DIRECTORY component, not the file name.+ assert!(!is_bridge_project_path("C:\\Users\\john\\Documents\\adom-demo.kicad_pro", &owned));+ assert!(!is_bridge_project_path("C:\\Users\\john\\Documents\\Adom-Demo Files\\x.kicad_pro", &owned));+ }++ #[test]+ fn sanitize_rewrites_only_bridge_entries() {+ let dir = std::env::temp_dir().join(format!("kicad-bridge-sanitize-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let cfg = dir.join("kicad.json");+ std::fs::write(&cfg, r#"{"system":{"open_projects":["C:\\u\\MyBoard\\MyBoard.kicad_pro","C:\\u\\adom-demo\\adom-demo.kicad_pro"],"file_history":["x"]},"other":1}"#).unwrap();+ let owned = HashSet::new();+ assert_eq!(sanitize_file(&cfg, &owned), 1);+ let data: Value = serde_json::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();+ assert_eq!(data["system"]["open_projects"], json!(["C:\\u\\MyBoard\\MyBoard.kicad_pro"]));+ assert_eq!(data["system"]["file_history"], json!(["x"]));+ assert_eq!(data["other"], json!(1));+ assert_eq!(sanitize_file(&cfg, &owned), 0);+ let _ = std::fs::remove_dir_all(&dir);+ }++ #[test]+ fn guards_expire_and_report_idle_once() {+ let mut g = Inner::default();+ let now = Instant::now();+ g.guards.push(Guard { verb: "kicad_open_board".into(), deadline: now - Duration::from_secs(1) });+ assert!(expire_guards(&mut g, now));+ assert!(g.guards.is_empty());+ assert!(!expire_guards(&mut g, now));+ g.guards.push(Guard { verb: "x".into(), deadline: now + Duration::from_secs(5) });+ assert!(!expire_guards(&mut g, now));+ assert_eq!(g.guards.len(), 1);+ }++ #[test]+ fn debug_block_has_the_python_fields() {+ let d = debug();+ for k in ["sweepStarted", "hookStarted", "sentinelStarted", "verbInflight", "guardCount", "parkedCount", "baselineCount", "bridgeWindows", "parkAttempts", "parkLastError", "sweepTicks", "ownedPids", "parkFights", "parkGaveUp", "guardianDemotes", "guardianDemoteLimit", "backgroundedOnce", "rule", "parkFightLog", "parkFightLimit"] {+ assert!(d.get(k).is_some(), "missing {k}");+ }+ }+}
rust/crates/kicad-platform/src/win/mod.rs+8−1@@ -13,16 +13,23 @@ //! first and refused on a hung window, for the same reason. //! - No SendInput, keybd_event, mouse_event, SetCursorPos, SetWindowsHookEx, no minimize, //! no off-screen parking, no WS_EX_NOACTIVATE. `push_to_background` is the only z-order-//! demotion and `bring_to_front` the only sanctioned activation.+//! demotion and `bring_to_front` the only sanctioned activation. The two exceptions are+//! explicit: `tour_input` is the one SendInput site (phase 5, refused unless the target+//! is the measured foreground), and `etiquette` is the one module that may write a+//! window style (to CLEAR a stale WS_EX_NOACTIVATE, probe-gated), and the one+//! WinEventHook (a notification hook, not an input hook). //! - Handles cross the trait as u64 and become `HWND` here; wide strings are built with //! `wide()` and read back with `from_wide()`. pub mod capture; pub mod enumerate;+pub mod etiquette; pub mod focus;+pub mod install; pub mod menu; pub mod messages; pub mod process;+pub mod tour_input; pub mod uia; use std::ffi::c_void;
rust/crates/kicad-platform/src/win/tour_input.rsadded+202@@ -0,0 +1,202 @@+//! 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 || focus::foreground() != h {+ return Err(NOT_FOREGROUND.into());+ }+ 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());+ }+}
rust/crates/kicad-platform/src/windows.rs+49−1@@ -97,7 +97,7 @@ impl Platform for Native { window_control: true, ui_automation: true, dialog_sweep: true,- focus_etiquette: false, // phase 4+ focus_etiquette: true, // phase 4: win/etiquette.rs ipc_api: false, // phase 2 silent_install: true, // phase 3b: kicad_upgrade (win/install.rs) }@@ -249,6 +249,54 @@ impl Platform for Native { fn installer_download_url(&self, version: &str) -> Option<String> { Some(win::install::download_url(version)) }+ // ---- Phase 4: the etiquette loop. See win/etiquette.rs.++ fn etiquette_start(&self) {+ win::etiquette::start()+ }+ fn etiquette_begin_verb(&self, verb: &str, spawns: bool) {+ win::etiquette::begin_verb(verb, spawns)+ }+ fn etiquette_end_verb(&self, verb: &str) {+ win::etiquette::end_verb(verb)+ }+ fn etiquette_register_pid(&self, pid: u32, project: Option<&Path>) {+ win::etiquette::register_pid(pid, project)+ }+ fn etiquette_sanction(&self, h: u64, seconds: f64) {+ win::etiquette::sanction(h, seconds)+ }+ fn etiquette_events(&self) -> Vec<serde_json::Value> {+ win::etiquette::events()+ }+ fn etiquette_debug(&self) -> serde_json::Value {+ win::etiquette::debug()+ }+ fn etiquette_owned(&self, h: u64) -> Option<bool> {+ Some(win::etiquette::owned(h))+ }++ // ---- Phase 5: tour input and narration. See win/tour_input.rs. Every input call is+ // refused unless `h` is the measured foreground window.++ fn tour_mouse_move(&self, h: u64, x: i32, y: i32) -> Result<(), String> {+ win::tour_input::mouse_move(h, x, y)+ }+ fn tour_mouse_button(&self, h: u64, button: &str, down: bool) -> Result<(), String> {+ win::tour_input::mouse_button(h, button, down)+ }+ fn tour_wheel(&self, h: u64, clicks: i32) -> Result<(), String> {+ win::tour_input::wheel(h, clicks)+ }+ fn tour_key(&self, h: u64, vk: u16) -> Result<(), String> {+ win::tour_input::key_press(h, vk)+ }+ fn tour_audio_play(&self, path: &Path, volume: f32) -> Result<u64, String> {+ win::tour_input::audio_play(path, volume)+ }+ fn tour_audio_stop(&self, handle: u64) -> Result<(), String> {+ win::tour_input::audio_stop(handle)+ } fn init_process(&self) { // Per-monitor DPI awareness so every rect is in physical pixels (kicad_ui.py did // this at import). Fails harmlessly if the manifest or an earlier call already set it.