main
John Lauer v0.1.37: lone-OK dialogs carry no decision - reaper clears them without needing the text c56dc01 1mo ago
"""dialogs.py — find, classify, read and dismiss Altium's dialogs BY PID, in the background.

Why by PID: a title match is a prefilter at best (a browser tab titled "Altium …"
would match). Every window here is confirmed to belong to an X2.exe process.

Classes seen on AD 26.9.1 (2026-08-29):
  TMessageForm  "X2"            script runtime error: "Unknown identifier … Continue execution?"  [OK|Cancel]
  TMessageForm  "Error"         script compile error / "Another script executing now"            [OK]
  TMessageForm  "Information"   "There is another instance of running, but it is busy"           [OK]  (owned by a HELPER X2, not the main one)
  TMessageForm  "Confirm"       save / overwrite prompts                                          [Yes|No|Cancel]
  #32770        Win32 common dialogs (file open/save)

Dismissal is a posted button click (BM_CLICK to the button hwnd found by text) —
no focus steal, no cursor. UIA via ab is the fallback (server.py has the callback).
"""
from __future__ import annotations

import ctypes
import sys
import time
from ctypes import wintypes

from handlers import altium_detect

IS_WINDOWS = sys.platform == "win32"
DIALOG_CLASSES = ("TMessageForm", "#32770", "TForm", "TMessageDialog", "TForm_RunScriptParameters")

# title/body substring -> (category, safe default action, meaning)
CATALOG = [
    ("another script executing", "script_busy", "OK",
     "A -R script request arrived while the agent loop (or another script) was resident. Dismiss; route work through the agent."),
    ("continue execution?", "script_runtime_error", "Cancel",
     "A script hit a runtime error. Cancel stops it cleanly; OK continues past the error."),
    ("undeclared identifier", "script_compile_error", "OK",
     "The script failed to compile; the engine is not halted after OK."),
    ("unknown identifier", "script_compile_error", "OK", "Compile error in RunScriptText text (statements only, no Var)."),
    ("select item to run", "run_script_picker", "Cancel",
     "Altium could not resolve the -R RunScript target (unit did not compile, or wrong ProcName) and opened its Run Script picker. Cancel it, fix the script."),
    ("another instance of", "second_instance_busy", "OK",
     "A helper X2 could not hand its -R request to the running instance because that instance is busy (a modal is open)."),
    ("do you want to save", "save_prompt", None, "Unsaved changes on close. Decide: Yes saves, No discards. Never default this."),
    ("save changes", "save_prompt", None, "Unsaved changes. Decide explicitly."),
    ("could not start vault explorer", "altium_extension_broken", "OK",
     "Altium's own VaultExplorer extension fails to load at startup. NOT caused by the bridge: the "
     "extension is correctly registered and its payload is complete (VaultExplorer.dll + 114 files), "
     "and Altium's private .NET 8.0.15 runtime loads fine. Informational — dismissing it costs "
     "nothing, and the standing reaper does so automatically."),
    ("file type not recognised", "unsupported_document", "OK",
     "Altium was handed a path it does not open. From the bridge that means a bad open call (the "
     "bridge now refuses unsupported extensions up front); at STARTUP it means Altium is restoring a "
     "document from the last session that it can no longer open. Safe to dismiss."),
    ("file type not recognized", "unsupported_document", "OK", "Same as 'not recognised' (US spelling)."),
    ("license maximum usage reached", "licence_seat_stranded", "OK",
     "The seat is held by an instance that never released it — almost always an Altium that was "
     "KILLED rather than closed. The licence page will still show Seats 0/1, which looks free and "
     "is not. FIX: close every other Altium POLITELY (altium_close, never process_kill), then "
     "altium_license {action:'use_license'}. Waiting does not clear it."),
    ("license renewal failed", "licence_seat_stranded", "OK",
     "Altium could not renew the On-Demand lease. Same cause and fix as 'maximum usage reached': a "
     "killed instance is still holding the seat. This dialog RE-FIRES on a timer until the seat is "
     "reclaimed, so clearing it is not the fix."),
    ("cannot focus a disabled or invisible window", "altium_internal", "Send and Close",
     "Altium's own crash-report dialog, usually raised when a window's state changed while it was "
     "focusing a document view (e.g. a restore/minimize cycle during a background capture). Its "
     "only button SENDS a report to Altium — try the title-bar X first if the user's data matters."),
    ("unsaved changes", "save_prompt", None,
     "Names the document(s) at risk — READ IT. On a shared machine the document may belong to "
     "ANOTHER AI thread; Save preserves their work, Don't Save destroys it. Never default this."),
    ("license", "licence", None, "Licence prompt — read it; usually informational."),
    ("trial", "licence", None, "Trial notice."),
]


