mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
refactor: update path response and link establishment timeout handling across various components to improve reliability and performance
This commit is contained in:
parent
062c66138d
commit
752b9be088
38 changed files with 562 additions and 103 deletions
|
|
@ -55,6 +55,7 @@ Allowed:
|
||||||
|
|
||||||
- Prefer event/handler and store-and-forward over blocking request/response UIs.
|
- Prefer event/handler and store-and-forward over blocking request/response UIs.
|
||||||
- Missing path: request path, allow propagate, surface recoverable error. Do not spin forever.
|
- Missing path: request path, allow propagate, surface recoverable error. Do not spin forever.
|
||||||
|
- Path and first-hop link waits use `path_utils.path_response_window` and `link.establishment_timeout`. Do not pin 15s (or any flat timer) for Nomad pages, RNCP, FileSync, LXST, or map fetches.
|
||||||
- Keep list APIs and announces slim. Do not ship multi-MB blobs in conversation lists.
|
- Keep list APIs and announces slim. Do not ship multi-MB blobs in conversation lists.
|
||||||
- Large files use RNCP / attachments / explicit transfer tools, not chat text fields.
|
- Large files use RNCP / attachments / explicit transfer tools, not chat text fields.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,11 @@ Announce ingest caps and store toggles live in `announce_manager.py` (`announce_
|
||||||
|
|
||||||
Missing path: request a path, allow LXMF propagate where that is the protocol, show a recoverable outbound state. Do not spin the UI until an ACK arrives on a LoRa-class link.
|
Missing path: request a path, allow LXMF propagate where that is the protocol, show a recoverable outbound state. Do not spin the UI until an ACK arrives on a LoRa-class link.
|
||||||
|
|
||||||
|
Do not pin a 15 second (or any fixed) timer for cold path requests or first-hop link setup. Reticulum already knows the interface bitrate.
|
||||||
|
|
||||||
|
- Path wait: `meshchatx/src/backend/path_utils.py` `path_response_window`. Uses `RNS.Reticulum.get_instance().get_first_hop_timeout()` (not `RNS.Transport.first_hop_timeout()`, which is wrong on a shared rnsd client) plus an airtime floor from the slowest online interface bitrate, clamped to `RNS.Reticulum.MINIMUM_BITRATE` (5 bps).
|
||||||
|
- Link wait: `link.establishment_timeout` plus `LINK_ESTABLISHMENT_MARGIN_S` via `link_establishment_window`. Pass `None` so callers do not override RNS.
|
||||||
|
|
||||||
Links are live sessions on top of paths. LXST calls and RRC hubs use links. LXMF mail is store-and-forward and must survive a missing path.
|
Links are live sessions on top of paths. LXST calls and RRC hubs use links. LXMF mail is store-and-forward and must survive a missing path.
|
||||||
|
|
||||||
## Key files
|
## Key files
|
||||||
|
|
|
||||||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -14,6 +14,10 @@ import RNS
|
||||||
|
|
||||||
from meshchatx.src.backend.announce_handler import AnnounceHandler
|
from meshchatx.src.backend.announce_handler import AnnounceHandler
|
||||||
from meshchatx.src.backend.log_redaction import redact_diagnostic_text
|
from meshchatx.src.backend.log_redaction import redact_diagnostic_text
|
||||||
|
from meshchatx.src.backend.path_utils import (
|
||||||
|
link_establishment_window,
|
||||||
|
path_response_window,
|
||||||
|
)
|
||||||
|
|
||||||
BUG_ASPECT = "mcx-bugs-v1"
|
BUG_ASPECT = "mcx-bugs-v1"
|
||||||
REPORT_PATH = "/report"
|
REPORT_PATH = "/report"
|
||||||
|
|
@ -440,7 +444,10 @@ class BugReportManager:
|
||||||
)
|
)
|
||||||
if not RNS.Transport.has_path(dest_hash):
|
if not RNS.Transport.has_path(dest_hash):
|
||||||
RNS.Transport.request_path(dest_hash)
|
RNS.Transport.request_path(dest_hash)
|
||||||
deadline = time.time() + float(args.get("path_timeout") or 15)
|
path_wait = float(args.get("path_timeout") or 0) or path_response_window(
|
||||||
|
dest_hash,
|
||||||
|
)
|
||||||
|
deadline = time.time() + path_wait
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
if RNS.Transport.has_path(dest_hash):
|
if RNS.Transport.has_path(dest_hash):
|
||||||
break
|
break
|
||||||
|
|
@ -468,7 +475,11 @@ class BugReportManager:
|
||||||
response_event.set()
|
response_event.set()
|
||||||
|
|
||||||
link.set_link_established_callback(on_established)
|
link.set_link_established_callback(on_established)
|
||||||
if not established.wait(timeout=float(args.get("link_timeout") or 20)):
|
link_wait = float(args.get("link_timeout") or 0) or link_establishment_window(
|
||||||
|
link,
|
||||||
|
dest_hash,
|
||||||
|
)
|
||||||
|
if not established.wait(timeout=link_wait):
|
||||||
try:
|
try:
|
||||||
link.teardown()
|
link.teardown()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
|
from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
|
||||||
GeoValidationError,
|
GeoValidationError,
|
||||||
OutboundHttpBlockedError,
|
OutboundHttpBlockedError,
|
||||||
|
|
@ -131,6 +130,8 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
|
||||||
zipfile,
|
zipfile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from meshchatx.src.backend.path_utils import path_response_window
|
||||||
|
|
||||||
# Same ceiling as RNProbeHandler.MAX_TIMEOUT_S
|
# Same ceiling as RNProbeHandler.MAX_TIMEOUT_S
|
||||||
PATH_PROBE_MIN_TIMEOUT_S = 1
|
PATH_PROBE_MIN_TIMEOUT_S = 1
|
||||||
PATH_PROBE_MAX_TIMEOUT_S = 600
|
PATH_PROBE_MAX_TIMEOUT_S = 600
|
||||||
|
|
@ -139,9 +140,11 @@ PATH_WAIT_REQUIRES_POST_MESSAGE = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_path_probe_timeout(raw, *, default=15):
|
def parse_path_probe_timeout(raw, *, default=None):
|
||||||
if raw is None or raw == "":
|
if raw is None or raw == "":
|
||||||
raw = default
|
raw = default
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return None, None
|
||||||
try:
|
try:
|
||||||
timeout_seconds = int(raw)
|
timeout_seconds = int(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
|
@ -157,7 +160,7 @@ def parse_path_probe_timeout(raw, *, default=15):
|
||||||
return timeout_seconds, None
|
return timeout_seconds, None
|
||||||
|
|
||||||
|
|
||||||
async def read_path_probe_timeout_raw(request, default=15):
|
async def read_path_probe_timeout_raw(request, default=None):
|
||||||
query = getattr(request, "query", None) or {}
|
query = getattr(request, "query", None) or {}
|
||||||
if "timeout" in query:
|
if "timeout" in query:
|
||||||
return query.get("timeout")
|
return query.get("timeout")
|
||||||
|
|
@ -303,10 +306,16 @@ def register_path_probe_routes(routes, app):
|
||||||
)
|
)
|
||||||
destination_hash_hex = destination_hash_bytes.hex()
|
destination_hash_hex = destination_hash_bytes.hex()
|
||||||
|
|
||||||
timeout_raw = await read_path_probe_timeout_raw(request, default=15)
|
timeout_raw = await read_path_probe_timeout_raw(request)
|
||||||
timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw)
|
timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw)
|
||||||
if timeout_error:
|
if timeout_error:
|
||||||
return web.json_response({"message": timeout_error}, status=400)
|
return web.json_response({"message": timeout_error}, status=400)
|
||||||
|
if timeout_seconds is None:
|
||||||
|
reticulum = app.reticulum if hasattr(app, "reticulum") else None
|
||||||
|
timeout_seconds = path_response_window(
|
||||||
|
destination_hash_bytes,
|
||||||
|
reticulum,
|
||||||
|
)
|
||||||
|
|
||||||
if destination_hash_hex in local_destination_hashes(app):
|
if destination_hash_hex in local_destination_hashes(app):
|
||||||
return local_path_response(destination_hash_hex)
|
return local_path_response(destination_hash_hex)
|
||||||
|
|
@ -483,13 +492,18 @@ def register_path_probe_routes(routes, app):
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
timeout_raw = await read_path_probe_timeout_raw(request, default=15)
|
timeout_raw = await read_path_probe_timeout_raw(request)
|
||||||
timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw)
|
timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw)
|
||||||
if timeout_error:
|
if timeout_error:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"message": f"Ping failed. {timeout_error}"},
|
{"message": f"Ping failed. {timeout_error}"},
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
if timeout_seconds is None:
|
||||||
|
reticulum = app.reticulum if hasattr(app, "reticulum") else None
|
||||||
|
timeout_seconds = int(
|
||||||
|
round(path_response_window(destination_hash, reticulum)),
|
||||||
|
)
|
||||||
|
|
||||||
# Split the budget so path discovery cannot consume the whole timeout.
|
# Split the budget so path discovery cannot consume the whole timeout.
|
||||||
path_budget_seconds = max(1, timeout_seconds // 2)
|
path_budget_seconds = max(1, timeout_seconds // 2)
|
||||||
|
|
|
||||||
|
|
@ -436,7 +436,11 @@ def register_rn_tools_routes(routes, app):
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
destination_hash_str = data.get("destination_hash", "")
|
destination_hash_str = data.get("destination_hash", "")
|
||||||
file_path = data.get("file_path", "")
|
file_path = data.get("file_path", "")
|
||||||
timeout = float(data.get("timeout", RNS.Transport.PATH_REQUEST_TIMEOUT))
|
timeout_raw = data.get("timeout")
|
||||||
|
try:
|
||||||
|
timeout = float(timeout_raw) if timeout_raw not in (None, "") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout = None
|
||||||
no_compress = bool(data.get("no_compress", False))
|
no_compress = bool(data.get("no_compress", False))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -496,7 +500,11 @@ def register_rn_tools_routes(routes, app):
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
destination_hash_str = data.get("destination_hash", "")
|
destination_hash_str = data.get("destination_hash", "")
|
||||||
file_path = data.get("file_path", "")
|
file_path = data.get("file_path", "")
|
||||||
timeout = float(data.get("timeout", RNS.Transport.PATH_REQUEST_TIMEOUT))
|
timeout_raw = data.get("timeout")
|
||||||
|
try:
|
||||||
|
timeout = float(timeout_raw) if timeout_raw not in (None, "") else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout = None
|
||||||
save_path = data.get("save_path")
|
save_path = data.get("save_path")
|
||||||
allow_overwrite = bool(data.get("allow_overwrite", False))
|
allow_overwrite = bool(data.get("allow_overwrite", False))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from meshchatx.src.backend.map_geo_validator import (
|
||||||
validate_geo_bytes,
|
validate_geo_bytes,
|
||||||
)
|
)
|
||||||
from meshchatx.src.backend.map_overlay_manager import atomic_write_bytes
|
from meshchatx.src.backend.map_overlay_manager import atomic_write_bytes
|
||||||
|
from meshchatx.src.backend.path_utils import path_response_window
|
||||||
from meshchatx.src.path_utils import is_path_within_dir
|
from meshchatx.src.path_utils import is_path_within_dir
|
||||||
|
|
||||||
_log = logging.getLogger("meshchatx.map_data")
|
_log = logging.getLogger("meshchatx.map_data")
|
||||||
|
|
@ -545,6 +546,13 @@ class MapDataManager:
|
||||||
if link_manager is None:
|
if link_manager is None:
|
||||||
raise MapDataError("link_unavailable")
|
raise MapDataError("link_unavailable")
|
||||||
path_timeout = int(self.config.map_overlay_path_timeout_seconds.get() or 30)
|
path_timeout = int(self.config.map_overlay_path_timeout_seconds.get() or 30)
|
||||||
|
try:
|
||||||
|
path_timeout = max(
|
||||||
|
float(path_timeout),
|
||||||
|
path_response_window(destination_hash, self.reticulum),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
path_timeout = float(path_timeout)
|
||||||
transfer_timeout = int(
|
transfer_timeout = int(
|
||||||
self.config.map_overlay_transfer_timeout_seconds.get() or 120
|
self.config.map_overlay_transfer_timeout_seconds.get() or 120
|
||||||
)
|
)
|
||||||
|
|
@ -553,7 +561,6 @@ class MapDataManager:
|
||||||
destination_hash,
|
destination_hash,
|
||||||
MAP_ASPECT,
|
MAP_ASPECT,
|
||||||
path_lookup_timeout=float(path_timeout),
|
path_lookup_timeout=float(path_timeout),
|
||||||
link_establishment_timeout=float(path_timeout),
|
|
||||||
)
|
)
|
||||||
if link is None:
|
if link is None:
|
||||||
code = (
|
code = (
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
@ -114,6 +115,14 @@ def _set_href_on_element(el: ET.Element, value: str | None) -> None:
|
||||||
el.attrib[key] = value
|
el.attrib[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
_WS_RE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_html_text(text: str) -> str:
|
||||||
|
return _WS_RE.sub(" ", _HTML_TAG_RE.sub(" ", text)).strip()
|
||||||
|
|
||||||
|
|
||||||
def _strip_description_html(el: ET.Element) -> bool:
|
def _strip_description_html(el: ET.Element) -> bool:
|
||||||
tag = _strip_ns(el.tag).lower()
|
tag = _strip_ns(el.tag).lower()
|
||||||
if tag != "description":
|
if tag != "description":
|
||||||
|
|
@ -129,8 +138,13 @@ def _strip_description_html(el: ET.Element) -> bool:
|
||||||
if child.tail and child.tail.strip():
|
if child.tail and child.tail.strip():
|
||||||
texts.append(child.tail.strip())
|
texts.append(child.tail.strip())
|
||||||
el.remove(child)
|
el.remove(child)
|
||||||
el.text = " ".join(texts) if texts else None
|
combined = " ".join(texts)
|
||||||
return had_children
|
changed = had_children
|
||||||
|
if "<" in combined and ">" in combined:
|
||||||
|
combined = _flatten_html_text(combined)
|
||||||
|
changed = True
|
||||||
|
el.text = combined or None
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
def _walk_strip_kml(
|
def _walk_strip_kml(
|
||||||
|
|
@ -277,7 +291,10 @@ def sanitize_kmz_bytes(data: bytes) -> SanitizeResult:
|
||||||
raise GeoValidationError("path_traversal")
|
raise GeoValidationError("path_traversal")
|
||||||
ext = _zip_entry_ext(name)
|
ext = _zip_entry_ext(name)
|
||||||
if ext not in ALLOWED_KMZ_EXTS:
|
if ext not in ALLOWED_KMZ_EXTS:
|
||||||
raise GeoValidationError("unsafe_kmz_entry")
|
# ArcGIS KMZ exports include unused .xsl balloon stylesheets.
|
||||||
|
# Drop sidecars instead of rejecting the archive.
|
||||||
|
stripped.append("skipped_kmz_entry")
|
||||||
|
continue
|
||||||
payload = zf.read(info.filename)
|
payload = zf.read(info.filename)
|
||||||
kept[name] = payload
|
kept[name] = payload
|
||||||
lower = name.lower()
|
lower = name.lower()
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ from meshchatx.src.backend.map_overlay_sources import (
|
||||||
parse_create_payload,
|
parse_create_payload,
|
||||||
)
|
)
|
||||||
from meshchatx.src.backend.nomadnet_downloader import NomadnetFileDownloader
|
from meshchatx.src.backend.nomadnet_downloader import NomadnetFileDownloader
|
||||||
|
from meshchatx.src.backend.path_utils import path_response_window
|
||||||
from meshchatx.src.backend.rngit_sparse_fetcher import (
|
from meshchatx.src.backend.rngit_sparse_fetcher import (
|
||||||
RngitFetchError,
|
RngitFetchError,
|
||||||
RngitSparseFetcher,
|
RngitSparseFetcher,
|
||||||
|
|
@ -623,6 +624,14 @@ class MapOverlayManager:
|
||||||
generation: int,
|
generation: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
path_timeout = self._cfg_int("map_overlay_path_timeout_seconds")
|
path_timeout = self._cfg_int("map_overlay_path_timeout_seconds")
|
||||||
|
try:
|
||||||
|
dest = bytes.fromhex(spec.destination_hash)
|
||||||
|
path_timeout = max(
|
||||||
|
float(path_timeout),
|
||||||
|
path_response_window(dest, self.reticulum),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
path_timeout = float(path_timeout)
|
||||||
transfer_timeout = self._cfg_int("map_overlay_transfer_timeout_seconds")
|
transfer_timeout = self._cfg_int("map_overlay_transfer_timeout_seconds")
|
||||||
job_timeout = self._cfg_int("map_overlay_job_timeout_seconds")
|
job_timeout = self._cfg_int("map_overlay_job_timeout_seconds")
|
||||||
|
|
||||||
|
|
@ -662,7 +671,6 @@ class MapOverlayManager:
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
downloader.download(
|
downloader.download(
|
||||||
path_lookup_timeout=path_timeout,
|
path_lookup_timeout=path_timeout,
|
||||||
link_establishment_timeout=path_timeout,
|
|
||||||
),
|
),
|
||||||
timeout=job_timeout,
|
timeout=job_timeout,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ from collections.abc import Callable
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
from meshchatx.src.backend import reticulum_pathfinding
|
from meshchatx.src.backend import reticulum_pathfinding
|
||||||
from meshchatx.src.backend.path_utils import path_response_window
|
from meshchatx.src.backend.path_utils import (
|
||||||
|
link_establishment_window,
|
||||||
|
path_response_window,
|
||||||
|
)
|
||||||
from meshchatx.src.backend.reticulum_pathfinding import ReticulumLike
|
from meshchatx.src.backend.reticulum_pathfinding import ReticulumLike
|
||||||
|
|
||||||
# Global cache for Nomad Network links (reuse instead of reconnecting per request).
|
# Global cache for Nomad Network links (reuse instead of reconnecting per request).
|
||||||
|
|
@ -293,14 +296,11 @@ class NomadnetDownloader:
|
||||||
self.link = link
|
self.link = link
|
||||||
|
|
||||||
if link_establishment_timeout is None:
|
if link_establishment_timeout is None:
|
||||||
rns_timeout = getattr(link, "establishment_timeout", None)
|
link_establishment_timeout = link_establishment_window(
|
||||||
if isinstance(rns_timeout, (int, float)) and rns_timeout > 0:
|
link,
|
||||||
link_establishment_timeout = rns_timeout + 5
|
self.destination_hash,
|
||||||
else:
|
self._reticulum,
|
||||||
link_establishment_timeout = path_response_window(
|
)
|
||||||
self.destination_hash,
|
|
||||||
self._reticulum,
|
|
||||||
)
|
|
||||||
timeout_after_seconds = time.time() + link_establishment_timeout
|
timeout_after_seconds = time.time() + link_establishment_timeout
|
||||||
|
|
||||||
while (
|
while (
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,24 @@
|
||||||
|
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
MIN_WINDOW_BITRATE = 50
|
# One path-request packet is about 234 bytes plus IFAC. 240 covers that.
|
||||||
PATH_EXCHANGE_BYTES = 240
|
PATH_EXCHANGE_BYTES = 240
|
||||||
|
LINK_ESTABLISHMENT_MARGIN_S = 5.0
|
||||||
|
_FALLBACK_PATH_TIMEOUT_S = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
def min_window_bitrate() -> float:
|
||||||
|
try:
|
||||||
|
value = float(RNS.Reticulum.MINIMUM_BITRATE)
|
||||||
|
if value > 0:
|
||||||
|
return value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 5.0
|
||||||
|
|
||||||
|
|
||||||
|
# Kept as a name for tests and callers. Equals RNS.Reticulum.MINIMUM_BITRATE (5).
|
||||||
|
MIN_WINDOW_BITRATE = min_window_bitrate()
|
||||||
|
|
||||||
|
|
||||||
def slowest_online_bitrate(reticulum=None):
|
def slowest_online_bitrate(reticulum=None):
|
||||||
|
|
@ -23,14 +39,49 @@ def slowest_online_bitrate(reticulum=None):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rns_path_request_timeout() -> float:
|
||||||
|
try:
|
||||||
|
return float(RNS.Transport.PATH_REQUEST_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
return _FALLBACK_PATH_TIMEOUT_S
|
||||||
|
|
||||||
|
|
||||||
def path_response_window(destination_hash, reticulum=None) -> float:
|
def path_response_window(destination_hash, reticulum=None) -> float:
|
||||||
if reticulum is None:
|
"""Seconds to wait for a cold path response on the slowest online interface.
|
||||||
reticulum = RNS.Reticulum.get_instance()
|
|
||||||
window = float(reticulum.get_first_hop_timeout(destination_hash))
|
Uses Reticulum.get_first_hop_timeout so a shared rnsd client sees the
|
||||||
|
instance interface timeouts, not the local socket timeout.
|
||||||
|
"""
|
||||||
|
window = 0.0
|
||||||
|
try:
|
||||||
|
if reticulum is None:
|
||||||
|
reticulum = RNS.Reticulum.get_instance()
|
||||||
|
window = float(reticulum.get_first_hop_timeout(destination_hash))
|
||||||
|
except Exception:
|
||||||
|
window = 0.0
|
||||||
bitrate = slowest_online_bitrate(reticulum)
|
bitrate = slowest_online_bitrate(reticulum)
|
||||||
if bitrate:
|
if bitrate:
|
||||||
|
floor_bps = min_window_bitrate()
|
||||||
window = max(
|
window = max(
|
||||||
window,
|
window,
|
||||||
2 * (PATH_EXCHANGE_BYTES * 8 / max(bitrate, MIN_WINDOW_BITRATE)) + 10,
|
2 * (PATH_EXCHANGE_BYTES * 8 / max(float(bitrate), floor_bps)) + 10,
|
||||||
)
|
)
|
||||||
return max(window, float(RNS.Transport.PATH_REQUEST_TIMEOUT))
|
return max(window, _rns_path_request_timeout())
|
||||||
|
|
||||||
|
|
||||||
|
def link_establishment_window(
|
||||||
|
link,
|
||||||
|
destination_hash=None,
|
||||||
|
reticulum=None,
|
||||||
|
) -> float:
|
||||||
|
"""Seconds to wait for a new RNS Link, from link.establishment_timeout.
|
||||||
|
|
||||||
|
Adds LINK_ESTABLISHMENT_MARGIN_S. Falls back to path_response_window
|
||||||
|
when RNS did not report an establishment timeout.
|
||||||
|
"""
|
||||||
|
rns_timeout = getattr(link, "establishment_timeout", None)
|
||||||
|
if isinstance(rns_timeout, (int, float)) and rns_timeout > 0:
|
||||||
|
return float(rns_timeout) + LINK_ESTABLISHMENT_MARGIN_S
|
||||||
|
if destination_hash is not None:
|
||||||
|
return path_response_window(destination_hash, reticulum)
|
||||||
|
return _rns_path_request_timeout()
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@ from typing import Any
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
from meshchatx.src.backend.management_identities import resolve_identity_path
|
from meshchatx.src.backend.management_identities import resolve_identity_path
|
||||||
|
from meshchatx.src.backend.path_utils import (
|
||||||
|
link_establishment_window,
|
||||||
|
path_response_window,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _truncated_hash_len() -> int:
|
def _truncated_hash_len() -> int:
|
||||||
|
|
@ -140,9 +144,16 @@ class _RemoteRequest:
|
||||||
self._link = RNS.Link(destination)
|
self._link = RNS.Link(destination)
|
||||||
self._link.set_link_established_callback(on_established)
|
self._link.set_link_established_callback(on_established)
|
||||||
self._link.set_link_closed_callback(on_closed)
|
self._link.set_link_closed_callback(on_closed)
|
||||||
|
request_wait = max(
|
||||||
|
float(self.timeout),
|
||||||
|
link_establishment_window(
|
||||||
|
self._link,
|
||||||
|
self.destination_hash,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not self._event.wait(timeout=max(1.0, float(self.timeout))):
|
if not self._event.wait(timeout=max(1.0, request_wait)):
|
||||||
raise TimeoutError("Remote management request timed out")
|
raise TimeoutError("Remote management request timed out")
|
||||||
if self._error is not None:
|
if self._error is not None:
|
||||||
raise self._error
|
raise self._error
|
||||||
|
|
@ -173,9 +184,7 @@ def remote_request(
|
||||||
if identity is None:
|
if identity is None:
|
||||||
raise ValueError(f"Could not load management identity from {resolved}")
|
raise ValueError(f"Could not load management identity from {resolved}")
|
||||||
wait = (
|
wait = (
|
||||||
float(timeout)
|
float(timeout) if timeout not in (None, "") else path_response_window(dest_hash)
|
||||||
if timeout not in (None, "")
|
|
||||||
else float(RNS.Transport.PATH_REQUEST_TIMEOUT)
|
|
||||||
)
|
)
|
||||||
return _RemoteRequest(dest_hash, identity, path, data, wait).run()
|
return _RemoteRequest(dest_hash, identity, path, data, wait).run()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
|
from meshchatx.src.backend.path_utils import path_response_window
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class OutboundPathOutcome:
|
class OutboundPathOutcome:
|
||||||
|
|
@ -233,7 +235,12 @@ def nudge_path_request(destination_hash: bytes) -> None:
|
||||||
RNS.Transport.request_path(destination_hash)
|
RNS.Transport.request_path(destination_hash)
|
||||||
|
|
||||||
|
|
||||||
def lxmf_path_wait_cap_seconds() -> float:
|
def lxmf_path_wait_cap_seconds(
|
||||||
|
destination_hash: bytes | None = None,
|
||||||
|
reticulum: Optional["ReticulumLike"] = None,
|
||||||
|
) -> float:
|
||||||
|
if destination_hash is not None:
|
||||||
|
return path_response_window(destination_hash, reticulum)
|
||||||
try:
|
try:
|
||||||
base = float(RNS.Transport.PATH_REQUEST_TIMEOUT)
|
base = float(RNS.Transport.PATH_REQUEST_TIMEOUT)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -245,8 +252,12 @@ async def await_transport_path_for_outbound_lxmf(
|
||||||
reticulum: Optional["ReticulumLike"],
|
reticulum: Optional["ReticulumLike"],
|
||||||
destination_hash_bytes: bytes,
|
destination_hash_bytes: bytes,
|
||||||
) -> OutboundPathOutcome:
|
) -> OutboundPathOutcome:
|
||||||
long_w = lxmf_path_wait_cap_seconds()
|
long_w = lxmf_path_wait_cap_seconds(destination_hash_bytes, reticulum)
|
||||||
short_w = max(15.0, long_w * 0.5)
|
try:
|
||||||
|
short_floor = float(RNS.Transport.PATH_REQUEST_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
short_floor = 15.0
|
||||||
|
short_w = max(long_w * 0.5, short_floor)
|
||||||
|
|
||||||
measure = prepare_fresh_path_request(reticulum, destination_hash_bytes)
|
measure = prepare_fresh_path_request(reticulum, destination_hash_bytes)
|
||||||
deadline = time.time() + long_w
|
deadline = time.time() + long_w
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from collections.abc import Callable
|
||||||
|
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
from .path_utils import path_response_window
|
from .path_utils import link_establishment_window, path_response_window
|
||||||
|
|
||||||
|
|
||||||
class RNCPHandler:
|
class RNCPHandler:
|
||||||
|
|
@ -38,6 +38,16 @@ class RNCPHandler:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _path_wait_seconds(
|
||||||
|
self,
|
||||||
|
destination_hash: bytes,
|
||||||
|
timeout: float | None,
|
||||||
|
) -> float:
|
||||||
|
window = path_response_window(destination_hash, self.reticulum)
|
||||||
|
if timeout is None:
|
||||||
|
return window
|
||||||
|
return max(float(timeout), window)
|
||||||
|
|
||||||
def _default_fetch_save_dir(self) -> str:
|
def _default_fetch_save_dir(self) -> str:
|
||||||
path = os.path.join(self.storage_dir, "rncp", "downloads")
|
path = os.path.join(self.storage_dir, "rncp", "downloads")
|
||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
|
|
@ -398,9 +408,8 @@ class RNCPHandler:
|
||||||
if not RNS.Transport.has_path(destination_hash):
|
if not RNS.Transport.has_path(destination_hash):
|
||||||
RNS.Transport.request_path(destination_hash)
|
RNS.Transport.request_path(destination_hash)
|
||||||
|
|
||||||
if timeout is None:
|
path_wait = self._path_wait_seconds(destination_hash, timeout)
|
||||||
timeout = path_response_window(destination_hash, self.reticulum)
|
timeout_after = time.time() + path_wait
|
||||||
timeout_after = time.time() + timeout
|
|
||||||
while (
|
while (
|
||||||
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
|
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
|
||||||
):
|
):
|
||||||
|
|
@ -420,7 +429,11 @@ class RNCPHandler:
|
||||||
)
|
)
|
||||||
|
|
||||||
link = RNS.Link(receiver_destination)
|
link = RNS.Link(receiver_destination)
|
||||||
timeout_after = time.time() + timeout
|
timeout_after = time.time() + link_establishment_window(
|
||||||
|
link,
|
||||||
|
destination_hash,
|
||||||
|
self.reticulum,
|
||||||
|
)
|
||||||
while link.status != RNS.Link.ACTIVE and time.time() < timeout_after:
|
while link.status != RNS.Link.ACTIVE and time.time() < timeout_after:
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
@ -501,9 +514,8 @@ class RNCPHandler:
|
||||||
if not RNS.Transport.has_path(destination_hash):
|
if not RNS.Transport.has_path(destination_hash):
|
||||||
RNS.Transport.request_path(destination_hash)
|
RNS.Transport.request_path(destination_hash)
|
||||||
|
|
||||||
if timeout is None:
|
path_wait = self._path_wait_seconds(destination_hash, timeout)
|
||||||
timeout = path_response_window(destination_hash, self.reticulum)
|
timeout_after = time.time() + path_wait
|
||||||
timeout_after = time.time() + timeout
|
|
||||||
while (
|
while (
|
||||||
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
|
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
|
||||||
):
|
):
|
||||||
|
|
@ -523,7 +535,11 @@ class RNCPHandler:
|
||||||
)
|
)
|
||||||
|
|
||||||
link = RNS.Link(listener_destination)
|
link = RNS.Link(listener_destination)
|
||||||
timeout_after = time.time() + timeout
|
timeout_after = time.time() + link_establishment_window(
|
||||||
|
link,
|
||||||
|
destination_hash,
|
||||||
|
self.reticulum,
|
||||||
|
)
|
||||||
while link.status != RNS.Link.ACTIVE and time.time() < timeout_after:
|
while link.status != RNS.Link.ACTIVE and time.time() < timeout_after:
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,8 @@ class RNProbeHandler:
|
||||||
|
|
||||||
timeout_after = time.time() + (
|
timeout_after = time.time() + (
|
||||||
timeout
|
timeout
|
||||||
or self.DEFAULT_TIMEOUT
|
if timeout is not None
|
||||||
+ self.reticulum.get_first_hop_timeout(destination_hash)
|
else path_response_window(destination_hash, self.reticulum)
|
||||||
)
|
)
|
||||||
while (
|
while (
|
||||||
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
|
not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ from typing import Optional
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
from meshchatx.src.backend import reticulum_pathfinding
|
from meshchatx.src.backend import reticulum_pathfinding
|
||||||
from meshchatx.src.backend.path_utils import path_response_window
|
from meshchatx.src.backend.path_utils import (
|
||||||
|
link_establishment_window,
|
||||||
|
path_response_window,
|
||||||
|
)
|
||||||
from meshchatx.src.backend.reticulum_pathfinding import ReticulumLike
|
from meshchatx.src.backend.reticulum_pathfinding import ReticulumLike
|
||||||
|
|
||||||
# Cache of established RNS Links keyed by (aspect_str, destination_hash_bytes).
|
# Cache of established RNS Links keyed by (aspect_str, destination_hash_bytes).
|
||||||
|
|
@ -38,10 +41,8 @@ LINK_IDLE_TTL_S = 30 * 60
|
||||||
# Wait granularity while polling for path / link (seconds).
|
# Wait granularity while polling for path / link (seconds).
|
||||||
_POLL_INTERVAL_S = 0.02
|
_POLL_INTERVAL_S = 0.02
|
||||||
|
|
||||||
# Slow path and link margins
|
# Slow path UI phase margin before finding_path_slow
|
||||||
PATH_MARGIN_S = 5.0
|
PATH_MARGIN_S = 5.0
|
||||||
LINK_MARGIN_S = 5.0
|
|
||||||
_FALLBACK_LINK_TIMEOUT_S = 15.0
|
|
||||||
|
|
||||||
|
|
||||||
def cached_link_count() -> int:
|
def cached_link_count() -> int:
|
||||||
|
|
@ -368,13 +369,14 @@ class RnsLinkManager:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the link establishment_timeout from RNS if not explicitly pinned
|
# Get the link establishment_timeout from RNS if not explicitly pinned
|
||||||
rns_timeout = getattr(link, "establishment_timeout", None)
|
|
||||||
if link_establishment_timeout is not None:
|
if link_establishment_timeout is not None:
|
||||||
deadline = time.time() + link_establishment_timeout
|
deadline = time.time() + link_establishment_timeout
|
||||||
elif isinstance(rns_timeout, (int, float)) and rns_timeout > 0:
|
|
||||||
deadline = time.time() + rns_timeout + LINK_MARGIN_S
|
|
||||||
else:
|
else:
|
||||||
deadline = time.time() + _FALLBACK_LINK_TIMEOUT_S
|
deadline = time.time() + link_establishment_window(
|
||||||
|
link,
|
||||||
|
destination_hash,
|
||||||
|
self._get_reticulum(),
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
while (
|
while (
|
||||||
link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED)
|
link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED)
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,7 @@ class RRCHub:
|
||||||
if gate is not None:
|
if gate is not None:
|
||||||
gate.acquire()
|
gate.acquire()
|
||||||
try:
|
try:
|
||||||
|
path_wait = timeout_s
|
||||||
if not RNS.Transport.has_path(self.hub_hash):
|
if not RNS.Transport.has_path(self.hub_hash):
|
||||||
RNS.Transport.request_path(self.hub_hash)
|
RNS.Transport.request_path(self.hub_hash)
|
||||||
try:
|
try:
|
||||||
|
|
@ -328,7 +329,7 @@ class RRCHub:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
|
||||||
hub_identity = None
|
hub_identity = None
|
||||||
deadline = time.monotonic() + timeout_s
|
deadline = time.monotonic() + path_wait
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
hub_identity = RNS.Identity.recall(self.hub_hash)
|
hub_identity = RNS.Identity.recall(self.hub_hash)
|
||||||
if hub_identity is not None:
|
if hub_identity is not None:
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from meshchatx.src.backend.meshchat_utils import (
|
||||||
hex_identifier_to_bytes,
|
hex_identifier_to_bytes,
|
||||||
normalize_hex_identifier,
|
normalize_hex_identifier,
|
||||||
)
|
)
|
||||||
|
from meshchatx.src.backend.path_utils import path_response_window
|
||||||
|
|
||||||
|
|
||||||
class Tee:
|
class Tee:
|
||||||
|
|
@ -638,9 +639,17 @@ class TelephoneManager:
|
||||||
|
|
||||||
if not RNS.Transport.has_path(call_destination_hash):
|
if not RNS.Transport.has_path(call_destination_hash):
|
||||||
self._update_initiation_status("Requesting path...")
|
self._update_initiation_status("Requesting path...")
|
||||||
|
path_wait = float(timeout_seconds)
|
||||||
|
try:
|
||||||
|
path_wait = max(
|
||||||
|
path_wait,
|
||||||
|
path_response_window(call_destination_hash),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
has_path = await self._await_path(
|
has_path = await self._await_path(
|
||||||
call_destination_hash,
|
call_destination_hash,
|
||||||
timeout_seconds=min(timeout_seconds, 10),
|
timeout_seconds=path_wait,
|
||||||
)
|
)
|
||||||
if self._is_initiation_cancelled():
|
if self._is_initiation_cancelled():
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -226,8 +226,11 @@
|
||||||
>
|
>
|
||||||
<!-- toggle button for desktop (h-10 aligns with Messages/Nomad collapse rows) -->
|
<!-- toggle button for desktop (h-10 aligns with Messages/Nomad collapse rows) -->
|
||||||
<div
|
<div
|
||||||
class="h-10 shrink-0 items-center justify-end gap-1 border-b border-gray-200 dark:border-zinc-800 px-2"
|
class="h-10 shrink-0 items-center gap-1 border-b border-gray-200 dark:border-zinc-800 px-2"
|
||||||
:class="isSidebarNavEditing && !isSidebarCollapsed ? 'flex' : 'hidden sm:flex'"
|
:class="[
|
||||||
|
isSidebarNavEditing && !isSidebarCollapsed ? 'flex' : 'hidden sm:flex',
|
||||||
|
isSidebarCollapsed ? 'justify-center' : 'justify-end',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-if="isSidebarNavEditing && !isSidebarCollapsed"
|
v-if="isSidebarNavEditing && !isSidebarCollapsed"
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@
|
||||||
isActive
|
isActive
|
||||||
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
|
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
|
||||||
: 'hover:bg-gray-100 dark:hover:bg-zinc-700',
|
: 'hover:bg-gray-100 dark:hover:bg-zinc-700',
|
||||||
isCollapsed ? 'overflow-visible' : 'overflow-hidden',
|
isCollapsed ? 'overflow-visible justify-center rounded-lg' : 'overflow-hidden rounded-r-full mr-2',
|
||||||
]"
|
]"
|
||||||
class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"
|
class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 p-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"
|
||||||
@click="handleNavigate($event, navigate)"
|
@click="handleNavigate($event, navigate)"
|
||||||
>
|
>
|
||||||
<span class="my-auto shrink-0">
|
<span class="my-auto shrink-0">
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
data-testid="sidebar-account-chip"
|
data-testid="sidebar-account-chip"
|
||||||
@click="onAccountChipClick"
|
@click="onAccountChipClick"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 p-3 pb-1">
|
<div class="flex items-center gap-2" :class="isCollapsed ? 'justify-center p-2' : 'p-3 pb-1'">
|
||||||
<RouterLink :to="{ name: 'profile.icon' }" class="shrink-0" @click.stop>
|
<RouterLink :to="{ name: 'profile.icon' }" class="shrink-0" @click.stop>
|
||||||
<LxmfUserIcon
|
<LxmfUserIcon
|
||||||
:icon-name="config.lxmf_user_icon_name"
|
:icon-name="config.lxmf_user_icon_name"
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@
|
||||||
<div v-if="config">
|
<div v-if="config">
|
||||||
<div class="bg-white border-t border-gray-200 dark:border-zinc-800 dark:bg-zinc-950">
|
<div class="bg-white border-t border-gray-200 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
<div
|
<div
|
||||||
class="flex text-gray-700 p-3 cursor-pointer"
|
class="flex text-gray-700 cursor-pointer"
|
||||||
|
:class="isCollapsed ? 'justify-center p-2' : 'p-3'"
|
||||||
@click="isShowingMyIdentitySection = !isShowingMyIdentitySection"
|
@click="isShowingMyIdentitySection = !isShowingMyIdentitySection"
|
||||||
>
|
>
|
||||||
<div class="my-auto mr-2 shrink-0">
|
<div :class="isCollapsed ? 'shrink-0' : 'my-auto mr-2 shrink-0'">
|
||||||
<RouterLink :to="{ name: 'profile.icon' }" @click.stop>
|
<RouterLink :to="{ name: 'profile.icon' }" @click.stop>
|
||||||
<LxmfUserIcon
|
<LxmfUserIcon
|
||||||
:icon-name="config.lxmf_user_icon_name"
|
:icon-name="config.lxmf_user_icon_name"
|
||||||
|
|
@ -72,13 +73,15 @@
|
||||||
|
|
||||||
<div class="bg-white border-t border-gray-200 dark:border-zinc-800 dark:bg-zinc-950">
|
<div class="bg-white border-t border-gray-200 dark:border-zinc-800 dark:bg-zinc-950">
|
||||||
<div
|
<div
|
||||||
class="flex text-gray-700 p-3 cursor-pointer dark:text-white"
|
class="flex text-gray-700 cursor-pointer dark:text-white"
|
||||||
|
:class="isCollapsed ? 'justify-center p-2' : 'p-3'"
|
||||||
data-testid="sidebar-announce-header"
|
data-testid="sidebar-announce-header"
|
||||||
@click="isShowingAnnounceSection = !isShowingAnnounceSection"
|
@click="isShowingAnnounceSection = !isShowingAnnounceSection"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="my-auto mr-2 flex shrink-0 items-center justify-center rounded-md border-0 bg-transparent p-0 text-inherit cursor-pointer"
|
class="flex shrink-0 items-center justify-center rounded-md border-0 bg-transparent p-0 text-inherit cursor-pointer"
|
||||||
|
:class="isCollapsed ? '' : 'my-auto mr-2'"
|
||||||
:title="$t('app.announce_now')"
|
:title="$t('app.announce_now')"
|
||||||
data-testid="sidebar-announce-radio"
|
data-testid="sidebar-announce-radio"
|
||||||
@click.stop="$emit('send-announce')"
|
@click.stop="$emit('send-announce')"
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 overflow-y-auto" :class="isEditing ? 'select-none' : ''" data-testid="sidebar-classic-nav">
|
<div class="flex-1 overflow-y-auto" :class="isEditing ? 'select-none' : ''" data-testid="sidebar-classic-nav">
|
||||||
<ul class="py-3 pr-2 space-y-1">
|
<ul class="py-3 space-y-1" :class="isCollapsed ? 'px-0' : 'pr-2'">
|
||||||
<li
|
<li
|
||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
:class="[
|
:class="[
|
||||||
|
isCollapsed ? 'justify-center' : '',
|
||||||
isEditing && draggingId === item.id && draggingKind === 'item' ? 'opacity-50' : '',
|
isEditing && draggingId === item.id && draggingKind === 'item' ? 'opacity-50' : '',
|
||||||
isEditing && dragOverKey === `item:${item.id}`
|
isEditing && dragOverKey === `item:${item.id}`
|
||||||
? 'ring-1 ring-blue-400 dark:ring-blue-500 rounded-r-full'
|
? 'ring-1 ring-blue-400 dark:ring-blue-500 rounded-r-full'
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,8 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
class="py-1 pr-2 space-y-1"
|
class="py-1 space-y-1"
|
||||||
|
:class="isCollapsed ? 'px-0' : 'pr-2'"
|
||||||
@dragover.prevent="setNavDragOver(`group-end:${group.id}`, $event)"
|
@dragover.prevent="setNavDragOver(`group-end:${group.id}`, $event)"
|
||||||
@drop.prevent="onGroupListDrop(group.id)"
|
@drop.prevent="onGroupListDrop(group.id)"
|
||||||
>
|
>
|
||||||
|
|
@ -57,6 +58,7 @@
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="flex items-center"
|
class="flex items-center"
|
||||||
:class="[
|
:class="[
|
||||||
|
isCollapsed ? 'justify-center' : '',
|
||||||
isEditing && draggingId === item.id && draggingKind === 'item' ? 'opacity-50' : '',
|
isEditing && draggingId === item.id && draggingKind === 'item' ? 'opacity-50' : '',
|
||||||
isEditing && dragOverKey === `item:${item.id}`
|
isEditing && dragOverKey === `item:${item.id}`
|
||||||
? 'ring-1 ring-blue-400 dark:ring-blue-500 rounded-r-full'
|
? 'ring-1 ring-blue-400 dark:ring-blue-500 rounded-r-full'
|
||||||
|
|
@ -145,8 +147,8 @@
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm font-semibold text-gray-700 hover:bg-gray-100 dark:text-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
class="flex w-full items-center gap-3 py-2.5 text-sm font-semibold text-gray-700 hover:bg-gray-100 dark:text-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
||||||
:class="isCollapsed ? 'justify-center' : ''"
|
:class="isCollapsed ? 'justify-center px-0' : 'px-4'"
|
||||||
data-testid="sidebar-more-toggle"
|
data-testid="sidebar-more-toggle"
|
||||||
@pointerdown="onNavHoldPointerDown"
|
@pointerdown="onNavHoldPointerDown"
|
||||||
@pointermove="onNavHoldPointerMove"
|
@pointermove="onNavHoldPointerMove"
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,7 @@
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="hidden sm:flex h-10 shrink-0 items-center border-b border-gray-200 dark:border-zinc-800 px-2"
|
class="hidden sm:flex h-10 shrink-0 items-center justify-center border-b border-gray-200 dark:border-zinc-800 px-2"
|
||||||
:class="collapsedHeaderJustifyClass"
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1001,9 +1000,6 @@ export default {
|
||||||
selectionEdgeBorderClass() {
|
selectionEdgeBorderClass() {
|
||||||
return this.isRightSidebar ? "border-r-2" : "border-l-2";
|
return this.isRightSidebar ? "border-r-2" : "border-l-2";
|
||||||
},
|
},
|
||||||
collapsedHeaderJustifyClass() {
|
|
||||||
return this.isRightSidebar ? "justify-start" : "justify-end";
|
|
||||||
},
|
|
||||||
collapsedStripChevronIcon() {
|
collapsedStripChevronIcon() {
|
||||||
return this.isRightSidebar ? "chevron-left" : "chevron-right";
|
return this.isRightSidebar ? "chevron-left" : "chevron-right";
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
class="flex flex-col h-full min-h-0 bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-800"
|
class="flex flex-col h-full min-h-0 bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-800"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="hidden sm:flex h-10 shrink-0 items-center justify-end border-b border-gray-200 dark:border-zinc-800 px-2"
|
class="hidden sm:flex h-10 shrink-0 items-center justify-center border-b border-gray-200 dark:border-zinc-800 px-2"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -97,18 +97,36 @@ function rewriteHrefAttrs(text, { zipLocalOk }) {
|
||||||
return { text: out, stripped };
|
return { text: out, stripped };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapDescriptionInner(inner) {
|
||||||
|
const trimmed = String(inner).trim();
|
||||||
|
const cdata = trimmed.match(/^<!\[CDATA\[([\s\S]*?)\]\]>$/);
|
||||||
|
if (cdata) {
|
||||||
|
return cdata[1];
|
||||||
|
}
|
||||||
|
return String(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXmlText(value) {
|
||||||
|
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeHtml(value) {
|
||||||
|
return /<[a-z][\s\S]*>/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
function stripDescriptionHtml(text) {
|
function stripDescriptionHtml(text) {
|
||||||
const stripped = [];
|
const stripped = [];
|
||||||
const out = String(text).replace(/<description\b[^>]*>([\s\S]*?)<\/description>/gi, (full, inner) => {
|
const out = String(text).replace(/<description\b[^>]*>([\s\S]*?)<\/description>/gi, (full, inner) => {
|
||||||
if (/<[a-z][\s\S]*>/i.test(inner)) {
|
const content = unwrapDescriptionInner(inner);
|
||||||
stripped.push("html_description");
|
if (!looksLikeHtml(content) && !looksLikeHtml(inner)) {
|
||||||
const plain = String(inner)
|
return full;
|
||||||
.replace(/<[^>]+>/g, " ")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
return `<description>${plain}</description>`;
|
|
||||||
}
|
}
|
||||||
return full;
|
stripped.push("html_description");
|
||||||
|
const plain = content
|
||||||
|
.replace(/<[^>]+>/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
return `<description>${escapeXmlText(plain)}</description>`;
|
||||||
});
|
});
|
||||||
return { text: out, stripped };
|
return { text: out, stripped };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,8 @@ export async function readKmzToFeatures(arrayBuffer, featureProjection) {
|
||||||
throw new KmlSanitizeError("path_traversal");
|
throw new KmlSanitizeError("path_traversal");
|
||||||
}
|
}
|
||||||
if (!kmzEntryAllowed(name)) {
|
if (!kmzEntryAllowed(name)) {
|
||||||
throw new KmlSanitizeError("unsafe_kmz_entry");
|
// ArcGIS KMZ exports include unused .xsl balloon stylesheets.
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const kmlName = findKmlEntryName(zip);
|
const kmlName = findKmlEntryName(zip);
|
||||||
|
|
|
||||||
|
|
@ -101,15 +101,67 @@ def test_sanitize_kml_keeps_data_png_icon():
|
||||||
assert b"data:image/png" in result.data
|
assert b"data:image/png" in result.data
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_kmz_rejects_svg_entry():
|
def test_sanitize_kml_flattens_cdata_html_description():
|
||||||
|
kml = b"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||||
|
<Placemark><name>Leaking Tanks</name>
|
||||||
|
<description><![CDATA[<h2>Hostile</h2><script>alert(1)</script>]]></description>
|
||||||
|
<Point><coordinates>-117.99,33.78,0</coordinates></Point>
|
||||||
|
</Placemark>
|
||||||
|
</Document></kml>"""
|
||||||
|
result = sanitize_geo_bytes(kml)
|
||||||
|
assert result.feature_count == 1
|
||||||
|
assert "html_description" in result.stripped
|
||||||
|
lower = result.data.lower()
|
||||||
|
assert b"<script" not in lower
|
||||||
|
assert b"<h2" not in lower
|
||||||
|
assert b"hostile" in lower
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_kmz_skips_unreferenced_svg_keeps_placemark():
|
||||||
kml = b"""<?xml version="1.0"?>
|
kml = b"""<?xml version="1.0"?>
|
||||||
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||||
<Placemark><Point><coordinates>1,2,0</coordinates></Point></Placemark>
|
<Placemark><Point><coordinates>1,2,0</coordinates></Point></Placemark>
|
||||||
</Document></kml>"""
|
</Document></kml>"""
|
||||||
data = _kmz({"doc.kml": kml, "icon.svg": b"<svg></svg>"})
|
data = _kmz({"doc.kml": kml, "icon.svg": b"<svg></svg>"})
|
||||||
with pytest.raises(GeoValidationError) as exc:
|
result = sanitize_geo_bytes(data)
|
||||||
sanitize_geo_bytes(data)
|
assert result.format == "kmz"
|
||||||
assert exc.value.code == "unsafe_kmz_entry"
|
assert result.feature_count == 1
|
||||||
|
assert "skipped_kmz_entry" in result.stripped
|
||||||
|
with zipfile.ZipFile(io.BytesIO(result.data)) as zf:
|
||||||
|
names = [n.replace("\\", "/").lower() for n in zf.namelist() if not n.endswith("/")]
|
||||||
|
assert "doc.kml" in names
|
||||||
|
assert "icon.svg" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_kmz_skips_arcgis_xsl_sidecar():
|
||||||
|
kml = b"""<?xml version="1.0"?>
|
||||||
|
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||||
|
<Style><IconStyle><Icon><href>Layer0_Symbol.png</href></Icon></IconStyle></Style>
|
||||||
|
<Placemark><name>ArcGIS Point</name>
|
||||||
|
<styleUrl>#s</styleUrl>
|
||||||
|
<Point><coordinates>1,2,0</coordinates></Point>
|
||||||
|
</Placemark>
|
||||||
|
</Document></kml>"""
|
||||||
|
data = _kmz(
|
||||||
|
{
|
||||||
|
"doc.kml": kml,
|
||||||
|
"F2E8A9CB2E0A446C9BCA87742DD683E5.xsl": (
|
||||||
|
b"<?xml version='1.0'?>"
|
||||||
|
b"<xsl:stylesheet xmlns:xsl='http://www.w3.org/1999/XSL/Transform' version='1.0'/>"
|
||||||
|
),
|
||||||
|
"Layer0_Symbol.png": TINY_PNG,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = sanitize_geo_bytes(data)
|
||||||
|
assert result.format == "kmz"
|
||||||
|
assert result.feature_count == 1
|
||||||
|
assert "skipped_kmz_entry" in result.stripped
|
||||||
|
with zipfile.ZipFile(io.BytesIO(result.data)) as zf:
|
||||||
|
names = [n.replace("\\", "/") for n in zf.namelist() if not n.endswith("/")]
|
||||||
|
assert "doc.kml" in names
|
||||||
|
assert "Layer0_Symbol.png" in names
|
||||||
|
assert not any(n.lower().endswith(".xsl") for n in names)
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_kmz_keeps_zip_local_png():
|
def test_sanitize_kmz_keeps_zip_local_png():
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,11 @@ import pytest
|
||||||
import RNS
|
import RNS
|
||||||
|
|
||||||
from meshchatx.src.backend.path_utils import (
|
from meshchatx.src.backend.path_utils import (
|
||||||
|
LINK_ESTABLISHMENT_MARGIN_S,
|
||||||
MIN_WINDOW_BITRATE,
|
MIN_WINDOW_BITRATE,
|
||||||
PATH_EXCHANGE_BYTES,
|
PATH_EXCHANGE_BYTES,
|
||||||
|
link_establishment_window,
|
||||||
|
min_window_bitrate,
|
||||||
path_response_window,
|
path_response_window,
|
||||||
slowest_online_bitrate,
|
slowest_online_bitrate,
|
||||||
)
|
)
|
||||||
|
|
@ -19,6 +22,12 @@ def _bitrate_floor_s(bitrate: float) -> float:
|
||||||
return 2 * (PATH_EXCHANGE_BYTES * 8 / max(bitrate, MIN_WINDOW_BITRATE)) + 10
|
return 2 * (PATH_EXCHANGE_BYTES * 8 / max(bitrate, MIN_WINDOW_BITRATE)) + 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_min_window_bitrate_matches_rns_minimum():
|
||||||
|
assert min_window_bitrate() == float(RNS.Reticulum.MINIMUM_BITRATE)
|
||||||
|
assert MIN_WINDOW_BITRATE == float(RNS.Reticulum.MINIMUM_BITRATE)
|
||||||
|
assert MIN_WINDOW_BITRATE == 5.0
|
||||||
|
|
||||||
|
|
||||||
def test_slowest_online_bitrate_picks_min_up_interface():
|
def test_slowest_online_bitrate_picks_min_up_interface():
|
||||||
reticulum = MagicMock()
|
reticulum = MagicMock()
|
||||||
reticulum.get_interface_stats.return_value = {
|
reticulum.get_interface_stats.return_value = {
|
||||||
|
|
@ -60,6 +69,18 @@ def test_path_response_window_uses_slow_bitrate_airtime_floor():
|
||||||
assert expected == pytest.approx(48.4)
|
assert expected == pytest.approx(48.4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_response_window_uses_five_bps_not_a_fifty_bps_clamp():
|
||||||
|
reticulum = MagicMock()
|
||||||
|
reticulum.get_first_hop_timeout.return_value = 2.0
|
||||||
|
reticulum.get_interface_stats.return_value = {
|
||||||
|
"interfaces": [{"status": True, "bitrate": 5}],
|
||||||
|
}
|
||||||
|
window = path_response_window(DEST, reticulum)
|
||||||
|
expected = _bitrate_floor_s(5)
|
||||||
|
assert expected == pytest.approx(778.0)
|
||||||
|
assert window == pytest.approx(expected)
|
||||||
|
|
||||||
|
|
||||||
def test_path_response_window_never_below_rns_path_request_timeout():
|
def test_path_response_window_never_below_rns_path_request_timeout():
|
||||||
reticulum = MagicMock()
|
reticulum = MagicMock()
|
||||||
reticulum.get_first_hop_timeout.return_value = 0.1
|
reticulum.get_first_hop_timeout.return_value = 0.1
|
||||||
|
|
@ -69,3 +90,29 @@ def test_path_response_window_never_below_rns_path_request_timeout():
|
||||||
window = path_response_window(DEST, reticulum)
|
window = path_response_window(DEST, reticulum)
|
||||||
assert window >= RNS.Transport.PATH_REQUEST_TIMEOUT
|
assert window >= RNS.Transport.PATH_REQUEST_TIMEOUT
|
||||||
assert window == float(RNS.Transport.PATH_REQUEST_TIMEOUT)
|
assert window == float(RNS.Transport.PATH_REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_response_window_survives_missing_reticulum_instance():
|
||||||
|
reticulum = MagicMock()
|
||||||
|
reticulum.get_first_hop_timeout.side_effect = RuntimeError("no instance")
|
||||||
|
reticulum.get_interface_stats.side_effect = RuntimeError("no instance")
|
||||||
|
window = path_response_window(DEST, reticulum)
|
||||||
|
assert window == float(RNS.Transport.PATH_REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_establishment_window_uses_rns_timeout_plus_margin():
|
||||||
|
link = MagicMock()
|
||||||
|
link.establishment_timeout = 12.0
|
||||||
|
assert link_establishment_window(link) == 12.0 + LINK_ESTABLISHMENT_MARGIN_S
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_establishment_window_falls_back_to_path_window():
|
||||||
|
link = MagicMock()
|
||||||
|
link.establishment_timeout = None
|
||||||
|
reticulum = MagicMock()
|
||||||
|
reticulum.get_first_hop_timeout.return_value = 2.0
|
||||||
|
reticulum.get_interface_stats.return_value = {
|
||||||
|
"interfaces": [{"status": True, "bitrate": 1_000_000}],
|
||||||
|
}
|
||||||
|
window = link_establishment_window(link, DEST, reticulum)
|
||||||
|
assert window == float(RNS.Transport.PATH_REQUEST_TIMEOUT)
|
||||||
|
|
|
||||||
|
|
@ -220,11 +220,21 @@ def test_prepare_fresh_uses_expire_path_without_reticulum():
|
||||||
req.assert_called_once_with(DEST)
|
req.assert_called_once_with(DEST)
|
||||||
|
|
||||||
|
|
||||||
def test_lxmf_path_wait_cap_uses_rns_default():
|
def test_lxmf_path_wait_cap_uses_rns_default_without_destination():
|
||||||
v = rp.lxmf_path_wait_cap_seconds()
|
v = rp.lxmf_path_wait_cap_seconds()
|
||||||
assert 30.0 <= v <= 120.0
|
assert 30.0 <= v <= 120.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_lxmf_path_wait_cap_uses_path_response_window_for_destination():
|
||||||
|
reticulum = MagicMock()
|
||||||
|
with patch(
|
||||||
|
"meshchatx.src.backend.reticulum_pathfinding.path_response_window",
|
||||||
|
return_value=86.8,
|
||||||
|
) as mocked:
|
||||||
|
assert rp.lxmf_path_wait_cap_seconds(DEST, reticulum) == 86.8
|
||||||
|
mocked.assert_called_once_with(DEST, reticulum)
|
||||||
|
|
||||||
|
|
||||||
def test_lxmf_path_wait_cap_falls_back_when_float_fails():
|
def test_lxmf_path_wait_cap_falls_back_when_float_fails():
|
||||||
with patch.object(RNS.Transport, "PATH_REQUEST_TIMEOUT", "x"):
|
with patch.object(RNS.Transport, "PATH_REQUEST_TIMEOUT", "x"):
|
||||||
assert rp.lxmf_path_wait_cap_seconds() == 30.0
|
assert rp.lxmf_path_wait_cap_seconds() == 30.0
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,43 @@ async def test_cancel_between_identity_resolved_and_path_request(telephone_manag
|
||||||
assert not telephone_manager.telephone.call.called
|
assert not telephone_manager.telephone.call.called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initiate_path_wait_uses_adaptive_window_not_ten_second_cap(
|
||||||
|
telephone_manager,
|
||||||
|
):
|
||||||
|
destination_hash = bytes.fromhex("ab" * 16)
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_await_path(_dest, timeout_seconds=15):
|
||||||
|
captured["timeout"] = timeout_seconds
|
||||||
|
return True
|
||||||
|
|
||||||
|
telephone_manager.telephone.call.side_effect = lambda *_a, **_k: setattr(
|
||||||
|
telephone_manager.telephone,
|
||||||
|
"call_status",
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"meshchatx.src.backend.telephone_manager.RNS.Identity.recall",
|
||||||
|
return_value=MagicMock(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"meshchatx.src.backend.telephone_manager.RNS.Transport.has_path",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"meshchatx.src.backend.telephone_manager.path_response_window",
|
||||||
|
return_value=86.8,
|
||||||
|
),
|
||||||
|
patch.object(telephone_manager, "_await_path", side_effect=fake_await_path),
|
||||||
|
):
|
||||||
|
await telephone_manager.initiate(destination_hash, timeout_seconds=15)
|
||||||
|
|
||||||
|
assert captured["timeout"] == 86.8
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cancel_after_path_found_before_dialling_stabilizes(telephone_manager):
|
async def test_cancel_after_path_found_before_dialling_stabilizes(telephone_manager):
|
||||||
destination_hash = bytes.fromhex("ee" * 16)
|
destination_hash = bytes.fromhex("ee" * 16)
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,19 @@ describe("AppSidebarNav edit hold", () => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("centers collapsed nav items and the More toggle", () => {
|
||||||
|
const wrapper = mountGrouped({ isCollapsed: true });
|
||||||
|
const more = wrapper.get('[data-testid="sidebar-more-toggle"]');
|
||||||
|
expect(more.classes()).toContain("justify-center");
|
||||||
|
expect(more.classes()).not.toContain("px-4");
|
||||||
|
const item = wrapper.get('[data-nav-item-id="messages"]');
|
||||||
|
expect(item.classes()).toContain("justify-center");
|
||||||
|
const link = item.find("a.w-full");
|
||||||
|
expect(link.classes()).toContain("justify-center");
|
||||||
|
expect(link.classes()).not.toContain("mr-2");
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not emit edit-start when the sidebar is collapsed", async () => {
|
it("does not emit edit-start when the sidebar is collapsed", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const wrapper = mountGrouped({ isCollapsed: true });
|
const wrapper = mountGrouped({ isCollapsed: true });
|
||||||
|
|
@ -131,6 +144,27 @@ describe("AppSidebarNav edit hold", () => {
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classic nav centers collapsed items", () => {
|
||||||
|
const wrapper = mount(AppSidebarClassicNav, {
|
||||||
|
props: {
|
||||||
|
navItems: groups[0].items,
|
||||||
|
isCollapsed: true,
|
||||||
|
isEditing: false,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [i18n],
|
||||||
|
stubs: {
|
||||||
|
RouterLink: RouterLinkStub,
|
||||||
|
MaterialDesignIcon: { template: '<span class="md-stub" />' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const item = wrapper.get('[data-nav-item-id="messages"]');
|
||||||
|
expect(item.classes()).toContain("justify-center");
|
||||||
|
expect(item.find("a.w-full").classes()).toContain("justify-center");
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it("classic nav also holds to edit when expanded", async () => {
|
it("classic nav also holds to edit when expanded", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const wrapper = mount(AppSidebarClassicNav, {
|
const wrapper = mount(AppSidebarClassicNav, {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,17 @@ describe("SidebarLink UI", () => {
|
||||||
expect(wrapper.vm.isCollapsed).toBe(true);
|
expect(wrapper.vm.isCollapsed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("centers the icon and hides text when collapsed", () => {
|
||||||
|
const wrapper = mountSidebarLink({ isCollapsed: true });
|
||||||
|
const innerLink = wrapper.find("a.justify-center");
|
||||||
|
expect(innerLink.exists()).toBe(true);
|
||||||
|
expect(innerLink.classes()).toContain("justify-center");
|
||||||
|
expect(innerLink.classes()).not.toContain("mr-2");
|
||||||
|
expect(innerLink.classes()).toContain("rounded-lg");
|
||||||
|
expect(wrapper.text()).not.toContain("Messages");
|
||||||
|
expect(wrapper.find(".icon-slot").exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not navigate when editMode is on", async () => {
|
it("does not navigate when editMode is on", async () => {
|
||||||
const wrapper = mountSidebarLink({ editMode: true });
|
const wrapper = mountSidebarLink({ editMode: true });
|
||||||
const innerLink = wrapper.find("a.rounded-r-full");
|
const innerLink = wrapper.find("a.rounded-r-full");
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ exports[`UI snapshot regression > FormSubLabel.vue > default sub-label 1`] = `"<
|
||||||
|
|
||||||
exports[`UI snapshot regression > IconButton.vue > icon button 1`] = `"<button type="button" class="text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-100 hover:bg-gray-100 dark:hover:bg-zinc-800 p-2 rounded-full w-9 h-9 flex items-center justify-center shrink-0 transition-all duration-200"><span class="icon">+</span></button>"`;
|
exports[`UI snapshot regression > IconButton.vue > icon button 1`] = `"<button type="button" class="text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-100 hover:bg-gray-100 dark:hover:bg-zinc-800 p-2 rounded-full w-9 h-9 flex items-center justify-center shrink-0 transition-all duration-200"><span class="icon">+</span></button>"`;
|
||||||
|
|
||||||
exports[`UI snapshot regression > SidebarLink.vue > sidebar link 1`] = `"<a class="router-link-stub" href="#" custom=""><a href="#" type="button" draggable="false" class="hover:bg-gray-100 dark:hover:bg-zinc-700 overflow-hidden w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"><span class="my-auto shrink-0"><span class="icon-slot">M</span></span><span class="my-auto flex w-full truncate transition-all duration-300">Messages</span></a></a>"`;
|
exports[`UI snapshot regression > SidebarLink.vue > sidebar link 1`] = `"<a class="router-link-stub" href="#" custom=""><a href="#" type="button" draggable="false" class="hover:bg-gray-100 dark:hover:bg-zinc-700 overflow-hidden rounded-r-full mr-2 w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 p-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"><span class="my-auto shrink-0"><span class="icon-slot">M</span></span><span class="my-auto flex w-full truncate transition-all duration-300">Messages</span></a></a>"`;
|
||||||
|
|
||||||
exports[`UI snapshot regression > Toast.vue > empty state 1`] = `"<div="" class="fixed max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
|
exports[`UI snapshot regression > Toast.vue > empty state 1`] = `"<div="" class="fixed max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,15 +116,60 @@ describe("kmlSanitize oracle", () => {
|
||||||
expect(out.stripped).toContain("remote_href");
|
expect(out.stripped).toContain("remote_href");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects kmz with svg entry", async () => {
|
it("flattens CDATA HTML descriptions without leaving a CDATA closer", () => {
|
||||||
|
const kml = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||||
|
<Placemark><name>Leaking Tanks</name>
|
||||||
|
<description><![CDATA[<h2>Hostile</h2></br><script>alert(1)</script>]]></description>
|
||||||
|
<Point><coordinates>-117.99,33.78,0</coordinates></Point>
|
||||||
|
</Placemark></Document></kml>`;
|
||||||
|
const out = sanitizeKmlText(kml);
|
||||||
|
expect(out.stripped).toContain("html_description");
|
||||||
|
expect(out.text).not.toMatch(/\]\]>/);
|
||||||
|
expect(out.text.toLowerCase()).not.toContain("<script");
|
||||||
|
expect(out.text.toLowerCase()).not.toContain("<h2");
|
||||||
|
const parsed = new DOMParser().parseFromString(out.text, "application/xml");
|
||||||
|
expect(parsed.getElementsByTagName("parsererror").length).toBe(0);
|
||||||
|
const features = readKmlToFeatures(kml, "EPSG:3857");
|
||||||
|
expect(features.length).toBe(1);
|
||||||
|
expect(String(features[0].get("name") || "")).toContain("Leaking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips unreferenced svg kmz entry and keeps placemarks", async () => {
|
||||||
const kml = `<?xml version="1.0"?>
|
const kml = `<?xml version="1.0"?>
|
||||||
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||||
<Placemark><Point><coordinates>1,2,0</coordinates></Point></Placemark>
|
<Placemark><name>Keep</name><Point><coordinates>1,2,0</coordinates></Point></Placemark>
|
||||||
</Document></kml>`;
|
</Document></kml>`;
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
zip.file("doc.kml", kml);
|
zip.file("doc.kml", kml);
|
||||||
zip.file("icon.svg", "<svg></svg>");
|
zip.file("icon.svg", "<svg></svg>");
|
||||||
const buf = await zip.generateAsync({ type: "arraybuffer" });
|
const buf = await zip.generateAsync({ type: "arraybuffer" });
|
||||||
await expect(readKmzToFeatures(buf, "EPSG:3857")).rejects.toMatchObject({ code: "unsafe_kmz_entry" });
|
const features = await readKmzToFeatures(buf, "EPSG:3857");
|
||||||
|
expect(features.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("imports ArcGIS-style kmz with unused xsl sidecar", async () => {
|
||||||
|
const kml = `<?xml version="1.0"?>
|
||||||
|
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||||
|
<Style id="s"><IconStyle><Icon><href>Layer0_Symbol.png</href></Icon></IconStyle></Style>
|
||||||
|
<Placemark><name>ArcGIS Point</name>
|
||||||
|
<styleUrl>#s</styleUrl>
|
||||||
|
<Point><coordinates>1,2,0</coordinates></Point>
|
||||||
|
</Placemark></Document></kml>`;
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file("doc.kml", kml);
|
||||||
|
zip.file(
|
||||||
|
"F2E8A9CB2E0A446C9BCA87742DD683E5.xsl",
|
||||||
|
"<?xml version='1.0'?><xsl:stylesheet xmlns:xsl='http://www.w3.org/1999/XSL/Transform' version='1.0'/>"
|
||||||
|
);
|
||||||
|
const png = Uint8Array.from(
|
||||||
|
atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="),
|
||||||
|
(c) => c.charCodeAt(0)
|
||||||
|
);
|
||||||
|
zip.file("Layer0_Symbol.png", png);
|
||||||
|
const buf = await zip.generateAsync({ type: "arraybuffer" });
|
||||||
|
const features = await readKmzToFeatures(buf, "EPSG:3857");
|
||||||
|
expect(features.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(String(features[0].get("name") || "")).toContain("ArcGIS");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
33
vendor/rns_filesync/rns_filesync/peers.py
vendored
33
vendor/rns_filesync/rns_filesync/peers.py
vendored
|
|
@ -75,16 +75,37 @@ def is_local_destination(destination_hash: bytes) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def adaptive_path_timeout(
|
||||||
|
destination_hash: bytes,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> float:
|
||||||
|
if timeout is not None:
|
||||||
|
return float(timeout)
|
||||||
|
try:
|
||||||
|
from meshchatx.src.backend.path_utils import path_response_window
|
||||||
|
|
||||||
|
return path_response_window(destination_hash)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
reticulum = RNS.Reticulum.get_instance()
|
||||||
|
window = float(reticulum.get_first_hop_timeout(destination_hash))
|
||||||
|
return max(window, float(RNS.Transport.PATH_REQUEST_TIMEOUT))
|
||||||
|
except Exception:
|
||||||
|
return PATH_TIMEOUT_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
def wait_for_path(
|
def wait_for_path(
|
||||||
destination_hash: bytes,
|
destination_hash: bytes,
|
||||||
timeout: float = PATH_TIMEOUT_DEFAULT,
|
timeout: float | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
wait_s = adaptive_path_timeout(destination_hash, timeout)
|
||||||
if RNS.Transport.has_path(destination_hash) or is_local_destination(
|
if RNS.Transport.has_path(destination_hash) or is_local_destination(
|
||||||
destination_hash,
|
destination_hash,
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
RNS.Transport.request_path(destination_hash)
|
RNS.Transport.request_path(destination_hash)
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + wait_s
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
if RNS.Transport.has_path(destination_hash) or is_local_destination(
|
if RNS.Transport.has_path(destination_hash) or is_local_destination(
|
||||||
destination_hash,
|
destination_hash,
|
||||||
|
|
@ -111,13 +132,19 @@ def establish_link(
|
||||||
*,
|
*,
|
||||||
established_callback: Callable | None = None,
|
established_callback: Callable | None = None,
|
||||||
closed_callback: Callable | None = None,
|
closed_callback: Callable | None = None,
|
||||||
timeout: float = LINK_TIMEOUT_DEFAULT,
|
timeout: float | None = None,
|
||||||
):
|
):
|
||||||
link = RNS.Link(
|
link = RNS.Link(
|
||||||
destination,
|
destination,
|
||||||
established_callback=established_callback,
|
established_callback=established_callback,
|
||||||
closed_callback=closed_callback,
|
closed_callback=closed_callback,
|
||||||
)
|
)
|
||||||
|
if timeout is None:
|
||||||
|
rns_timeout = getattr(link, "establishment_timeout", None)
|
||||||
|
if isinstance(rns_timeout, (int, float)) and rns_timeout > 0:
|
||||||
|
timeout = float(rns_timeout) + 5.0
|
||||||
|
else:
|
||||||
|
timeout = LINK_TIMEOUT_DEFAULT
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
while link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED):
|
while link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED):
|
||||||
if time.time() > deadline:
|
if time.time() > deadline:
|
||||||
|
|
|
||||||
14
vendor/rns_filesync/rns_filesync/service.py
vendored
14
vendor/rns_filesync/rns_filesync/service.py
vendored
|
|
@ -18,7 +18,6 @@ from rns_filesync.constants import (
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
ASPECT,
|
ASPECT,
|
||||||
BLOCK_SIZE,
|
BLOCK_SIZE,
|
||||||
LINK_TIMEOUT_DEFAULT,
|
|
||||||
PATH_TIMEOUT_DEFAULT,
|
PATH_TIMEOUT_DEFAULT,
|
||||||
RECONNECT_BASE_INTERVAL,
|
RECONNECT_BASE_INTERVAL,
|
||||||
RECONNECT_MAX_INTERVAL,
|
RECONNECT_MAX_INTERVAL,
|
||||||
|
|
@ -33,6 +32,7 @@ from rns_filesync.inventory import (
|
||||||
)
|
)
|
||||||
from rns_filesync.paths import PathJailError, normalize_relpath, resolve_under_root
|
from rns_filesync.paths import PathJailError, normalize_relpath, resolve_under_root
|
||||||
from rns_filesync.peers import (
|
from rns_filesync.peers import (
|
||||||
|
adaptive_path_timeout,
|
||||||
create_outbound_destination,
|
create_outbound_destination,
|
||||||
establish_link,
|
establish_link,
|
||||||
hex_hash,
|
hex_hash,
|
||||||
|
|
@ -276,7 +276,7 @@ class FileSyncService:
|
||||||
def connect_peer(
|
def connect_peer(
|
||||||
self,
|
self,
|
||||||
identity_hash: str | bytes,
|
identity_hash: str | bytes,
|
||||||
timeout: float = PATH_TIMEOUT_DEFAULT,
|
timeout: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Connect using identity hash (destination hash accepted as fallback)."""
|
"""Connect using identity hash (destination hash accepted as fallback)."""
|
||||||
peer_hash = parse_hash(identity_hash)
|
peer_hash = parse_hash(identity_hash)
|
||||||
|
|
@ -291,8 +291,7 @@ class FileSyncService:
|
||||||
|
|
||||||
identity, how = resolve_peer_identity(peer_hash)
|
identity, how = resolve_peer_identity(peer_hash)
|
||||||
if identity is None:
|
if identity is None:
|
||||||
# Path request may populate known destinations for destination hashes.
|
wait_for_path(peer_hash, timeout=timeout)
|
||||||
wait_for_path(peer_hash, timeout=min(timeout, 5.0))
|
|
||||||
identity, how = resolve_peer_identity(peer_hash)
|
identity, how = resolve_peer_identity(peer_hash)
|
||||||
if identity is None:
|
if identity is None:
|
||||||
msg = f"could not recall identity for {peer_hex}"
|
msg = f"could not recall identity for {peer_hex}"
|
||||||
|
|
@ -309,7 +308,6 @@ class FileSyncService:
|
||||||
destination,
|
destination,
|
||||||
established_callback=self._on_link_established,
|
established_callback=self._on_link_established,
|
||||||
closed_callback=self._on_link_closed,
|
closed_callback=self._on_link_closed,
|
||||||
timeout=LINK_TIMEOUT_DEFAULT,
|
|
||||||
)
|
)
|
||||||
if link is None:
|
if link is None:
|
||||||
msg = f"link failed for {peer_hex}"
|
msg = f"link failed for {peer_hex}"
|
||||||
|
|
@ -1053,7 +1051,11 @@ class FileSyncService:
|
||||||
|
|
||||||
def job(target=peer_id):
|
def job(target=peer_id):
|
||||||
try:
|
try:
|
||||||
result = self.connect_peer(target, timeout=PATH_TIMEOUT_DEFAULT)
|
try:
|
||||||
|
wait_s = adaptive_path_timeout(parse_hash(target))
|
||||||
|
except Exception:
|
||||||
|
wait_s = PATH_TIMEOUT_DEFAULT
|
||||||
|
result = self.connect_peer(target, timeout=wait_s)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if result.get("ok"):
|
if result.get("ok"):
|
||||||
self._reconnect_backoff.pop(target, None)
|
self._reconnect_backoff.pop(target, None)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue