← All Pull Requests

Detect the ADDevelop build: title regex accepts an edition word without version parens (issue 2) #1

Closed opened by Colby Knox 2026-08-31

Fixes issue 2. Base: the v0.1.8 release zip's handlers/altium_detect.py byte for byte (the page repo does not carry handlers/, so this PR ADDS the file; if your working tree is ahead, hand-lift the two hunks). Hunk 1: _TITLE_RE becomes Altium Designer(?: )?(?: ())?(?: - )?$ so 'Home Page - Altium Designer Develop' classifies. Hunk 2: the main-window guard requires the match to carry a version, an edition, or a licence tail, or to be the whole title, so a bare mid-string 'Altium Designer' in a dialog cannot classify as main. Offline-proven against six titles: both live main shapes (retail with parens + licence, Develop without), Develop with a licence tail, a bare document title, and the X2/Confirm negatives, all passing; py_compile clean. Live on Colby-ROG2 the same patch is deployed into the bridge cache and verified end to end (running true, agent bootstrap unblocked); numbers in issue 2.

Diff Skip to comments (3)

--- a/handlers/altium_detect.py+++ b/handlers/altium_detect.py@@ -0,0 +1,254 @@+"""altium_detect.py — where is Altium Designer, is it running, which windows are its own.++Detection facts (measured on AD 26.9.1, 2026-08-29):+  * install root  C:\\Program Files\\Altium\\AD<nn>\\X2.EXE   (one dir per major version)+  * process name  X2.exe; the main window class is TDocumentForm and its title ends+    with "- Altium Designer (26.9.1) - <licence state>" ("No License" / "Trial" / …)+  * a second X2.EXE launched with a -R<Server>:<Command>(…) argument FORWARDS the+    command to the running instance and exits (single-instance); it only spawns a+    new instance when none is running.+  * modal script errors are owned dialogs titled exactly "X2" (VCL TMessageForm) —+    see dialogs.py.++Never read a user-profile path from ONE env var (ab strips the bridge's env; see+kicad-bridge's LOCALAPPDATA lesson) — derive from several and take the first hit.+"""+from __future__ import annotations++import glob+import os+import re+import subprocess+import sys+from pathlib import Path++IS_WINDOWS = sys.platform == "win32"+EXE_NAME = "X2.EXE"+PROC_NAME = "X2"+HOST_APP = "Altium Designer"++# Two live title shapes (both measured): the retail build ends+# "... - Altium Designer (26.9.1)[ - <licence state>]", while the ADDevelop+# build ends "... - Altium Designer Develop[ - <licence state>]" with NO+# version parens (Colby-ROG2, 26.9.1.9). Requiring the parens made the main+# window unclassifiable on Develop: running read False with the window+# plainly there, which also refused the agent bootstrap (altium-bridge#2).+_TITLE_RE = re.compile(r"Altium Designer(?: (?P<edition>[A-Z][A-Za-z]+))?(?: \((?P<ver>[\d.]+)\))?(?: - (?P<lic>.+))?$")+++def _program_files_roots() -> list[Path]:+    roots = []+    for var in ("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"):+        v = os.environ.get(var)+        if v:+            roots.append(Path(v))+    for fixed in (r"C:\Program Files", r"C:\Program Files (x86)"):+        roots.append(Path(fixed))+    seen, out = set(), []+    for r in roots:+        k = str(r).lower()+        if k not in seen:+            seen.add(k)+            out.append(r)+    return out+++def _user_profile_candidates() -> list[Path]:+    c = []+    for var in ("USERPROFILE",):+        v = os.environ.get(var)+        if v:+            c.append(Path(v))+    try:+        c.append(Path.home())+    except Exception:+        pass+    return c+++def appdata_dir() -> Path | None:+    """%APPDATA%\\Altium — where prefs/logs live. Tolerates a stripped env."""+    for var in ("APPDATA",):+        v = os.environ.get(var)+        if v and (Path(v) / "Altium").exists():+            return Path(v) / "Altium"+    for up in _user_profile_candidates():+        p = up / "AppData" / "Roaming" / "Altium"+        if p.exists():+            return p+    return None+++def _version_from_dir(d: Path) -> str:+    m = re.search(r"AD(\d+)$", d.name)+    return m.group(1) if m else d.name+++def _exe_file_version(exe: Path) -> str | None:+    if not IS_WINDOWS:+        return None+    try:+        import ctypes+        from ctypes import wintypes+        ver = ctypes.windll.version+        size = ver.GetFileVersionInfoSizeW(str(exe), None)+        if not size:+            return None+        buf = ctypes.create_string_buffer(size)+        ver.GetFileVersionInfoW(str(exe), 0, size, buf)+        lp = ctypes.c_void_p()+        ln = wintypes.UINT()+        ver.VerQueryValueW(buf, "\\", ctypes.byref(lp), ctypes.byref(ln))+        class VS_FIXEDFILEINFO(ctypes.Structure):+            _fields_ = [("dwSignature", wintypes.DWORD), ("dwStrucVersion", wintypes.DWORD),+                        ("dwFileVersionMS", wintypes.DWORD), ("dwFileVersionLS", wintypes.DWORD),+                        ("dwProductVersionMS", wintypes.DWORD), ("dwProductVersionLS", wintypes.DWORD)]+        ffi = ctypes.cast(lp, ctypes.POINTER(VS_FIXEDFILEINFO)).contents+        ms, ls = ffi.dwFileVersionMS, ffi.dwFileVersionLS+        return f"{ms >> 16}.{ms & 0xFFFF}.{ls >> 16}.{ls & 0xFFFF}"+    except Exception:+        return None+++def detect_installs() -> list[dict]:+    """Every Altium install on the box, newest major first."""+    found = {}+    for root in _program_files_roots():+        for exe in glob.glob(str(root / "Altium" / "AD*" / EXE_NAME)):+            p = Path(exe)+            d = p.parent+            found[str(d).lower()] = {+                "installed": True,+                "base_dir": str(d),+                "exe": str(p),+                "system_dir": str(d / "System"),+                "major": _version_from_dir(d),+                "fileVersion": _exe_file_version(p),+            }+    installs = list(found.values())+    installs.sort(key=lambda i: int(i["major"]) if str(i["major"]).isdigit() else 0, reverse=True)+    return installs+++def running_pids() -> list[int]:+    """PIDs of every X2.exe — via a Toolhelp32 snapshot (ctypes, no subprocess).+    The earlier `tasklist` probe took 7+ s under load and timed out, which made+    every verb report 'Altium not running' while its window was plainly there."""+    if not IS_WINDOWS:+        return []+    import ctypes+    from ctypes import wintypes+    TH32CS_SNAPPROCESS = 0x00000002++    class PROCESSENTRY32W(ctypes.Structure):+        _fields_ = [("dwSize", wintypes.DWORD), ("cntUsage", wintypes.DWORD),+                    ("th32ProcessID", wintypes.DWORD), ("th32DefaultHeapID", ctypes.POINTER(ctypes.c_ulong)),+                    ("th32ModuleID", wintypes.DWORD), ("cntThreads", wintypes.DWORD),+                    ("th32ParentProcessID", wintypes.DWORD), ("pcPriClassBase", ctypes.c_long),+                    ("dwFlags", wintypes.DWORD), ("szExeFile", ctypes.c_wchar * 260)]++    k32 = ctypes.windll.kernel32+    snap = k32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)+    if snap == wintypes.HANDLE(-1).value or snap == -1:+        return []+    pids = []+    try:+        pe = PROCESSENTRY32W()+        pe.dwSize = ctypes.sizeof(PROCESSENTRY32W)+        ok = k32.Process32FirstW(snap, ctypes.byref(pe))+        while ok:+            if pe.szExeFile.lower() == EXE_NAME.lower():+                pids.append(int(pe.th32ProcessID))+            ok = k32.Process32NextW(snap, ctypes.byref(pe))+    finally:+        k32.CloseHandle(snap)+    return pids+++# ---- windows owned by Altium ------------------------------------------------++def _enum_windows_for_pids(pids: set[int]) -> list[dict]:+    """Top-level windows whose owning process is one of `pids` (ctypes, no deps)."""+    if not IS_WINDOWS or not pids:+        return []+    import ctypes+    from ctypes import wintypes+    user32 = ctypes.windll.user32+    res = []++    @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)+    def cb(hwnd, _):+        pid = wintypes.DWORD()+        user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))+        if pid.value in pids:+            length = user32.GetWindowTextLengthW(hwnd)+            buf = ctypes.create_unicode_buffer(length + 1)+            user32.GetWindowTextW(hwnd, buf, length + 1)+            cls = ctypes.create_unicode_buffer(256)+            user32.GetClassNameW(hwnd, cls, 256)+            rect = wintypes.RECT()+            user32.GetWindowRect(hwnd, ctypes.byref(rect))+            res.append({+                "hwnd": int(hwnd), "pid": int(pid.value),+                "title": buf.value, "className": cls.value,+                "visible": bool(user32.IsWindowVisible(hwnd)),+                "enabled": bool(user32.IsWindowEnabled(hwnd)),+                "rect": {"left": rect.left, "top": rect.top, "right": rect.right, "bottom": rect.bottom,+                         "width": rect.right - rect.left, "height": rect.bottom - rect.top},+            })+        return True++    user32.EnumWindows(cb, 0)+    return res+++def altium_windows() -> list[dict]:+    """Every visible top-level window owned by an X2.exe, classified."""+    wins = _enum_windows_for_pids(set(running_pids()))+    out = []+    for w in wins:+        if not w["visible"] or (w["rect"]["width"] < 20 and w["rect"]["height"] < 20):+            continue+        t = w["title"] or ""+        m = _TITLE_RE.search(t)+        kind = "other"+        # The edition word alone ("Altium Designer Develop") is a real main+        # title; a bare "Altium Designer" mid-string could be a dialog, so+        # require the match to carry a version, an edition, or a licence tail,+        # or to BE the whole title.+        if m and (m.group("ver") or m.group("edition") or m.group("lic") or m.group(0) == t):+            kind = "main"+            w["adVersion"] = m.group("ver")+            w["licenceState"] = (m.group("lic") or "").strip() or None+            w["document"] = t.split(" - Altium Designer")[0].strip() if " - Altium Designer" in t else None+        elif t == "X2":+            kind = "script_error_modal"+        elif w["className"] in ("TMessageForm", "#32770"):+            kind = "dialog"+        w["kind"] = kind+        out.append(w)+    # main window first, then dialogs, then the rest+    order = {"main": 0, "script_error_modal": 1, "dialog": 2, "other": 3}+    out.sort(key=lambda w: order.get(w["kind"], 9))+    return out+++def main_window() -> dict | None:+    for w in altium_windows():+        if w["kind"] == "main":+            return w+    return None+++def licence_state() -> dict:+    """Parsed from the main window title (the cheapest truthful source)."""+    mw = main_window()+    if not mw:+        return {"running": False, "licensed": None, "state": None}+    st = mw.get("licenceState")+    licensed = None+    if st:+        licensed = st.lower() not in ("no license", "no licence", "viewer")+    return {"running": True, "licensed": licensed, "state": st, "adVersion": mw.get("adVersion"),+            "hwnd": mw["hwnd"], "pid": mw["pid"]}+

