Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""UI-agnostic API over the core (docs/ARCHITECTURE.md, "API seam").

UIs import from here, never from app.core / app.services directly for
actions or state. This package never imports a GUI toolkit.
"""

from __future__ import annotations

from app.api.app_api import AppApi
from app.api.events import Dispatcher, EventHub, Subscription, direct
from app.api.servers import MemorySample, NoServerJarError, ServerInfo, ServerRunningError
from app.api.tasks import Task
from app.core.server_events import ServerEvent

__all__ = [
"AppApi", "Dispatcher", "EventHub", "MemorySample", "NoServerJarError", "ServerEvent",
"ServerInfo", "ServerRunningError", "Subscription", "Task", "direct",
]
88 changes: 88 additions & 0 deletions app/api/app_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""AppApi: the one object a UI talks to.

Inputs and outputs are plain Python (str, dict, dataclasses, PIL images);
nothing here imports a GUI toolkit. A UI creates one AppApi, sets the event
dispatcher for its main loop, and calls the areas below.
"""

from __future__ import annotations

import concurrent.futures
import logging
from typing import Optional

from app.api.areas import BackupsApi, ConsoleApi, JavaApi, SettingsApi, TunnelApi
from app.api.events import Dispatcher, EventHub, direct
from app.api.mods import ModsApi
from app.api.servers import ServersApi
from app.api.storage import StorageApi
from app.api.tasks import TaskRunner
from app.core.server_events import EventBus

logger = logging.getLogger(__name__)


class AppApi:
def __init__(self, bus: Optional[EventBus] = None, manager=None,
executor: Optional[concurrent.futures.Executor] = None,
dispatcher: Dispatcher = direct, mods: Optional[ModsApi] = None,
java: Optional[JavaApi] = None, settings: Optional[SettingsApi] = None):
self.bus = bus or EventBus()
if manager is None:
# Imported here: ZBBManager wires every service at construction,
# which tests of the API alone don't need.
from app.core.core import ZBBManager
manager = ZBBManager(self.bus)
self._manager = manager
self._executor = executor or concurrent.futures.ThreadPoolExecutor(
max_workers=4, thread_name_prefix="api-task")
self.events = EventHub(self.bus, dispatcher)
self.tasks = TaskRunner(self._executor, lambda: self.events.dispatcher)

self.servers = ServersApi(manager, self.bus, self.tasks)
self.console = ConsoleApi(manager)
self.tunnel = TunnelApi(manager)
self.backups = BackupsApi(manager)
# PlayerOrchestrator already takes and returns plain values.
self.players = manager.players
self.mods = mods or ModsApi(create_snapshot=self.backups.create_snapshot)
self.java = java or JavaApi()
self.settings = settings or SettingsApi()
self.storage = StorageApi()

@property
def legacy_manager(self):
"""The ZBBManager, for the CustomTkinter screens not ported to this
API yet (players panel, settings, server properties). New code and
the Qt UI must not use it."""
return self._manager

def set_dispatcher(self, dispatcher: Dispatcher) -> None:
"""Where event handlers, task progress and completion run from now on."""
self.events.set_dispatcher(dispatcher)

def start_background_services(self) -> None:
"""Tick loop, version pre-warm, tunnel auto-start (once, at startup)."""
self._manager.bootstrap()

def shutdown(self) -> None:
"""Stop servers, tunnel and monitors; then kill any child process that
survived the graceful stop. Blocking (seconds): call off the UI thread."""
self.tasks.cancel_all()
self.events.close()
self._manager.shutdown()
self._kill_orphan_processes()

