app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
main
Colby Knox
0.2.41: licence_expired catalog entry + native WM_CLOSE fallback for UIA-refusing OK boxes
5230f68
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
//! Freeze-safe native window/process introspection (adom/altium-bridge#6, adom/adom-bridge#122).
//!
//! The close watchdog's whole job is to act DURING a wedged Altium shutdown, and that is exactly
//! when ab's `desktop_enum_windows` / `process_list` hang or return empty: an empty answer read as
//! "no window" is how the 0.2.8 watchdog missed both freezes on 2026-09-02. This module asks
//! Win32 directly, using ONLY non-messaging calls:
//!
//! * `EnumWindows` walks the shell's window list without touching any window's thread.
//! * `GetClassNameW`, `GetWindowThreadProcessId`, `IsWindowVisible` read cached/kernel state.
//! * NEVER `GetWindowTextW`: it SENDS WM_GETTEXT into the window's (possibly hung) thread and
//! inherits the hang. Titles are deliberately not available here; match by class + pid.
//!
//! Everything else in the bridge keeps going through ab; this module exists solely so the
//! safety path cannot be blinded by the very freeze it guards against.
#[cfg(windows)]
mod imp {
use std::sync::Mutex;
type Hwnd = isize;
#[link(name = "user32")]
extern "system" {
fn EnumWindows(cb: extern "system" fn(Hwnd, isize) -> i32, lparam: isize) -> i32;
fn GetClassNameW(hwnd: Hwnd, buf: *mut u16, len: i32) -> i32;
fn GetWindowThreadProcessId(hwnd: Hwnd, pid: *mut u32) -> u32;
fn IsWindowVisible(hwnd: Hwnd) -> i32;
fn IsHungAppWindow(hwnd: Hwnd) -> i32;
fn IsWindowEnabled(hwnd: Hwnd) -> i32;
fn PostThreadMessageW(tid: u32, msg: u32, wparam: usize, lparam: isize) -> i32;
fn PostMessageW(hwnd: Hwnd, msg: u32, wparam: usize, lparam: isize) -> i32;
}
const WM_QUIT: u32 = 0x0012;
const WM_CLOSE: u32 = 0x0010;
/// Post WM_CLOSE to one window — the dismissal its titlebar X performs, POSTED so it cannot
/// inherit a hang. The dialog fallback lane: some Information boxes carry an OK that exposes
/// no UIA Invoke (measured 2026-09-21 on the expired-licence box), and closing the box IS the
/// acknowledgement.
pub fn post_message_close(hwnd: i64) -> bool {
unsafe { PostMessageW(hwnd as Hwnd, WM_CLOSE, 0, 0) != 0 }
}
/// Ask a UI thread to leave its message loop — Delphi's `Application.Terminate`, reached from
/// outside. POSTED (never sent), so it cannot inherit a hang, and unlike TerminateProcess the
/// app runs its own shutdown: measured 2026-09-21 on AD 26.10.1, Altium exited and RELEASED
/// the On-Demand licence seat (a taskkill strands it). This is the only way out of a modal
/// orphan — see `pid_main_window_disabled`.
pub fn post_quit_to_thread(tid: u32) -> bool {
unsafe { PostThreadMessageW(tid, WM_QUIT, 0, 0) != 0 }
}
/// One visible top-level window: pid, class, hwnd, and whether its UI thread is HUNG. The
/// freeze in #6 leaves the main window PRESENT but non-responding (measured 2026-09-02: X2
/// alive, TDocumentForm still visible, the desktop wedged) — so "gone" is not the signal;
/// "hung" is. `IsHungAppWindow` reports the thread's ghost state WITHOUT sending a message,
/// so it stays truthful during the freeze it detects.
#[derive(Clone, Debug)]
pub struct WinRow {
pub pid: u32,
pub class: String,
pub hwnd: i64,
pub hung: bool,
/// Windows disables EVERY other top-level window of a process while one of them is modal.
/// A disabled main window with no dialog on screen is the "modal orphan" signature.
pub enabled: bool,
/// The window's UI thread, for `post_quit_to_thread`.
pub tid: u32,
}
// EnumWindows wants a plain function pointer; rows are collected through a mutex the
// callback can reach. Enumeration is short (hundreds of windows) and single-threaded per
// call, guarded by CALL_LOCK so two watchdog ticks can never interleave collections.
static CALL_LOCK: Mutex<()> = Mutex::new(());
static ROWS: Mutex<Vec<WinRow>> = Mutex::new(Vec::new());
extern "system" fn on_window(hwnd: Hwnd, _lparam: isize) -> i32 {
unsafe {
if IsWindowVisible(hwnd) == 0 {
return 1; // keep enumerating
}
let mut pid: u32 = 0;
let tid = GetWindowThreadProcessId(hwnd, &mut pid);
if pid == 0 {
return 1;
}
let mut buf = [0u16; 128];
let n = GetClassNameW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
let class = String::from_utf16_lossy(&buf[..n.max(0) as usize]);
let hung = IsHungAppWindow(hwnd) != 0;
if let Ok(mut rows) = ROWS.lock() {
let enabled = IsWindowEnabled(hwnd) != 0;
rows.push(WinRow { pid, class, hwnd: hwnd as i64, hung, enabled, tid });
}
}
1
}
/// Every VISIBLE top-level window as (pid, class). `None` only if the enumeration itself
/// failed — an empty Vec is a real "no windows", which is the distinction ab's lane loses.
pub fn visible_windows() -> Option<Vec<WinRow>> {
let _guard = CALL_LOCK.lock().ok()?;
if let Ok(mut rows) = ROWS.lock() {
rows.clear();
}
let ok = unsafe { EnumWindows(on_window, 0) };
if ok == 0 {
return None;
}
ROWS.lock().ok().map(|r| r.clone())
}
}
#[cfg(windows)]
mod proc_imp {
//! Native process enumeration + termination (kernel32 toolhelp), so the zombie-breaker can
//! see and end a wedged X2 even when ab's process_list is empty or hung mid-freeze.
#[repr(C)]
struct ProcessEntry32W {
dw_size: u32,
cnt_usage: u32,
th32_process_id: u32,
th32_default_heap_id: usize,
th32_module_id: u32,
cnt_threads: u32,
th32_parent_process_id: u32,
pc_pri_class_base: i32,
dw_flags: u32,
sz_exe_file: [u16; 260],
}
#[link(name = "kernel32")]
extern "system" {
fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> isize;
fn Process32FirstW(snap: isize, entry: *mut ProcessEntry32W) -> i32;
fn Process32NextW(snap: isize, entry: *mut ProcessEntry32W) -> i32;
fn CloseHandle(h: isize) -> i32;
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> isize;
fn TerminateProcess(h: isize, code: u32) -> i32;
}
const TH32CS_SNAPPROCESS: u32 = 0x2;
const PROCESS_TERMINATE: u32 = 0x0001;
/// Pids of every process whose image name matches `image` case-insensitively (e.g. "X2.EXE").
/// `None` only if the snapshot itself failed.
pub fn pids_of(image: &str) -> Option<Vec<u32>> {
let want = image.to_ascii_lowercase();
unsafe {
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snap == -1 || snap == 0 {
return None;
}
let mut out = Vec::new();
let mut entry: ProcessEntry32W = std::mem::zeroed();
entry.dw_size = std::mem::size_of::<ProcessEntry32W>() as u32;
let mut ok = Process32FirstW(snap, &mut entry);
while ok != 0 {
let name_len = entry.sz_exe_file.iter().position(|&c| c == 0).unwrap_or(260);
let name = String::from_utf16_lossy(&entry.sz_exe_file[..name_len]).to_ascii_lowercase();
if name == want {
out.push(entry.th32_process_id);
}
ok = Process32NextW(snap, &mut entry);
}
CloseHandle(snap);
Some(out)
}
}
/// Terminate `pid`. Returns whether the terminate call succeeded.
pub fn kill(pid: u32) -> bool {
unsafe {
let h = OpenProcess(PROCESS_TERMINATE, 0, pid);
if h == 0 {
return false;
}
let ok = TerminateProcess(h, 1);
CloseHandle(h);
ok != 0
}
}
}
#[cfg(windows)]
pub use imp::{post_message_close, post_quit_to_thread, visible_windows, WinRow};
#[cfg(windows)]
pub use proc_imp::{kill, pids_of};
#[cfg(not(windows))]
pub fn pids_of(_image: &str) -> Option<Vec<u32>> {
None
}
#[cfg(not(windows))]
pub fn kill(_pid: u32) -> bool {
false
}
#[cfg(not(windows))]
pub fn post_message_close(_hwnd: i64) -> bool {
false
}
#[cfg(not(windows))]
pub fn post_quit_to_thread(_tid: u32) -> bool {
false
}
#[cfg(not(windows))]
#[derive(Clone, Debug)]
pub struct WinRow {
pub pid: u32,
pub class: String,
pub hwnd: i64,
pub hung: bool,
pub enabled: bool,
pub tid: u32,
}
#[cfg(not(windows))]
pub fn visible_windows() -> Option<Vec<WinRow>> {
None // non-Windows builds (tests on the Linux build container) have no window session
}
/// Does `pid` own a visible top-level window of Altium's main-window class? The main document
/// frame is `TDocumentForm` (measured on AD26 Designer and ADDevelop alike); during shutdown it
/// is destroyed well before X2.EXE exits, which is the closing signature the watchdog keys on.
pub fn pid_has_main_window(pid: u32, rows: &[WinRow]) -> bool {
rows.iter().any(|w| w.pid == pid && w.class == "TDocumentForm")
}
/// Is `pid`'s main window present but NON-RESPONDING? This is the actual freeze signature on
/// this build (2026-09-02, live): the frame stays visible while its thread hangs and the
/// desktop wedges around it. The 0.2.8-era "window gone" heuristic never fires in this state.
pub fn pid_main_window_hung(pid: u32, rows: &[WinRow]) -> bool {
rows.iter().any(|w| w.pid == pid && w.class == "TDocumentForm" && w.hung)
}
/// Is `pid`'s main window DISABLED? Windows disables a process's other top-level windows while
/// one of them is modal, so with no dialog on screen this means Altium is stuck inside a modal
/// loop whose dialog is gone — the app accepts no input, no menu, no Alt+F4, and every `-R`
/// hand-off bounces off "another instance ... is busy" (measured 2026-09-21 on AD 26.10.1, after
/// a close-time save prompt: the single licence seat stayed held and nothing but a kill looked
/// possible). `close` escapes it with `post_quit_to_thread`.
pub fn pid_main_window_disabled(pid: u32, rows: &[WinRow]) -> bool {
rows.iter().any(|w| w.pid == pid && w.class == "TDocumentForm" && !w.enabled)
}
/// The UI thread that owns `pid`'s main window.
pub fn pid_ui_thread(pid: u32, rows: &[WinRow]) -> Option<u32> {
rows.iter().find(|w| w.pid == pid && w.class == "TDocumentForm").map(|w| w.tid)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pid_matching_is_class_scoped() {
let rows = vec![
WinRow { pid: 10, class: "TDocumentForm".into(), hwnd: 1, hung: false, enabled: true, tid: 100 },
WinRow { pid: 11, class: "#32770".into(), hwnd: 2, hung: false, enabled: false, tid: 101 },
];
assert!(pid_has_main_window(10, &rows));
assert!(!pid_has_main_window(11, &rows), "a dialog is not a main window");
assert!(!pid_has_main_window(12, &rows));
}
#[test]
fn modal_orphan_is_the_disabled_main_window() {
let rows = vec![
WinRow { pid: 10, class: "TDocumentForm".into(), hwnd: 1, hung: false, enabled: false, tid: 100 },
WinRow { pid: 11, class: "TDocumentForm".into(), hwnd: 2, hung: false, enabled: true, tid: 101 },
];
assert!(pid_main_window_disabled(10, &rows), "disabled main window = stuck in a modal loop");
assert!(!pid_main_window_disabled(11, &rows), "a healthy Altium is never disabled");
assert_eq!(pid_ui_thread(10, &rows), Some(100));
assert_eq!(pid_ui_thread(99, &rows), None);
}
}