feat: add ConnectionManager

This commit is contained in:
aclist 2026-02-23 00:29:52 +09:00
parent e56b89fd64
commit 6bbe0d6882
9 changed files with 159 additions and 101 deletions

View File

@ -1,14 +1,18 @@
import logging
import requests
from pathlib import Path
from typing import Optional, TYPE_CHECKING
from dzgui.api.servers import Record
from dzgui.config.query import lookup
from dzgui.const.endpoints import BM_SERVERS
from dzgui.const.enum import Preferences
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from dzgui.api.servers import Record
def get_attributes(config: Path, uid: int) -> str:
# TODO: handle if key is not set
# TODO: tests for malformed IDs/values
@ -18,7 +22,7 @@ def get_attributes(config: Path, uid: int) -> str:
payload: dict[str, str] = {
"filter[game]": "dayz",
"sort": "-players",
"filter[ids][whitelist]": str(uid)
"filter[ids][whitelist]": str(uid),
}
res = requests.get(BM_SERVERS, params=payload, headers=hdr)
res.raise_for_status()
@ -26,13 +30,14 @@ def get_attributes(config: Path, uid: int) -> str:
return j
def map_id_to_record(config: Path, uid: int) -> Record | None:
def map_id_to_record(config: Path, uid: int) -> Optional["Record"]:
from dzgui.api.servers import Record
try:
record = get_attributes(config, uid)
ip = record["ip"]
port = record["port"]
qport = record["portQuery"]
return Record(ip, port, qport)
except Exception as e:
logger.warn(e)
except Exception:
return None

View File

@ -12,6 +12,7 @@ import typing # noqa
from dataclasses import dataclass
from typing import Union
from dzgui.api.bm import map_id_to_record
from dzgui.const.constants import REQUEST_TIMEOUT
from dzgui.const.endpoints import STEAM_SERVERS
from dzgui.util import strings
@ -451,3 +452,27 @@ def get_rules(ip: str, qport: int) -> list[int]:
except Exception as e:
logger.critical(e)
return []
def response_to_fq_ip(res: dict) -> str:
ip = res["addr"].split(":")[0]
gameport = res["gameport"]
qport = res["addr"].split(":")[1]
return f"{ip}:{gameport}:{qport}"
def query_id_or_ip(addr: str) -> None:
# NOTE: Battlemetrics
if addr.isdigit():
try:
config = self.controller.get_prefs().paths.config
resolved = map_id_to_record(config, addr)
res = query_direct(resolved.ip, resolved.qport)
except Exception as e:
logger.critical(e)
return None
else:
record = addr.split(":")
ip, qport = record[0], record[1]
res = query_direct(ip, int(qport))
return res

View File

@ -113,8 +113,8 @@ class Controller(GObject.GObject):
self.emitter = Emitter()
self.emitter.connect("map_selection_changed", self._on_map_selection_changed)
self.emitter.connect("check_toggled", self._on_check_toggled)
self.emitter.connect("servers_loaded", self._on_servers_loaded)
self.emitter.connect("servers_loaded_init", self._on_servers_loaded_init)
#self.emitter.connect("servers_loaded", self._on_servers_loaded)
# NOTE: suppress requests until entire UI is loaded
self.loaded = False
@ -861,7 +861,11 @@ class Controller(GObject.GObject):
self.filter_man = filter_man
def populate_model(self, tv: Gtk.TreeView) -> None:
ServerModelManager(self, tv, first_iteration=True)
# NOTE: skip on previously loaded tabs
if tv.is_loaded():
self.emitter.emit("servers_loaded", tv.get_enum())
return
ServerModelManager(self, tv, first_iteration=True).load()
def get_favorite(self) -> tuple[str, str] | tuple[None, None]:
fav = str(self.query_config(Preferences.FAV_LBL))
@ -913,14 +917,14 @@ class Controller(GObject.GObject):
store = self.get_map_store()
self.emitter.emit("load_maps", store)
def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None:
return
# NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None
# TODO: this should be internal to servers page
state = self.has_server_model()
tv = self.get_active_treeview()
tv.set_headers_visible(state)
tv.set_headers_clickable(state)
#def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None:
# return
# # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None
# # TODO: this should be internal to servers page
# state = self.has_server_model()
# tv = self.get_active_treeview()
# tv.set_headers_visible(state)
# tv.set_headers_clickable(state)
def has_server_model(self) -> bool:
treeview = self.get_active_treeview()
@ -946,6 +950,9 @@ class Controller(GObject.GObject):
return self.mediator.notebook
def get_servers(self) -> "Notebook":
return self.mediator.servers
def get_server_notebook(self) -> "Notebook":
return self.mediator.servers.notebook
def get_window(self) -> "OuterWindow":