def _kill_orphan_processes(self) -> None:
# Last resort during exit: a failure here is unactionable (the Job
# Object reaps survivors when the app dies), so it is only logged.
for owner in (getattr(self._manager, "server_runner", None),
getattr(self._manager, "playit_manager", None)):
proc = getattr(owner, "process", None) if owner else None
if proc is None or proc.poll() is not None:
continue
try:
proc.kill()
proc.wait(timeout=2)
except Exception as exc: # noqa: BLE001 - see the comment above
logger.debug("Orphan process kill failed: %s", exc)
111 changes: 111 additions & 0 deletions app/api/areas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""The smaller areas of the API: console, tunnel, players, backups, Java, settings."""

from __future__ import annotations

import logging
from dataclasses import dataclass
from typing import Any, Optional

from app.services.java_detector import JavaDetector
from app.services.settings_manager import SettingsManager

logger = logging.getLogger(__name__)


class ConsoleApi:
def __init__(self, manager):
self._manager = manager

def send(self, command: str) -> bool:
"""Send a console command to the running server. False when none runs.
The safety check (and the blocked-command notice) is in the core."""
if not command or not self._manager.is_running():
return False
self._manager.send_command(command)
return True


class TunnelApi:
def __init__(self, manager):
self._manager = manager

def start(self) -> None:
self._manager.start_tunnel()

def stop(self) -> None:
self._manager.stop_tunnel()

def reset(self, mode: str = "soft") -> None:
"""Delete the tunnels, keep the agent linked ("soft"). Blocking."""
self._manager.reset_tunnel(mode=mode)

def link(self, setup_code: str) -> bool:
"""Link the Playit agent with a setup code. Blocking."""
return bool(self._manager.link_playit_manually(setup_code))

@property
def is_linked(self) -> bool:
playit = getattr(self._manager, "playit_manager", None)
return bool(playit is not None and playit.is_linked)

@property
def is_running(self) -> bool:
playit = getattr(self._manager, "playit_manager", None)
return bool(playit is not None and getattr(playit, "running", False))


class BackupsApi:
def __init__(self, manager):
self._manager = manager

def create_snapshot(self, server_name: str) -> tuple:
"""Pre-update snapshot: (path, None) or (None, error). Blocking."""
return self._manager.create_pre_update_snapshot(server_name)

def delete(self, server_name: str, backup_path: str) -> tuple:
return self._manager.delete_backup(server_name, backup_path)


@dataclass(frozen=True)
class JavaSummary:
major: int
source: str # "PORTABLE", "PATH", "REGISTRY", ...
version_string: str

@property
def portable(self) -> bool:
return self.source == "PORTABLE"


class JavaApi:
def __init__(self, detector_factory=JavaDetector):
self._detector_factory = detector_factory

def best(self) -> Optional[JavaSummary]:
"""The preferred Java found on this machine, or None. Blocking."""
installations = self._detector_factory().detect_all()
if not installations:
return None
best = installations[0]
return JavaSummary(best.major, best.source, best.version_string)


class SettingsApi:
"""App-wide settings (config/zbb_settings.json)."""

def __init__(self, settings: Optional[SettingsManager] = None):
self._settings = settings

@property
def _store(self) -> SettingsManager:
return self._settings or SettingsManager()

def get(self, key: str, default: Any = None) -> Any:
return self._store.get(key, default)

def set(self, key: str, value: Any) -> None:
self._store.set(key, value)

def save(self) -> None:
"""Write pending changes now (they are otherwise debounced)."""
self._store.save()
92 changes: 92 additions & 0 deletions app/api/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Event subscriptions for UIs, on top of the core EventBus.

The core emits on whatever thread did the work. A UI may only touch its
widgets from its own thread, so every subscription goes through a
*dispatcher*: a callable that takes a zero-argument function and runs it
where the UI wants it. CustomTkinter passes ``lambda fn: root.after(0, fn)``,
a Qt UI a queued signal; tests and headless callers use ``direct``.
"""

from __future__ import annotations

import logging
import threading
from typing import Any, Callable, Optional

from app.core.server_events import EventBus

logger = logging.getLogger(__name__)

Dispatcher = Callable[[Callable[[], None]], None]


def direct(fn: Callable[[], None]) -> None:
"""Run on the emitting thread (headless use, tests)."""
fn()


class Subscription:
"""One handler on one event. ``cancel()`` stops delivery at once, also
for calls a dispatcher has queued but not run yet."""

def __init__(self, bus: EventBus, event: str, handler: Callable[[Any], None], dispatcher: Dispatcher):
self.event = event
self._bus = bus
self._handler = handler
self._dispatcher = dispatcher
self._active = True

@property
def active(self) -> bool:
return self._active

def _deliver(self, data: Any) -> None:
if not self._active:
return

def run():
if self._active:
self._handler(data)

self._dispatcher(run)

def cancel(self) -> None:
if not self._active:
return
self._active = False
self._bus.unsubscribe(self.event, self._deliver)


class EventHub:
"""Subscribe to core events with a dispatcher (see the module docstring)."""

def __init__(self, bus: EventBus, dispatcher: Dispatcher = direct):
self.bus = bus
self._dispatcher = dispatcher
self._subscriptions: list[Subscription] = []
self._lock = threading.Lock()

@property
def dispatcher(self) -> Dispatcher:
return self._dispatcher

def set_dispatcher(self, dispatcher: Dispatcher) -> None:
"""Default for subscriptions made from now on (the UI sets it once
its main loop object exists)."""
self._dispatcher = dispatcher

def subscribe(self, event: str, handler: Callable[[Any], None],
dispatcher: Optional[Dispatcher] = None) -> Subscription:
sub = Subscription(self.bus, event, handler, dispatcher or self._dispatcher)
with self._lock:
self._subscriptions = [s for s in self._subscriptions if s.active]
self._subscriptions.append(sub)
self.bus.subscribe(event, sub._deliver)
return sub

def close(self) -> None:
"""Cancel every subscription made through this hub."""
with self._lock:
subs, self._subscriptions = self._subscriptions, []
for sub in subs:
sub.cancel()
Loading
Loading