Private
Public Access
1
0

new: dev: initialize the GTK client #11

This commit is contained in:
2026-04-28 08:28:51 +02:00
parent 199bb7e525
commit 6484133199
29 changed files with 2788 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
"""
This file is part of the SplendidBear Websites' projects.
Copyright (c) 2026 @ www.splendidbear.org
For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
"""
from __future__ import annotations
import json
import logging
import threading
import time
from collections.abc import Callable
from typing import Any
import requests
from gi.repository import GLib
from mineseeker import config
from mineseeker.api import client
from mineseeker.constants import SSE_RECONNECT_INITIAL, SSE_RECONNECT_MAX
log = logging.getLogger(__name__)
class SseListener:
"""
Opens a Mercure SSE connection in a daemon thread and dispatches
parsed JSON messages back to the GTK main thread via GLib.idle_add().
Message routing mirrors useServerCommunication.jsx handleMercureMessage():
payload.type == "challenge" → on_challenge(payload)
payload.type == "challenge-response" → on_challenge_response(payload)
payload.type == "heartbeat" → on_heartbeat(payload)
"data" key present → on_topic(payload)
"msg" key present → on_unsubscribe(payload)
(none of the above) → on_subscribe(payload)
"""
def __init__(
self,
game_assoc: str,
mercure_jwt: str,
*,
on_subscribe: Callable[[dict], Any] | None = None,
on_unsubscribe: Callable[[dict], Any] | None = None,
on_topic: Callable[[dict], Any] | None = None,
on_challenge: Callable[[dict], Any] | None = None,
on_challenge_response: Callable[[dict], Any] | None = None,
on_heartbeat: Callable[[dict], Any] | None = None,
) -> None:
self._game_assoc = game_assoc
self._mercure_jwt = mercure_jwt
self._handlers = {
"subscribe": on_subscribe,
"unsubscribe": on_unsubscribe,
"topic": on_topic,
"challenge": on_challenge,
"challenge-response": on_challenge_response,
"heartbeat": on_heartbeat,
}
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
# ------------------------------------------------------------------
# Public control
# ------------------------------------------------------------------
def start(self) -> None:
"""Start the background SSE listener thread."""
self._stop_event.clear()
self._thread = threading.Thread(
target=self._run, daemon=True, name="sse-listener"
)
self._thread.start()
def stop(self) -> None:
"""Signal the background thread to stop."""
self._stop_event.set()
# ------------------------------------------------------------------
# Background thread
# ------------------------------------------------------------------
def _build_url(self) -> str:
topic = f"mineseeker/channel/{self._game_assoc}"
return f"{config.MERCURE_URL}?topic={topic}"
def _run(self) -> None:
backoff = SSE_RECONNECT_INITIAL
while not self._stop_event.is_set():
try:
self._stream()
backoff = SSE_RECONNECT_INITIAL # reset on clean disconnect
except Exception as exc:
if self._stop_event.is_set():
break
log.warning("SSE connection lost (%s), reconnecting in %.1fs", exc, backoff)
time.sleep(backoff)
backoff = min(backoff * 2, SSE_RECONNECT_MAX)
def _stream(self) -> None:
"""Open the SSE stream and process events until stopped or error."""
url = self._build_url()
headers = {
"Authorization": f"Bearer {self._mercure_jwt}",
"Accept": "text/event-stream",
"Cache-Control": "no-cache",
}
# Use the requests session from client.py so cookies are included
resp = client.get_session().get(
url, headers=headers, stream=True, timeout=(10, None)
)
resp.raise_for_status()
# Parse the raw SSE stream manually (sseclient-py would also work
# but avoids an extra dependency on GLib-aware loops)
data_lines: list[str] = []
for raw_line in resp.iter_lines(decode_unicode=True):
if self._stop_event.is_set():
break
if raw_line.startswith("data:"):
data_lines.append(raw_line[5:].lstrip(" "))
elif raw_line == "" and data_lines:
# Empty line signals end of event — dispatch it
payload_str = "\n".join(data_lines)
data_lines = []
try:
payload = json.loads(payload_str)
GLib.idle_add(self._dispatch, payload)
except json.JSONDecodeError:
log.debug("Non-JSON SSE data ignored: %s", payload_str)
def _dispatch(self, payload: dict) -> bool:
"""Called on the GTK main thread via GLib.idle_add."""
msg_type = payload.get("type")
if msg_type == "challenge":
self._call("challenge", payload)
elif msg_type == "challenge-response":
self._call("challenge-response", payload)
elif msg_type == "heartbeat":
self._call("heartbeat", payload)
elif "data" in payload:
self._call("topic", payload)
elif "msg" in payload:
self._call("unsubscribe", payload)
else:
self._call("subscribe", payload)
return GLib.SOURCE_REMOVE # run once only
def _call(self, key: str, payload: dict) -> None:
handler = self._handlers.get(key)
if handler is not None:
try:
handler(payload)
except Exception:
log.exception("Error in SSE handler '%s'", key)