Comments

Colby Knox · 1mo ago

Scope clarification after the live run: the detector fix itself is verified on the box (running true where it read false, diagnostics/status/readiness now agree, screenshots work). The agent bootstrap now PASSES the gate this PR unblocks, but then halts on a separate template compile error on the ADDevelop build (Undeclared identifier: PostProcess, issue 3), so end-to-end agent liveness is not yet achieved there. This PR stands on its own; issue 3 is the remaining arm.

John Lauer · 1mo ago

Landed in v0.1.9 by hand-lifting both hunks onto the working tree (which was ahead of the release zip, as you flagged) — the edition-word regex and the main-window guard are in handlers/altium_detect.py verbatim in shape, proven against the six title cases. Closing rather than merging only because the PR's base was the zip-extracted file; the repo now carries the full source tree so future PRs can merge directly. Credit to you in the 0.1.9 changelog.

John Lauer · 1mo ago

Shipped in v0.1.9 — both your hunks are in handlers/altium_detect.py (edition-aware _TITLE_RE + the whole-title/version/edition/licence guard), proven against your six titles. Hand-lifted onto the working tree per your note, since the repo was behind the zip. Root cause of that: the page repo wasn't carrying handlers/ at all — now fixed, the full source tree (handlers/agent/extension/tools) is in the repo, so your next PR targets real source. Full credit to you for the diagnosis and regex. Closing in favor of the 0.1.9 commit.

Log in to comment.