def _text_of(hwnd: int) -> str:
    """All Static/label text under a dialog (WM_GETTEXT on children), joined."""
    if not IS_WINDOWS:
        return ""
    user32 = ctypes.windll.user32
    parts = []

    @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
    def cb(h, _):
        n = user32.GetWindowTextLengthW(h)
        if n:
            buf = ctypes.create_unicode_buffer(n + 1)
            user32.GetWindowTextW(h, buf, n + 1)
            cls = ctypes.create_unicode_buffer(64)
            user32.GetClassNameW(h, cls, 64)
            parts.append((cls.value, buf.value))
        return True

    user32.EnumChildWindows(hwnd, cb, 0)
    texts = [t for c, t in parts if c.lower() not in ("button", "tbutton", "txpbutton")]
    return " ".join(t.strip() for t in texts if t.strip())


def _uia_text_of(hwnd: int) -> str:
    """Altium PAINTS its dialog messages instead of using Static controls, so WM_GETTEXT returns
    nothing but the BUTTON labels — 'Could not start Vault Explorer…' came back as just "OK", every
    such dialog classified as `unknown`, and the standing reaper (correctly) refused to touch it.
    UIA can see the painted text, so fall back to it whenever the local read is useless."""
    try:
        import caller_identity
        r = caller_identity.ad_callback("desktop_ui_text", {"hwnd": hwnd}, on_behalf=False, timeout=8) or {}
    except Exception:
        return ""
    v = r.get("text") or r.get("texts") or ""
    if isinstance(v, list):
        v = " ".join(str(x) for x in v if x)
    return str(v or "")


def _body_of(hwnd: int, buttons: list) -> str:
    """The dialog's MESSAGE, with the button labels stripped — what classification needs."""
    body = _text_of(hwnd)
    labels = {str((b or {}).get("text") or "").strip().lower() for b in (buttons or [])}
    stripped = " ".join(w for w in body.split() if w.strip().lower() not in labels).strip()
    if len(stripped) < 12:            # nothing but button labels: ask UIA
        ui = _uia_text_of(hwnd)
        if len(ui) > len(stripped):
            return ui
    return body




def _buttons_of(hwnd: int) -> list[dict]:
    if not IS_WINDOWS:
        return []
    user32 = ctypes.windll.user32
    out = []

    @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
    def cb(h, _):
        cls = ctypes.create_unicode_buffer(64)
        user32.GetClassNameW(h, cls, 64)
        # Altium's OK is a WinXPButtons.TXPBitBtn (measured) — match btn|button
        if "button" in cls.value.lower() or "btn" in cls.value.lower():
            n = user32.GetWindowTextLengthW(h)
            buf = ctypes.create_unicode_buffer(n + 1)
            user32.GetWindowTextW(h, buf, n + 1)
            out.append({"hwnd": int(h), "text": buf.value.replace("&", ""), "className": cls.value,
                        "enabled": bool(user32.IsWindowEnabled(h))})
        return True

    user32.EnumChildWindows(hwnd, cb, 0)
    return out


