diff --git a/README.md b/README.md index 810dc98..e9f4ef5 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,21 @@ Delay in seconds to allow login page to load.\ Level of zoom with `100` being 100%.\ (Default: 100%) +### Browser + +Selects the browser engine used to render the dashboard. + +- `luakit` (default) - the original lightweight WebKitGTK browser. Recommended + for Raspberry Pi and most hardware; existing installs are unchanged. +- `chromium` - a full Chromium engine driven over the DevTools protocol. Use + this if Luakit fails to render on your hardware. In particular, WebKitGTK's + threaded compositor can hard-hang some Intel GPUs (e.g. Iris Xe - the kernel + logs an `i915` GPU HANG and the display freezes); Chromium renders cleanly on + that hardware and also handles WebRTC/H.264 camera streams well. + +See the [Chromium Browser Engine](#chromium-browser-engine) section for how it +works, the auth requirement, and how to extend it. (Default: `luakit`) + ### Browser Refresh Time between browser refreshes. Set to `0` to disable.\ @@ -305,6 +320,78 @@ E.g., `sudo docker exec -it addon_haoskiosk bash` ______________________________________________________________________ +## Chromium Browser Engine + +Setting `Browser` to `chromium` swaps the Luakit/WebKitGTK engine for a full +Chromium engine. This exists because WebKitGTK's threaded compositor hard-hangs +some Intel GPUs (notably Iris Xe / Gen12): the kernel logs an `i915` GPU HANG and +the display freezes, with no Luakit setting that avoids it. Chromium drives the +same GPU without issue. + +### What changes in chromium mode + +- **Launch** - Chromium starts in `--kiosk` on X11/Ozone with GPU rasterization + and `--remote-debugging-port=9222` (the DevTools/CDP port). Flags live in the + `case "$BROWSER"` block in `run.sh`. +- **Control** - Chromium has no Luakit `-n` single-instance trick or xdotool + keybindings, so the REST API talks to it over CDP instead: `launch_url` issues + `Page.navigate` and `refresh_browser` issues `Page.reload` (see + `_cdp_command()` in `rest_server.py`). The Luakit paths are untouched. +- **First-run prompt** - a managed policy written to + `/etc/chromium/policies/managed/haoskiosk.json` disables the "Sign in to + Chromium" / sync nag so the kiosk boots straight to the dashboard. + +### Helpers + +Two stdlib-only Python daemons start alongside Chromium. Both read `HA_URL`, +`HA_DASHBOARD`, and `REMOTE_DEBUG_PORT` from the environment that `run.sh` +exports: + +- **`cdp_auth.py`** - self-healing login. If Chromium lands on the HA login page, + it mints a session token over the trusted loopback and injects it into + `localStorage`, then navigates to the dashboard. It is a no-op once the profile + is authenticated (the persistent `--user-data-dir` keeps the token across + restarts). +- **`kiosk_overlay.py`** - injects a fixed, always-visible "back to dashboard" + button into the DOM whenever Chromium is on a non-dashboard page (a game, or an + external site like Google Maps/Earth). It is composited in-page over CDP, so it + works even on third-party pages without a second X window. + +### Auth requirement (important) + +The hands-off login in `cdp_auth.py` currently relies on the **`trusted_networks` +auth provider** trusting the loopback address. With that in place the kiosk +authenticates itself from a cold profile with no keyboard. Add it to +`configuration.yaml`, for example: + +```yaml +homeassistant: + auth_providers: + - type: homeassistant # keep first so normal password login still works + - type: trusted_networks + trusted_networks: + - 127.0.0.1/32 + - ::1 + trusted_users: + 127.0.0.1: + ::1: + allow_bypass_login: true +``` + +Without `trusted_networks`, Chromium reaches the login page and stops there: a +username/password **form-fill fallback is not yet implemented** and is the main +open design question for this feature. The `ha_username` / `ha_password` options +are accepted but unused on the chromium path today. + +### Extending + +- Change kiosk flags: edit the `chromium)` arm of the `case "$BROWSER"` block in + `run.sh`. +- Add a new kiosk-out target: nothing special is needed - `kiosk_overlay.py` + shows the back button on any URL that is not the dashboard. +- Restyle the back button: edit the inline `style` string in `kiosk_overlay.py`. +- Add new CDP-driven controls: follow `_cdp_command()` in `rest_server.py`. + ## REST APIs ### launch_url {"url": "\"} diff --git a/haoskiosk/CHANGELOG.md b/haoskiosk/CHANGELOG.md index f40501e..3ea9272 100644 --- a/haoskiosk/CHANGELOG.md +++ b/haoskiosk/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +- Added a `browser` option to choose the rendering engine: `luakit` (default, + unchanged) or `chromium`. +- Chromium is driven over the DevTools protocol - `launch_url` and + `refresh_browser` use CDP instead of the luakit `-n`/xdotool paths. +- Added `cdp_auth.py` (self-healing login via the `trusted_networks` loopback) + and `kiosk_overlay.py` (in-page back-to-dashboard button on kiosk-out pages). +- Added a Chromium managed policy to suppress the first-run sign-in prompt. +- Motivation: WebKitGTK (luakit) hard-hangs some Intel GPUs (e.g. Iris Xe - + kernel `i915` GPU HANG); Chromium renders cleanly on that hardware. + ## v1.3.2 - April 2026 - Added explicit BUILD_FROM location to Dockerfile for ha core 2026.04+ diff --git a/haoskiosk/Dockerfile b/haoskiosk/Dockerfile index e65dd10..7418756 100644 --- a/haoskiosk/Dockerfile +++ b/haoskiosk/Dockerfile @@ -69,6 +69,14 @@ RUN apk update && apk add --no-cache \ #=============================================================================== +##### Optional: bake in Chromium at build time (opt-in via BUILD_BROWSER=chromium). +##### Default 'luakit' pulls nothing in, so the luakit image is unchanged. +ARG BUILD_BROWSER=luakit +RUN if [ "$BUILD_BROWSER" = "chromium" ]; then \ + apk add --no-cache chromium \ + && rm -rf /var/cache/apk/*; \ + fi + ##### Set the display variable ENV DISPLAY=:0 @@ -82,6 +90,8 @@ RUN chmod a+x /run.sh COPY mouse_touch_inputs.py gesture_commands.json / COPY rest_server.py / +COPY cdp_auth.py / +COPY kiosk_overlay.py / #### Patches # Need to patch 'unique_instance.lua' so that new instance urls overwrite active url rather than add new tab diff --git a/haoskiosk/README.md b/haoskiosk/README.md index 810dc98..e9f4ef5 100644 --- a/haoskiosk/README.md +++ b/haoskiosk/README.md @@ -112,6 +112,21 @@ Delay in seconds to allow login page to load.\ Level of zoom with `100` being 100%.\ (Default: 100%) +### Browser + +Selects the browser engine used to render the dashboard. + +- `luakit` (default) - the original lightweight WebKitGTK browser. Recommended + for Raspberry Pi and most hardware; existing installs are unchanged. +- `chromium` - a full Chromium engine driven over the DevTools protocol. Use + this if Luakit fails to render on your hardware. In particular, WebKitGTK's + threaded compositor can hard-hang some Intel GPUs (e.g. Iris Xe - the kernel + logs an `i915` GPU HANG and the display freezes); Chromium renders cleanly on + that hardware and also handles WebRTC/H.264 camera streams well. + +See the [Chromium Browser Engine](#chromium-browser-engine) section for how it +works, the auth requirement, and how to extend it. (Default: `luakit`) + ### Browser Refresh Time between browser refreshes. Set to `0` to disable.\ @@ -305,6 +320,78 @@ E.g., `sudo docker exec -it addon_haoskiosk bash` ______________________________________________________________________ +## Chromium Browser Engine + +Setting `Browser` to `chromium` swaps the Luakit/WebKitGTK engine for a full +Chromium engine. This exists because WebKitGTK's threaded compositor hard-hangs +some Intel GPUs (notably Iris Xe / Gen12): the kernel logs an `i915` GPU HANG and +the display freezes, with no Luakit setting that avoids it. Chromium drives the +same GPU without issue. + +### What changes in chromium mode + +- **Launch** - Chromium starts in `--kiosk` on X11/Ozone with GPU rasterization + and `--remote-debugging-port=9222` (the DevTools/CDP port). Flags live in the + `case "$BROWSER"` block in `run.sh`. +- **Control** - Chromium has no Luakit `-n` single-instance trick or xdotool + keybindings, so the REST API talks to it over CDP instead: `launch_url` issues + `Page.navigate` and `refresh_browser` issues `Page.reload` (see + `_cdp_command()` in `rest_server.py`). The Luakit paths are untouched. +- **First-run prompt** - a managed policy written to + `/etc/chromium/policies/managed/haoskiosk.json` disables the "Sign in to + Chromium" / sync nag so the kiosk boots straight to the dashboard. + +### Helpers + +Two stdlib-only Python daemons start alongside Chromium. Both read `HA_URL`, +`HA_DASHBOARD`, and `REMOTE_DEBUG_PORT` from the environment that `run.sh` +exports: + +- **`cdp_auth.py`** - self-healing login. If Chromium lands on the HA login page, + it mints a session token over the trusted loopback and injects it into + `localStorage`, then navigates to the dashboard. It is a no-op once the profile + is authenticated (the persistent `--user-data-dir` keeps the token across + restarts). +- **`kiosk_overlay.py`** - injects a fixed, always-visible "back to dashboard" + button into the DOM whenever Chromium is on a non-dashboard page (a game, or an + external site like Google Maps/Earth). It is composited in-page over CDP, so it + works even on third-party pages without a second X window. + +### Auth requirement (important) + +The hands-off login in `cdp_auth.py` currently relies on the **`trusted_networks` +auth provider** trusting the loopback address. With that in place the kiosk +authenticates itself from a cold profile with no keyboard. Add it to +`configuration.yaml`, for example: + +```yaml +homeassistant: + auth_providers: + - type: homeassistant # keep first so normal password login still works + - type: trusted_networks + trusted_networks: + - 127.0.0.1/32 + - ::1 + trusted_users: + 127.0.0.1: + ::1: + allow_bypass_login: true +``` + +Without `trusted_networks`, Chromium reaches the login page and stops there: a +username/password **form-fill fallback is not yet implemented** and is the main +open design question for this feature. The `ha_username` / `ha_password` options +are accepted but unused on the chromium path today. + +### Extending + +- Change kiosk flags: edit the `chromium)` arm of the `case "$BROWSER"` block in + `run.sh`. +- Add a new kiosk-out target: nothing special is needed - `kiosk_overlay.py` + shows the back button on any URL that is not the dashboard. +- Restyle the back button: edit the inline `style` string in `kiosk_overlay.py`. +- Add new CDP-driven controls: follow `_cdp_command()` in `rest_server.py`. + ## REST APIs ### launch_url {"url": "\"} diff --git a/haoskiosk/cdp_auth.py b/haoskiosk/cdp_auth.py new file mode 100644 index 0000000..1a182cf --- /dev/null +++ b/haoskiosk/cdp_auth.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# Self-healing kiosk auth for the chromium browser option. +# Waits for the DevTools port; if chromium is on a login page, mints a session +# token over the trusted loopback (trusted_networks) and injects it into +# localStorage so the dashboard loads authenticated. No-op when already authed. +import socket, base64, os, json, struct, time, urllib.request, urllib.parse, sys + +HA = os.environ.get("HA_URL", "http://127.0.0.1:8123").rstrip("/") +DASH = os.environ.get("HA_DASHBOARD", "").lstrip("/") +PORT = int(os.environ.get("REMOTE_DEBUG_PORT", "9222")) +DASH_URL = HA + "/" + DASH if DASH else HA + "/" +CLIENT = HA + "/" + +def log(*a): print("[cdp_auth]", *a, flush=True) + +def targets(): + return json.load(urllib.request.urlopen(f"http://127.0.0.1:{PORT}/json", timeout=3)) + +page = None +for _ in range(60): + try: + page = next((t for t in targets() if t.get("type") == "page"), None) + if page: break + except Exception: pass + time.sleep(1) +if not page: + log("DevTools never came up; giving up"); sys.exit(0) + +def current_url(): + try: + return next((t.get("url","") for t in targets() if t.get("type")=="page"), "") + except Exception: return "" + +need = False +for _ in range(8): + u = current_url() + if "/auth/authorize" in u or "/auth/login" in u: + need = True; break + time.sleep(1) +if not need: + log("already authenticated; nothing to do"); sys.exit(0) +log("login page detected; authenticating via trusted loopback") + +def post(url, data, form=False): + if form: + body = urllib.parse.urlencode(data).encode(); ct = "application/x-www-form-urlencoded" + else: + body = json.dumps(data).encode(); ct = "application/json" + return json.load(urllib.request.urlopen(urllib.request.Request(url, body, {"Content-Type": ct}), timeout=5)) + +try: + flow = post(HA+"/auth/login_flow", {"client_id":CLIENT,"handler":["trusted_networks",None],"redirect_uri":CLIENT}) + code = flow.get("result") + if not code: + log("trusted_networks unavailable; form-fill fallback not implemented"); sys.exit(0) + tok = post(HA+"/auth/token", {"grant_type":"authorization_code","code":code,"client_id":CLIENT}, form=True) +except Exception as e: + log("token mint failed:", e); sys.exit(0) + +hass = { + "access_token": tok["access_token"], "token_type": tok.get("token_type","Bearer"), + "refresh_token": tok["refresh_token"], "expires_in": tok.get("expires_in",1800), + "ha_auth_provider": tok.get("ha_auth_provider","trusted_networks"), + "expires": int(time.time()*1000) + tok.get("expires_in",1800)*1000, + "clientId": CLIENT, "hassUrl": HA, +} + +def recvn(s,n): + b=b"" + while len(b)H",n) + else: h.append(0x80|127); h+=struct.pack(">Q",n) + h+=m; s.sendall(bytes(h)+bytes(b^m[i%4] for i,b in enumerate(d))) +def ws_recv(s): + b0=recvn(s,1) + if not b0: return None + op=b0[0]&0x0f; l=recvn(s,1)[0]&0x7f + if l==126: l=struct.unpack(">H",recvn(s,2))[0] + elif l==127: l=struct.unpack(">Q",recvn(s,8))[0] + p=recvn(s,l) + if op in (0x8,0x9): return (("close" if op==0x8 else "ping"), p) + return ("text", p.decode("utf-8","replace")) +def cmd(s,i,method,params=None): + ws_send(s,{"id":i,"method":method,"params":params or {}}) + while True: + r=ws_recv(s) + if r is None or r[0]=="close": return None + if r[0]!="text": continue + try: o=json.loads(r[1]) + except: continue + if o.get("id")==i: return o + +try: + path = page["webSocketDebuggerUrl"].split(str(PORT),1)[1] + s = ws_connect(path) + cmd(s,1,"Page.enable") + cmd(s,2,"Page.navigate",{"url":CLIENT}) + time.sleep(3) + expr = "localStorage.setItem('hassTokens', %s); 'ok'" % json.dumps(json.dumps(hass)) + cmd(s,3,"Runtime.evaluate",{"expression":expr,"returnByValue":True}) + cmd(s,4,"Page.navigate",{"url":DASH_URL}) + log("authenticated; navigated to", DASH_URL) +except Exception as e: + log("CDP inject failed:", e) +sys.exit(0) diff --git a/haoskiosk/config.yaml b/haoskiosk/config.yaml index 30d1262..7b39e47 100644 --- a/haoskiosk/config.yaml +++ b/haoskiosk/config.yaml @@ -70,6 +70,7 @@ privileged: options: ha_url: "http://localhost:8123" ha_dashboard: "" + browser: luakit login_delay: 1.0 zoom_level: 100 browser_refresh: 600 @@ -110,6 +111,7 @@ schema: ha_password: password ha_url: str ha_dashboard: str? + browser: list(luakit|chromium) login_delay: float(0,) zoom_level: int(10,1000) browser_refresh: int(0,) diff --git a/haoskiosk/kiosk_overlay.py b/haoskiosk/kiosk_overlay.py new file mode 100644 index 0000000..25ba769 --- /dev/null +++ b/haoskiosk/kiosk_overlay.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# On-screen "back to dashboard" button for chromium kiosk-out pages. +# Watches the DevTools port; when chromium is on a non-dashboard page (a game or +# an external site like Maps/Earth) it injects a fixed, always-visible back button +# into the DOM. Tapping it returns to the dashboard. No-op on the dashboard itself. +# Consistent across every kiosk-out target; composited in-page (no extra X window). +import socket, base64, os, json, struct, time, urllib.request + +HA = os.environ.get("HA_URL", "http://127.0.0.1:8123").rstrip("/") +DASH = os.environ.get("HA_DASHBOARD", "").lstrip("/") +PORT = int(os.environ.get("REMOTE_DEBUG_PORT", "9222")) +DASH_URL = HA + "/" + DASH if DASH else HA + "/" + +INJECT = """(function(){ + var onDash = location.href.indexOf('%URL%') === 0; + var b = document.getElementById('haoskiosk-back'); + if (onDash) { if (b) b.remove(); return 'dashboard'; } + if (b) return 'exists'; + b = document.createElement('div'); + b.id = 'haoskiosk-back'; + b.setAttribute('style','position:fixed;top:50%;left:18px;transform:translateY(-50%);z-index:2147483647;'+ + 'background:#1c1c1c;color:#fff;font:700 22px/1 system-ui,Segoe UI,Arial,sans-serif;'+ + 'padding:18px 24px;display:flex;align-items:center;gap:12px;cursor:pointer;'+ + 'letter-spacing:.04em;box-shadow:0 3px 14px rgba(0,0,0,.55);user-select:none;'+ + '-webkit-tap-highlight-color:transparent;'); + b.innerHTML = '‹DASHBOARD'; + b.addEventListener('click', function(){ location.href = '%URL%'; }); + (document.body || document.documentElement).appendChild(b); + return 'added'; +})();""".replace('%URL%', DASH_URL) + +def recvn(s,n): + b=b"" + while len(b)H",n) + else: h.append(0x80|127); h+=struct.pack(">Q",n) + h+=m; s.sendall(bytes(h)+bytes(b^m[i%4] for i,b in enumerate(d))) +def ws_recv(s): + b0=recvn(s,1) + if not b0: return None + op=b0[0]&0x0f; l=recvn(s,1)[0]&0x7f + if l==126: l=struct.unpack(">H",recvn(s,2))[0] + elif l==127: l=struct.unpack(">Q",recvn(s,8))[0] + p=recvn(s,l) + if op in (0x8,0x9): return (("close" if op==0x8 else "ping"), p) + return ("text", p.decode("utf-8","replace")) +def evaluate(wsurl, expr): + s=ws_connect(wsurl.split(str(PORT),1)[1]) + try: + ws_send(s,{"id":1,"method":"Runtime.evaluate","params":{"expression":expr,"returnByValue":True}}) + while True: + r=ws_recv(s) + if r is None or r[0]=="close": return None + if r[0]!="text": continue + o=json.loads(r[1]) + if o.get("id")==1: return o + finally: + try: s.close() + except Exception: pass + +def page_info(): + ts=json.load(urllib.request.urlopen(f"http://127.0.0.1:{PORT}/json", timeout=3)) + p=next((t for t in ts if t.get("type")=="page"), None) + return (p.get("url","") if p else "", p.get("webSocketDebuggerUrl","") if p else "") + +# wait for the debug port, then watch + inject +for _ in range(60): + try: + if page_info()[1]: break + except Exception: pass + time.sleep(1) +while True: + try: + url, wsurl = page_info() + if wsurl and url.startswith(("http://","https://")) and not url.startswith(DASH_URL): + res = evaluate(wsurl, INJECT) + val = ((res or {}).get('result') or {}).get('result', {}).get('value') + if val and val != 'exists': + print('[overlay]', val, url, flush=True) + except Exception: + pass + time.sleep(1) diff --git a/haoskiosk/rest_server.py b/haoskiosk/rest_server.py index 91ac329..68206b5 100644 --- a/haoskiosk/rest_server.py +++ b/haoskiosk/rest_server.py @@ -67,6 +67,7 @@ from datetime import datetime from functools import wraps from typing import Any, Awaitable, cast, Callable, Final, Literal, TypedDict, TypeVar +import aiohttp #type: ignore[import-not-found] #pylint: disable=import-error from aiohttp import web #type: ignore[import-not-found] #pylint: disable=import-error #------------------------------------------------------------------------------- @@ -92,6 +93,8 @@ SHORT_TIMEOUT: int = 5 # Timeout used for simple commands DEFAULT_LAUNCH_URL = f"{(os.getenv('HA_URL') or 'about:blank').rstrip('/')}/{os.getenv('HA_DASHBOARD') or ''}".strip('/') +BROWSER: str = os.getenv("BROWSER", "luakit").lower() +CDP_PORT: int = int(os.getenv("REMOTE_DEBUG_PORT", "9222")) # --------------------------------------------------------------------------- # @@ -458,19 +461,70 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: } ### URL & Refresh +async def _cdp_command(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """Drive the chromium DevTools endpoint (navigate/reload) over the debug port.""" + base = f"http://127.0.0.1:{CDP_PORT}" + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=5)) as session: + async with session.get(f"{base}/json") as resp: + page_targets = await resp.json() + page = next((t for t in page_targets if t.get("type") == "page"), None) + if not page or "webSocketDebuggerUrl" not in page: + raise RuntimeError("no chromium page target on the debug port") + async with session.ws_connect(page["webSocketDebuggerUrl"]) as ws: + await ws.send_json({"id": 1, "method": method, "params": params or {}}) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + payload = json.loads(msg.data) + if payload.get("id") == 1: + return payload + return {} + + +async def _cdp_hard_refresh() -> None: + """Full hard refresh for chromium. HA serves /local/ with max-age 31d, so a + plain ignoreCache reload is defeated by the frontend service worker; clear the + HTTP cache + service workers + cache storage first, then reload. One WS session.""" + base = f"http://127.0.0.1:{CDP_PORT}" + origin = (os.getenv("HA_URL") or "http://127.0.0.1:8123").rstrip("/") + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=8)) as session: + async with session.get(f"{base}/json") as resp: + targets = await resp.json() + page = next((t for t in targets if t.get("type") == "page"), None) + if not page or "webSocketDebuggerUrl" not in page: + raise RuntimeError("no chromium page target on the debug port") + async with session.ws_connect(page["webSocketDebuggerUrl"]) as ws: + async def cmd(mid: int, method: str, params: dict[str, Any] | None = None) -> None: + await ws.send_json({"id": mid, "method": method, "params": params or {}}) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT and json.loads(msg.data).get("id") == mid: + return + await cmd(1, "Network.enable") + await cmd(2, "Network.clearBrowserCache") + await cmd(3, "Storage.clearDataForOrigin", + {"origin": origin, "storageTypes": "service_workers,cache_storage"}) + await cmd(4, "Page.enable") + await cmd(5, "Page.reload", {"ignoreCache": True}) + + @register_function("launch_url", optional=["url"], validators={"url": is_valid_url}) async def handle_launch_url(data: Payload) -> dict[str, Any]: """Launch browser with given URL.""" url = str(data["url"]) if data.get("url") else DEFAULT_LAUNCH_URL if url != "about:blank" and not url.startswith(("http://", "https://")): url = "http://" + url - asyncio.create_task(execute_command(["luakit", "-n", url], log_prefix="launch_url", allow_command=True)) # Run in the background + if BROWSER in ("chromium", "chromium-browser"): + await _cdp_command("Page.navigate", {"url": url}) + else: + asyncio.create_task(execute_command(["luakit", "-n", url], log_prefix="launch_url", allow_command=True)) # Run in the background result = {"success": True, "stdout": "", "stderr": "", "returncode": 0} return {"success": result["success"], "result": result} @register_function("refresh_browser") async def handle_refresh_browser(data: Payload) -> dict[str, Any]: # pylint: disable=unused-argument - """Send Ctrl+R to refresh browser.""" + """Refresh the browser: CDP reload for chromium, Ctrl+R for luakit.""" + if BROWSER in ("chromium", "chromium-browser"): + await _cdp_hard_refresh() + return {"success": True} result = await execute_command( ["xdotool", "key", "--clearmodifiers", "ctrl+r"], timeout=SHORT_TIMEOUT, log_prefix="refresh_browser", allow_command=True) return {"success": result["success"]} diff --git a/haoskiosk/run.sh b/haoskiosk/run.sh index da38135..09686ec 100755 --- a/haoskiosk/run.sh +++ b/haoskiosk/run.sh @@ -82,7 +82,6 @@ trap cleanup HUP INT QUIT ABRT TERM EXIT ################################################################################ #### Variables -BROWSER="luakit" BROWSER_FLAGS= ################################################################################ @@ -128,6 +127,37 @@ load_config_var HA_DASHBOARD "" load_config_var LOGIN_DELAY 1.0 load_config_var ZOOM_LEVEL 100 load_config_var BROWSER_REFRESH 600 +load_config_var BROWSER luakit + +#### Per-browser launch flags + process-liveness match pattern +case "$BROWSER" in + chromium) + if command -v chromium >/dev/null 2>&1; then + BROWSER="chromium" + elif command -v chromium-browser >/dev/null 2>&1; then + BROWSER="chromium-browser" + fi + # Suppress first-run / sign-in / promo screens for unattended kiosk use + mkdir -p /etc/chromium/policies/managed + cat > /etc/chromium/policies/managed/haoskiosk.json << 'KIOSK_POLICY' +{ + "BrowserSignin": 0, + "SyncDisabled": true, + "MetricsReportingEnabled": false, + "DefaultBrowserSettingEnabled": false, + "PromotionalTabsEnabled": false, + "SearchEngineChoiceScreenEnabled": false +} +KIOSK_POLICY + BROWSER_FLAGS="--kiosk --ozone-platform=x11 --touch-events=enabled --ignore-gpu-blocklist --enable-gpu-rasterization --enable-zero-copy --enable-features=VaapiVideoDecoder --no-sandbox --no-first-run --no-default-browser-check --disable-search-engine-choice-screen --password-store=basic --user-data-dir=/data/chromium --remote-debugging-port=9222" + BROWSER_MATCH="chromium" + ;; + *) + BROWSER="luakit" + BROWSER_FLAGS="" + BROWSER_MATCH="^luakit " + ;; +esac load_config_var SCREEN_TIMEOUT 600 # Default to 600 seconds load_config_var OUTPUT_NUMBER 1 # Which *CONNECTED* Physical video output to use (Defaults to 1) #NOTE: By only considering *CONNECTED* output, this maximizes the chance of finding an output @@ -140,6 +170,13 @@ load_config_var MAP_TOUCH_INPUTS true load_config_var CURSOR_TIMEOUT 5 # Default to 5 seconds load_config_var KEYBOARD_LAYOUT us load_config_var ONSCREEN_KEYBOARD false +# Chromium sandboxes its renderer and stays silent to assistive tech until +# accessibility is explicitly forced. Onboard auto-show needs those events, so +# add the flag ONLY when the onscreen keyboard is enabled (keeps the a11y-tree +# CPU/RAM cost off systems that do not use it, e.g. keyboard-less Pi kiosks). +if [ "$BROWSER" = "chromium" ] && [ "$ONSCREEN_KEYBOARD" = true ]; then + BROWSER_FLAGS="$BROWSER_FLAGS --force-renderer-accessibility=complete" +fi load_config_var SAVE_ONSCREEN_CONFIG true load_config_var XORG_CONF "" load_config_var XORG_APPEND_REPLACE append @@ -160,7 +197,14 @@ fi ################################################################################ ### GTK and DBUS-related environment variables to improve stability -export NO_AT_BRIDGE=1 # Stop GTK from touching at-spi bus +# at-spi bridge: luakit/GTK is stabler with it OFF, but Onboard auto-show on +# Chromium REQUIRES the bridge ON to receive text-field focus events. Only keep +# NO_AT_BRIDGE=1 when NOT relying on Chromium + onscreen keyboard together. +if [ "$BROWSER" = "chromium" ] && [ "$ONSCREEN_KEYBOARD" = true ]; then + unset NO_AT_BRIDGE # Allow at-spi so Onboard auto-show hears focus events +else + export NO_AT_BRIDGE=1 # Stop GTK from touching at-spi bus (luakit stability) +fi export GTK_USE_PORTAL=0 # Disable portals export GIO_USE_VFS=local # Local-only GIO export DBUS_SESSION_BUS_TIMEOUT=5000 # Shorten DBUS timeouts @@ -670,9 +714,18 @@ if [ "$DEBUG_MODE" != true ]; then $BROWSER ${BROWSER_FLAGS:+$BROWSER_FLAGS} "$HA_URL/$HA_DASHBOARD" & bashio::log.info "Launching $BROWSER browser(PID=$!): $HA_URL/$HA_DASHBOARD" + # Chromium: self-healing auth -- inject a session token via the debug port + # so the kiosk lands on the dashboard authenticated (no on-screen login). + case "$BROWSER" in + chromium|chromium-browser) + ( python3 /cdp_auth.py >/tmp/cdp_auth.log 2>&1 || true ) & + ( python3 /kiosk_overlay.py >/tmp/kiosk_overlay.log 2>&1 || true ) & + ;; + esac + count=0 while true; do # Wait for all browser processes to exit - if pgrep -f -- "^$BROWSER " > /dev/null 2>&1; then + if pgrep -f -- "$BROWSER_MATCH" > /dev/null 2>&1; then count=0 else count=$((count + 1)) diff --git a/haoskiosk/translations/en.yaml b/haoskiosk/translations/en.yaml index 2f007c2..f958862 100644 --- a/haoskiosk/translations/en.yaml +++ b/haoskiosk/translations/en.yaml @@ -23,6 +23,11 @@ configuration: zoom_level: name: "Zoom Level" description: "Zoom factor 100 = 100% [Default: 100]" + browser: + name: "Browser" + description: | + Rendering engine: luakit (default) or chromium. Use chromium if luakit + fails to render on your hardware, e.g. Intel Iris Xe. [Default: luakit] browser_refresh: name: "Browser Refresh" description: |