From 82f5112f4b6d733953f85c3ff049d5cf30d48ede Mon Sep 17 00:00:00 2001 From: Wes Sander Date: Tue, 18 Aug 2026 14:59:53 -0400 Subject: [PATCH] viewer: make /api/phone poll configurable (default off); expose /api/config\n\nShort-term mitigation for WDA 'heavy-tail' wedge: viewer no longer polls /api/phone by default. Server exposes phone_poll_seconds via /api/config. Default = 0 (disabled).\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/phone_harness/config.py | 6 ++++++ src/phone_harness/viewer.html | 40 ++++++++++++++++++++++++++++++++++- src/phone_harness/viewer.py | 15 +++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/phone_harness/config.py b/src/phone_harness/config.py index a771b7e..f899e2f 100644 --- a/src/phone_harness/config.py +++ b/src/phone_harness/config.py @@ -67,3 +67,9 @@ def get(key: str, default: str | None = None) -> str | None: MJPEG_FPS = int(get("MJPEG_FPS", "60") or "60") MJPEG_QUALITY = int(get("MJPEG_QUALITY", "70") or "70") MJPEG_SCALE = int(get("MJPEG_SCALE", "50") or "50") + +# Viewer poll for /api/phone: seconds between polls. 0 disables the periodic +# poll (viewer will still call loadPhone() on load). Default 0 avoids the +# viewer accidentally triggering a WDA wedge; operators can enable it in +# .env with VIEWER_PHONE_POLL_SECONDS=10 for a 10s poll. +VIEWER_PHONE_POLL_SECONDS = float(get("VIEWER_PHONE_POLL_SECONDS", "0") or "0") diff --git a/src/phone_harness/viewer.html b/src/phone_harness/viewer.html index 760e904..50d8a99 100644 --- a/src/phone_harness/viewer.html +++ b/src/phone_harness/viewer.html @@ -934,7 +934,19 @@

Phone

setInterval(loadApproval, 1000); // a blocked send must surface fast loadApproval(); loadGate(); - setInterval(loadPhone, 10000); + // Configure periodic polling of /api/phone from server side. Server may + // return 0 to disable the periodic poll (recommended when a device is + // susceptible to WDA heavy-tail wedges). The viewer still calls loadPhone + // once on load so the UI is populated. + fetch('/api/config').then(r => r.json()).then(cfg => { + const s = Number(cfg.phone_poll_seconds) || 0; + if (s > 0) { + setInterval(loadPhone, Math.max(1000, Math.round(s * 1000))); + } + }).catch(() => { + // Fallback: keep the previous 10s poll if config cannot be read. + setInterval(loadPhone, 10000); + }); // Both clamps are measured, so both are wrong the moment the window changes // size. Re-fit on resize only — never on a poll, or the column would thrash. let refit; @@ -1118,6 +1130,12 @@

Phone

// ---- keyboard: type on your keyboard, characters go to the phone ----------- let keyBuf = ''; let keyBusy = false; +const ARROW_KEYS = { + ArrowLeft: '\uE012', ArrowRight: '\uE014', + ArrowUp: '\uE013', ArrowDown: '\uE015' +}; +let arrowBuf = []; +let arrowBusy = false; async function flushKeys() { if (keyBusy || !keyBuf) return; const text = keyBuf; keyBuf = ''; @@ -1130,6 +1148,20 @@

Phone

if (keyBuf) flushKeys(); // send what queued up while we were busy } } +async function sendArrowKey(key) { + arrowBuf.push(key); + if (arrowBusy) return; + arrowBusy = true; + try { + while (arrowBuf.length) { + const next = arrowBuf.shift(); + await fetch('/api/key', {method:'POST', headers:JSON_HDR, + body: JSON.stringify({key: next})}); + } + } finally { + arrowBusy = false; + } +} window.addEventListener('paste', async (ev) => { if (ovOpen) return; if (!inputEnabled || phoneBusy()) return; @@ -1168,6 +1200,12 @@

Phone

} if (ev.ctrlKey || ev.metaKey || ev.altKey) return; // keep browser shortcuts if (phoneBusy()) { ev.preventDefault(); return; } // keys aimed at the pre-action screen + const arrow = ARROW_KEYS[ev.key]; + if (arrow) { + ev.preventDefault(); + sendArrowKey(arrow); + return; + } let ch = null; if (ev.key === 'Enter') ch = '\n'; else if (ev.key === 'Backspace') ch = '\b'; diff --git a/src/phone_harness/viewer.py b/src/phone_harness/viewer.py index f1117ce..65ab0da 100644 --- a/src/phone_harness/viewer.py +++ b/src/phone_harness/viewer.py @@ -563,6 +563,17 @@ def do_GET(self): # noqa: vulture pass _LAST_PHONE = info self._json(info) + elif path == "/api/config": + # Viewer configuration (client-visible). phone_poll_seconds is + # the interval in seconds for the viewer to poll /api/phone. + # 0 means disabled; the viewer still calls loadPhone() once on + # load so the UI shows current state. + try: + self._json( + {"phone_poll_seconds": float(config.VIEWER_PHONE_POLL_SECONDS)} + ) + except Exception: + self._json({"phone_poll_seconds": 0.0}) elif path == "/api/doctor": global _LAST_DOCTOR if _ACTION_LOCK.locked() and _LAST_DOCTOR is not None: @@ -654,6 +665,10 @@ def do_POST(self): # noqa: vulture with _action_slot(): self.client.type_text(str(payload.get("text", ""))) self._json({"ok": True}) + elif path == "/api/key": + with _action_slot(): + self.client.key_press(str(payload["key"])) + self._json({"ok": True}) elif path == "/api/clipboard": text = str(payload.get("text", payload.get("content", ""))) try: