"""Diagnostics: `phone-harness --doctor` walks the ladder for the phone the helpers would drive — the config default, or `--doctor ios|android`.""" import os import shutil import subprocess import tempfile from pathlib import Path def _check(label, ok, hint="", fatal=True): """Print a check; a fatal failure is remembered for the verdict.""" mark = "PASS" if ok else "FAIL" print(f" [{mark}] {label}" + (f" — {hint}" if not ok and hint else "")) if not ok and fatal: _failures.append(label) return ok _failures = [] def run_doctor(platform=None): from . import config platform = (platform or config.get("platform")).lower() print(f"phone-harness doctor — {platform}" f"{'' if platform == config.get('platform') else ' (default is ' + config.get('platform') + ')'}\n") _failures.clear() if platform == "android": _doctor_android() else: _doctor_ios() ok = not _failures print("\nall clear" if ok else "\nfix the FAILs above, then re-run") return 0 if ok else 1 # --- iPhone: pyobjc -> permissions -> Mirroring -> capture -> OCR ----------- def _doctor_ios(): try: import Quartz, Vision, AppKit # noqa: F401 _check("pyobjc frameworks (Quartz, Vision, AppKit)", True) except ImportError as e: _check("pyobjc frameworks", False, f"pip install pyobjc-framework-Quartz pyobjc-framework-Vision " f"pyobjc-framework-Cocoa ({e})") return from ApplicationServices import AXIsProcessTrusted _check("Accessibility permission (taps & keystrokes)", AXIsProcessTrusted(), "System Settings > Privacy & Security > Accessibility: enable your terminal") import Quartz as Q _check("Screen Recording permission (seeing the phone)", bool(Q.CGPreflightScreenCaptureAccess()), "System Settings > Privacy & Security > Screen Recording: enable your terminal") from . import mirror _check(f"{mirror.APP_NAME} installed", Path(mirror.APP_PATH).exists(), "requires macOS Sequoia+ with a paired iPhone") running = mirror.running_app() is not None _check(f"{mirror.APP_NAME} running", running, "will auto-launch on first use — not fatal", fatal=False) win = mirror.find_window() _check("mirroring window found", win is not None, "open iPhone Mirroring once manually to pair the phone") if not win: return with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: path = f.name try: # Never crash here: a missing Screen Recording grant is exactly what # this check exists to report. r = subprocess.run(["screencapture", "-x", "-o", "-l", str(win["id"]), path], capture_output=True) size = os.path.getsize(path) if os.path.exists(path) else 0 good = r.returncode == 0 and size > 20_000 _check(f"window capture works ({size} bytes)", good, "capture failed or is blank — Screen Recording permission " "needs a terminal restart to take effect") if good: from . import ocr n = len(ocr.recognize(path, win)) _check(f"Vision OCR works ({n} text boxes)", True) finally: if os.path.exists(path): os.unlink(path) from . import ios state = ios.IPhone().send("session.state") _check(f"session state: {state}", state == "ready", "an interstitial is up (iPhone in Use / Connect / Mac Locked) — " "clear it on the Mac; lock the iPhone if it says in use", fatal=False) # --- Android: adb -> a phone -> authorised -> awake -> tree ------------------ def _doctor_android(): from . import config adb = str(config.get("android.adb")) if not shutil.which(adb): _check(f"adb found ({adb})", False, "brew install android-platform-tools, or set android.adb to the binary") return _check(f"adb found ({shutil.which(adb)})", True) _check("scrcpy found (optional: live mirror during `android awake`)", bool(shutil.which("scrcpy")), "brew install scrcpy — not required", fatal=False) from . import android phone = android.Android() state = phone.send("session.state") hints = { "no-device": "plug in with USB debugging on and tap Allow, or " "Wireless debugging + `phone-harness android pair CODE`", "unauthorized": "tap Allow on the phone's 'Allow USB debugging?' prompt", "offline": "unplug/replug, or toggle Wireless debugging off and on", "locked": "unlock the phone (`phone-harness android awake` keeps it so)", "no-adb": "adb did not answer", } _check(f"a phone is reachable and ready (state: {state})", state in ("ready", "locked"), hints.get(state, "")) if state == "locked": _check("phone unlocked", False, hints["locked"], fatal=False) if state not in ("ready",): return b = phone.send("screen.bounds") label = android._phone_label(b["id"]) if b else "?" _check(f"talking to {label} ({b['id']}), screen {b['w']}x{b['h']}", bool(b)) try: n = len(phone.send("tree")) _check(f"accessibility tree readable ({n} nodes)", n > 0) except RuntimeError as e: _check("accessibility tree readable", False, str(e)[:120], fatal=False) reg = config.devices_of("android") _check(f"remembered phones: {', '.join(reg['phones']) or 'none'}; primary: " f"{reg['primary'] or 'none'}", True)