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

+38 -26
View File
@@ -21,6 +21,7 @@ Milestone M4.
"""
import asyncio
import contextlib
import hashlib
import logging
import os
@@ -31,7 +32,6 @@ import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Optional, Tuple
from gateway.platforms.base import (
_looks_like_image,
@@ -54,9 +54,11 @@ _SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
# Magic-byte containers that are unambiguously audio (vs video-in-same-box).
_AUDIO_CONTAINERS = {"m4a", "ogg", "flac", "wav", "mp3", "aac"}
_VIDEO_CONTAINERS = {"mp4", "webm"}
# Longest file extension we trust from the client (e.g. ".webm").
_MAX_EXT_LEN = 6
# Extension -> MIME for outbound offers (the app picks a player/viewer from it).
_EXT_TO_MIME: Dict[str, str] = {
_EXT_TO_MIME: dict[str, str] = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
@@ -93,7 +95,7 @@ _EXT_TO_MIME: Dict[str, str] = {
}
# MIME -> extension for inbound caching (the cache helpers take an ext).
_MIME_TO_EXT: Dict[str, str] = {
_MIME_TO_EXT: dict[str, str] = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
@@ -139,7 +141,7 @@ def ext_for_mime(mime: str, filename: str, default: str) -> str:
if ext:
return ext
file_ext = os.path.splitext(filename or "")[1].lower()
if file_ext and len(file_ext) <= 6:
if file_ext and len(file_ext) <= _MAX_EXT_LEN:
return file_ext
return default
@@ -190,7 +192,7 @@ class UploadSession:
mime: str,
filename: str,
declared_size: int,
request_id: Optional[int],
request_id: int | None,
max_bytes: int,
tmp_dir: Path,
):
@@ -242,14 +244,12 @@ class UploadSession:
def close(self) -> None:
"""Discard the session and remove the temp file."""
try:
# Best-effort cleanup: a file that is already gone (or a handle that
# is already closed) needs no further handling.
with contextlib.suppress(Exception):
self._fh.close()
except Exception:
pass
try:
with contextlib.suppress(OSError):
os.unlink(self.tmp_path)
except OSError:
pass
class MediaStore:
@@ -267,9 +267,9 @@ class MediaStore:
self._tmp_dir.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
# (device_id, media_ref) -> UploadSession (one active per device)
self._uploads: Dict[Tuple[str, str], UploadSession] = {}
self._inbound: Dict[str, MediaEntry] = {}
self._outbound: Dict[str, MediaEntry] = {}
self._uploads: dict[tuple[str, str], UploadSession] = {}
self._inbound: dict[str, MediaEntry] = {}
self._outbound: dict[str, MediaEntry] = {}
# ── Inbound uploads ───────────────────────────────────────────────────
@@ -281,11 +281,11 @@ class MediaStore:
mime: str,
filename: str,
declared_size: int,
request_id: Optional[int],
request_id: int | None,
max_bytes: int,
) -> UploadSession:
with self._lock:
for (dev, _ref), sess in self._uploads.items():
for dev, _ref in self._uploads:
if dev == device_id:
raise MediaError(
"unsupported", "an upload is already in progress on this connection"
@@ -293,13 +293,19 @@ class MediaStore:
if media_ref in self._inbound:
raise MediaError("unsupported", f"media_ref {media_ref} already used")
sess = UploadSession(
media_ref, kind, mime, filename, declared_size, request_id,
max_bytes, self._tmp_dir,
media_ref,
kind,
mime,
filename,
declared_size,
request_id,
max_bytes,
self._tmp_dir,
)
self._uploads[(device_id, media_ref)] = sess
return sess
def get_upload(self, device_id: str, media_ref: Optional[str] = None) -> Optional[UploadSession]:
def get_upload(self, device_id: str, media_ref: str | None = None) -> UploadSession | None:
with self._lock:
if media_ref is not None:
return self._uploads.get((device_id, media_ref))
@@ -354,8 +360,8 @@ class MediaStore:
# hermes cap (gateway.max_inbound_media_bytes) or a
# non-image payload masquerading as an image.
if "too large" in str(e):
raise MediaError("media_too_large", str(e))
raise MediaError("unsupported", str(e))
raise MediaError("media_too_large", str(e)) from e
raise MediaError("unsupported", str(e)) from e
entry = MediaEntry(
media_id=media_ref,
@@ -370,17 +376,20 @@ class MediaStore:
self._inbound[media_ref] = entry
logger.info(
"android: upload %s cached as %s (%s, %d bytes)",
media_ref, kind, path, len(data),
media_ref,
kind,
path,
len(data),
)
return entry
finally:
sess.close()
def get_inbound(self, media_ref: str) -> Optional[MediaEntry]:
def get_inbound(self, media_ref: str) -> MediaEntry | None:
with self._lock:
return self._inbound.get(media_ref)
def pop_inbound(self, media_ref: str) -> Optional[MediaEntry]:
def pop_inbound(self, media_ref: str) -> MediaEntry | None:
with self._lock:
return self._inbound.pop(media_ref, None)
@@ -402,7 +411,7 @@ class MediaStore:
self._outbound[entry.media_id] = entry
return entry
def get_outbound(self, media_id: str) -> Optional[MediaEntry]:
def get_outbound(self, media_id: str) -> MediaEntry | None:
with self._lock:
return self._outbound.get(media_id)
@@ -438,6 +447,9 @@ async def stream_file(
caller treats the raised error as an aborted pull).
"""
sent = 0
# Safe: ``path`` is produced by hermes ``cache_*_from_bytes`` (a path inside
# hermes's own media cache dir), never derived from raw user input.
# pi-lens-ignore: python-path-traversal
with open(path, "rb") as f:
while True:
chunk = f.read(chunk_bytes)
@@ -445,4 +457,4 @@ async def stream_file(
break
await asyncio.wait_for(ws.send(chunk), timeout=timeout)
sent += len(chunk)
return sent
return sent