Compare commits

..
1 Commits
Author SHA1 Message Date
aclist 6966db8ae7 Merge 74bf4b5cfc into d3f64c1ba5 2026-05-19 18:35:54 +09:00
26 changed files with 112 additions and 286 deletions

No files matched your search

-1
View File
@@ -34,7 +34,6 @@
- Early load alerts button
- Preconnect dialog
- Preconnect warnings/failsafes like filesize
- Save filters per server context between sessions
## Changed
+3 -3
View File
@@ -1,14 +1,14 @@
# TODO: link to here from sphinx docs
# Installing dev dependencies
uv pip install -e .[dev]
# Setting up precommit hooks
pre-commit install
# Fetching submodules
git submodule update --recursive --init
# Installing dev dependencies
uv pip install -e .[dev]
# Building documentation
sphinx-build -M html source build -a
+1 -1
View File
@@ -34,7 +34,7 @@ DZG-004: On Steam Deck, some mods in the Workshop show a black screen when DZGUI
---------------------------------------------------------------------------------------------------------
This is a bug in the Steam client that is being tracked at Valve's Steam for Linux issue tracker here: https://github.com/ValveSoftware/steam-for-linux/issues/9598.
To resolve this issue, manually intervene in the Steam client by selecting a different context (e.g., Store, Library), waiting for it to load, then navigating back to the Workshop context. This should
To resolve this issue, manually intervene in the Steam client by selecting a different context (e.g., Store, Library), waiting for it to load, then navigating back to the Workshop context. This should
clear the blockage and allow the contents to render.
.. _DZG-005:
-1
View File
@@ -472,7 +472,6 @@ def query_by_ip(addr: str) -> A2SInfo:
return A2SInfo(Record("0", 0, 0), None)
return query_by_record(record, update_gameport=True)
def query_by_id(server_id: int, key: str) -> A2SInfo:
"""
Used with numeric Battlemetrics IDs
+3 -3
View File
@@ -17,7 +17,6 @@ class Xdg:
system: Path
debug: Path
ips: Path
filters: Path
def is_writeable(path_str: str) -> bool:
@@ -64,6 +63,9 @@ def get_xdg_paths() -> dict:
def parse_filepaths(xdg: dict) -> Xdg:
# cache = xdg["XDG_CACHE_HOME"]
# data = xdg["XDG_DATA_HOME"]
config = xdg["XDG_CONFIG_HOME"]
state = xdg["XDG_STATE_HOME"]
@@ -78,7 +80,6 @@ def parse_filepaths(xdg: dict) -> Xdg:
history = state / "dzg.history"
versions = state / "dzg.versions"
ips = state / "ips.csv"
filters = state / "dzg.filters.json"
return Xdg(
config,
@@ -90,5 +91,4 @@ def parse_filepaths(xdg: dict) -> Xdg:
system,
debug,
ips,
filters
)
-4
View File
@@ -12,7 +12,6 @@ if TYPE_CHECKING:
# TODO: if servers fail to load, may leave dangling widgets waiting for a signal
class Emitter(GObject.GObject):
def __init__(self) -> None:
super().__init__()
@@ -151,6 +150,3 @@ class Emitter(GObject.GObject):
def keyword_set(self, keyword: str) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def lan_page_initialized(self) -> None:
pass
+11 -30
View File
@@ -84,6 +84,7 @@ 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_init", self._on_servers_loaded_init)
# NOTE: suppress requests until entire UI is loaded
self.loaded = False
@@ -259,21 +260,6 @@ class Controller(GObject.GObject):
def select_colorized(self) -> None:
self.mod_man.select_colorized()
def get_all_tree_filters(self) -> dict[str, dict[str, bool]]:
servers = self.get_servers()
all_filters: dict[str, dict[str, bool]] = {}
trees = (
servers.get_browser(),
servers.get_saved(),
servers.get_recent(),
servers.get_lan(),
)
for tree in trees:
enum = str(tree.get_enum())
filters = tree.get_filter_man().get_filters()
all_filters[enum] = filters
return all_filters
def get_filter_man(self) -> "FilterManager":
"""Each ServerTreeView has an atomic FilterManager"""
return self.get_active_treeview().get_filter_man()
@@ -317,22 +303,8 @@ class Controller(GObject.GObject):
self.emitter.emit("servers_loaded", tv.get_enum())
return
self.mediator.statusbar.set_by_context(tv.get_enum(), "")
self.mediator.filters.button_grid.block_toggles(True)
self.populate_filter_prefs()
self.mediator.filters.button_grid.block_toggles(False)
self.emitter.emit("lan_page_initialized")
ServerModelManager(self, tv).load()
def populate_filter_prefs(self) -> None:
tv = self.get_active_treeview()
filters = self.config_man.get_filters(tv)
if filters is None:
return
filter_man = self.get_filter_man()
for filt, state in filters.items():
filter_man.set_filter(filt, state)
self.mediator.filters.set_filters(filters)
def get_dist_cache(self) -> dict[str, "Haversine"]:
return self.dist_cache
@@ -347,7 +319,16 @@ class Controller(GObject.GObject):
filter_man = self.get_filter_man()
return filter_man.get_filters()
# FIXME: wipe maps store when changing tabs if model is none
# TODO: rename
def _on_servers_loaded_init(self, emitter: "Emitter") -> None:
"""Triggered after servers load but prior to maps loading"""
# FIXME: wipe maps store when changing tabs if model is none
# e.g. select recent, toggle map, then select lan -> not wiped
tv = self.get_active_treeview()
if tv.loaded is False:
return
store = self.get_map_store()
self.emitter.emit("load_maps", store)
def has_server_model(self) -> bool:
treeview = self.get_active_treeview()
-1
View File
@@ -34,7 +34,6 @@
- Early load alerts button
- Preconnect dialog
- Preconnect warnings/failsafes like filesize
- Save filters per server context between sessions
## Changed
+8 -19
View File
@@ -17,28 +17,17 @@ def has_new_config(config: Path) -> bool:
return config.exists()
def convert_cols_file(res: Path) -> dict[str, int] | None:
j = read_json(res)
cols = j["cols"]
# NOTE: implies prior conversion
if "View" in cols:
return None
# NOTE: user may not have changed these widths in API 6
try:
cols["View"] = cols.pop("Perspective")
cols["Max"] = cols.pop("Maximum")
except Exception:
pass
return j
def migrate_cols_file(res: Path) -> None:
# NOTE: filename "dzg.columns.json" is API 7 spec
# NOTE: dzg.columns.json is API 7 spec
old_res = Path.home() / LEGACY_COLS_PATH
if old_res.is_file():
j = convert_cols_file(old_res)
if j is not None:
write_json(j, res)
j = read_json(old_res)
cols = j["cols"]
if "View" in cols:
return
cols["View"] = cols.pop("Perspective")
cols["Max"] = cols.pop("Maximum")
write_json(j, res)
def copy_state_files(state_path: Path) -> None:
-13
View File
@@ -159,9 +159,6 @@ class ConfigManager:
def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None:
columns = tv.get_columns()
filters = self.controller.get_all_tree_filters()
filters_file = self.prefs.paths.filters
columns_file = self.prefs.paths.columns
try:
data = read_json(columns_file)
@@ -176,7 +173,6 @@ class ConfigManager:
try:
write_json(data, columns_file)
write_json(filters, filters_file)
except Exception as e:
logger.critical(e)
@@ -220,12 +216,3 @@ class ConfigManager:
h = WINDOW_DEFAULT_Y
logger.info(f"Using default window size {w},{h}")
window.set_default_size(w, h)
def get_filters(self, tv: "ServerTreeView") -> Any | None:
filters = self.prefs.paths.filters
try:
enum = tv.get_enum()
filters = read_json(filters)
return filters[str(enum)]
except Exception:
return None
-67
View File
@@ -1,67 +0,0 @@
import logging
import requests
import shutil
import subprocess
import tarfile
from typing import TYPE_CHECKING
from dzgui.const.constants import (
APP_NAME,
TMP_EXE,
TMP_PATH,
TMP_TARBALL,
)
from dzgui.strings import dialogs
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.views.dialogs.generic import ExceptionDialog, QuitDialog
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
logger = logging.getLogger(APP_NAME)
class UpdateManager:
def __init__(self, controller: "Controller") -> None:
self.thread_man = ThreadingManager(controller)
self.controller = controller
@call_on_thread(dialogs.fetching_update)
def update_version(self, exe_path: str, url: str) -> None:
try:
res = requests.get(url)
if res.status_code == 200:
with open(TMP_TARBALL, "wb") as file:
file.write(res.content)
with tarfile.open(TMP_TARBALL) as tar:
tar.extractall(TMP_PATH)
shutil.move(TMP_EXE, exe_path)
proc = subprocess.run([exe_path, "self", "restore"])
if proc.returncode == 0:
func = StoredFunc(self._on_update_success)
self.thread_man.set_cleanup_func(func, destroy_first=True)
else:
msg = dialogs.failed_to_update
func = StoredFunc(self._on_update_failure, msg)
self.thread_man.set_cleanup_func(func, destroy_first=True)
except Exception as e:
func = StoredFunc(self._on_update_failure, e)
self.thread_man.set_cleanup_func(func, destroy_first=True)
logger.warning(e)
def _on_update_success(self) -> None:
msg = dialogs.update_success
dialog = QuitDialog(self.controller, msg)
dialog.run()
def _on_update_failure(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.run()
+12 -18
View File
@@ -73,7 +73,6 @@ class ServerModelManager:
self._dump_history()
case ServerTab.LAN:
# NOTE: LAN tab is only loaded on demand
self.emitter.emit("lan_page_initialized")
pass
case _:
pass
@@ -124,10 +123,8 @@ class ServerModelManager:
parsed = Servers.parse_json(servers)
self._push_data(parsed)
@call_on_thread(dialog.scanning, show_cancel=True)
@call_on_thread(dialog.scanning)
def dump_lan(self, port: int, early_abort: bool) -> None:
self.first_iteration = True
servers = []
ports = range(1, 256)
failure_func = StoredFunc(self._cleanup_on_failure)
@@ -138,20 +135,19 @@ class ServerModelManager:
for future in as_completed(futures):
try:
if self.controller.get_exit_event().is_set():
event.set()
return
if self.controller.get_cancel_event().is_set():
event.set()
self.controller.clear_cancel_event()
return
res = future.result(timeout=LAN_TIMEOUT)
if res is None:
continue
servers.append(res)
if early_abort is True:
# NOTE: on first non-empty hit, flag pending threads to close
event.set()
break
servers.append(res)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_success)
)
return
servers.append(res)
except Exception as e:
logger.critical(e)
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
@@ -243,11 +239,9 @@ class ServerModelManager:
filter_man = self.tv.get_filter_man()
maps = self._get_new_maps()
filter_man.set_unique_maps(maps)
store = filter_man.get_map_store()
self.first_iteration = False
self.emitter.emit("load_maps", store)
# self.emitter.emit("servers_loaded_init")
self.emitter.emit("servers_loaded_init")
def add_to_history(self, record: dict[str, Any]) -> None:
proxy_man = self._get_proxy_man()
@@ -380,24 +374,24 @@ class ServerModelManager:
# NOTE: maps are set outside of thread because it triggers map changed signals
maps = self._get_new_maps()
filter_man.set_unique_maps(maps)
store = filter_man.get_map_store()
self.emitter.emit("load_maps", store)
self.first_iteration = False
self.emitter.emit("servers_loaded_init")
self.emitter.emit("saved_servers_changed")
def _update_maps(self) -> None:
filter_man = self.tv.get_filter_man()
filter_man.set_unique_maps(self._get_new_maps())
store = filter_man.get_map_store()
self.emitter.emit("load_maps", store)
self.emitter.emit("servers_loaded_init")
self.first_iteration = False
def _cleanup_on_success(self) -> None:
proxy = self._get_proxy_man().get_proxy_model()
self.tv.set_model(proxy)
# TODO: servers_loaded vs servers_reloaded
self.emitter.emit("servers_loaded", self.enum)
if self.first_iteration:
self._update_maps()
+6 -4
View File
@@ -2,13 +2,15 @@ import json
from pathlib import Path
from typing import Any
def read_json(path: Path) -> Any:
try:
with open(path, "r") as infile:
data = json.load(infile)
return data
except Exception as e:
try:
data = json.load(infile)
return data
except json.decoder.JSONDecodeError as e:
raise e
except OSError as e:
raise e
+5 -12
View File
@@ -94,11 +94,9 @@ class RefreshButton(IconTextButton):
icon=REFRESH_ICON,
label=atomic_buttons.refresh,
)
self.controller = controller
emitter = self.controller.get_emitter()
self.loading = False
self.is_clicked = False
self.time = 30
@@ -112,21 +110,17 @@ class RefreshButton(IconTextButton):
def _on_refresh_clicked(self, button: Self) -> None:
"""Spawned in a thread"""
self.is_clicked = True
self.loading = True
self.controller.refresh_tree()
# TODO: get server tab enum
# if LAN tab, reload existing entries in place
def start_decrement(self, emitter: "Emitter", tab: "ServerTab") -> None:
if self.is_clicked is False:
return
if self.loading:
return
self.set_sensitive(False)
self.loading = True
self.is_clicked = False
self.show_time(True)
GLib.timeout_add_seconds(1, self.decrement)
self.set_sensitive(False)
self.loading = False
self.show_time(True)
GLib.timeout_add_seconds(1, self.decrement)
def decrement(self) -> bool:
self.time -= 1
@@ -134,7 +128,6 @@ class RefreshButton(IconTextButton):
self.time = 30
self.show_time(False)
self.set_sensitive(True)
self.loading = False
return False
self.show_time(True)
return True
-6
View File
@@ -68,7 +68,6 @@ class ButtonGrid(Gtk.Grid):
)
def reload_filters(self) -> None:
# TODO: may be superfluous
checkboxes = self.checks
self.block_toggles(True)
filters = self.controller.get_enabled_filters()
@@ -331,8 +330,3 @@ class FilterPanel(Gtk.Box):
self.block_map_change_propagation = False
return
self.emitter.emit("map_selection_changed", name)
def set_filters(self, filters: dict[str, bool]) -> None:
for check in self.button_grid.checks:
label = check.get_label()
check.set_active(filters[label])
+53 -14
View File
@@ -1,5 +1,9 @@
import logging
import os
import requests
import shutil
import subprocess
import tarfile
from typing import Literal, TYPE_CHECKING
@@ -9,14 +13,20 @@ from dzgui.const.constants import (
NO_FILL,
FILL,
NO_PADDING,
TMP_EXE,
TMP_PATH,
TMP_TARBALL,
)
from dzgui.const.update import ALLOW_UPDATES
from dzgui.const.enum import ServerTab
from dzgui.managers.update import UpdateManager
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import dialogs
from dzgui.util.clip import copy_clipboard
from dzgui.views.components.buttonbox import ButtonBox
from dzgui.views.components.filter_panel import FilterPanel
from dzgui.views.components.mod_panel import ModSelectionPanel
from dzgui.views.components.buttons import IconTextButton, RefreshButton, KeysButton
from dzgui.views.dialogs.generic import ExceptionDialog, QuitDialog
import gi
@@ -35,13 +45,13 @@ class RightPanel(Gtk.Box):
def __init__(self, controller: "Controller"):
super().__init__(spacing=6, orientation=Gtk.Orientation.VERTICAL)
self.thread_man = ThreadingManager(controller)
self.controller = controller
self.controller.register_widget("right_panel", self)
emitter = controller.get_emitter()
emitter.connect("servers_loaded", self._on_servers_loaded)
emitter.connect("server_page_changed", self._on_server_page_changed)
emitter.connect("lan_page_initialized", self._on_lan_page_init)
self.button_vbox = ButtonBox(controller)
self.filters_vbox = FilterPanel(controller)
@@ -85,20 +95,54 @@ class RightPanel(Gtk.Box):
valign=Gtk.Align.END,
spacing=10,
)
exe_path = os.getenv("PYAPP")
if exe_path and update is not None:
if ALLOW_UPDATES and update is not None:
self.update_button.set_halign(Gtk.Align.END)
self.gutter_box.add(self.update_button)
self.update_button.connect(
"clicked", self._on_update_button_clicked, exe_path, update
"clicked", self._on_update_button_clicked, update
)
self.gutter_box.add(eb)
self.pack_start(self.gutter_box, NO_EXPAND, FILL, NO_PADDING)
def _on_update_button_clicked(
self, button: Gtk.Button, exe_path: str, url: str
) -> None:
UpdateManager(self.controller).update_version(exe_path, url)
def _on_update_success(self) -> None:
msg = dialogs.update_success
dialog = QuitDialog(self.controller, msg)
dialog.run()
def _on_update_failure(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.run()
@call_on_thread(dialogs.fetching_update)
def _on_update_button_clicked(self, button: Gtk.Button, url: str) -> None:
try:
res = requests.get(url)
if res.status_code == 200:
with open(TMP_TARBALL, "wb") as file:
file.write(res.content)
with tarfile.open(TMP_TARBALL) as tar:
tar.extractall(TMP_PATH)
exe_path = os.getenv("PYAPP")
if exe_path is None:
msg = dialogs.failed_to_update
func = StoredFunc(self._on_update_failure, msg)
self.thread_man.set_cleanup_func(func, destroy_first=True)
return
shutil.move(TMP_EXE, exe_path)
proc = subprocess.run([exe_path, "self", "restore"])
if proc.returncode == 0:
func = StoredFunc(self._on_update_success)
self.thread_man.set_cleanup_func(func, destroy_first=True)
else:
msg = dialogs.failed_to_update
func = StoredFunc(self._on_update_failure, msg)
self.thread_man.set_cleanup_func(func, destroy_first=True)
except Exception as e:
func = StoredFunc(self._on_update_failure, e)
self.thread_man.set_cleanup_func(func, destroy_first=True)
logger.warning(e)
def _on_server_page_changed(
self, emitter: "Emitter", page: "ServerTreeView"
@@ -107,11 +151,6 @@ class RightPanel(Gtk.Box):
if page.loaded is True:
return
self.filters_vbox.set_sensitive(False)
# TODO: unless it is lan page
#self.refresh_button.set_sensitive(False)
def _on_lan_page_init(self, emitter: "Emitter") -> None:
self.filters_vbox.set_sensitive(False)
def _on_servers_loaded(self, emitter: "Emitter", context: "ServerTab") -> None:
# TODO: similar logic on notebook page change
+2 -2
View File
@@ -111,9 +111,9 @@ class Statusbar(Gtk.Grid):
context: Union["ServerTab", NotebookPage],
) -> None:
self.spinner.stop()
# FIXME: CalcDist is being called when table is not loaded
# TODO: CalcDist is being called when table is not loaded
if dist is None:
return
self.set_by_context(context, "")
else:
pretty = self.append_distance(dist)
self.set_by_context(context, pretty)
+1 -1
View File
@@ -11,5 +11,5 @@ class ModsMixin:
self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn
) -> None:
mod = self.get_value_at_index(2) # type: ignore
mod = self.get_value_at_index(2)
self.controller.open_workshop_page(mod) # type: ignore
+2 -4
View File
@@ -72,7 +72,6 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
logger.critical(e)
valid_json = False
# NOTE: reasonable defaults for long columns
width_map = {
"Name": 800,
"Map": 300,
@@ -97,9 +96,8 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
column.set_fixed_width(saved_size)
column.set_expand(True)
else:
if column_title in width_map:
w = width_map[column_title]
column.set_fixed_width(w)
w = width_map[column_title]
column.set_fixed_width(w)
if column_title == "Ping":
column.set_cell_data_func(renderer, self._get_ping)
+1 -1
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [
{name = "aclist"}
]
version = "7.0.0b5"
version = "7.0.0b3"
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
readme = "README.md"
+1 -6
View File
@@ -28,6 +28,7 @@ env["PYAPP_PROJECT_NAME"] = appname
env["PYAPP_EXEC_SPEC"] = entrypoint
env["PYAPP_PROJECT_PATH"] = wheel
env["PYAPP_PASS_LOCATION"] = "true"
# env["PYAPP_PYTHON_VERSION"] = "3.13"
# NOTE: explicitly install all dependencies into distribution
env["PYAPP_SKIP_INSTALL"] = "true"
@@ -45,9 +46,3 @@ if proc.returncode == 0:
with tarfile.open(tarpath, "w:gz") as tar:
tar.add(release_exe, arcname=appname)
print(f"Wrote tarfile to '{tarpath}'")
proc = subprocess.run([release_exe, "-v"], capture_output=True, text=True)
assert proc.stdout.rstrip() == version
release_exe.unlink()
Path(wheel).unlink()
-14
View File
@@ -1,14 +0,0 @@
{
"cols": {
"Name": 1,
"Map": 1,
"Gametime": 1,
"Players": 1,
"Queue": 1,
"IP": 1,
"Qport": 1,
"Ping": 1,
"Maximum": 1,
"Perspective": 1
}
}
-12
View File
@@ -1,12 +0,0 @@
{
"cols": {
"Name": 398,
"Map": 181,
"Gametime": 151,
"Players": 119,
"Queue": 96,
"IP": 247,
"Qport": 94,
"Ping": 253
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"cols": {
"Name": 1,
"Map": 1,
"Gametime": 1,
"Players": 1,
"Queue": 1,
"IP": 1,
"Qport": 1,
"Ping": 1,
"View": 1
}
}
-33
View File
@@ -1,33 +0,0 @@
import pytest
from dzgui.init.migrate import convert_cols_file
from tests.fixtures import fixture_path
@pytest.fixture
def columns_with_perspective():
return fixture_path("columns_1")
@pytest.fixture
def columns_without_perspective():
return fixture_path("columns_2")
@pytest.fixture
def columns_with_view():
return fixture_path("columns_3")
@pytest.mark.config
def test_columns_with_perspective(columns_with_perspective):
j = convert_cols_file(columns_with_perspective)
assert "View" in j["cols"]
assert "Max" in j["cols"]
@pytest.mark.config
def test_columns_without_perspective(columns_without_perspective):
j = convert_cols_file(columns_without_perspective)
assert "View" not in j["cols"]
assert "Max" not in j["cols"]
@pytest.mark.config
def test_columns_with_view(columns_with_view):
j = convert_cols_file(columns_with_view)
assert j is None
+3 -3
View File
@@ -2,14 +2,14 @@ from dzgui.util import localize
def test_de(monkeypatch):
monkeypatch.setenv("LC_ALL", "de_DE.UTF-8")
monkeypatch.setenv("LC_CTYPE", "de_DE.UTF-8")
localize.set_locale()
assert localize.number(9900) == "9.900"
assert localize.number(99.88) == "99,88"
def test_en(monkeypatch):
monkeypatch.setenv("LC_ALL", "en_US.UTF-8")
monkeypatch.setenv("LC_CTYPE", "en_US.UTF-8")
localize.set_locale()
assert localize.number(9900) == "9,900"
assert localize.number(99.88) == "99.88"
@@ -22,7 +22,7 @@ def test_none(monkeypatch):
def test_empty(monkeypatch):
monkeypatch.setenv("LC_ALL", "")
monkeypatch.setenv("LC_CTYPE", "")
localize.set_locale()
assert localize.number(9900) == "9,900"
assert localize.number(99.88) == "99.88"