src/voxip_kiosk -> src/daynivo_kiosk, with every import, the launchers, install.sh, the Makefile and the manager's gunicorn app path. An upgrade replaces /usr/lib/voxip-kiosk/python as a whole, so the old package disappears; tested by upgrading an installation of the previous commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
4.8 KiB
Python
127 lines
4.8 KiB
Python
"""Minimal Chrome DevTools Protocol client for the local kiosk browser.
|
|
|
|
Chromium listens on 127.0.0.1 only (``--remote-debugging-address``); this
|
|
client refuses any other host. Each call opens a short-lived WebSocket to
|
|
the page target, which keeps the client stateless and robust against
|
|
browser restarts.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import itertools
|
|
import json
|
|
import urllib.request
|
|
from typing import Any
|
|
|
|
DEFAULT_PORT = 9222
|
|
_LOCAL_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
|
|
|
|
|
class CDPError(Exception):
|
|
pass
|
|
|
|
|
|
class CDP:
|
|
def __init__(self, host: str = "127.0.0.1", port: int = DEFAULT_PORT, timeout: float = 5.0):
|
|
if host not in _LOCAL_HOSTS:
|
|
raise ValueError("DevTools is alleen lokaal bereikbaar")
|
|
self.host = host
|
|
self.port = port
|
|
self.timeout = timeout
|
|
self._ids = itertools.count(1)
|
|
|
|
# -- HTTP endpoints -------------------------------------------------
|
|
|
|
def _http_json(self, path: str, method: str = "GET") -> Any:
|
|
req = urllib.request.Request(f"http://{self.host}:{self.port}{path}", method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
return json.loads(resp.read().decode("utf-8"))
|
|
except (OSError, ValueError) as exc:
|
|
raise CDPError(f"DevTools niet bereikbaar: {exc}") from exc
|
|
|
|
def version(self) -> dict[str, Any]:
|
|
return self._http_json("/json/version")
|
|
|
|
def pages(self) -> list[dict[str, Any]]:
|
|
return [t for t in self._http_json("/json/list") if t.get("type") == "page"]
|
|
|
|
def main_page(self) -> dict[str, Any] | None:
|
|
pages = self.pages()
|
|
return pages[0] if pages else None
|
|
|
|
def new_page(self, url: str) -> dict[str, Any]:
|
|
from urllib.parse import quote
|
|
|
|
return self._http_json("/json/new?" + quote(url, safe=""), method="PUT")
|
|
|
|
# -- WebSocket commands ---------------------------------------------
|
|
|
|
def call(self, ws_url: str, method: str, params: dict[str, Any] | None = None, timeout: float | None = None) -> dict[str, Any]:
|
|
try:
|
|
import websocket # websocket-client (python3-websocket)
|
|
except ImportError as exc: # pragma: no cover - dependency missing
|
|
raise CDPError("python3-websocket is niet geïnstalleerd") from exc
|
|
|
|
msg_id = next(self._ids)
|
|
try:
|
|
# Chromium rejects DevTools WebSockets that carry a foreign Origin;
|
|
# we send none at all.
|
|
ws = websocket.create_connection(ws_url, timeout=timeout or self.timeout, suppress_origin=True)
|
|
except Exception as exc:
|
|
raise CDPError(f"Verbinding met DevTools mislukt: {exc}") from exc
|
|
try:
|
|
ws.send(json.dumps({"id": msg_id, "method": method, "params": params or {}}))
|
|
while True:
|
|
reply = json.loads(ws.recv())
|
|
if reply.get("id") == msg_id:
|
|
break
|
|
except Exception as exc:
|
|
raise CDPError(f"DevTools-opdracht {method} mislukt: {exc}") from exc
|
|
finally:
|
|
try:
|
|
ws.close()
|
|
except Exception:
|
|
pass
|
|
if "error" in reply:
|
|
raise CDPError(f"{method}: {reply['error'].get('message', reply['error'])}")
|
|
return reply.get("result", {})
|
|
|
|
def _page_ws(self) -> str:
|
|
page = self.main_page()
|
|
if not page or not page.get("webSocketDebuggerUrl"):
|
|
raise CDPError("Geen browserpagina gevonden")
|
|
return page["webSocketDebuggerUrl"]
|
|
|
|
def current_url(self) -> str | None:
|
|
page = self.main_page()
|
|
return page.get("url") if page else None
|
|
|
|
def navigate(self, url: str) -> None:
|
|
self.call(self._page_ws(), "Page.navigate", {"url": url})
|
|
|
|
def reload(self, ignore_cache: bool = False) -> None:
|
|
self.call(self._page_ws(), "Page.reload", {"ignoreCache": ignore_cache})
|
|
|
|
def responsive(self, ws_url: str | None = None, timeout: float = 5.0) -> bool:
|
|
"""True when the page's renderer still executes JavaScript."""
|
|
try:
|
|
result = self.call(ws_url or self._page_ws(), "Runtime.evaluate", {"expression": "1+1", "returnByValue": True}, timeout=timeout)
|
|
except CDPError:
|
|
return False
|
|
return result.get("result", {}).get("value") == 2
|
|
|
|
def screenshot(self, quality: int = 70) -> bytes:
|
|
result = self.call(self._page_ws(), "Page.captureScreenshot", {"format": "jpeg", "quality": quality}, timeout=15)
|
|
try:
|
|
return base64.b64decode(result["data"])
|
|
except (KeyError, ValueError) as exc:
|
|
raise CDPError("Ongeldige screenshot ontvangen") from exc
|
|
|
|
def close_browser(self) -> None:
|
|
ws_url = self.version().get("webSocketDebuggerUrl")
|
|
if not ws_url:
|
|
raise CDPError("Geen browser-endpoint gevonden")
|
|
self.call(ws_url, "Browser.close")
|