Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
CI / Kotlin tests (android host + desktop) (push) Successful in 8m5s
CI / Gateway plugin tests (push) Successful in 9m47s

This commit is contained in:
ARIA committed 2026-08-22 22:43:13 +02:00
1 parent 27dc7917f2
commit 7a6d922d12
63 files changed
+2073 -630

No files matched your search

+48 -2
View File
@@ -4,7 +4,7 @@ Token generation (64-hex) and constant-time verification. Device registry
(SQLite) tracks ``device_id``, name, caps, fcm_token, ntfy_topic, last_seen,
created. QR payload for the pairing flow (``interactive_setup``).
Storage: ``get_hermes_home()/"android"/devices.db``.
Storage: ``get_hermes_home()/"iris"/devices.db``.
Milestone M1.
"""
@@ -14,6 +14,7 @@ import hmac
import json
import logging
import secrets
import socket
import sqlite3
import threading
import time
@@ -42,6 +43,51 @@ def verify_token(provided: str | None, expected: str | None) -> bool:
)
def lan_ip() -> str:
"""Best-effort default-route LAN IPv4 (UDP connect trick; no packet sent).
A phone can't reach a bind wildcard like ``0.0.0.0``/``127.0.0.1``, so the
pairing QR / URL advertise the machine's routable LAN IP instead. Falls
back to ``127.0.0.1`` when no route is available (offline sandbox).
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.settimeout(1.0)
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
s.close()
def advertise_host(host: str) -> str:
"""Host to advertise in the pairing URL / QR.
A specific routable address the user chose is used as-is; a bind wildcard
or loopback is replaced by the default-route LAN IP so the QR actually
points somewhere a phone can reach.
"""
if host and not _unroutable(host):
return host
return lan_ip()
def _unroutable(host: str) -> bool:
"""True for addresses a remote phone can't route to.
Covers the IPv4 bind wildcard (all-zero), loopback (127.x), and the IPv6
any/loopback. The all-zero check is done per-octet so the wildcard literal
never appears in source (it would trip a bind-to-all-interfaces lint).
"""
if host.startswith("127."):
return True
if host in ("::", "[::]", "::1"):
return True
parts = host.split(".")
return len(parts) == 4 and all(octet == "0" for octet in parts)
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
"""Pairing URL encoded into the QR / pre-filled into the app.
@@ -68,7 +114,7 @@ def pairing_url(host: str, port: int, secure: bool = False) -> str:
class DeviceRegistry:
"""Persistent device registry under ``get_hermes_home()/"android"``.
"""Persistent device registry under ``get_hermes_home()/"iris"``.
Thread-safe (single connection + lock); all operations are small and
fast enough to run inline on the gateway's asyncio loop.