Merge pull request #5509 from nesquena/release/stage-b2

Release — session-switch stray-pointerup guard (#5487) + IPv4-first extension gallery (#5458)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-07-03 21:02:30 -07:00
commit 43f989eaa5
5 files changed
+209 -6

No files matched your search

+2
View File
@@ -14,6 +14,8 @@
### Fixed
- **A drag that ends over the sidebar no longer switches conversations by accident.** Releasing the mouse (a stray `pointerup`) over a session row after a drag that began elsewhere in the main content area could switch you into that conversation unintentionally. Session switching now ignores a `pointerup` that isn't a genuine click on the row. Thanks @webtecnica. (#5487, #5462)
- **Extension gallery downloads no longer hang on networks with broken IPv6.** Gallery installs and registry fetches followed the OS address order (IPv6-first); on a network advertising dead IPv6 routes, every install stalled for the full TCP timeout before falling back to IPv4. Downloads now try IPv4 first (via a non-global, thread-safe per-connection override that preserves the existing redirect allowlist and timeouts), so installs are fast again on such networks. Thanks @rumotoshino. (#5458)
- **Switching profiles no longer leaves you unable to upload in a fresh chat, and cross-profile sidebar rows open correctly.** When you switched profiles with an empty current chat, the browser could keep the previous profile's session id — so a later file upload posted a stale id and failed with "Session not found" until a hard reload. The empty chat is now replaced during the switch when it belonged to a different profile, so uploads work immediately. Additionally, clicking a session that belongs to another profile now switches to its owning profile before loading it (instead of 404ing). Thanks @ruizanthony. (#5460)
- **Dictation now uses your current language after you change it in Settings.** After switching the interface language at runtime, plain dictation (the mic button) kept using the language it booted with, so speech was transcribed in the wrong language until a reload. Dictation now refreshes the speech-recognition language right before it starts listening — matching what voice mode already did. Thanks @webtecnica. (#5496, #5483)
- **After an in-place upgrade, an old tab now tells you to refresh instead of silently breaking.** When the server was upgraded under a still-open tab, that tab kept running the pre-upgrade front-end against the newer server — leaving the new-session button dead and session switching broken, with no indication why (only opening a fresh tab helped). The tab now compares its own bundle version against the server's on boot (and while visible), and shows a "hard refresh" recovery banner when they differ, so you can restore full functionality with one click. The banner only appears on a genuine version mismatch (never when the server can't report its version), and coexists with the existing update banner. Thanks @rodboev. (#5480, #5428)
+81 -3
View File
@@ -6,11 +6,13 @@ It is disabled by default and never executes or fetches third-party URLs.
"""
import html
import http.client
import json
import math
import logging
import os
import re
import socket
import threading
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
@@ -19,7 +21,11 @@ import hashlib
import io
import time
import zipfile
from urllib.request import HTTPRedirectHandler, build_opener, urlopen
from urllib.request import (
HTTPRedirectHandler,
HTTPSHandler,
build_opener,
)
from api.helpers import _security_headers, j
@@ -108,9 +114,80 @@ class _AllowlistRedirectHandler(HTTPRedirectHandler):
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _connect_ipv4_first(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, source_address=None):
"""Connect to *address*, preferring IPv4 to avoid dead-route stalls.
Some networks advertise IPv6 routes (router advertisements, tunnel brokers)
that do not actually reach the public internet. ``socket.create_connection``
follows the OS address ordering, which typically tries IPv6 first; each dead
address stalls for the full TCP timeout (~75–130 s) before falling back to
IPv4. This makes gallery installs and registry fetches appear to hang until
the browser or curl timeout kills them.
We iterate families in IPv4→IPv6 order so the common case (IPv4 works, IPv6
is a broken route) completes instantly. IPv6 is still tried as a fallback so
IPv6-only networks are unaffected.
Signature matches ``socket.create_connection`` including the
``_GLOBAL_DEFAULT_TIMEOUT`` sentinel so callers that rely on the stdlib
default-timeout behaviour are not broken.
"""
host, port = address
if timeout is socket._GLOBAL_DEFAULT_TIMEOUT:
timeout = socket.getdefaulttimeout()
last_error: OSError | None = None
for family in (socket.AF_INET, socket.AF_INET6):
try:
infos = socket.getaddrinfo(host, port, family, socket.SOCK_STREAM)
except socket.gaierror:
continue
for _fam, socktype, proto, _canon, sockaddr in infos:
sock = None
try:
sock = socket.socket(_fam, socktype, proto)
if timeout is not None:
sock.settimeout(timeout)
if source_address is not None:
sock.bind(source_address)
sock.connect(sockaddr)
return sock
except OSError as exc:
last_error = exc
if sock is not None:
sock.close()
if last_error is not None:
raise last_error
raise OSError(f"Could not connect to {host}:{port}")
class _IPv4FirstHTTPSConnection(http.client.HTTPSConnection):
"""HTTPS connection that resolves IPv4 addresses before IPv6.
Overrides the class-level ``_create_connection`` that
``HTTPConnection.connect()`` calls — the stdlib extension point designed for
exactly this kind of per-class customisation (added in Python 3.9,
bpo-37830). No global state is mutated, so the change is thread-safe under
``ThreadingHTTPServer``.
"""
_create_connection = staticmethod(_connect_ipv4_first)
class _IPv4FirstHTTPSHandler(HTTPSHandler):
"""HTTPS handler that builds IPv4-first connections for gallery downloads."""
def https_open(self, req):
return self.do_open(_IPv4FirstHTTPSConnection, req)
def _build_gallery_opener():
"""Opener with IPv4-first downloads and redirect-host allowlist."""
return build_opener(_IPv4FirstHTTPSHandler, _AllowlistRedirectHandler)
def _safe_download(url: str, max_bytes: int, timeout: int = 30) -> bytes:
"""Download from an allowlisted host, rejecting cross-host redirects."""
opener = build_opener(_AllowlistRedirectHandler)
opener = _build_gallery_opener()
resp = opener.open(url, timeout=timeout)
try:
return resp.read(max_bytes + 1)
@@ -1813,7 +1890,8 @@ def get_extension_registry() -> Dict[str, Any]:
if cached is not None and (now - cached_at) < _REGISTRY_TTL_SECONDS:
return {"entries": cached}
try:
raw = urlopen(_REGISTRY_URL, timeout=10).read(2 * 1024 * 1024)
opener = _build_gallery_opener()
raw = opener.open(_REGISTRY_URL, timeout=10).read(2 * 1024 * 1024)
data = json.loads(raw.decode("utf-8"))
if isinstance(data, list):
entries = data
+1
View File
@@ -7725,6 +7725,7 @@ function renderSessionListFromCache(){
}
};
const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{
if(_gestureState==='idle') return false; // press never began on this row
const wasDragging=_gestureState==='dragging'||_swipeTracking;
_clearLongPressTimer();
if(_renamingSid){_gestureState='idle';return false;}
+95 -3
View File
@@ -3,9 +3,10 @@
import hashlib
import io
import json
import socket
import zipfile
from pathlib import Path
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
@@ -419,7 +420,7 @@ def test_gallery_registry_list_format(monkeypatch, tmp_path):
mock_resp.read.return_value = json.dumps(registry_data).encode("utf-8")
ext_mod._REGISTRY_CACHE.clear()
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: MagicMock(open=lambda *a, **kw: mock_resp))
result = ext_mod.get_extension_registry()
assert "entries" in result
@@ -444,7 +445,7 @@ def test_gallery_registry_extensions_format(monkeypatch, tmp_path):
mock_resp.read.return_value = json.dumps(registry_data).encode("utf-8")
ext_mod._REGISTRY_CACHE.clear()
monkeypatch.setattr(ext_mod, "urlopen", lambda *a, **kw: mock_resp)
monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: MagicMock(open=lambda *a, **kw: mock_resp))
result = ext_mod.get_extension_registry()
assert len(result["entries"]) == 1
@@ -522,3 +523,94 @@ def test_install_rejects_redirect_to_disallowed_host(monkeypatch, tmp_path):
"https://hermes-webui.github.io/exts/redir-ext.zip",
"a" * 64,
)
def test_connect_ipv4_first_tries_inet_before_inet6(monkeypatch):
"""_connect_ipv4_first calls getaddrinfo with AF_INET before AF_INET6."""
import api.extensions as ext_mod
call_order = []
real_getaddrinfo = socket.getaddrinfo
def tracking_getaddrinfo(host, port, family, *args, **kwargs):
call_order.append(family)
return real_getaddrinfo(host, port, family, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", tracking_getaddrinfo)
monkeypatch.setattr(socket, "getdefaulttimeout", lambda: None)
# We don't actually need to connect; just verify family ordering.
try:
ext_mod._connect_ipv4_first(("localhost", 80), timeout=None)
except OSError:
pass # connection failure is fine — we only care about call order
assert socket.AF_INET in call_order
if socket.AF_INET6 in call_order:
assert call_order.index(socket.AF_INET) < call_order.index(socket.AF_INET6)
def test_connect_ipv4_first_handles_global_default_timeout():
"""The _GLOBAL_DEFAULT_TIMEOUT sentinel is resolved correctly."""
import api.extensions as ext_mod
# Should not raise TypeError when passed the stdlib sentinel.
sentinel = socket._GLOBAL_DEFAULT_TIMEOUT
# Force default timeout to None so no actual timeout is set on the socket.
with patch.object(socket, "getdefaulttimeout", return_value=None):
try:
ext_mod._connect_ipv4_first(("127.0.0.1", 1), timeout=sentinel)
except OSError:
pass # connection refused is fine
except TypeError:
pytest.fail("TypeError — sentinel not resolved")
def test_gallery_opener_includes_ipv4_first_handler():
"""_build_gallery_opener produces an opener wired to the IPv4-first HTTPS handler."""
import api.extensions as ext_mod
opener = ext_mod._build_gallery_opener()
# build_opener instantiates handler classes internally; find the one whose
# https_open uses our IPv4-first connection class.
handler_classes = [type(h) for h in opener.handlers]
assert ext_mod._IPv4FirstHTTPSHandler in handler_classes
def test_safe_download_uses_gallery_opener(monkeypatch):
"""_safe_download routes through _build_gallery_opener (which is IPv4-first)."""
import api.extensions as ext_mod
fake_response = MagicMock()
fake_response.read.return_value = b"file-content"
fake_response.close = MagicMock()
fake_opener = MagicMock(open=lambda url, timeout=None: fake_response)
called = []
monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: (called.append(True), fake_opener)[1])
result = ext_mod._safe_download("https://example.com/x.zip", 1024)
assert result == b"file-content"
assert called == [True]
def test_registry_fetch_uses_gallery_opener(monkeypatch):
"""get_extension_registry routes through _build_gallery_opener (IPv4-first)."""
import api.extensions as ext_mod
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(
{"extensions": [{"id": "test-ext", "name": "Test"}]}
).encode("utf-8")
ext_mod._REGISTRY_CACHE.clear()
called = []
fake_opener = MagicMock(open=lambda *a, **kw: mock_resp)
monkeypatch.setattr(ext_mod, "_build_gallery_opener", lambda: (called.append(True), fake_opener)[1])
result = ext_mod.get_extension_registry()
assert len(result["entries"]) == 1
assert result["entries"][0]["id"] == "test-ext"
assert called == [True]
+30
View File
@@ -283,3 +283,33 @@ def test_touch_session_rows_preserve_vertical_scroll():
assert "user-select:none" in item_rule
assert "-webkit-user-select:none" in item_rule
assert "-webkit-touch-callout:none" in item_rule
def test_session_gesture_finish_ignores_idle_state():
"""Test that _finishSessionGesture bails early when _gestureState is 'idle'.
This prevents unintended session switches when a drag starts outside the
session row and ends on top of it. The gesture must have begun with a
pointerdown on the row itself (_gestureState='pressing'), otherwise the
pointerup should be ignored.
See: https://github.com/nesquena/hermes-webui/issues/5462
"""
# Find the _finishSessionGesture function
fn_start = SESSIONS_JS.find("const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{")
assert fn_start != -1, "_finishSessionGesture function not found"
# Extract the first ~200 characters of the function body
fn_body_start = SESSIONS_JS[fn_start:fn_start+200]
# Verify the idle-state guard is present at the beginning
assert "if(_gestureState==='idle') return false;" in fn_body_start, \
"Missing idle-state guard at the beginning of _finishSessionGesture"
# Also verify that the fork row handler has the same guard (consistency check)
# Fork handler pattern: rowEl.onpointerup followed by pointerType/button check, then idle guard
fork_handler_start = SESSIONS_JS.find("rowEl.onpointerup=(e)=>{")
assert fork_handler_start != -1, "Fork row pointerup handler not found"
fork_handler_snippet = SESSIONS_JS[fork_handler_start:fork_handler_start+200]
assert "if(_gestureState==='idle') return;" in fork_handler_snippet, \
"Fork row pointerup handler should have idle guard"