← 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

John Lauer ·8207d782e3 ·27d ago ·parent 5186c6f
7 files changed +1581−5
rust/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.