Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.\
Expand Down Expand Up @@ -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: <your-kiosk-user-id>
::1: <your-kiosk-user-id>
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": "\<url>"}
Expand Down
12 changes: 12 additions & 0 deletions haoskiosk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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+
Expand Down
10 changes: 10 additions & 0 deletions haoskiosk/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
87 changes: 87 additions & 0 deletions haoskiosk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.\
Expand Down Expand Up @@ -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: <your-kiosk-user-id>
::1: <your-kiosk-user-id>
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": "\<url>"}
Expand Down
120 changes: 120 additions & 0 deletions haoskiosk/cdp_auth.py
Original file line number Diff line number Diff line change
@@ -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)<n:
c=s.recv(n-len(b))
if not c: break
b+=c
return b
def ws_connect(path):
s=socket.create_connection(("127.0.0.1",PORT),timeout=5)
k=base64.b64encode(os.urandom(16)).decode()
s.sendall((f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{PORT}\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Key: {k}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
r=b""
while b"\r\n\r\n" not in r: r+=s.recv(4096)
return s
def ws_send(s,o):
d=json.dumps(o).encode(); n=len(d); m=os.urandom(4); h=bytearray([0x81])
if n<126: h.append(0x80|n)
elif n<65536: h.append(0x80|126); h+=struct.pack(">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)
2 changes: 2 additions & 0 deletions haoskiosk/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,)
Expand Down
Loading