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.
Detect the ADDevelop build: title regex accepts an edition word without version parens (issue 2) #1
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)
@@ -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
Log in to comment.