Clean up lint/LSP across gateway, Android, and desktop (alpha -> stable)

Gateway (gateway-plugin/):
- Fix interactive_setup broken imports: print helpers were imported from the
  wrong hermes module (hermes_cli.config instead of hermes_cli.cli_output) plus
  a non-existent print_code; the try/except swallowed the ImportError so
  `hermes gateway setup` for android always bailed out early.
- Fix release_scoped_lock type error (str | None passed where str required).
- Rewrite empty `except: pass` blocks as contextlib.suppress with rationale.
- Restructure two ambiguous ws_server try blocks (hello-auth, frame loop).
- Ruff cleanup: type annotations, import sorting, line wrapping, magic values
  -> named constants, `raise ... from e`, complexity. Add gateway-plugin/ruff.toml.
- Add pyrightconfig.json so the Python LSP resolves hermes-runtime imports.
- Suppress verified false positives inline (parameterized SQL, column-name
  "secrets", hermes-generated media path).

Android (app/androidApp + app/shared):
- Consolidate launcher icons into a single mipmap-anydpi (minSdk 29 >= 26) with
  the monochrome layer; clears ObsoleteSdkInt + MonochromeLauncherIcon.
- Bump core-splashscreen 1.0.1 -> 1.2.0; pin targetSdk 34 (deliberate).
- Suppress verified findings inline (LAN ws:// default, correct GCM IV usage).

Desktop (app/desktopApp):
- Move the desktop to a Java 21 runtime (org.gradle.java.home) and set the
  desktop jvmTarget to 21 (Android stays JVM 17 / minSdk 29). Fixes the startup
  UnsupportedClassVersionError and restores Markdown renderer 0.44.0.

Tooling/config:
- .pi-lens.json: disable verified-noisy heuristics (documented in docs).
- .gitleaks.toml: allowlist git-ignored false-positive paths.
- docs/18-code-review.md: full findings + verification.

Verified: ruff clean, pyright 0 errors, 64/64 gateway tests, all Kotlin tests,
Android lint 0 issues, Android installed+launched on device, desktop launches
on JDK 21.
This commit is contained in:
ARIA committed 2026-08-21 18:47:03 +02:00
1 parent 9f3f9842c8
commit 678c0344c8
27 files changed
+928 -454

No files matched your search

+51 -47
View File
@@ -24,11 +24,12 @@ Milestone M1.
"""
import asyncio
import contextlib
import logging
import ssl
import time
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
from typing import Any
from websockets.asyncio.server import ServerConnection, serve
from websockets.exceptions import ConnectionClosed
@@ -61,6 +62,9 @@ CLOSE_REPLACED = 4402
CLOSE_RATE_LIMITED = 4403
CLOSE_SHUTDOWN = 1001
# Max length of a client-supplied device_id.
MAX_DEVICE_ID_LEN = 128
class _TokenBucket:
"""Minimal token bucket (stdlib only). One instance per connection."""
@@ -93,9 +97,9 @@ class DeviceConnection:
device_id: str
device_name: str
ws: ServerConnection
caps: Dict[str, Any] = field(default_factory=dict)
fcm_token: Optional[str] = None
ntfy_topic: Optional[str] = None
caps: dict[str, Any] = field(default_factory=dict)
fcm_token: str | None = None
ntfy_topic: str | None = None
connected_at: float = field(default_factory=time.time)
rate_bucket: _TokenBucket = field(
default_factory=lambda: _TokenBucket(INBOUND_RATE_PER_S, INBOUND_BURST)
@@ -108,8 +112,8 @@ class WsServer:
def __init__(self, adapter: Any, devices: DeviceRegistry):
self._adapter = adapter
self._devices = devices
self._server: Optional[Any] = None
self._connections: Dict[str, DeviceConnection] = {}
self._server: Any | None = None
self._connections: dict[str, DeviceConnection] = {}
self._lock = asyncio.Lock()
# ── Lifecycle ─────────────────────────────────────────────────────────
@@ -118,7 +122,7 @@ class WsServer:
"""Bind and start serving. Raises on bind failure (adapter maps it
to a retryable fatal error)."""
adapter = self._adapter
ssl_ctx: Optional[ssl.SSLContext] = None
ssl_ctx: ssl.SSLContext | None = None
if adapter.ws_cert and adapter.ws_key:
try:
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
@@ -144,36 +148,38 @@ class WsServer:
)
except OSError as e:
adapter._set_fatal_error(
"bind_failed", f"WS bind on {adapter.host}:{adapter.port} failed: {e}",
"bind_failed",
f"WS bind on {adapter.host}:{adapter.port} failed: {e}",
retryable=True,
)
raise
scheme = "wss" if ssl_ctx else "ws"
logger.info(
"android: WS server listening on %s://%s:%s/ws",
scheme, adapter.host, adapter.port,
scheme,
adapter.host,
adapter.port,
)
async def stop(self) -> None:
"""Stop serving and close all device sockets."""
if self._server is not None:
self._server.close()
try:
# Best-effort: the server is already closing; a failure here is
# not actionable (nothing left to clean up besides the registry).
with contextlib.suppress(Exception):
await self._server.wait_closed()
except Exception:
pass
self._server = None
for conn in list(self._connections.values()):
try:
# Best-effort: a socket that is already gone needs no handling.
with contextlib.suppress(Exception):
await conn.ws.close(code=CLOSE_SHUTDOWN, reason="gateway shutting down")
except Exception:
pass
self._connections.clear()
# ── Registry ──────────────────────────────────────────────────────────
@property
def connections(self) -> Dict[str, DeviceConnection]:
def connections(self) -> dict[str, DeviceConnection]:
return dict(self._connections)
def has_devices(self) -> bool:
@@ -182,7 +188,7 @@ class WsServer:
def device_ids(self) -> list:
return list(self._connections.keys())
def connection(self, device_id: str) -> Optional[DeviceConnection]:
def connection(self, device_id: str) -> DeviceConnection | None:
return self._connections.get(device_id)
# ── Outbound ──────────────────────────────────────────────────────────
@@ -194,11 +200,12 @@ class WsServer:
data = frame.to_json()
sent = 0
for conn in list(self._connections.values()):
try:
# Best-effort: a dead or stalled socket is skipped (it is
# deregistered on its own close); one slow peer must not starve
# the rest of the broadcast.
with contextlib.suppress(Exception):
await asyncio.wait_for(conn.ws.send(data), timeout=SEND_TIMEOUT_S)
sent += 1
except Exception:
pass
return sent
async def send_to(self, device_id: str, frame: protocol.Frame) -> bool:
@@ -218,11 +225,11 @@ class WsServer:
# 1. hello auth -----------------------------------------------------
try:
raw = await asyncio.wait_for(ws.recv(), timeout=HELLO_TIMEOUT_S)
except asyncio.TimeoutError:
logger.warning("android: dropping socket with no hello (timeout)")
await self._close_quiet(ws, 1000, "no hello")
return
except ConnectionClosed:
except (asyncio.TimeoutError, ConnectionClosed) as e:
if isinstance(e, asyncio.TimeoutError):
logger.warning("android: dropping socket with no hello (timeout)")
await self._close_quiet(ws, 1000, "no hello")
# A peer that vanished before hello needs no further handling.
return
frame = protocol.Frame.from_json(raw)
@@ -238,7 +245,7 @@ class WsServer:
return
device_id = str(payload.get("device_id") or "").strip()
if not device_id or len(device_id) > 128:
if not device_id or len(device_id) > MAX_DEVICE_ID_LEN:
await self._reject(ws, "device_id required")
return
@@ -281,10 +288,9 @@ class WsServer:
self._connections[device_id] = conn
if old is not None:
# Same device re-paired from a new socket: the new one wins.
try:
# Best-effort close of the superseded socket.
with contextlib.suppress(Exception):
await old.ws.close(code=CLOSE_REPLACED, reason="replaced by newer connection")
except Exception:
pass
ack = protocol.hello_ack(
server_caps=self._adapter.server_caps(),
@@ -311,10 +317,11 @@ class WsServer:
# flood doesn't re-trigger the error+close per frame.
if not await self._on_frame(ws, device_id, raw):
break
except ConnectionClosed:
pass
except Exception:
logger.warning("android: frame loop error for %s", device_id, exc_info=True)
except Exception as e:
# A clean disconnect (ConnectionClosed) is the normal path and is
# not worth a warning; anything else is unexpected.
if not isinstance(e, ConnectionClosed):
logger.warning("android: frame loop error for %s", device_id, exc_info=True)
finally:
async with self._lock:
current = self._connections.get(device_id)
@@ -324,7 +331,9 @@ class WsServer:
try:
self._adapter.on_connection_closed(device_id)
except Exception:
logger.warning("android: connection cleanup failed for %s", device_id, exc_info=True)
logger.warning(
"android: connection cleanup failed for %s", device_id, exc_info=True
)
logger.info("android: device disconnected: %s", device_id)
# ── Inbound dispatch ──────────────────────────────────────────────────
@@ -346,14 +355,10 @@ class WsServer:
# close, same pattern as auth rejection.
conn = self._connection_for(ws)
if conn is not None and not conn.rate_bucket.consume():
logger.warning(
"android: inbound rate limit exceeded for %s; closing", device_id
)
logger.warning("android: inbound rate limit exceeded for %s; closing", device_id)
await self._send_quiet(
ws,
protocol.error(
protocol.ERR_RATE_LIMITED, "inbound frame rate limit exceeded"
),
protocol.error(protocol.ERR_RATE_LIMITED, "inbound frame rate limit exceeded"),
)
await self._close_quiet(ws, CLOSE_RATE_LIMITED, "rate limited")
return False
@@ -406,7 +411,7 @@ class WsServer:
# ── Helpers ───────────────────────────────────────────────────────────
def _connection_for(self, ws: ServerConnection) -> Optional[DeviceConnection]:
def _connection_for(self, ws: ServerConnection) -> DeviceConnection | None:
"""The live registry entry for this exact socket (identity match, so
a replaced socket never consumes the new connection's bucket)."""
for conn in self._connections.values():
@@ -415,17 +420,16 @@ class WsServer:
return None
async def _send_quiet(self, ws: ServerConnection, frame: protocol.Frame) -> None:
try:
# "Quiet" by contract: the caller does not care whether the peer was
# still there (e.g. an error frame right before the close).
with contextlib.suppress(Exception):
await ws.send(frame.to_json())
except Exception:
pass
async def _reject(self, ws: ServerConnection, reason: str) -> None:
await self._send_quiet(ws, protocol.error(protocol.ERR_AUTH, reason))
await self._close_quiet(ws, CLOSE_AUTH_FAILED, "auth failed")
async def _close_quiet(self, ws: ServerConnection, code: int, reason: str) -> None:
try:
# "Quiet" by contract: closing an already-closed socket is a no-op.
with contextlib.suppress(Exception):
await ws.close(code=code, reason=reason)
except Exception:
pass