app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
1.0.50: 1.0.50: the background guard (backgroundGuard) is OFF by default. John, after watching it live: 'you just are t…
cad51ac
1mo ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450
//! 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 ──────────────────
/// User input this recent, with the cursor on the shell or on the window, is the user's own click.
const USER_INPUT_GRACE_SECS: f64 = 1.5;
/// A window created by a verb stays "fresh" (pushable) this long after that verb ends.
const FRESH_AFTER_VERB: Duration = Duration::from_secs(3);
/// 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,
/// The window the bridge is OPENING RIGHT NOW: it did not exist when the verb in flight began
/// (or the verb ended under 3 s ago). Only such a window may ever be pushed back. Every other
/// KiCad window is the user's to raise by click, Alt-Tab, Win+number or anything else, with no
/// guessing about what caused the foreground change (John, 2026-09-21: "i just tried clicking to
/// bring kicad to the foreground and then you yank it to the background on me").
pub fresh: 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.fresh {
return Verdict::Leave("an existing window, the user's to raise (left alone)");
}
// The bridge is the cause (a verb in flight, a guard, our fresh spawn): bounce even if
// this hwnd was bounced before. A box with the foreground lock off (arav-rog) lets a
// KiCad window come forward twice while it loads, and the once-ledger left the second
// one on top of the user's window (gate on 2026-09-12). User-initiated foregrounds are
// already excluded above (clicked_here, alt_tabbed, sanctioned, baseline).
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.already_bounced {
return Verdict::Leave("already backgrounded once (hands off)");
}
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>,
/// KiCad windows that existed when the verb now in flight began. Refreshed at EVERY verb start
/// (`baseline` above is only refreshed when no guard is armed, which during back-to-back verbs
/// is never, so it cannot answer "is this window new").
verb_baseline: HashSet<u64>,
last_verb_end: Option<Instant>,
/// The foreground window when the current run of verbs began, if KiCad.
baseline_fg: Option<u64>,
/// The user's most recent non-KiCad foreground window; a bounce hands the activation
/// back to it, because a Z-order push alone does not hold while KiCad stays active.
last_user_fg: 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
}
/// A window that belongs to the Windows shell: by class, or because explorer.exe owns it. The bridge
/// never forces one of these to the foreground and never treats one as "the user's window".
fn is_shell_window(h: u64, cache: &mut ExeCache) -> bool {
if h == 0 {
return false;
}
if SHELL_CLASSES.contains(&class_name(hwnd(h)).as_str()) {
return true;
}
let exe = cache.exe(pid_of(hwnd(h))).to_lowercase();
exe.ends_with("explorer.exe") || exe.ends_with("shellexperiencehost.exe") || exe.ends_with("startmenuexperiencehost.exe") || exe.ends_with("searchhost.exe")
}
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);
// A click is the polled button state (120 ms sentinel, so a quick click is missed and the
// user's own window was bounced "sometimes") OR any user input in the last 1.5 s. Either one
// only counts with the cursor on the shell (a taskbar button, Start) or on this window:
// someone typing in their editor while KiCad raises itself has the cursor elsewhere.
// Two kinds of evidence, and they do NOT get the same hit test (1.0.46):
// - a mouse BUTTON seen down (polled): the cursor on this window or on the shell is a click on it;
// - merely RECENT INPUT of any kind: only the cursor on the SHELL counts (a taskbar button,
// Start). 1.0.40 also accepted "cursor over this window", which is true whenever a new KiCad
// window happens to open underneath the cursor of someone who is just using their machine:
// on AdomLapper the Schematic Editor was judged "user-foregrounded" and left on top of John's
// browser with the guard ON (2026-09-21 11:50:36).
let button = within(g.last_click, CLICK_WINDOW, now);
let recent_input = ago < USER_INPUT_GRACE_SECS;
let clicked_here = (button && (cursor_over(h) || cursor_over_shell())) || (recent_input && 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)),
fresh: !g.verb_baseline.contains(&h) && (g.inflight > 0 || within(g.last_verb_end, FRESH_AFTER_VERB, now)),
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));
// Guard switched off by the user: decide as usual so the log still says what WOULD have
// happened (that is the point of an A/B), but touch nothing.
let verdict = match verdict {
Verdict::Bounce(_) if !GUARD_ENABLED.load(std::sync::atomic::Ordering::Relaxed) => {
let mut g = lock();
g.backgrounded_once.remove(&(h, pid));
g.bounces = g.bounces.saturating_sub(1);
Verdict::Leave("guard OFF: would have pushed this window (left alone)")
}
v => v,
};
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) => {
// 1.0.38 gave the once-per-window slot back here so a refused push would be retried.
// REVERTED in 1.0.39: on winvm the same window was then pushed again and again within
// seconds, and every push also forces the user's previous window to the front. When
// that window was the taskbar John had just clicked, Explorer was hammered and died.
// One push per window, landed or not, until the retry can be done without touching
// the user's window (wiki #112).
lock().last_error = e;
"push refused (window hung)"
}
};
if BOUNCE_HOLD_NOACTIVATE {
suppress_activation(h);
}
if SUPPRESS_FLASH.load(std::sync::atomic::Ordering::Relaxed) {
focus::stop_flash(hwnd(h));
}
// Hand the activation back to the user's window (restoring what they had);
// without this KiCad keeps the activation and raises itself again.
let user = lock().last_user_fg;
let shell = user != 0 && is_shell_window(user, &mut ExeCache::default());
// ASK, never force (John, 2026-09-21: "just push the kicad window to the background but you
// never force the previous window to the foreground"). One plain SetForegroundWindow so the
// keyboard does not stay in a KiCad window the user cannot see; no thread-input attach, no
// ShowWindow, no BringWindowToTop, never a shell window. If Windows says no, that is the end.
let restored = user != 0 && user != h && !shell && focus::request_foreground(hwnd(user));
lock().bounce_holds.insert(h, now + BOUNCE_HOLD);
log(&format!("[bg] {h}: sent behind ({result}); user window {user} {}.", if restored { "restored" } else { "not restored" }));
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;
let mut flash_tick: u32 = 0;
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)"));
}
}
// 1b. Attention flash: about once a second, clear it on the windows the bridge owns.
flash_tick = flash_tick.wrapping_add(1);
if flash_tick % 8 == 0 && SUPPRESS_FLASH.load(std::sync::atomic::Ordering::Relaxed) {
for h in kicad_hwnds(&mut cache) {
if is_owned_window(h) {
focus::stop_flash(hwnd(h));
}
}
}
// 2. The transition.
let fg = focus::foreground();
// NEVER a shell window. Clicking a taskbar button makes the taskbar the foreground window for
// a moment; recording it here meant the next bounce "restored" it with bring_to_front, which
// attaches to the target's input thread and calls ShowWindow / BringWindowToTop /
// SetForegroundWindow on it. Done to the Windows 11 taskbar that crashed Explorer inside
// Taskbar.View.dll (0xc0000005), twice in a row, the moment John clicked a KiCad taskbar
// button (AdomLapper, 2026-09-21; the same thing killed Explorer on winvm the night before).
if fg != 0 && !is_kicad_exe(&exe_of(fg, &mut cache)) && !is_shell_window(fg, &mut cache) {
lock().last_user_fg = fg;
}
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 };
}
if g.inflight == 0 {
g.verb_baseline = kicad_hwnds(&mut cache).into_iter().collect();
}
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 g.inflight == 0 {
g.last_verb_end = Some(now);
}
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;
}
/// Cancel Windows' attention flash on bridge-owned KiCad windows (setting `activityIndicator`:
/// "progress" and "none" suppress it, "flash" and "both" leave it alone).
static SUPPRESS_FLASH: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
pub fn set_flash_suppressed(on: bool) {
SUPPRESS_FLASH.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// The user's A/B switch (setting `backgroundGuard`, the dashboard's "Keep KiCad in the background").
static GUARD_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); // default off from 1.0.50 (wiki #114)
pub fn enabled() -> bool {
GUARD_ENABLED.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn is_shell_hwnd(h: u64) -> bool {
is_shell_window(h, &mut ExeCache::default())
}
pub fn set_enabled(on: bool) {
let was = GUARD_ENABLED.swap(on, std::sync::atomic::Ordering::Relaxed);
if was != on {
log(&format!("[focus] background guard switched {}", if on { "ON" } else { "OFF: KiCad windows are never pushed" }));
if !on {
allow_activation_all(); // nothing the guard ever set may outlive it
}
}
}
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, fresh: true, ..Default::default() }
}
#[test]
fn an_existing_window_is_never_pushed_whatever_the_bridge_is_doing() {
// A verb in flight, a guard armed, inside the spawn watch, NO click or Alt seen (the poll
// missed it): the window existed before this verb, so it is the user's to raise.
let t = Transition { fresh: false, guards: 2, inflight: 1, spawn_watch: true, ..steal() };
assert_eq!(decide(&t), Verdict::Leave("an existing window, the user's to raise (left alone)"));
// The window the verb is opening right now is still kept behind.
let t = Transition { fresh: true, guards: 1, inflight: 1, ..steal() };
assert!(matches!(decide(&t), Verdict::Bounce(_)));
}
#[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() {
// This test failed silently from 2026-09-12 (the "bounce again during a guard" change) until
// 2026-09-21, because the win/ tests never run in the Linux container. The contract as it
// really is: with nothing armed a window is pushed at most once; while the bridge is opening
// it (guard or verb in flight) it may be pushed again, and `fresh` limits that to the one
// window being opened.
let mut t = steal();
t.already_bounced = true;
assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("already backgrounded once")));
t.guards = 1;
assert!(matches!(decide(&t), Verdict::Bounce(_)));
t.fresh = false;
assert!(matches!(decide(&t), Verdict::Leave(l) if l.starts_with("an existing window")));
}
#[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}");
}
}
}