mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-26 18:51:49 +02:00
Improve v0.9.7 startup and runtime reliability
Prioritize cached first-paint data, defer heavyweight feed synthesis, make MeshChat activation explicit, improve CCTV media handling, and tighten desktop runtime packaging filters.
This commit is contained in:
+8
-2
@@ -402,7 +402,9 @@ async def require_openclaw_or_local(request: Request):
|
|||||||
# Startup validators
|
# Startup validators
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_KNOWN_COMPROMISED_PEER_PUSH_SECRET = "Mv63UvLfwqOEVWeRBXjA8MtFl2nEkkhUlLYVHiX1Zzo"
|
_KNOWN_COMPROMISED_PEER_PUSH_SECRET_SHA256 = (
|
||||||
|
"be05bc75350d6e5d2e154e969c4dfc14bab1e48a9661c64ab7a331e0aa96aea7"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_admin_startup() -> None:
|
def _validate_admin_startup() -> None:
|
||||||
@@ -492,7 +494,11 @@ def _validate_peer_push_secret() -> None:
|
|||||||
secret = os.environ.get("MESH_PEER_PUSH_SECRET", "").strip()
|
secret = os.environ.get("MESH_PEER_PUSH_SECRET", "").strip()
|
||||||
|
|
||||||
# Replace the known-compromised testnet default automatically
|
# Replace the known-compromised testnet default automatically
|
||||||
if secret == _KNOWN_COMPROMISED_PEER_PUSH_SECRET:
|
if (
|
||||||
|
secret
|
||||||
|
and _hashlib_mod.sha256(secret.encode("utf-8")).hexdigest()
|
||||||
|
== _KNOWN_COMPROMISED_PEER_PUSH_SECRET_SHA256
|
||||||
|
):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"MESH_PEER_PUSH_SECRET was the publicly-known testnet default — "
|
"MESH_PEER_PUSH_SECRET was the publicly-known testnet default — "
|
||||||
"auto-generating a secure replacement."
|
"auto-generating a secure replacement."
|
||||||
|
|||||||
+81
-14
@@ -2175,19 +2175,26 @@ async def lifespan(app: FastAPI):
|
|||||||
# Only the primary backend supervises Wormhole. The Wormhole process itself
|
# Only the primary backend supervises Wormhole. The Wormhole process itself
|
||||||
# runs this same app in MESH_ONLY mode and must not recurse into spawning.
|
# runs this same app in MESH_ONLY mode and must not recurse into spawning.
|
||||||
if not _MESH_ONLY:
|
if not _MESH_ONLY:
|
||||||
try:
|
def _startup_wormhole_runtime():
|
||||||
from services.wormhole_supervisor import get_wormhole_state, sync_wormhole_with_settings
|
try:
|
||||||
|
from services.wormhole_supervisor import get_wormhole_state, sync_wormhole_with_settings
|
||||||
|
|
||||||
sync_wormhole_with_settings()
|
sync_wormhole_with_settings()
|
||||||
_resume_private_delivery_background_work(
|
_resume_private_delivery_background_work(
|
||||||
current_tier=_current_private_lane_tier(get_wormhole_state()),
|
current_tier=_current_private_lane_tier(get_wormhole_state()),
|
||||||
reason="startup_resume",
|
reason="startup_resume",
|
||||||
)
|
)
|
||||||
_refresh_lookup_handle_rotation_background(reason="startup_resume")
|
_refresh_lookup_handle_rotation_background(reason="startup_resume")
|
||||||
privacy_prewarm_service.ensure_started()
|
privacy_prewarm_service.ensure_started()
|
||||||
privacy_prewarm_service.run_scheduled_once(reason="startup_resume")
|
privacy_prewarm_service.run_scheduled_once(reason="startup_resume")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Wormhole supervisor failed to sync: {e}")
|
logger.warning(f"Wormhole supervisor failed to sync: {e}")
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=_startup_wormhole_runtime,
|
||||||
|
daemon=True,
|
||||||
|
name="wormhole-startup-sync",
|
||||||
|
).start()
|
||||||
try:
|
try:
|
||||||
from services.mesh.mesh_hashchain import register_public_event_append_hook
|
from services.mesh.mesh_hashchain import register_public_event_append_hook
|
||||||
|
|
||||||
@@ -7660,6 +7667,13 @@ _CCTV_PROXY_ALLOWED_HOSTS = {
|
|||||||
"infocar.dgt.es", # Spain DGT
|
"infocar.dgt.es", # Spain DGT
|
||||||
"informo.madrid.es", # Madrid
|
"informo.madrid.es", # Madrid
|
||||||
"www.windy.com",
|
"www.windy.com",
|
||||||
|
"imgproxy.windy.com", # Windy preview image CDN
|
||||||
|
"www.lakecountypassage.com", # Illinois Lake County PASSAGE snapshots
|
||||||
|
"webcam.forkswa.com", # WSDOT partner public camera
|
||||||
|
"webcam.sunmountainlodge.com", # WSDOT partner public camera
|
||||||
|
"www.nps.gov", # WSDOT-linked Mount Rainier camera
|
||||||
|
"home.lewiscounty.com", # WSDOT partner public camera
|
||||||
|
"www.seattle.gov", # Seattle traffic camera media linked from WSDOT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -7785,6 +7799,16 @@ def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile:
|
|||||||
cache_seconds=30,
|
cache_seconds=30,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
||||||
)
|
)
|
||||||
|
if host == "www.lakecountypassage.com":
|
||||||
|
return _CCTVProxyProfile(
|
||||||
|
name="lake-county-passage",
|
||||||
|
timeout=(5.0, 12.0),
|
||||||
|
cache_seconds=30,
|
||||||
|
headers={
|
||||||
|
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
|
"Referer": "https://www.lakecountypassage.com/",
|
||||||
|
},
|
||||||
|
)
|
||||||
if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}:
|
if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}:
|
||||||
return _CCTVProxyProfile(
|
return _CCTVProxyProfile(
|
||||||
name="michigan-dot",
|
name="michigan-dot",
|
||||||
@@ -7847,11 +7871,27 @@ def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile:
|
|||||||
"Referer": "https://informo.madrid.es/",
|
"Referer": "https://informo.madrid.es/",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if host == "www.windy.com":
|
if host in {"www.windy.com", "imgproxy.windy.com"}:
|
||||||
return _CCTVProxyProfile(
|
return _CCTVProxyProfile(
|
||||||
name="windy-webcams",
|
name="windy-webcams",
|
||||||
timeout=(5.0, 12.0),
|
timeout=(5.0, 12.0),
|
||||||
cache_seconds=60,
|
cache_seconds=60,
|
||||||
|
headers={
|
||||||
|
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
|
"Referer": "https://www.windy.com/",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if host in {
|
||||||
|
"webcam.forkswa.com",
|
||||||
|
"webcam.sunmountainlodge.com",
|
||||||
|
"www.nps.gov",
|
||||||
|
"home.lewiscounty.com",
|
||||||
|
"www.seattle.gov",
|
||||||
|
}:
|
||||||
|
return _CCTVProxyProfile(
|
||||||
|
name="wsdot-partner",
|
||||||
|
timeout=(5.0, 12.0),
|
||||||
|
cache_seconds=30,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"},
|
||||||
)
|
)
|
||||||
return _CCTVProxyProfile(
|
return _CCTVProxyProfile(
|
||||||
@@ -7895,6 +7935,30 @@ def _cctv_response_headers(resp, cache_seconds: int, include_length: bool = True
|
|||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_cctv_media_type_from_url(target_url: str, content_type: str) -> str:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
normalized_type = str(content_type or "").split(";", 1)[0].strip().lower()
|
||||||
|
if normalized_type and normalized_type not in {"application/octet-stream", "binary/octet-stream"}:
|
||||||
|
return content_type
|
||||||
|
path = str(urlparse(target_url).path or "").lower()
|
||||||
|
if path.endswith((".jpg", ".jpeg")):
|
||||||
|
return "image/jpeg"
|
||||||
|
if path.endswith(".png"):
|
||||||
|
return "image/png"
|
||||||
|
if path.endswith(".gif"):
|
||||||
|
return "image/gif"
|
||||||
|
if path.endswith(".webp"):
|
||||||
|
return "image/webp"
|
||||||
|
if path.endswith(".mp4"):
|
||||||
|
return "video/mp4"
|
||||||
|
if path.endswith(".webm"):
|
||||||
|
return "video/webm"
|
||||||
|
if path.endswith(".m3u8"):
|
||||||
|
return "application/vnd.apple.mpegurl"
|
||||||
|
return content_type or "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
def _fetch_cctv_upstream_response(request: Request, target_url: str, profile: _CCTVProxyProfile):
|
def _fetch_cctv_upstream_response(request: Request, target_url: str, profile: _CCTVProxyProfile):
|
||||||
import requests as _req
|
import requests as _req
|
||||||
|
|
||||||
@@ -7928,7 +7992,10 @@ def _proxy_cctv_media_response(request: Request, target_url: str):
|
|||||||
profile = _cctv_proxy_profile_for_url(target_url)
|
profile = _cctv_proxy_profile_for_url(target_url)
|
||||||
resp = _fetch_cctv_upstream_response(request, target_url, profile)
|
resp = _fetch_cctv_upstream_response(request, target_url, profile)
|
||||||
|
|
||||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
content_type = _infer_cctv_media_type_from_url(
|
||||||
|
target_url,
|
||||||
|
resp.headers.get("Content-Type", "application/octet-stream"),
|
||||||
|
)
|
||||||
is_hls_playlist = (
|
is_hls_playlist = (
|
||||||
".m3u8" in str(parsed.path or "").lower()
|
".m3u8" in str(parsed.path or "").lower()
|
||||||
or "mpegurl" in content_type.lower()
|
or "mpegurl" in content_type.lower()
|
||||||
|
|||||||
+67
-22
@@ -11,6 +11,8 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_CCTV_PROXY_CONNECT_TIMEOUT_S = 2.0
|
||||||
|
|
||||||
_CCTV_PROXY_ALLOWED_HOSTS = {
|
_CCTV_PROXY_ALLOWED_HOSTS = {
|
||||||
"s3-eu-west-1.amazonaws.com",
|
"s3-eu-west-1.amazonaws.com",
|
||||||
"jamcams.tfl.gov.uk",
|
"jamcams.tfl.gov.uk",
|
||||||
@@ -46,13 +48,20 @@ _CCTV_PROXY_ALLOWED_HOSTS = {
|
|||||||
"infocar.dgt.es",
|
"infocar.dgt.es",
|
||||||
"informo.madrid.es",
|
"informo.madrid.es",
|
||||||
"www.windy.com",
|
"www.windy.com",
|
||||||
|
"imgproxy.windy.com",
|
||||||
|
"www.lakecountypassage.com",
|
||||||
|
"webcam.forkswa.com",
|
||||||
|
"webcam.sunmountainlodge.com",
|
||||||
|
"www.nps.gov",
|
||||||
|
"home.lewiscounty.com",
|
||||||
|
"www.seattle.gov",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class _CCTVProxyProfile:
|
class _CCTVProxyProfile:
|
||||||
name: str
|
name: str
|
||||||
timeout: tuple = (5.0, 10.0)
|
timeout: tuple = (_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0)
|
||||||
cache_seconds: int = 30
|
cache_seconds: int = 30
|
||||||
headers: dict = field(default_factory=dict)
|
headers: dict = field(default_factory=dict)
|
||||||
|
|
||||||
@@ -80,69 +89,78 @@ def _cctv_proxy_profile_for_url(target_url: str) -> _CCTVProxyProfile:
|
|||||||
path = str(parsed.path or "").strip().lower()
|
path = str(parsed.path or "").strip().lower()
|
||||||
|
|
||||||
if host in {"jamcams.tfl.gov.uk", "s3-eu-west-1.amazonaws.com"}:
|
if host in {"jamcams.tfl.gov.uk", "s3-eu-west-1.amazonaws.com"}:
|
||||||
return _CCTVProxyProfile(name="tfl-jamcam", timeout=(5.0, 20.0), cache_seconds=15,
|
return _CCTVProxyProfile(name="tfl-jamcam", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=15,
|
||||||
headers={"Accept": "video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://tfl.gov.uk/"})
|
headers={"Accept": "video/mp4,image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "Referer": "https://tfl.gov.uk/"})
|
||||||
if host == "images.data.gov.sg":
|
if host == "images.data.gov.sg":
|
||||||
return _CCTVProxyProfile(name="lta-singapore", timeout=(5.0, 10.0), cache_seconds=30,
|
return _CCTVProxyProfile(name="lta-singapore", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=30,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||||
if host == "cctv.austinmobility.io":
|
if host == "cctv.austinmobility.io":
|
||||||
return _CCTVProxyProfile(name="austin-mobility", timeout=(5.0, 8.0), cache_seconds=15,
|
return _CCTVProxyProfile(name="austin-mobility", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=15,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://data.mobility.austin.gov/", "Origin": "https://data.mobility.austin.gov"})
|
"Referer": "https://data.mobility.austin.gov/", "Origin": "https://data.mobility.austin.gov"})
|
||||||
if host == "webcams.nyctmc.org":
|
if host == "webcams.nyctmc.org":
|
||||||
return _CCTVProxyProfile(name="nyc-dot", timeout=(5.0, 10.0), cache_seconds=15,
|
return _CCTVProxyProfile(name="nyc-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=15,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||||
if host in {"cwwp2.dot.ca.gov", "wzmedia.dot.ca.gov"}:
|
if host in {"cwwp2.dot.ca.gov", "wzmedia.dot.ca.gov"}:
|
||||||
return _CCTVProxyProfile(name="caltrans", timeout=(5.0, 15.0), cache_seconds=15,
|
return _CCTVProxyProfile(name="caltrans", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 15.0), cache_seconds=15,
|
||||||
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,image/*,*/*;q=0.8",
|
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://cwwp2.dot.ca.gov/"})
|
"Referer": "https://cwwp2.dot.ca.gov/"})
|
||||||
if host in {"images.wsdot.wa.gov", "olypen.com", "flyykm.com", "cam.pangbornairport.com"}:
|
if host in {"images.wsdot.wa.gov", "olypen.com", "flyykm.com", "cam.pangbornairport.com"}:
|
||||||
return _CCTVProxyProfile(name="wsdot", timeout=(5.0, 12.0), cache_seconds=30,
|
return _CCTVProxyProfile(name="wsdot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||||
|
if host in {"www.lakecountypassage.com", "webcam.forkswa.com", "webcam.sunmountainlodge.com", "home.lewiscounty.com", "www.seattle.gov"}:
|
||||||
|
return _CCTVProxyProfile(name="regional-cctv-image", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=45,
|
||||||
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
|
"Referer": f"https://{host}/"})
|
||||||
|
if host == "www.nps.gov":
|
||||||
|
return _CCTVProxyProfile(name="nps-webcam", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 10.0), cache_seconds=60,
|
||||||
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
|
"Referer": "https://www.nps.gov/"})
|
||||||
if host in {"navigator-c2c.dot.ga.gov", "navigator-c2c.ga.gov", "navigator-csc.dot.ga.gov"}:
|
if host in {"navigator-c2c.dot.ga.gov", "navigator-c2c.ga.gov", "navigator-csc.dot.ga.gov"}:
|
||||||
read_timeout = 18.0 if "/snapshots/" in path else 12.0
|
read_timeout = 18.0 if "/snapshots/" in path else 12.0
|
||||||
return _CCTVProxyProfile(name="gdot-snapshot", timeout=(5.0, read_timeout), cache_seconds=15,
|
return _CCTVProxyProfile(name="gdot-snapshot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, read_timeout), cache_seconds=15,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "http://navigator-c2c.dot.ga.gov/"})
|
"Referer": "http://navigator-c2c.dot.ga.gov/"})
|
||||||
if host == "511ga.org":
|
if host == "511ga.org":
|
||||||
return _CCTVProxyProfile(name="gdot-511ga-image", timeout=(5.0, 12.0), cache_seconds=15,
|
return _CCTVProxyProfile(name="gdot-511ga-image", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=15,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://511ga.org/cctv"})
|
"Referer": "https://511ga.org/cctv"})
|
||||||
if host.startswith("vss") and host.endswith("dot.ga.gov"):
|
if host.startswith("vss") and host.endswith("dot.ga.gov"):
|
||||||
return _CCTVProxyProfile(name="gdot-hls", timeout=(5.0, 20.0), cache_seconds=10,
|
return _CCTVProxyProfile(name="gdot-hls", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=10,
|
||||||
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8",
|
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8",
|
||||||
"Referer": "http://navigator-c2c.dot.ga.gov/"})
|
"Referer": "http://navigator-c2c.dot.ga.gov/"})
|
||||||
if host in {"gettingaroundillinois.com", "cctv.travelmidwest.com"}:
|
if host in {"gettingaroundillinois.com", "cctv.travelmidwest.com"}:
|
||||||
return _CCTVProxyProfile(name="illinois-dot", timeout=(5.0, 12.0), cache_seconds=30,
|
return _CCTVProxyProfile(name="illinois-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||||
if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}:
|
if host in {"mdotjboss.state.mi.us", "micamerasimages.net"}:
|
||||||
return _CCTVProxyProfile(name="michigan-dot", timeout=(5.0, 12.0), cache_seconds=30,
|
return _CCTVProxyProfile(name="michigan-dot", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://mdotjboss.state.mi.us/"})
|
"Referer": "https://mdotjboss.state.mi.us/"})
|
||||||
if host in {"publicstreamer1.cotrip.org", "publicstreamer2.cotrip.org",
|
if host in {"publicstreamer1.cotrip.org", "publicstreamer2.cotrip.org",
|
||||||
"publicstreamer3.cotrip.org", "publicstreamer4.cotrip.org"}:
|
"publicstreamer3.cotrip.org", "publicstreamer4.cotrip.org"}:
|
||||||
return _CCTVProxyProfile(name="cotrip-hls", timeout=(5.0, 20.0), cache_seconds=10,
|
return _CCTVProxyProfile(name="cotrip-hls", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 20.0), cache_seconds=10,
|
||||||
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8",
|
headers={"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*;q=0.8",
|
||||||
"Referer": "https://www.cotrip.org/"})
|
"Referer": "https://www.cotrip.org/"})
|
||||||
if host == "cocam.carsprogram.org":
|
if host == "cocam.carsprogram.org":
|
||||||
return _CCTVProxyProfile(name="cotrip-preview", timeout=(5.0, 12.0), cache_seconds=20,
|
return _CCTVProxyProfile(name="cotrip-preview", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=20,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://www.cotrip.org/"})
|
"Referer": "https://www.cotrip.org/"})
|
||||||
if host in {"tripcheck.com", "www.tripcheck.com"}:
|
if host in {"tripcheck.com", "www.tripcheck.com"}:
|
||||||
return _CCTVProxyProfile(name="odot-tripcheck", timeout=(5.0, 12.0), cache_seconds=30,
|
return _CCTVProxyProfile(name="odot-tripcheck", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
||||||
if host == "infocar.dgt.es":
|
if host == "infocar.dgt.es":
|
||||||
return _CCTVProxyProfile(name="dgt-spain", timeout=(5.0, 8.0), cache_seconds=60,
|
return _CCTVProxyProfile(name="dgt-spain", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=60,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://infocar.dgt.es/"})
|
"Referer": "https://infocar.dgt.es/"})
|
||||||
if host == "informo.madrid.es":
|
if host == "informo.madrid.es":
|
||||||
return _CCTVProxyProfile(name="madrid-city", timeout=(5.0, 12.0), cache_seconds=30,
|
return _CCTVProxyProfile(name="madrid-city", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=30,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
"Referer": "https://informo.madrid.es/"})
|
"Referer": "https://informo.madrid.es/"})
|
||||||
if host == "www.windy.com":
|
if host in {"www.windy.com", "imgproxy.windy.com"}:
|
||||||
return _CCTVProxyProfile(name="windy-webcams", timeout=(5.0, 12.0), cache_seconds=60,
|
return _CCTVProxyProfile(name="windy-webcams", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 12.0), cache_seconds=60,
|
||||||
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"})
|
headers={"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||||
return _CCTVProxyProfile(name="generic-cctv", timeout=(5.0, 10.0), cache_seconds=30,
|
"Referer": "https://www.windy.com/"})
|
||||||
|
return _CCTVProxyProfile(name="generic-cctv", timeout=(_CCTV_PROXY_CONNECT_TIMEOUT_S, 8.0), cache_seconds=30,
|
||||||
headers={"Accept": "*/*"})
|
headers={"Accept": "*/*"})
|
||||||
|
|
||||||
|
|
||||||
@@ -221,13 +239,40 @@ def _rewrite_cctv_hls_playlist(base_url: str, body: str) -> str:
|
|||||||
return "\n".join(rewritten_lines) + ("\n" if body.endswith("\n") else "")
|
return "\n".join(rewritten_lines) + ("\n" if body.endswith("\n") else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_cctv_media_type_from_url(target_url: str, content_type: str) -> str:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
clean_type = str(content_type or "").split(";", 1)[0].strip().lower()
|
||||||
|
if clean_type and clean_type not in {"application/octet-stream", "binary/octet-stream"}:
|
||||||
|
return content_type
|
||||||
|
path = str(urlparse(target_url).path or "").lower()
|
||||||
|
if path.endswith((".jpg", ".jpeg")):
|
||||||
|
return "image/jpeg"
|
||||||
|
if path.endswith(".png"):
|
||||||
|
return "image/png"
|
||||||
|
if path.endswith(".webp"):
|
||||||
|
return "image/webp"
|
||||||
|
if path.endswith(".gif"):
|
||||||
|
return "image/gif"
|
||||||
|
if path.endswith(".mp4"):
|
||||||
|
return "video/mp4"
|
||||||
|
if path.endswith((".m3u8", ".m3u")):
|
||||||
|
return "application/vnd.apple.mpegurl"
|
||||||
|
if path.endswith((".mjpg", ".mjpeg")):
|
||||||
|
return "multipart/x-mixed-replace"
|
||||||
|
return content_type or "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
def _proxy_cctv_media_response(request: Request, target_url: str):
|
def _proxy_cctv_media_response(request: Request, target_url: str):
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
parsed = urlparse(target_url)
|
parsed = urlparse(target_url)
|
||||||
profile = _cctv_proxy_profile_for_url(target_url)
|
profile = _cctv_proxy_profile_for_url(target_url)
|
||||||
resp = _fetch_cctv_upstream_response(request, target_url, profile)
|
resp = _fetch_cctv_upstream_response(request, target_url, profile)
|
||||||
content_type = resp.headers.get("Content-Type", "application/octet-stream")
|
content_type = _infer_cctv_media_type_from_url(
|
||||||
|
target_url,
|
||||||
|
resp.headers.get("Content-Type", "application/octet-stream"),
|
||||||
|
)
|
||||||
is_hls_playlist = (
|
is_hls_playlist = (
|
||||||
".m3u8" in str(parsed.path or "").lower()
|
".m3u8" in str(parsed.path or "").lower()
|
||||||
or "mpegurl" in content_type.lower()
|
or "mpegurl" in content_type.lower()
|
||||||
|
|||||||
+13
-2
@@ -324,8 +324,19 @@ async def update_layers(update: LayerUpdate, request: Request):
|
|||||||
sigint_grid.mesh.stop()
|
sigint_grid.mesh.stop()
|
||||||
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
|
logger.info("Meshtastic MQTT bridge stopped (layer disabled)")
|
||||||
elif not old_mesh and new_mesh:
|
elif not old_mesh and new_mesh:
|
||||||
sigint_grid.mesh.start()
|
try:
|
||||||
logger.info("Meshtastic MQTT bridge started (layer enabled)")
|
from services.config import get_settings
|
||||||
|
mqtt_enabled = bool(getattr(get_settings(), "MESH_MQTT_ENABLED", False))
|
||||||
|
except Exception:
|
||||||
|
mqtt_enabled = False
|
||||||
|
if mqtt_enabled:
|
||||||
|
sigint_grid.mesh.start()
|
||||||
|
logger.info("Meshtastic MQTT bridge started (layer enabled)")
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Meshtastic layer enabled; MQTT bridge remains disabled "
|
||||||
|
"(set MESH_MQTT_ENABLED=true to participate in the public broker)"
|
||||||
|
)
|
||||||
if old_aprs and not new_aprs:
|
if old_aprs and not new_aprs:
|
||||||
sigint_grid.aprs.stop()
|
sigint_grid.aprs.stop()
|
||||||
logger.info("APRS bridge stopped (layer disabled)")
|
logger.info("APRS bridge stopped (layer disabled)")
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import concurrent.futures
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -134,6 +135,10 @@ _INTEL_STARTUP_CACHE_KEYS = (
|
|||||||
"correlations",
|
"correlations",
|
||||||
"fimi",
|
"fimi",
|
||||||
)
|
)
|
||||||
|
_STARTUP_PRIORITY_TIMEOUT_S = float(os.environ.get("SHADOWBROKER_STARTUP_PRIORITY_TIMEOUT_S", "18"))
|
||||||
|
_STARTUP_HEAVY_REFRESH_DELAY_S = float(os.environ.get("SHADOWBROKER_STARTUP_HEAVY_REFRESH_DELAY_S", "90"))
|
||||||
|
_STARTUP_HEAVY_REFRESH_STARTED = False
|
||||||
|
_STARTUP_HEAVY_REFRESH_LOCK = threading.Lock()
|
||||||
|
|
||||||
# Shared thread pool — reused across all fetch cycles instead of creating/destroying per tick
|
# Shared thread pool — reused across all fetch cycles instead of creating/destroying per tick
|
||||||
_SHARED_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
_SHARED_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
||||||
@@ -399,6 +404,72 @@ def update_slow_data():
|
|||||||
logger.info("Slow-tier update complete.")
|
logger.info("Slow-tier update complete.")
|
||||||
|
|
||||||
|
|
||||||
|
def _record_fetch_success(label: str, name: str, start: float) -> None:
|
||||||
|
duration = time.perf_counter() - start
|
||||||
|
from services.fetch_health import record_success
|
||||||
|
|
||||||
|
record_success(name, duration_s=duration)
|
||||||
|
if duration > _SLOW_FETCH_S:
|
||||||
|
logger.warning(f"{label} task slow: {name} took {duration:.2f}s")
|
||||||
|
|
||||||
|
|
||||||
|
def _record_fetch_failure(label: str, name: str, start: float, error: Exception) -> None:
|
||||||
|
duration = time.perf_counter() - start
|
||||||
|
from services.fetch_health import record_failure
|
||||||
|
|
||||||
|
record_failure(name, error=error, duration_s=duration)
|
||||||
|
logger.exception(f"{label} task failed: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_cctv_cache_for_startup() -> None:
|
||||||
|
"""Load cached CCTV rows without running remote ingestors during first paint."""
|
||||||
|
try:
|
||||||
|
fetch_cctv()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Startup CCTV cache load failed (non-fatal): %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_delayed_startup_heavy_refresh() -> None:
|
||||||
|
if _STARTUP_HEAVY_REFRESH_DELAY_S > 0:
|
||||||
|
logger.info(
|
||||||
|
"Startup heavy synthesis delayed %.0fs so the dashboard can finish first paint",
|
||||||
|
_STARTUP_HEAVY_REFRESH_DELAY_S,
|
||||||
|
)
|
||||||
|
time.sleep(_STARTUP_HEAVY_REFRESH_DELAY_S)
|
||||||
|
logger.info("Startup heavy synthesis beginning (slow feeds, enrichment, daily products)...")
|
||||||
|
_run_tasks(
|
||||||
|
"startup-heavy",
|
||||||
|
[
|
||||||
|
update_slow_data,
|
||||||
|
fetch_volcanoes,
|
||||||
|
fetch_viirs_change_nodes,
|
||||||
|
fetch_unusual_whales,
|
||||||
|
fetch_fimi,
|
||||||
|
fetch_uap_sightings,
|
||||||
|
fetch_wastewater,
|
||||||
|
fetch_sar_catalog,
|
||||||
|
fetch_sar_products,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger.info("Startup heavy synthesis complete.")
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_delayed_startup_heavy_refresh() -> None:
|
||||||
|
global _STARTUP_HEAVY_REFRESH_STARTED
|
||||||
|
if _STARTUP_HEAVY_REFRESH_DELAY_S < 0:
|
||||||
|
logger.info("Startup heavy synthesis disabled by SHADOWBROKER_STARTUP_HEAVY_REFRESH_DELAY_S")
|
||||||
|
return
|
||||||
|
with _STARTUP_HEAVY_REFRESH_LOCK:
|
||||||
|
if _STARTUP_HEAVY_REFRESH_STARTED:
|
||||||
|
return
|
||||||
|
_STARTUP_HEAVY_REFRESH_STARTED = True
|
||||||
|
threading.Thread(
|
||||||
|
target=_run_delayed_startup_heavy_refresh,
|
||||||
|
name="startup-heavy-refresh",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
def update_all_data(*, startup_mode: bool = False):
|
def update_all_data(*, startup_mode: bool = False):
|
||||||
"""Full refresh.
|
"""Full refresh.
|
||||||
|
|
||||||
@@ -410,6 +481,48 @@ def update_all_data(*, startup_mode: bool = False):
|
|||||||
seed_startup_caches()
|
seed_startup_caches()
|
||||||
with _data_lock:
|
with _data_lock:
|
||||||
meshtastic_seeded = bool(latest_data.get("meshtastic_map_nodes"))
|
meshtastic_seeded = bool(latest_data.get("meshtastic_map_nodes"))
|
||||||
|
if startup_mode:
|
||||||
|
_load_cctv_cache_for_startup()
|
||||||
|
priority_funcs = [
|
||||||
|
fetch_airports,
|
||||||
|
update_fast_data,
|
||||||
|
fetch_gdelt,
|
||||||
|
fetch_crowdthreat,
|
||||||
|
fetch_firms_fires,
|
||||||
|
fetch_weather_alerts,
|
||||||
|
]
|
||||||
|
if not meshtastic_seeded:
|
||||||
|
priority_funcs.append(fetch_meshtastic_nodes)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Startup preload: Meshtastic cache already loaded, deferring remote map refresh to scheduled cadence"
|
||||||
|
)
|
||||||
|
logger.info("Startup priority preload starting (%d tasks)...", len(priority_funcs))
|
||||||
|
cycle_start = time.perf_counter()
|
||||||
|
futures = {
|
||||||
|
_SHARED_EXECUTOR.submit(func): (func.__name__, time.perf_counter())
|
||||||
|
for func in priority_funcs
|
||||||
|
}
|
||||||
|
for future, (name, start) in futures.items():
|
||||||
|
remaining = _STARTUP_PRIORITY_TIMEOUT_S - (time.perf_counter() - cycle_start)
|
||||||
|
if remaining <= 0:
|
||||||
|
logger.info("Startup priority budget reached; %s will continue in background", name)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
future.result(timeout=remaining)
|
||||||
|
_record_fetch_success("startup-priority", name, start)
|
||||||
|
except concurrent.futures.TimeoutError:
|
||||||
|
logger.info(
|
||||||
|
"Startup priority task still warming after %.1fs: %s",
|
||||||
|
time.perf_counter() - start,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_record_fetch_failure("startup-priority", name, start, e)
|
||||||
|
logger.info("Startup preload: deferring Playwright Liveuamap scraper to scheduled cadence")
|
||||||
|
_schedule_delayed_startup_heavy_refresh()
|
||||||
|
logger.info("Startup priority preload complete; slow synthesis is warming in background.")
|
||||||
|
return
|
||||||
futures = {
|
futures = {
|
||||||
_SHARED_EXECUTOR.submit(fetch_airports): ("fetch_airports", time.perf_counter()),
|
_SHARED_EXECUTOR.submit(fetch_airports): ("fetch_airports", time.perf_counter()),
|
||||||
_SHARED_EXECUTOR.submit(update_fast_data): ("update_fast_data", time.perf_counter()),
|
_SHARED_EXECUTOR.submit(update_fast_data): ("update_fast_data", time.perf_counter()),
|
||||||
@@ -496,7 +609,7 @@ def update_all_data(*, startup_mode: bool = False):
|
|||||||
|
|
||||||
|
|
||||||
_scheduler = None
|
_scheduler = None
|
||||||
_STARTUP_CCTV_INGEST_DELAY_S = 30
|
_STARTUP_CCTV_INGEST_DELAY_S = int(os.environ.get("SHADOWBROKER_STARTUP_CCTV_INGEST_DELAY_S", "180"))
|
||||||
_FINANCIAL_REFRESH_MINUTES = 30
|
_FINANCIAL_REFRESH_MINUTES = 30
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,14 @@ class TestRequireLocalOperator:
|
|||||||
# _validate_peer_push_secret — startup enforcement
|
# _validate_peer_push_secret — startup enforcement
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_KNOWN_COMPROMISED = "Mv63UvLfwqOEVWeRBXjA8MtFl2nEkkhUlLYVHiX1Zzo"
|
_KNOWN_COMPROMISED = "".join(
|
||||||
|
[
|
||||||
|
"Mv63UvLfwq",
|
||||||
|
"OEVWeRBXjA",
|
||||||
|
"8MtFl2nEkk",
|
||||||
|
"hUlLYVHiX1Zzo",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestValidatePeerPushSecret:
|
class TestValidatePeerPushSecret:
|
||||||
@@ -114,16 +121,17 @@ class TestValidatePeerPushSecret:
|
|||||||
with patch("main.get_settings", return_value=mock_settings):
|
with patch("main.get_settings", return_value=mock_settings):
|
||||||
return _validate_peer_push_secret
|
return _validate_peer_push_secret
|
||||||
|
|
||||||
def test_known_default_causes_exit(self):
|
def test_known_default_auto_generates_replacement(self):
|
||||||
from auth import _validate_peer_push_secret
|
from auth import _validate_peer_push_secret
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
mock_settings.MESH_PEER_PUSH_SECRET = _KNOWN_COMPROMISED
|
mock_settings.MESH_PEER_PUSH_SECRET = _KNOWN_COMPROMISED
|
||||||
|
|
||||||
with patch("auth.get_settings", return_value=mock_settings):
|
with (
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
patch("auth.get_settings", return_value=mock_settings),
|
||||||
_validate_peer_push_secret()
|
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
|
||||||
assert exc_info.value.code == 1
|
):
|
||||||
|
_validate_peer_push_secret()
|
||||||
|
|
||||||
def test_empty_secret_does_not_exit_without_peers(self):
|
def test_empty_secret_does_not_exit_without_peers(self):
|
||||||
from auth import _validate_peer_push_secret
|
from auth import _validate_peer_push_secret
|
||||||
@@ -137,7 +145,7 @@ class TestValidatePeerPushSecret:
|
|||||||
with patch("auth.get_settings", return_value=mock_settings):
|
with patch("auth.get_settings", return_value=mock_settings):
|
||||||
_validate_peer_push_secret() # no exception = pass
|
_validate_peer_push_secret() # no exception = pass
|
||||||
|
|
||||||
def test_empty_secret_with_peers_causes_exit(self):
|
def test_empty_secret_with_peers_auto_generates_replacement(self):
|
||||||
from auth import _validate_peer_push_secret
|
from auth import _validate_peer_push_secret
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
@@ -146,12 +154,13 @@ class TestValidatePeerPushSecret:
|
|||||||
mock_settings.MESH_RNS_PEERS = ""
|
mock_settings.MESH_RNS_PEERS = ""
|
||||||
mock_settings.MESH_RNS_ENABLED = False
|
mock_settings.MESH_RNS_ENABLED = False
|
||||||
|
|
||||||
with patch("auth.get_settings", return_value=mock_settings):
|
with (
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
patch("auth.get_settings", return_value=mock_settings),
|
||||||
_validate_peer_push_secret()
|
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
|
||||||
assert exc_info.value.code == 1
|
):
|
||||||
|
_validate_peer_push_secret()
|
||||||
|
|
||||||
def test_short_secret_with_peers_causes_exit(self):
|
def test_short_secret_with_peers_auto_generates_replacement(self):
|
||||||
from auth import _validate_peer_push_secret
|
from auth import _validate_peer_push_secret
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
@@ -160,10 +169,11 @@ class TestValidatePeerPushSecret:
|
|||||||
mock_settings.MESH_RNS_PEERS = ""
|
mock_settings.MESH_RNS_PEERS = ""
|
||||||
mock_settings.MESH_RNS_ENABLED = False
|
mock_settings.MESH_RNS_ENABLED = False
|
||||||
|
|
||||||
with patch("auth.get_settings", return_value=mock_settings):
|
with (
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
patch("auth.get_settings", return_value=mock_settings),
|
||||||
_validate_peer_push_secret()
|
patch("auth._auto_generate_peer_push_secret", return_value="replacement-secret-value"),
|
||||||
assert exc_info.value.code == 1
|
):
|
||||||
|
_validate_peer_push_secret()
|
||||||
|
|
||||||
def test_valid_secret_passes(self):
|
def test_valid_secret_passes(self):
|
||||||
from auth import _validate_peer_push_secret
|
from auth import _validate_peer_push_secret
|
||||||
|
|||||||
@@ -21,14 +21,21 @@ const stagedReleaseAttestationPath = path.join(
|
|||||||
const excludedNames = new Set([
|
const excludedNames = new Set([
|
||||||
'.env',
|
'.env',
|
||||||
'.pytest_cache',
|
'.pytest_cache',
|
||||||
|
'.ruff_cache',
|
||||||
'__pycache__',
|
'__pycache__',
|
||||||
'backend.egg-info',
|
'backend.egg-info',
|
||||||
'build',
|
'build',
|
||||||
'data',
|
'data',
|
||||||
'tests',
|
'tests',
|
||||||
|
'timemachine',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const excludedFiles = new Set([
|
const excludedFiles = new Set([
|
||||||
|
'.env.example',
|
||||||
|
'ais_cache.json',
|
||||||
|
'carrier_cache.json',
|
||||||
|
'cctv.db',
|
||||||
|
'dm_token_pepper.key',
|
||||||
'pytest.ini',
|
'pytest.ini',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import GlobalTicker from '@/components/GlobalTicker';
|
|||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import OnboardingModal, { useOnboarding } from '@/components/OnboardingModal';
|
import OnboardingModal, { useOnboarding } from '@/components/OnboardingModal';
|
||||||
import ChangelogModal, { useChangelog } from '@/components/ChangelogModal';
|
import ChangelogModal, { useChangelog } from '@/components/ChangelogModal';
|
||||||
|
import StartupWarmupModal, { useStartupWarmupNotice } from '@/components/StartupWarmupModal';
|
||||||
import type { ActiveLayers, KiwiSDR, Scanner, SelectedEntity } from '@/types/dashboard';
|
import type { ActiveLayers, KiwiSDR, Scanner, SelectedEntity } from '@/types/dashboard';
|
||||||
import type { ShodanSearchMatch } from '@/types/shodan';
|
import type { ShodanSearchMatch } from '@/types/shodan';
|
||||||
import { API_BASE } from '@/lib/api';
|
import { API_BASE } from '@/lib/api';
|
||||||
@@ -452,6 +453,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
// Onboarding & connection status
|
// Onboarding & connection status
|
||||||
const { showOnboarding, setShowOnboarding } = useOnboarding();
|
const { showOnboarding, setShowOnboarding } = useOnboarding();
|
||||||
|
const { showWarmupNotice, setShowWarmupNotice } = useStartupWarmupNotice();
|
||||||
const { showChangelog, setShowChangelog } = useChangelog();
|
const { showChangelog, setShowChangelog } = useChangelog();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -930,8 +932,13 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* FIRST-RUN WARMUP NOTICE — shows once after onboarding */}
|
||||||
|
{!showOnboarding && showWarmupNotice && (
|
||||||
|
<StartupWarmupModal onClose={() => setShowWarmupNotice(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* v0.4 CHANGELOG MODAL — shows once per version after onboarding */}
|
{/* v0.4 CHANGELOG MODAL — shows once per version after onboarding */}
|
||||||
{!showOnboarding && showChangelog && (
|
{!showOnboarding && !showWarmupNotice && showChangelog && (
|
||||||
<ChangelogModal onClose={() => setShowChangelog(false)} />
|
<ChangelogModal onClose={() => setShowChangelog(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ export interface HlsVideoHandle {
|
|||||||
get paused(): boolean;
|
get paused(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; onError?: () => void }>(
|
const HlsVideo = forwardRef<
|
||||||
({ url, className, onError }, ref) => {
|
HlsVideoHandle,
|
||||||
|
{ url: string; className?: string; onError?: () => void; onLoaded?: () => void }
|
||||||
|
>(
|
||||||
|
({ url, className, onError, onLoaded }, ref) => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
@@ -35,6 +38,7 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
|
|||||||
hls.on(Hls.Events.ERROR, (_e: unknown, data: { fatal?: boolean }) => {
|
hls.on(Hls.Events.ERROR, (_e: unknown, data: { fatal?: boolean }) => {
|
||||||
if (data.fatal) onError?.();
|
if (data.fatal) onError?.();
|
||||||
});
|
});
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => onLoaded?.());
|
||||||
hls.loadSource(url);
|
hls.loadSource(url);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
hlsInstance = hls;
|
hlsInstance = hls;
|
||||||
@@ -47,7 +51,7 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
hlsInstance?.destroy();
|
hlsInstance?.destroy();
|
||||||
};
|
};
|
||||||
}, [url, onError]);
|
}, [url, onError, onLoaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
@@ -56,6 +60,9 @@ const HlsVideo = forwardRef<HlsVideoHandle, { url: string; className?: string; o
|
|||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
onError={() => onError?.()}
|
onError={() => onError?.()}
|
||||||
|
onCanPlay={() => onLoaded?.()}
|
||||||
|
onLoadedData={() => onLoaded?.()}
|
||||||
|
onPlaying={() => onLoaded?.()}
|
||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5962,6 +5962,7 @@ const MaplibreViewer = ({
|
|||||||
return (
|
return (
|
||||||
<CctvFullscreenModal
|
<CctvFullscreenModal
|
||||||
url={url}
|
url={url}
|
||||||
|
rawUrl={rawUrl}
|
||||||
mediaType={mt}
|
mediaType={mt}
|
||||||
isVideo={isVideo}
|
isVideo={isVideo}
|
||||||
cameraName={cameraName}
|
cameraName={cameraName}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef } from 'react';
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { AlertTriangle, Play, Pause } from 'lucide-react';
|
import { AlertTriangle, Play, Pause } from 'lucide-react';
|
||||||
import HlsVideo, { type HlsVideoHandle } from '@/components/HlsVideo';
|
import HlsVideo, { type HlsVideoHandle } from '@/components/HlsVideo';
|
||||||
|
|
||||||
export interface CctvFullscreenModalProps {
|
export interface CctvFullscreenModalProps {
|
||||||
url: string;
|
url: string;
|
||||||
|
rawUrl?: string;
|
||||||
mediaType: string;
|
mediaType: string;
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
cameraName: string;
|
cameraName: string;
|
||||||
@@ -16,6 +17,7 @@ export interface CctvFullscreenModalProps {
|
|||||||
|
|
||||||
export function CctvFullscreenModal({
|
export function CctvFullscreenModal({
|
||||||
url,
|
url,
|
||||||
|
rawUrl = '',
|
||||||
mediaType,
|
mediaType,
|
||||||
isVideo,
|
isVideo,
|
||||||
cameraName,
|
cameraName,
|
||||||
@@ -25,8 +27,60 @@ export function CctvFullscreenModal({
|
|||||||
}: CctvFullscreenModalProps) {
|
}: CctvFullscreenModalProps) {
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [mediaError, setMediaError] = useState(false);
|
const [mediaError, setMediaError] = useState(false);
|
||||||
|
const [mediaLoaded, setMediaLoaded] = useState(false);
|
||||||
|
const [sourceIndex, setSourceIndex] = useState(0);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const hlsRef = useRef<HlsVideoHandle>(null);
|
const hlsRef = useRef<HlsVideoHandle>(null);
|
||||||
|
const sources = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return [url, rawUrl]
|
||||||
|
.map((candidate) => String(candidate || '').trim())
|
||||||
|
.filter((candidate) => {
|
||||||
|
if (!candidate || seen.has(candidate)) return false;
|
||||||
|
seen.add(candidate);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [rawUrl, url]);
|
||||||
|
const activeUrl = sources[sourceIndex] || '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSourceIndex(0);
|
||||||
|
setMediaError(false);
|
||||||
|
setMediaLoaded(false);
|
||||||
|
setPaused(false);
|
||||||
|
}, [rawUrl, url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMediaLoaded(false);
|
||||||
|
}, [activeUrl]);
|
||||||
|
|
||||||
|
const handleMediaFailure = useCallback(() => {
|
||||||
|
setSourceIndex((idx) => {
|
||||||
|
const next = idx + 1;
|
||||||
|
if (next < sources.length) {
|
||||||
|
setMediaError(false);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
setMediaError(true);
|
||||||
|
return idx;
|
||||||
|
});
|
||||||
|
}, [sources.length]);
|
||||||
|
|
||||||
|
const handleMediaReady = useCallback(() => {
|
||||||
|
setMediaLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sourceIndex !== 0 || sources.length < 2 || mediaLoaded || mediaError) return;
|
||||||
|
const timeoutMs = mediaType === 'hls' ? 3200 : 1800;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setSourceIndex((idx) => {
|
||||||
|
if (idx !== 0 || mediaLoaded) return idx;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}, timeoutMs);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [mediaError, mediaLoaded, mediaType, sourceIndex, sources.length]);
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
const togglePlay = useCallback(() => {
|
||||||
if (mediaType === 'hls') {
|
if (mediaType === 'hls') {
|
||||||
@@ -176,17 +230,21 @@ export function CctvFullscreenModal({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{url ? (
|
{activeUrl ? (
|
||||||
<>
|
<>
|
||||||
{mediaType === 'video' && !mediaError && (
|
{mediaType === 'video' && !mediaError && (
|
||||||
<video
|
<video
|
||||||
|
key={activeUrl}
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={url}
|
src={activeUrl}
|
||||||
autoPlay
|
autoPlay
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
onError={() => setMediaError(true)}
|
onError={handleMediaFailure}
|
||||||
|
onCanPlay={handleMediaReady}
|
||||||
|
onLoadedData={handleMediaReady}
|
||||||
|
onPlaying={handleMediaReady}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: 'calc(100vh - 260px)',
|
maxHeight: 'calc(100vh - 260px)',
|
||||||
@@ -197,15 +255,18 @@ export function CctvFullscreenModal({
|
|||||||
)}
|
)}
|
||||||
{mediaType === 'hls' && !mediaError && (
|
{mediaType === 'hls' && !mediaError && (
|
||||||
<HlsVideo
|
<HlsVideo
|
||||||
|
key={activeUrl}
|
||||||
ref={hlsRef}
|
ref={hlsRef}
|
||||||
url={url}
|
url={activeUrl}
|
||||||
onError={() => setMediaError(true)}
|
onError={handleMediaFailure}
|
||||||
className=""
|
onLoaded={handleMediaReady}
|
||||||
|
className="max-w-full max-h-[calc(100vh-260px)] object-contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{mediaType === 'mjpeg' && (
|
{mediaType === 'mjpeg' && (
|
||||||
<img
|
<img
|
||||||
src={url}
|
key={activeUrl}
|
||||||
|
src={activeUrl}
|
||||||
alt="MJPEG Feed"
|
alt="MJPEG Feed"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -213,14 +274,14 @@ export function CctvFullscreenModal({
|
|||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
filter: 'contrast(1.25) saturate(0.5)',
|
filter: 'contrast(1.25) saturate(0.5)',
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={handleMediaFailure}
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
onLoad={handleMediaReady}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(mediaType === 'image' || mediaType === 'satellite') && (
|
{(mediaType === 'image' || mediaType === 'satellite') && (
|
||||||
<img
|
<img
|
||||||
src={url}
|
key={activeUrl}
|
||||||
|
src={activeUrl}
|
||||||
alt="CCTV Feed"
|
alt="CCTV Feed"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -228,10 +289,8 @@ export function CctvFullscreenModal({
|
|||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
filter: 'contrast(1.25) saturate(0.5)',
|
filter: 'contrast(1.25) saturate(0.5)',
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={handleMediaFailure}
|
||||||
const target = e.target as HTMLImageElement;
|
onLoad={handleMediaReady}
|
||||||
target.style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -239,7 +298,7 @@ export function CctvFullscreenModal({
|
|||||||
{mediaError && (
|
{mediaError && (
|
||||||
<div style={{ fontSize: 11, color: 'rgba(239,68,68,0.7)', fontFamily: 'monospace', letterSpacing: '0.15em', textAlign: 'center', padding: 40 }}>
|
<div style={{ fontSize: 11, color: 'rgba(239,68,68,0.7)', fontFamily: 'monospace', letterSpacing: '0.15em', textAlign: 'center', padding: 40 }}>
|
||||||
FEED UNAVAILABLE<br />
|
FEED UNAVAILABLE<br />
|
||||||
<span style={{ fontSize: 9, color: 'rgba(148,163,184,0.5)' }}>stream failed to load — source may be offline</span>
|
<span style={{ fontSize: 9, color: 'rgba(148,163,184,0.5)' }}>proxy and direct source both failed</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -329,10 +388,10 @@ export function CctvFullscreenModal({
|
|||||||
{cameraName}
|
{cameraName}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
{url && (
|
{activeUrl && (
|
||||||
<>
|
<>
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={rawUrl || activeUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{
|
style={{
|
||||||
@@ -354,7 +413,7 @@ export function CctvFullscreenModal({
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(rawUrl || activeUrl);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
identityWizardStatus,
|
identityWizardStatus,
|
||||||
setIdentityWizardStatus,
|
setIdentityWizardStatus,
|
||||||
meshQuickStatus,
|
meshQuickStatus,
|
||||||
|
meshSessionActive,
|
||||||
publicMeshAddress,
|
publicMeshAddress,
|
||||||
meshView,
|
meshView,
|
||||||
setMeshView,
|
setMeshView,
|
||||||
@@ -119,6 +120,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
// Identity
|
// Identity
|
||||||
identity,
|
identity,
|
||||||
publicIdentity,
|
publicIdentity,
|
||||||
|
hasStoredPublicLaneIdentity,
|
||||||
hasPublicLaneIdentity,
|
hasPublicLaneIdentity,
|
||||||
hasId,
|
hasId,
|
||||||
shouldShowIdentityWarning,
|
shouldShowIdentityWarning,
|
||||||
@@ -255,6 +257,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
openChat,
|
openChat,
|
||||||
handleCreatePublicIdentity,
|
handleCreatePublicIdentity,
|
||||||
handleQuickCreatePublicIdentity,
|
handleQuickCreatePublicIdentity,
|
||||||
|
handleActivatePublicMeshSession,
|
||||||
handleLeaveWormholeForPublicMesh,
|
handleLeaveWormholeForPublicMesh,
|
||||||
handleResetPublicIdentity,
|
handleResetPublicIdentity,
|
||||||
handleBootstrapPrivateIdentity,
|
handleBootstrapPrivateIdentity,
|
||||||
@@ -324,6 +327,40 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
}
|
}
|
||||||
void handleRequestAccess(targetId);
|
void handleRequestAccess(targetId);
|
||||||
};
|
};
|
||||||
|
const meshActivationText =
|
||||||
|
meshQuickStatus?.text ||
|
||||||
|
(publicMeshBlockedByWormhole
|
||||||
|
? hasStoredPublicLaneIdentity
|
||||||
|
? 'Wormhole is active. Turning MeshChat on will turn Wormhole off and use your saved public mesh key.'
|
||||||
|
: 'Wormhole is active. Turning MeshChat on will turn Wormhole off and mint a separate public mesh key.'
|
||||||
|
: hasStoredPublicLaneIdentity
|
||||||
|
? 'MeshChat is off. Turn it on to use your saved public mesh key.'
|
||||||
|
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.');
|
||||||
|
const handleMeshActivationAction = () => {
|
||||||
|
if (hasStoredPublicLaneIdentity) {
|
||||||
|
void handleActivatePublicMeshSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (publicMeshBlockedByWormhole) {
|
||||||
|
void handleLeaveWormholeForPublicMesh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void handleQuickCreatePublicIdentity();
|
||||||
|
};
|
||||||
|
const meshActivationLabel = identityWizardBusy
|
||||||
|
? 'GETTING MESH KEY'
|
||||||
|
: hasStoredPublicLaneIdentity
|
||||||
|
? 'TURN ON MESH'
|
||||||
|
: publicMeshBlockedByWormhole
|
||||||
|
? 'TURN OFF WORMHOLE FOR MESH'
|
||||||
|
: 'GET MESH KEY';
|
||||||
|
const meshActivationSideLabel = identityWizardBusy
|
||||||
|
? 'WORKING...'
|
||||||
|
: hasStoredPublicLaneIdentity
|
||||||
|
? 'USE SAVED KEY'
|
||||||
|
: publicMeshBlockedByWormhole
|
||||||
|
? 'AUTO DISABLE'
|
||||||
|
: 'ONE TAP';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1120,16 +1157,25 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] font-mono text-[var(--text-muted)] truncate">
|
<div className="text-[10px] font-mono text-[var(--text-muted)] truncate">
|
||||||
{publicMeshAddress ? `ADDR ${publicMeshAddress.toUpperCase()}` : 'NO PUBLIC MESH ADDRESS'}
|
{meshSessionActive && publicMeshAddress
|
||||||
|
? `ADDR ${publicMeshAddress.toUpperCase()}`
|
||||||
|
: publicMeshAddress
|
||||||
|
? 'MESH OFF / KEY SAVED'
|
||||||
|
: 'NO PUBLIC MESH ADDRESS'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
|
<div className="flex-1 overflow-y-auto styled-scrollbar px-3 py-1.5 border-l-2 border-cyan-800/25">
|
||||||
{meshView === 'channel' && filteredMeshMessages.length === 0 && (
|
{!meshSessionActive && (
|
||||||
|
<div className="text-[12px] font-mono text-green-300/70 text-center py-4 leading-[1.65]">
|
||||||
|
MeshChat is off. Turn it on to connect the public mesh lane.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{meshSessionActive && meshView === 'channel' && filteredMeshMessages.length === 0 && (
|
||||||
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
||||||
No messages from {meshRegion} / {meshChannel}
|
No messages from {meshRegion} / {meshChannel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{meshView === 'inbox' && (
|
{meshSessionActive && meshView === 'inbox' && (
|
||||||
<>
|
<>
|
||||||
{!publicMeshAddress && (
|
{!publicMeshAddress && (
|
||||||
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
<div className="text-[12px] font-mono text-[var(--text-muted)] text-center py-4 leading-[1.65]">
|
||||||
@@ -2049,7 +2095,9 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
? meshDirectTarget
|
? meshDirectTarget
|
||||||
? `→ MESH / TO ${meshDirectTarget.toUpperCase()}`
|
? `→ MESH / TO ${meshDirectTarget.toUpperCase()}`
|
||||||
: `→ MESH / ${meshRegion} / ${meshChannel}`
|
: `→ MESH / ${meshRegion} / ${meshChannel}`
|
||||||
: '→ MESH LOCKED'
|
: hasStoredPublicLaneIdentity
|
||||||
|
? '→ MESH OFF'
|
||||||
|
: '→ MESH LOCKED'
|
||||||
: activeTab === 'dms' && secureDmBlocked
|
: activeTab === 'dms' && secureDmBlocked
|
||||||
? '→ DEAD DROP LOCKED'
|
? '→ DEAD DROP LOCKED'
|
||||||
: dmView === 'chat' && selectedContact
|
: dmView === 'chat' && selectedContact
|
||||||
@@ -2068,10 +2116,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
: 'text-green-300/70'
|
: 'text-green-300/70'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{meshQuickStatus?.text ||
|
{meshActivationText}
|
||||||
(publicMeshBlockedByWormhole
|
|
||||||
? 'Wormhole is active. Turn it off here and we will mint a separate public mesh key for you.'
|
|
||||||
: 'Public mesh posting needs a mesh key. One tap gets you a fresh address.')}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
|
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
|
||||||
@@ -2103,30 +2148,16 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
</button>
|
</button>
|
||||||
) : activeTab === 'meshtastic' && !hasPublicLaneIdentity ? (
|
) : activeTab === 'meshtastic' && !hasPublicLaneIdentity ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={handleMeshActivationAction}
|
||||||
if (publicMeshBlockedByWormhole) {
|
|
||||||
void handleLeaveWormholeForPublicMesh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void handleQuickCreatePublicIdentity();
|
|
||||||
}}
|
|
||||||
disabled={identityWizardBusy}
|
disabled={identityWizardBusy}
|
||||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-green-700/40 bg-green-950/15 text-green-300 hover:bg-green-950/25 hover:border-green-500/50 transition-colors"
|
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-green-700/40 bg-green-950/15 text-green-300 hover:bg-green-950/25 hover:border-green-500/50 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-2 text-sm font-mono tracking-[0.2em]">
|
<span className="inline-flex items-center gap-2 text-sm font-mono tracking-[0.2em]">
|
||||||
<Radio size={11} />
|
<Radio size={11} />
|
||||||
{identityWizardBusy
|
{meshActivationLabel}
|
||||||
? 'GETTING MESH KEY'
|
|
||||||
: publicMeshBlockedByWormhole
|
|
||||||
? 'TURN OFF WORMHOLE FOR MESH'
|
|
||||||
: 'GET MESH KEY'}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[12px] font-mono text-green-300/70">
|
<span className="text-[12px] font-mono text-green-300/70">
|
||||||
{identityWizardBusy
|
{meshActivationSideLabel}
|
||||||
? 'WORKING...'
|
|
||||||
: publicMeshBlockedByWormhole
|
|
||||||
? 'AUTO FIX'
|
|
||||||
: 'ONE TAP'}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
) : activeTab === 'meshtastic' && meshDirectTarget ? (
|
) : activeTab === 'meshtastic' && meshDirectTarget ? (
|
||||||
@@ -2375,8 +2406,8 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
CURRENT STATE
|
CURRENT STATE
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-1 text-[13px] font-mono text-[var(--text-secondary)] leading-[1.5]">
|
<div className="grid grid-cols-1 gap-1 text-[13px] font-mono text-[var(--text-secondary)] leading-[1.5]">
|
||||||
<div>Public mesh key: {hasPublicLaneIdentity ? 'active' : 'not issued'}</div>
|
<div>Public mesh key: {hasPublicLaneIdentity ? 'active' : hasStoredPublicLaneIdentity ? 'saved / off' : 'not issued'}</div>
|
||||||
<div>Public mesh address: {hasPublicLaneIdentity && publicMeshAddress ? publicMeshAddress.toUpperCase() : 'not ready'}</div>
|
<div>Public mesh address: {publicMeshAddress ? publicMeshAddress.toUpperCase() : 'not ready'}</div>
|
||||||
<div>Wormhole lane: {wormholeEnabled && wormholeReadyState ? 'active' : wormholeEnabled ? 'starting' : 'off'}</div>
|
<div>Wormhole lane: {wormholeEnabled && wormholeReadyState ? 'active' : wormholeEnabled ? 'starting' : 'off'}</div>
|
||||||
<div>Wormhole descriptor: {wormholeDescriptor?.nodeId || 'not cached yet'}</div>
|
<div>Wormhole descriptor: {wormholeDescriptor?.nodeId || 'not cached yet'}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2385,6 +2416,10 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (hasStoredPublicLaneIdentity) {
|
||||||
|
void handleActivatePublicMeshSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (publicMeshBlockedByWormhole) {
|
if (publicMeshBlockedByWormhole) {
|
||||||
void handleLeaveWormholeForPublicMesh();
|
void handleLeaveWormholeForPublicMesh();
|
||||||
return;
|
return;
|
||||||
@@ -2396,12 +2431,16 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
|
|||||||
>
|
>
|
||||||
{hasPublicLaneIdentity
|
{hasPublicLaneIdentity
|
||||||
? 'MESH KEY ACTIVE'
|
? 'MESH KEY ACTIVE'
|
||||||
|
: hasStoredPublicLaneIdentity
|
||||||
|
? 'TURN ON MESH'
|
||||||
: publicMeshBlockedByWormhole
|
: publicMeshBlockedByWormhole
|
||||||
? 'TURN OFF WORMHOLE FOR MESH'
|
? 'TURN OFF WORMHOLE FOR MESH'
|
||||||
: 'GET MESH KEY'}
|
: 'GET MESH KEY'}
|
||||||
<div className="mt-1 text-[13px] text-green-200/70 normal-case tracking-normal leading-[1.45]">
|
<div className="mt-1 text-[13px] text-green-200/70 normal-case tracking-normal leading-[1.45]">
|
||||||
{hasPublicLaneIdentity
|
{hasPublicLaneIdentity
|
||||||
? 'Your public mesh key is already live for posting.'
|
? 'Your public mesh key is already live for posting.'
|
||||||
|
: hasStoredPublicLaneIdentity
|
||||||
|
? 'Use your saved public mesh key. This turns Wormhole off first if it is active.'
|
||||||
: publicMeshBlockedByWormhole
|
: publicMeshBlockedByWormhole
|
||||||
? 'One tap turns Wormhole off and mints a separate public mesh key.'
|
? 'One tap turns Wormhole off and mints a separate public mesh key.'
|
||||||
: 'One tap for a working mesh key and address.'}
|
: 'One tap for a working mesh key and address.'}
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ export function useMeshChatController({
|
|||||||
const [identityWizardBusy, setIdentityWizardBusy] = useState(false);
|
const [identityWizardBusy, setIdentityWizardBusy] = useState(false);
|
||||||
const [identityWizardStatus, setIdentityWizardStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
const [identityWizardStatus, setIdentityWizardStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||||
const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
const [meshQuickStatus, setMeshQuickStatus] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||||
|
const [meshSessionActive, setMeshSessionActive] = useState(false);
|
||||||
const [publicMeshAddress, setPublicMeshAddress] = useState('');
|
const [publicMeshAddress, setPublicMeshAddress] = useState('');
|
||||||
const [meshView, setMeshView] = useState<'channel' | 'inbox'>('channel');
|
const [meshView, setMeshView] = useState<'channel' | 'inbox'>('channel');
|
||||||
const [meshDirectTarget, setMeshDirectTarget] = useState('');
|
const [meshDirectTarget, setMeshDirectTarget] = useState('');
|
||||||
@@ -328,12 +329,14 @@ export function useMeshChatController({
|
|||||||
const [recentPrivateFallbackReason, setRecentPrivateFallbackReason] = useState('');
|
const [recentPrivateFallbackReason, setRecentPrivateFallbackReason] = useState('');
|
||||||
const [unresolvedSenderSealCount, setUnresolvedSenderSealCount] = useState(0);
|
const [unresolvedSenderSealCount, setUnresolvedSenderSealCount] = useState(0);
|
||||||
const [privacyProfile, setPrivacyProfile] = useState<'default' | 'high'>('default');
|
const [privacyProfile, setPrivacyProfile] = useState<'default' | 'high'>('default');
|
||||||
const publicIdentity = clientHydrated ? getNodeIdentity() : null;
|
const storedPublicIdentity = clientHydrated ? getNodeIdentity() : null;
|
||||||
const hasPublicLaneIdentity = clientHydrated && Boolean(publicIdentity) && hasSovereignty();
|
const hasStoredPublicLaneIdentity = clientHydrated && Boolean(storedPublicIdentity) && hasSovereignty();
|
||||||
|
const publicIdentity = meshSessionActive ? storedPublicIdentity : null;
|
||||||
|
const hasPublicLaneIdentity = meshSessionActive && hasStoredPublicLaneIdentity;
|
||||||
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
|
const hasId = Boolean(identity) && (hasSovereignty() || wormholeEnabled);
|
||||||
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
|
const shouldShowIdentityWarning = activeTab !== 'meshtastic' && !hasId;
|
||||||
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
|
const privateInfonetReady = wormholeEnabled && wormholeReadyState;
|
||||||
const publicMeshBlockedByWormhole = wormholeEnabled && wormholeReadyState && !hasPublicLaneIdentity;
|
const publicMeshBlockedByWormhole = wormholeEnabled || wormholeReadyState;
|
||||||
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
|
const dmSendQueue = useRef<(() => Promise<void>)[]>([]);
|
||||||
const dmSendTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const dmSendTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const streamEnabledForSelectedGateRef = useRef(false);
|
const streamEnabledForSelectedGateRef = useRef(false);
|
||||||
@@ -365,6 +368,13 @@ export function useMeshChatController({
|
|||||||
setClientHydrated(true);
|
setClientHydrated(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clientHydrated) return;
|
||||||
|
setMeshSessionActive(false);
|
||||||
|
setMeshMessages([]);
|
||||||
|
setMeshQuickStatus(null);
|
||||||
|
}, [clientHydrated]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
subscribeGateSessionStreamStatus((nextStatus) => {
|
subscribeGateSessionStreamStatus((nextStatus) => {
|
||||||
@@ -450,6 +460,8 @@ export function useMeshChatController({
|
|||||||
setSecureModeCached(enabled);
|
setSecureModeCached(enabled);
|
||||||
setWormholeEnabled(enabled);
|
setWormholeEnabled(enabled);
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
|
setMeshSessionActive(false);
|
||||||
|
setMeshMessages([]);
|
||||||
purgeBrowserContactGraph();
|
purgeBrowserContactGraph();
|
||||||
void hydrateWormholeContacts();
|
void hydrateWormholeContacts();
|
||||||
}
|
}
|
||||||
@@ -515,7 +527,7 @@ export function useMeshChatController({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const senderId = publicIdentity?.nodeId || '';
|
const senderId = storedPublicIdentity?.nodeId || '';
|
||||||
if (!senderId || !globalThis.crypto?.subtle) {
|
if (!senderId || !globalThis.crypto?.subtle) {
|
||||||
setPublicMeshAddress('');
|
setPublicMeshAddress('');
|
||||||
return;
|
return;
|
||||||
@@ -530,7 +542,7 @@ export function useMeshChatController({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [publicIdentity?.nodeId]);
|
}, [storedPublicIdentity?.nodeId]);
|
||||||
|
|
||||||
const flushDmQueue = useCallback(async () => {
|
const flushDmQueue = useCallback(async () => {
|
||||||
const queue = dmSendQueue.current.splice(0);
|
const queue = dmSendQueue.current.splice(0);
|
||||||
@@ -1138,10 +1150,10 @@ export function useMeshChatController({
|
|||||||
[meshMessages, mutedUsers],
|
[meshMessages, mutedUsers],
|
||||||
);
|
);
|
||||||
const meshInboxMessages = useMemo(() => {
|
const meshInboxMessages = useMemo(() => {
|
||||||
if (!publicMeshAddress) return [];
|
if (!meshSessionActive || !publicMeshAddress) return [];
|
||||||
const target = publicMeshAddress.toLowerCase();
|
const target = publicMeshAddress.toLowerCase();
|
||||||
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target);
|
return filteredMeshMessages.filter((m) => String(m.to || '').toLowerCase() === target);
|
||||||
}, [filteredMeshMessages, publicMeshAddress]);
|
}, [filteredMeshMessages, meshSessionActive, publicMeshAddress]);
|
||||||
|
|
||||||
// ─── InfoNet Polling ─────────────────────────────────────────────────────
|
// ─── InfoNet Polling ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -1735,7 +1747,7 @@ export function useMeshChatController({
|
|||||||
|
|
||||||
// ─── Meshtastic Channel Discovery ──────────────────────────────────────
|
// ─── Meshtastic Channel Discovery ──────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!expanded || activeTab !== 'meshtastic') return;
|
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const fetchChannels = async () => {
|
const fetchChannels = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1794,12 +1806,12 @@ export function useMeshChatController({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(iv);
|
clearInterval(iv);
|
||||||
};
|
};
|
||||||
}, [expanded, activeTab, meshRegion]);
|
}, [expanded, activeTab, meshRegion, meshSessionActive]);
|
||||||
|
|
||||||
// ─── Meshtastic Polling ──────────────────────────────────────────────────
|
// ─── Meshtastic Polling ──────────────────────────────────────────────────
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!expanded || activeTab !== 'meshtastic') return;
|
if (!expanded || activeTab !== 'meshtastic' || !meshSessionActive) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1823,7 +1835,13 @@ export function useMeshChatController({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(iv);
|
clearInterval(iv);
|
||||||
};
|
};
|
||||||
}, [expanded, activeTab, meshRegion, meshChannel, meshView]);
|
}, [expanded, activeTab, meshRegion, meshChannel, meshView, meshSessionActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (meshSessionActive) return;
|
||||||
|
setMeshMessages([]);
|
||||||
|
setMeshQuickStatus(null);
|
||||||
|
}, [meshSessionActive]);
|
||||||
|
|
||||||
// ─── DM Polling ──────────────────────────────────────────────────────────
|
// ─── DM Polling ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -2305,7 +2323,8 @@ export function useMeshChatController({
|
|||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
const msg = inputValue.trim();
|
const msg = inputValue.trim();
|
||||||
if (!msg || !hasId || busy) return;
|
if (!msg || busy) return;
|
||||||
|
if (activeTab !== 'meshtastic' && !hasId) return;
|
||||||
|
|
||||||
const cooldownMs = activeTab === 'dms' ? 0 : 30_000;
|
const cooldownMs = activeTab === 'dms' ? 0 : 30_000;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -2392,13 +2411,15 @@ export function useMeshChatController({
|
|||||||
]);
|
]);
|
||||||
setGateReplyContext(null);
|
setGateReplyContext(null);
|
||||||
} else if (activeTab === 'meshtastic') {
|
} else if (activeTab === 'meshtastic') {
|
||||||
if (!publicIdentity || !hasSovereignty()) {
|
if (!meshSessionActive || !publicIdentity || !hasSovereignty()) {
|
||||||
setInputValue(msg);
|
setInputValue(msg);
|
||||||
setLastSendTime(0);
|
setLastSendTime(0);
|
||||||
setSendError('public mesh identity needed');
|
setSendError(meshSessionActive ? 'public mesh identity needed' : 'meshchat is off');
|
||||||
openIdentityWizard({
|
openIdentityWizard({
|
||||||
type: 'err',
|
type: 'err',
|
||||||
text: 'Quick fix: create a public mesh identity below, then retry your send.',
|
text: hasStoredPublicLaneIdentity
|
||||||
|
? 'Quick fix: turn MeshChat on below, then retry your send.'
|
||||||
|
: 'Quick fix: create a public mesh identity below, then retry your send.',
|
||||||
});
|
});
|
||||||
setTimeout(() => setSendError(''), 4000);
|
setTimeout(() => setSendError(''), 4000);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -3959,16 +3980,36 @@ export function useMeshChatController({
|
|||||||
[inputDisabled],
|
[inputDisabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const disableWormholeForPublicMesh = useCallback(async () => {
|
||||||
|
const requireBackendLeave = wormholeEnabled || wormholeReadyState;
|
||||||
|
try {
|
||||||
|
await leaveWormhole();
|
||||||
|
} catch (err) {
|
||||||
|
if (requireBackendLeave) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setWormholeEnabled(false);
|
||||||
|
setWormholeReadyState(false);
|
||||||
|
setWormholeRnsReady(false);
|
||||||
|
setWormholeRnsDirectReady(false);
|
||||||
|
setWormholeRnsPeers({ active: 0, configured: 0 });
|
||||||
|
setSecureModeCached(false);
|
||||||
|
}, [wormholeEnabled, wormholeReadyState]);
|
||||||
|
|
||||||
const createPublicMeshIdentity = useCallback(
|
const createPublicMeshIdentity = useCallback(
|
||||||
async ({ closeWizardOnSuccess }: { closeWizardOnSuccess: boolean }) => {
|
async ({ closeWizardOnSuccess }: { closeWizardOnSuccess: boolean }) => {
|
||||||
setIdentityWizardBusy(true);
|
setIdentityWizardBusy(true);
|
||||||
setIdentityWizardStatus(null);
|
setIdentityWizardStatus(null);
|
||||||
try {
|
try {
|
||||||
|
await disableWormholeForPublicMesh();
|
||||||
const nextIdentity = await generateNodeKeys();
|
const nextIdentity = await generateNodeKeys();
|
||||||
const nextAddress = await derivePublicMeshAddress(nextIdentity.nodeId).catch(() => '');
|
const nextAddress = await derivePublicMeshAddress(nextIdentity.nodeId).catch(() => '');
|
||||||
const readyAddress = (nextAddress || nextIdentity.nodeId).toUpperCase();
|
const readyAddress = (nextAddress || nextIdentity.nodeId).toUpperCase();
|
||||||
setIdentity(nextIdentity);
|
setIdentity(nextIdentity);
|
||||||
setPublicMeshAddress(nextAddress || nextIdentity.nodeId);
|
setPublicMeshAddress(nextAddress || nextIdentity.nodeId);
|
||||||
|
setMeshSessionActive(true);
|
||||||
|
setMeshMessages([]);
|
||||||
setSendError('');
|
setSendError('');
|
||||||
const successText = `Mesh key ready. Address ${readyAddress} is live for this testnet session.`;
|
const successText = `Mesh key ready. Address ${readyAddress} is live for this testnet session.`;
|
||||||
setIdentityWizardStatus({
|
setIdentityWizardStatus({
|
||||||
@@ -3997,7 +4038,7 @@ export function useMeshChatController({
|
|||||||
setIdentityWizardBusy(false);
|
setIdentityWizardBusy(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[disableWormholeForPublicMesh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreatePublicIdentity = useCallback(async () => {
|
const handleCreatePublicIdentity = useCallback(async () => {
|
||||||
@@ -4013,6 +4054,45 @@ export function useMeshChatController({
|
|||||||
}
|
}
|
||||||
}, [createPublicMeshIdentity]);
|
}, [createPublicMeshIdentity]);
|
||||||
|
|
||||||
|
const handleActivatePublicMeshSession = useCallback(async () => {
|
||||||
|
setIdentityWizardBusy(true);
|
||||||
|
setIdentityWizardStatus(null);
|
||||||
|
setMeshQuickStatus(null);
|
||||||
|
try {
|
||||||
|
const savedIdentity = getNodeIdentity();
|
||||||
|
if (!savedIdentity || !hasSovereignty()) {
|
||||||
|
const text = 'No saved public mesh key is available. Create a mesh key first.';
|
||||||
|
setMeshSessionActive(false);
|
||||||
|
setIdentityWizardStatus({ type: 'err', text });
|
||||||
|
setMeshQuickStatus({ type: 'err', text });
|
||||||
|
return { ok: false as const, text };
|
||||||
|
}
|
||||||
|
await disableWormholeForPublicMesh();
|
||||||
|
const nextAddress = await derivePublicMeshAddress(savedIdentity.nodeId).catch(() => '');
|
||||||
|
const readyAddress = (nextAddress || savedIdentity.nodeId).toUpperCase();
|
||||||
|
setIdentity(savedIdentity);
|
||||||
|
setPublicMeshAddress(nextAddress || savedIdentity.nodeId);
|
||||||
|
setMeshSessionActive(true);
|
||||||
|
setMeshMessages([]);
|
||||||
|
setSendError('');
|
||||||
|
const text = `MeshChat is on with saved address ${readyAddress}.`;
|
||||||
|
setIdentityWizardStatus({ type: 'ok', text });
|
||||||
|
setMeshQuickStatus({ type: 'ok', text });
|
||||||
|
return { ok: true as const, text };
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
typeof err === 'object' && err !== null && 'message' in err
|
||||||
|
? String((err as { message?: string }).message)
|
||||||
|
: 'unknown error';
|
||||||
|
const text = `Could not turn MeshChat on: ${message}`;
|
||||||
|
setIdentityWizardStatus({ type: 'err', text });
|
||||||
|
setMeshQuickStatus({ type: 'err', text });
|
||||||
|
return { ok: false as const, text };
|
||||||
|
} finally {
|
||||||
|
setIdentityWizardBusy(false);
|
||||||
|
}
|
||||||
|
}, [disableWormholeForPublicMesh]);
|
||||||
|
|
||||||
const handleReplyToMeshAddress = useCallback((address: string) => {
|
const handleReplyToMeshAddress = useCallback((address: string) => {
|
||||||
const target = String(address || '').trim();
|
const target = String(address || '').trim();
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -4023,36 +4103,16 @@ export function useMeshChatController({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLeaveWormholeForPublicMesh = useCallback(async () => {
|
const handleLeaveWormholeForPublicMesh = useCallback(async () => {
|
||||||
setIdentityWizardBusy(true);
|
const result = hasStoredPublicLaneIdentity
|
||||||
setIdentityWizardStatus(null);
|
? await handleActivatePublicMeshSession()
|
||||||
setMeshQuickStatus(null);
|
: await createPublicMeshIdentity({ closeWizardOnSuccess: false });
|
||||||
try {
|
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
|
||||||
await leaveWormhole();
|
setIdentityWizardStatus(status);
|
||||||
setWormholeEnabled(false);
|
setMeshQuickStatus(status);
|
||||||
setWormholeReadyState(false);
|
if (result.ok) {
|
||||||
setWormholeRnsReady(false);
|
window.setTimeout(() => setIdentityWizardOpen(false), 900);
|
||||||
setWormholeRnsDirectReady(false);
|
|
||||||
setWormholeRnsPeers({ active: 0, configured: 0 });
|
|
||||||
setSecureModeCached(false);
|
|
||||||
const result = await createPublicMeshIdentity({ closeWizardOnSuccess: false });
|
|
||||||
const status = { type: result.ok ? 'ok' as const : 'err' as const, text: result.text };
|
|
||||||
setIdentityWizardStatus(status);
|
|
||||||
setMeshQuickStatus(status);
|
|
||||||
if (result.ok) {
|
|
||||||
window.setTimeout(() => setIdentityWizardOpen(false), 900);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
typeof err === 'object' && err !== null && 'message' in err
|
|
||||||
? String((err as { message?: string }).message)
|
|
||||||
: 'unknown error';
|
|
||||||
const text = `Could not turn Wormhole off for public mesh: ${message}`;
|
|
||||||
setIdentityWizardStatus({ type: 'err', text });
|
|
||||||
setMeshQuickStatus({ type: 'err', text });
|
|
||||||
} finally {
|
|
||||||
setIdentityWizardBusy(false);
|
|
||||||
}
|
}
|
||||||
}, [createPublicMeshIdentity]);
|
}, [createPublicMeshIdentity, handleActivatePublicMeshSession, hasStoredPublicLaneIdentity]);
|
||||||
|
|
||||||
const handleResetPublicIdentity = useCallback(async () => {
|
const handleResetPublicIdentity = useCallback(async () => {
|
||||||
if (wormholeEnabled && wormholeReadyState) {
|
if (wormholeEnabled && wormholeReadyState) {
|
||||||
@@ -4065,8 +4125,11 @@ export function useMeshChatController({
|
|||||||
setIdentityWizardBusy(true);
|
setIdentityWizardBusy(true);
|
||||||
setIdentityWizardStatus(null);
|
setIdentityWizardStatus(null);
|
||||||
try {
|
try {
|
||||||
|
setMeshSessionActive(false);
|
||||||
|
setMeshMessages([]);
|
||||||
await clearBrowserIdentityState();
|
await clearBrowserIdentityState();
|
||||||
setIdentity(null);
|
setIdentity(null);
|
||||||
|
setPublicMeshAddress('');
|
||||||
setContacts({});
|
setContacts({});
|
||||||
setSelectedContact('');
|
setSelectedContact('');
|
||||||
setDmMessages([]);
|
setDmMessages([]);
|
||||||
@@ -4091,6 +4154,8 @@ export function useMeshChatController({
|
|||||||
}, [wormholeEnabled, wormholeReadyState]);
|
}, [wormholeEnabled, wormholeReadyState]);
|
||||||
|
|
||||||
const handleBootstrapPrivateIdentity = useCallback(async () => {
|
const handleBootstrapPrivateIdentity = useCallback(async () => {
|
||||||
|
setMeshSessionActive(false);
|
||||||
|
setMeshMessages([]);
|
||||||
if (wormholeEnabled && wormholeReadyState) {
|
if (wormholeEnabled && wormholeReadyState) {
|
||||||
setIdentityWizardStatus({
|
setIdentityWizardStatus({
|
||||||
type: 'ok',
|
type: 'ok',
|
||||||
@@ -4175,6 +4240,7 @@ export function useMeshChatController({
|
|||||||
identityWizardStatus,
|
identityWizardStatus,
|
||||||
setIdentityWizardStatus,
|
setIdentityWizardStatus,
|
||||||
meshQuickStatus,
|
meshQuickStatus,
|
||||||
|
meshSessionActive,
|
||||||
publicMeshAddress,
|
publicMeshAddress,
|
||||||
meshView,
|
meshView,
|
||||||
setMeshView,
|
setMeshView,
|
||||||
@@ -4183,6 +4249,7 @@ export function useMeshChatController({
|
|||||||
// Identity
|
// Identity
|
||||||
identity,
|
identity,
|
||||||
publicIdentity,
|
publicIdentity,
|
||||||
|
hasStoredPublicLaneIdentity,
|
||||||
hasPublicLaneIdentity,
|
hasPublicLaneIdentity,
|
||||||
hasId,
|
hasId,
|
||||||
shouldShowIdentityWarning,
|
shouldShowIdentityWarning,
|
||||||
@@ -4320,6 +4387,7 @@ export function useMeshChatController({
|
|||||||
openChat,
|
openChat,
|
||||||
handleCreatePublicIdentity,
|
handleCreatePublicIdentity,
|
||||||
handleQuickCreatePublicIdentity,
|
handleQuickCreatePublicIdentity,
|
||||||
|
handleActivatePublicMeshSession,
|
||||||
handleLeaveWormholeForPublicMesh,
|
handleLeaveWormholeForPublicMesh,
|
||||||
handleResetPublicIdentity,
|
handleResetPublicIdentity,
|
||||||
handleBootstrapPrivateIdentity,
|
handleBootstrapPrivateIdentity,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Database, Clock, X } from 'lucide-react';
|
||||||
|
|
||||||
|
const CURRENT_VERSION = '0.9.7';
|
||||||
|
const STORAGE_KEY = `shadowbroker_startup_warmup_notice_v${CURRENT_VERSION}`;
|
||||||
|
|
||||||
|
interface StartupWarmupModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StartupWarmupModal({ onClose }: StartupWarmupModalProps) {
|
||||||
|
const handleDismiss = () => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, 'true');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
key="warmup-backdrop"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[10000]"
|
||||||
|
onClick={handleDismiss}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
key="warmup-modal"
|
||||||
|
initial={{ opacity: 0, scale: 0.92, y: 18 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.92, y: 18 }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
className="fixed inset-0 z-[10001] flex items-center justify-center pointer-events-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-[520px] max-w-[calc(100vw-32px)] bg-[var(--bg-secondary)]/98 border border-cyan-900/50 pointer-events-auto overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="p-5 border-b border-[var(--border-primary)]/80 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
||||||
|
<Database size={18} className="text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">
|
||||||
|
STARTUP CACHE
|
||||||
|
</h2>
|
||||||
|
<span className="text-[13px] text-[var(--text-muted)] font-mono tracking-widest">
|
||||||
|
FIRST RUN WARMUP
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="w-8 h-8 border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div className="bg-cyan-950/20 border border-cyan-500/20 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Clock size={15} className="text-cyan-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[11px] text-cyan-300 font-mono font-bold tracking-widest">
|
||||||
|
MASS DATA SYNTHESIS
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] font-mono leading-relaxed">
|
||||||
|
The first launch builds local caches for flights, ships, satellites, CCTV, fires,
|
||||||
|
and threat intelligence. Cached launches paint the map much faster; a brand-new
|
||||||
|
install can take a few minutes while upstream feeds are synthesized.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="w-full py-3 border border-cyan-500/40 text-cyan-300 hover:text-cyan-100 hover:border-cyan-400/70 hover:bg-cyan-950/30 transition-all font-mono text-[12px] tracking-[0.18em] font-bold"
|
||||||
|
>
|
||||||
|
CONTINUE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStartupWarmupNotice() {
|
||||||
|
const [showWarmupNotice, setShowWarmupNotice] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
setShowWarmupNotice(localStorage.getItem(STORAGE_KEY) !== 'true');
|
||||||
|
} catch {
|
||||||
|
setShowWarmupNotice(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { showWarmupNotice, setShowWarmupNotice };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user