def classify(title: str, body: str) -> dict:
    hay = f"{title} {body}".lower()
    for needle, cat, action, meaning in CATALOG:
        if needle in hay:
            return {"category": cat, "safeAction": action, "meaning": meaning}
    return {"category": "unknown", "safeAction": None,
            "meaning": "Not in the catalog — read the text and decide; never blind-dismiss."}


def scan(main_pid_only: bool = False) -> list[dict]:
    """Every dialog window owned by any X2.exe (helpers included, flagged).

    SCOPED to the selected instance when one is set (John, 2026-09-01): with Designer and Designer
    Develop both running, an unscoped sweep lets one AI thread see — and `dismiss_dialogs` CLEAR —
    a modal belonging to the other thread's Altium."""
    wins = altium_detect.altium_windows()
    try:
        from handlers import agent as _agent
        sel = _agent.current_instance()
    except Exception:
        sel = None
    if sel and sel.get("pid"):
        wins = [w for w in wins if w.get("pid") == sel["pid"]]
    main = next((w for w in wins if w["kind"] == "main"), None)
    main_pid = main["pid"] if main else None
    out = []
    main_disabled = bool(main) and not main.get("enabled", True)
    for w in wins:
        if w["kind"] == "main":
            continue
        # Any enabled top-level window while the MAIN window is disabled IS the modal,
        # whatever its class (measured: TForm_RunScriptParameters "Select Item To Run").
        modal_by_state = main_disabled and w.get("enabled") and w["pid"] == main_pid and w["className"] not in ("TClientBarControl", "TThemedNotificationPopup", "TAnimatedNotificationControl")
        if w["className"] not in DIALOG_CLASSES and w["kind"] not in ("dialog", "script_error_modal") and not modal_by_state:
            continue
        _btns = _buttons_of(w["hwnd"])
        body = _body_of(w["hwnd"], _btns)
        d = {
            "hwnd": w["hwnd"], "pid": w["pid"], "title": w["title"], "className": w["className"],
            "body": body, "buttons": _btns,
            "ownedByMainInstance": (main_pid is not None and w["pid"] == main_pid),
            "rect": w["rect"],
        }
        d.update(classify(w["title"], body))
        if main_pid_only and not d["ownedByMainInstance"]:
            continue
        out.append(d)
    return out


def press(hwnd: int, button_text: str) -> dict:
    """Click a dialog button by its text — background, two ways:
    1. BM_CLICK posted to the button hwnd (TXPBitBtn / Button classes);
    2. ab's UIA InvokePattern (desktop_ui_click) — Altium's TMessageForm exposes
       'OK'/'Cancel' as invokable Buttons even when Win32 posting is ignored."""
    if not IS_WINDOWS:
        return {"success": False, "error": "windows only"}
    user32 = ctypes.windll.user32
    for b in _buttons_of(hwnd):
        if b["text"].strip().lower() == button_text.strip().lower():
            BM_CLICK = 0x00F5
            user32.PostMessageW(b["hwnd"], BM_CLICK, 0, 0)
            time.sleep(0.4)
            if not user32.IsWindow(hwnd):
                return {"success": True, "clicked": b["text"], "buttonHwnd": b["hwnd"], "via": "BM_CLICK"}
            break
    try:
        import caller_identity
        r = caller_identity.ad_callback("desktop_ui_click", {"hwnd": hwnd, "name": button_text, "role": "button"})
        time.sleep(0.4)
        if not user32.IsWindow(hwnd):
            return {"success": True, "clicked": button_text, "via": "uia", "ab": r}
        return {"success": False, "error": f"dialog {hwnd} still open after UIA click", "ab": r,
                "buttons": [b["text"] for b in _buttons_of(hwnd)]}
    except Exception as e:
        return {"success": False, "error": f"no button '{button_text}' on hwnd {hwnd} ({e})",
                "buttons": [b["text"] for b in _buttons_of(hwnd)]}


def close(hwnd: int) -> dict:
    """WM_CLOSE = the dialog's Cancel/X path. Background."""
    if not IS_WINDOWS:
        return {"success": False, "error": "windows only"}
    WM_CLOSE = 0x0010
    ctypes.windll.user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)
    return {"success": True, "posted": "WM_CLOSE", "hwnd": hwnd}


# ---- the standing reaper (John, 2026-09-01: "always pre-empt these from ever showing up") ------
#
# Clearing a dialog AFTER a verb notices it is not pre-emption — the user has already seen it. This
# runs continuously from bridge start and presses the catalog's SAFE action the moment one of our
# known nuisances appears, whether or not any verb is running. Altium's own startup warnings
# (VaultExplorer, "File type not recognised") are exactly the case: no verb is in flight when they
# appear, so nothing else would ever catch them.
#
# HARD RULES:
#   * only dialogs whose CATALOG entry declares a safeAction — never `unknown`, never a save prompt
#     (safeAction None), never anything naming a document
#   * every press is recorded, and surfaced by altium_dialog_log / altium_status
#   * one press per hwnd; a dialog that re-fires (License Renewal Failed) is logged once per hwnd so
#     the log stays readable

_REAPED: list = []
_REAPED_HWNDS: set = set()
_REAPER_STOP = None


def reaped_log(limit: int = 25) -> list:
    return _REAPED[-limit:]


def _reap_once() -> int:
    n = 0
    for d in scan():
        act = (d.get("safeAction") or d.get("classification", {}).get("safeAction")
               if isinstance(d.get("classification"), dict) else d.get("safeAction"))
        if not act:
            c = classify(d.get("title") or "", d.get("body") or "")
            act, cat = c.get("safeAction"), c.get("category")
        else:
            cat = d.get("category")
        # Altium PAINTS its dialog messages: they are in neither WM_GETTEXT nor the UIA tree (the
        # subtree of the VaultExplorer warning is literally Window "Warning" -> panes -> Button
        # "OK"). So text classification cannot see them. What CAN be trusted is the button set:
        #
        #   A dialog whose ONLY button is OK carries NO DECISION. Dismissing it can acknowledge,
        #   never choose. Every dialog that asks something of the user has >= 2 buttons — a save
        #   prompt (Save / Don't Save / Cancel), a licence chooser (Choose a license / Continue), a
        #   confirm (Yes / No). So a lone OK is safe to press even when the text is unreadable.
        labels = [str((b or {}).get("text") or "").strip().lower() for b in (d.get("buttons") or [])]
        lone_ok = labels == ["ok"]
        if not act and lone_ok:
            act, cat = "OK", "single_ok_acknowledgement"
        if not act or cat in (None, "unknown", "save_prompt"):
            continue
        if cat != "single_ok_acknowledgement" and len(labels) > 1 and act not in labels:
            continue  # the catalog's action is not even on this dialog — do not improvise
        hw = d.get("hwnd")
        if hw in _REAPED_HWNDS:
            continue
        try:
            import caller_identity
            r = caller_identity.ad_callback("desktop_ui_click",
                                            {"hwnd": hw, "name": act, "role": "button"},
                                            on_behalf=False, timeout=8) or {}
            ok = bool(r.get("success"))
        except Exception:
            ok = False
        _REAPED_HWNDS.add(hw)
        _REAPED.append({"at": time.time(), "title": d.get("title"), "category": cat,
                        "pressed": act, "ok": ok,
                        "note": "pre-empted by the standing reaper — the user never had to deal with it"})
        del _REAPED[:-200]
        n += 1
    return n


def start_reaper(poll_sec: float = 1.5):
    """Start the standing reaper. Idempotent."""
    global _REAPER_STOP
    if _REAPER_STOP is not None:
        return False
    import threading
    _REAPER_STOP = threading.Event()

    def _loop():
        while not _REAPER_STOP.is_set():
            try:
                _reap_once()
            except Exception:
                pass
            _REAPER_STOP.wait(poll_sec)

    threading.Thread(target=_loop, daemon=True, name="altium-dialog-reaper").start()
    return True