View File

@ -0,0 +1,35 @@
from typing import TYPE_CHECKING
import dzgui.api.servers as Servers
from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager
from dzgui.util.strings import api_warn_msg, dialog
from dzgui.views.dialogs.generic import ExceptionDialog
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
class ConnectionManager:
def __init__(self, controller: "Controller") -> None:
self.controller = controller
self.thread_man = ThreadingManager(parent=controller)
@call_on_thread(dialog.querying)
def _connect_by_id_or_ip(self, addr: str) -> None:
res = Servers.query_id_or_ip(addr)
if res is None:
self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure))
# TODO: add to history if successful
print(res)
def _connection_failure(self) -> None:
# TODO: more explicit warning message, not necessarily API failure?
dialog = ExceptionDialog(self.controller, api_warn_msg)
dialog.run()

View File

@ -21,7 +21,6 @@ def call_on_thread(dialog_str: str) -> Callable:
def wrapper(*args, **kwargs):
self = args[0]
stored = StoredFunc(func, *args, **kwargs)
print("SELF", self)
if not hasattr(self, "thread_man"):
raise AttributeError
if type(self.thread_man) is not ThreadingManager:

View File

@ -1,40 +1,36 @@
from datetime import datetime
import logging
import threading
from concurrent.futures import wait, as_completed
from concurrent.futures import as_completed
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, TYPE_CHECKING
import dzgui.api.servers as Servers
from dzgui.const.enum import FilterMode, Preferences, ServerTab
from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
)
from dzgui.const.enum import FilterMode, Preferences, ServerTab
from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager
from dzgui.util.strings import api_warn_msg, dialog
from dzgui.views.dialogs.generic import ExceptionDialog
from typing import Optional, TYPE_CHECKING
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402
from gi.repository import Gtk # noqa E402
LAN_TIMEOUT = 0.5
API_TIMEOUT = 3
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
from dzgui.model.filtered_model import FilteredModelManager
logger = logging.getLogger(__name__)
# TODO: failure: spawns error dialog
# TODO: non failure with empty model: updates statusbar
# TODO: non failure with empty model: updates statusbar with help text
class ServerModelManager:
@ -42,28 +38,32 @@ class ServerModelManager:
self, controller: "Controller", tv: Gtk.TreeView, first_iteration=False
) -> None:
enum = tv.get_enum()
self.tv = tv
self.enum = tv.get_enum()
self.controller = controller
self.emitter = controller.get_emitter()
if tv.is_loaded():
self.emitter.emit("servers_loaded", enum)
return
self.tv = tv
self.jobs = 1
# NOTE: store filter man for access inside thread
self.filter_man = tv.get_filter_man()
# FIXME: change WaitDialog to use parent window only
self.thread_man = ThreadingManager(parent=controller)
# TODO: if first iteration, clear filter man control model
# literal first load: iteration 1
# refresh: iteration 1 (wipe model)
# filter: iteration N+1
# TODO: can drop first iteration arg and process in methods
self.first_iteration = first_iteration
# TODO: pass servers.saved treeview when using conpan regardless of current context
match enum:
def load(self) -> None:
"""
There may be cases where you want to instantiate this class without dumping servers,
e.g., adding saved servers from another tab
"""
match self.enum:
case ServerTab.BROWSER:
# NOTE: extra DAYZ_EXP param
self.thread_man.set_job_count(len(Servers.params) + 1)
@ -71,14 +71,12 @@ class ServerModelManager:
case ServerTab.SAVED:
self._dump_favorites()
case ServerTab.RECENT:
# TODO: get row count
self._dump_history()
case ServerTab.LAN:
# NOTE: LAN tab is only loaded on demand
pass
case _:
pass
# self._dump_lan()
@call_on_thread(dialog.fetching)
def _dump_api(self) -> None:
@ -113,7 +111,6 @@ class ServerModelManager:
j = res.json
servers += j["response"]["servers"]
# TODO: try/except when parsing
parsed = Servers.parse_json(servers)
self._push_data(parsed, FilterMode.INITIAL)
@ -150,11 +147,10 @@ class ServerModelManager:
parsed = Servers.parse_json(servers)
self._push_data(parsed, FilterMode.INITIAL)
# TODO: strings
@call_on_thread("dumping ips")
@call_on_thread(dialog.fetching)
def _dump_ips(self, ips: list[str]) -> None:
# NOTE: block malformed records (TODO: add github issue no.)
# TODO: sanitize ip list at config time, drop this
# TODO: sanitize ip list at config time and drop this
ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != ""]
job = Servers.query_direct
with ThreadPoolExecutor() as executor:
@ -183,42 +179,32 @@ class ServerModelManager:
parsed = Servers.parse_json(servers)
self._push_data(parsed, FilterMode.INITIAL)
def _query_ip_id(self, addr: str) -> None:
# NOTE: Battlemetrics
if addr.isdigit():
# FIXME:
config = self.controller.get_prefs().paths.config
resolved = map_id_to_record(config, addr)
res = Servers.query_direct(resolved.ip, resolved.qport)
else:
record = addr.split(":")
ip, qport = record[0], record[1]
res = Servers.query_direct(ip, int(qport))
return res
# TODO: strings
@call_on_thread("querying address")
def _connect_by_id_or_ip(self, addr: str) -> None:
res = self._query_ip_id(addr)
# TODO: unimplemented
@call_on_thread("querying address")
@call_on_thread(dialog.querying)
def _add_by_id_or_ip(self, addr: str) -> None:
res = self.query_ip_id(addr)
# TODO: investigate this
res = Servers.query_id_or_ip(addr)
if res is None:
self.set_cleanup_func(StoredFunc(self._cleanup_on_failure))
self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure))
return
# NOTE: single record insertion
self.insert_record = Servers.parse_json([res])
# TODO: add into saved servers file
# TODO: update saved servers model
# NOTE: this can be called from other tabs--if current focus is not ServerTab.SAVED, update label only
# TODO: saved servers might not be loaded yet, in which case should just update local file only
# TODO: perform simple equality comparison of self.tv.get_enum() == ServerTab.SAVED
# FIXME: filter man is saved on a per tab basis, so this will mismatch
# check if servers.get_active_treeview() is same as self.tv
self.set_cleanup_func(StoredFunc(self._cleanup_on_insert))
record = Servers.parse_json([res])
filter_man = self._get_filter_man()
model = filter_man.get_control()
# TODO: make this a method of ConfigManager
fqip = Servers.response_to_fq_ip(res)
ips = self.controller.query_config(Preferences.IP_LIST)
ips.append(fqip)
self.controller.update_config(Preferences.IP_LIST, ips)
if model is not None:
# NOTE: single record insertion
model.append(record[0])
# TODO: if all filters are already applied, strange behavior may occur
# -> need to insert and update per current filters
filter_man.filter(FilterMode.INITIAL)
self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip))
def _dump_history(self) -> None:
history = self.controller.get_prefs().paths.history
@ -242,6 +228,7 @@ class ServerModelManager:
def _dump_favorites(self) -> None:
ips = self.controller.query_config(Preferences.IP_LIST)
self.thread_man.set_job_count(len(ips))
# TODO: customize statusbar to mention how records can be added via contextmenu
if len(ips) == 0:
# FIXME: this is not a failure, just a quiet exit with custom statusbar
@ -252,25 +239,20 @@ class ServerModelManager:
return
self._dump_ips(ips)
def _cleanup_on_insert(self) -> None:
filter_man = self.get_filter_man()
model = filter_man.get_control()
# FIXME: this is a single row insertion,
# but refiltration should occur in thread for consistency/scalability
model.append(self.insert_record[0])
proxy = filter_man.filter(FilterMode.INITIAL)
# TODO: get proxy model out of thread
proxy = self.get_filter_man().get_proxy_model()
def _cleanup_single_ip(self) -> None:
proxy = self._get_filter_man().get_proxy_model()
self.tv.set_model(proxy)
# TODO: update statusbar
context = self.tv.get_enum()
# TODO: if current tab != self.saved, add label
# TODO: adding a row may update available maps
# TODO: if all filters are already applied, strange behavior may occur
# -> need to insert and update per current filters
self.emitter.emit("servers_loaded", context)
self.emitter.emit("servers_loaded", self.enum)
self._update_maps()
def _update_maps(self) -> None:
map_man = self.tv.get_map_man()
map_man.set_unique_maps(self._get_new_maps())
self.emitter.emit("servers_loaded_init")
self.first_iteration = False
def _cleanup_on_success(self) -> None:
self.tv.set_model(self.to_insert)
@ -285,14 +267,8 @@ class ServerModelManager:
context = self.tv.get_enum()
self.emitter.emit("servers_loaded", context)
# CHORE: this is placeholder logic,
# refills map combo with new maps
if self.first_iteration:
map_man = self.tv.get_map_man()
map_man.set_unique_maps(self.new_maps)
self.emitter.emit("servers_loaded_init")
self.first_iteration = False
self.new_maps = None
self._update_maps()
def _cleanup_on_failure(self, show_dialog=True) -> None:
map_man = self.tv.get_map_man()
@ -334,10 +310,16 @@ class ServerModelManager:
self.to_insert = manager.get_proxy_model()
u_maps = set([row[1] for row in data])
self.new_maps = sorted(u_maps)
self._set_new_maps(sorted(u_maps))
self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success))
def _set_new_maps(self, maps: list[str]) -> None:
self.new_maps = maps
def _get_new_maps(self) -> list[str]:
return self.new_maps
def _get_filter_man(self) -> "FilteredModelManager":
return self.filter_man

View File

@ -1,5 +1,6 @@
from typing import TYPE_CHECKING
from dzgui.managers.connection import ConnectionManager
from dzgui.model.servers import ServerModelManager
from dzgui.util.strings import connect_panel, fav_panel, lan_panel
from dzgui.views.components.buttons import (
@ -178,6 +179,7 @@ class AddPanel(Gtk.Frame):
self.add_server.connect("clicked", self._on_add_clicked)
self.conn_server = SteamConnectButton()
self.conn_server.connect("clicked", self._on_connect_clicked)
self.conn_server.set_sensitive(False)
self.add_server.set_sensitive(False)
@ -199,11 +201,14 @@ class AddPanel(Gtk.Frame):
self.add(self.grid)
def _on_connect_clicked(self, button: Gtk.Button) -> None:
text = self.entry.get_text()
ConnectionManager(self.controller)._connect_by_id_or_ip(text)
def _submit_query(self) -> None:
text = self.entry.get_text()
# TODO: call servermodelmanager
ServerModelManager().add_by_id_or_ip(text)
# self.controller.add_by_id_or_ip(text)
saved = self.controller.get_servers().get_saved()
ServerModelManager(self.controller, saved)._add_by_id_or_ip(text)
def _on_activate(self, entry: Gtk.Entry) -> None:
if not self.add_server.is_sensitive():

View File

@ -22,7 +22,7 @@ class Breadcrumbs(Gtk.Label):
self.set_markup(crumbs)
notebook = controller.get_notebook()
servers = controller.get_servers()
servers = controller.get_server_notebook()
notebook.connect_after("switch-page", self._on_notebook_page_changed)
servers.connect_after("switch-page", self._on_server_tab_changed)

View File

@ -2,7 +2,6 @@ import logging
from typing import Optional, Self, TYPE_CHECKING
from dzgui.controllers.mc import StoredFunc
from dzgui.const.enum import ContextMenuGroup, ServerTab
from dzgui.views.trees.tree_servers import ServerTreeView
from dzgui.util.strings import server_labels
@ -16,6 +15,7 @@ logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.controllers.mc import Emitter
class ServerNotebook(Gtk.ScrolledWindow):