Merge branch 'fix/dayz-launcher' of github.com:aclist/dztui into fix/dayz-launcher

This commit is contained in:
aclist 2026-08-20 14:14:49 +09:00
commit 3f1aff7cc6
18 changed files with 256 additions and 85 deletions

View File

@ -16,8 +16,8 @@ By contrast, wireless routers and enterprise-grade routers may be less likely to
If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for
the server's query port.
In addition, packets sent from server responses are expected to be a standard size (see warning below).
Deviation from this may cause your router to discard incoming responses from the server.
In addition, a uniform packet size is expected. If your router's MTU setting differs from the default of 1,500,
it could lead to incoming responses being discarded (see warning below).
.. important::

View File

@ -151,6 +151,22 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
return hashes
def connect_debug(client: str, addr: str, appid: int, name: str, mods: list[str]) -> str:
concat = concat_mods(mods)
client_args = concat_bash_args(client)
params = [
"-applaunch",
str(appid),
f"-connect={addr}",
"-nolauncher",
"-nosplash",
"-skipintro",
f"-name={name}",
f"-mod={concat}",
]
client_args.extend(params)
return " ".join(client_args)
# TODO: set config to name=user, use official server and no mods,
# ensure that formatted string is identical to fixture with same hash
def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int:

View File

@ -73,12 +73,6 @@ class Preferences(EnumWithAttrs):
NAME = {
"key": "name",
}
INSTALL = {
"key": "auto_install",
}
DEBUG = {
"key": "debug",
}
DEFAULT = {
"key": "default_steam_path",
}
@ -91,9 +85,6 @@ class Preferences(EnumWithAttrs):
IP_LIST = {
"key": "ip_list",
}
BRANCH = {
"key": "branch",
}
START_TAB = {
"key": "start_tab",
}

View File

@ -114,9 +114,6 @@ class Controller(GObject.GObject):
def query_config(self, key: Preferences) -> Any:
return self.config_man.lookup(key)
def is_auto_install(self) -> bool:
return bool(self.query_config(Preferences.INSTALL))
def suppress_signal(
self, owner: Any, child: Any, func_name: str, state: bool
) -> None:
@ -266,6 +263,7 @@ class Controller(GObject.GObject):
self.open_page(NotebookPage.LOG)
except Exception as e:
dialog = ExceptionDialog(self, str(e))
dialog.show_all()
dialog.run()
def select_colorized(self) -> None:
@ -299,6 +297,7 @@ class Controller(GObject.GObject):
write_diagnostic(self.prefs.paths.config, file)
except Exception as e:
dialog = ExceptionDialog(self, str(e))
dialog.show_all()
dialog.run()
def update_steam_api_key(self, text: str) -> None:
@ -526,6 +525,9 @@ class Controller(GObject.GObject):
ind = self.config_man.get_start_tab()
self.get_servers().notebook.set_current_page(ind)
def get_debug_args(self) -> str:
return self.connection_man.get_debug_args()
def update_and_load_to_menu(self) -> None:
self.connection_man.update_and_connect(menu_only=True)

View File

@ -178,8 +178,8 @@ class ConfigManager:
logger.critical(e)
trace = traceback.format_exc()
dialog = ExceptionDialog(self.controller, trace)
dialog.show_all()
dialog.run()
raise e
def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None:
columns = tv.get_columns()

View File

@ -13,6 +13,7 @@ from dzgui.api.shortcuts import Shortcuts
from dzgui.api.steam import (
connect,
connect_debug,
get_app_allows_downloads,
get_app_name,
get_needs_update,
@ -45,6 +46,7 @@ from dzgui.strings.dialogs import (
waiting_for_mods,
waiting_for_directories,
)
from dzgui.strings import kb
from dzgui.strings.server_mods import checkmark, resync
from dzgui.util.format import format_mib
from dzgui.util.strings import dialog, server_timeout
@ -193,7 +195,9 @@ class ConnectionManager:
is_last = self.is_last_server()
# TODO: strings
invalid_mods = [(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod"]
invalid_mods = [
(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod"
]
prereqs = Prerequisites(
name=info.server_name,
@ -298,8 +302,17 @@ class ConnectionManager:
def _server_timeout(self) -> None:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.set_details_buffer(kb.DZG_006)
dialog.show_all()
dialog.run()
def get_debug_args(self) -> str:
addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME)
return connect_debug(
self.client, addr, self.appid, playername, self.remote_mod_ids
)
def _connect_steam(self, menu_only: bool) -> None:
addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME)

View File

@ -22,9 +22,7 @@ from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManage
from dzgui.model.model_factory import FastInsertListStore, ModelFactory
from dzgui.strings import dialogs
from dzgui.util.format import format_mods
from dzgui.util.strings import server_timeout
from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.dialogs.generic import ExceptionDialog
import dzgui.api.pefile as PeFile
@ -213,10 +211,6 @@ class ModManager:
func = StoredFunc(self._on_stale_mods_found, stale)
self.thread_man.set_cleanup_func(func)
def _server_timeout(self) -> None:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.run()
def select_colorized(self) -> None:
model = self.treeview.get_model()
if model is None:

View File

@ -64,4 +64,5 @@ class UpdateManager:
def _on_update_failure(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.show_all()
dialog.run()

View File

@ -14,7 +14,7 @@ from dzgui.const.constants import (
)
from dzgui.const.enum import FilterMode, Preferences, ServerTab
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import dialogs
from dzgui.strings import dialogs, kb
from dzgui.util.strings import server_timeout, dialog
from dzgui.views.dialogs.generic import ExceptionDialog
@ -405,6 +405,7 @@ class ServerModelManager:
if show_dialog:
dialog = ExceptionDialog(self.controller, dialogs.load_error_lan)
dialog.show_all()
dialog.run()
def _cleanup_on_failure(self, show_dialog: bool = True) -> None:
@ -420,6 +421,8 @@ class ServerModelManager:
# customize statusbar and dialog accordingly
if show_dialog:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.set_details_buffer(kb.DZG_006)
dialog.show_all()
dialog.run()
def _push_data(self, data: list[Any]) -> None:

View File

@ -0,0 +1,2 @@
collapse_all = "Collapse all"
expand_all = "Expand all"

View File

@ -20,3 +20,6 @@ ok = "OK"
mission_dialog = "Set a mission folder location"
custom_mod_dialog = "Set location to a custom mods folder"
debug_heading = "Debug"
debug_secondary = "Launch arguments"

13
dzgui/strings/kb.py Normal file
View File

@ -0,0 +1,13 @@
DZG_006 = """The leading cause of specific servers periodically timing out is local network configuration.
Many third-party DayZ servers use a server rental/hosting provider with DDoS protection.
This can cause responses from servers to originate from a server other than the one originally queried.
Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation).
By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue.
If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port.
In addition, a uniform packet size is expected. If your router's MTU (maximum transmission unit) setting differs from the default of 1500, it could lead to incoming responses being discarded."""

View File

@ -2,6 +2,7 @@ update_mods = "Update mods and connect"
connect = "Connect"
connect_last = "Load to menu screen"
connect_last_tooltip = "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting."
debug = "Debug"
back = "Back"
cancel = "Cancel"
warnings = "Warnings"

View File

@ -1,6 +1,7 @@
from typing import Literal, Self, TYPE_CHECKING
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL
from dzgui.strings import dialogs
from dzgui.util import strings
from dzgui.views.components.buttons import ClipboardButton
@ -169,58 +170,30 @@ class QuitDialog(GenericDialog):
self.controller.save_res_and_quit()
class ExceptionDialog(GenericDialog):
"""
Error dialog with rich traceback.
Usage:
try:
foo()
except Exception:
trace = traceback.format_exc()
dialog = ExceptionDialog(Controller, trace)
dialog.run()
"""
def __init__(self, controller: "Controller", trace: str):
class TextBufferDialog(GenericDialog):
def __init__(
self,
controller: "Controller",
mtype: Gtk.MessageType,
heading: str,
secondary: str,
text: str,
):
super().__init__(
controller=controller,
text=strings.error_heading,
mtype=Gtk.MessageType.ERROR,
text=heading,
mtype=mtype,
buttons=Gtk.ButtonsType.NONE,
secondary=strings.something_wrong,
secondary=secondary,
)
self.trace = trace
# NOTE: box expands to end of content area
scrollable = Gtk.ScrolledWindow(
propagate_natural_height=True, max_content_height=500
)
box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL)
# TODO: wrap/truncate long messages
textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10
)
textview.set_buffer(Gtk.TextBuffer(text=self.trace))
box.pack_start(textview, EXPAND, FILL, 10)
scrollable.add(box)
content = self.get_content_area()
content.set_spacing(0)
# TODO: padding around top of content area when traceback is long
content.add(scrollable)
copy_button = ClipboardButton(controller, self.get_trace)
self.text = text
copy_button = ClipboardButton(controller, self.get_text)
self.add_action_widget(copy_button, Gtk.ResponseType.NONE)
self.add_button("OK", Gtk.ResponseType.OK)
self.show_all()
self.ok = self.get_widget_for_response(Gtk.ResponseType.OK)
if self.ok is not None:
self.ok.grab_focus()
self.connect("response", self._on_response)
def get_trace(self) -> str:
return self.trace
def get_text(self) -> str:
return self.text
def _on_response(
self, dialog: Self, response: Gtk.ResponseType
@ -236,3 +209,114 @@ class ExceptionDialog(GenericDialog):
return None
case _:
return None
class DebugDialog(TextBufferDialog):
def __init__(self, controller: "Controller", debug: str):
super().__init__(
controller=controller,
mtype=Gtk.MessageType.INFO,
heading=dialogs.debug_heading,
secondary=dialogs.debug_secondary,
text=debug,
)
scrollable = Gtk.ScrolledWindow(
propagate_natural_height=False,
min_content_height=200,
max_content_height=200,
margin_bottom=10,
)
textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.CHAR, editable=False, left_margin=10, right_margin=10
)
textview.set_buffer(Gtk.TextBuffer(text=debug))
scrollable.add(textview)
content = self.get_content_area()
content.set_spacing(0)
content.add(scrollable)
class ExceptionDialog(TextBufferDialog):
"""
Error dialog with rich traceback.
Usage:
try:
foo()
except Exception:
trace = traceback.format_exc()
dialog = ExceptionDialog(Controller, trace)
dialog.show_all()
dialog.run()
"""
def __init__(self, controller: "Controller", trace: str):
super().__init__(
controller=controller,
heading=strings.error_heading,
secondary=strings.something_wrong,
text=trace,
mtype=Gtk.MessageType.ERROR,
)
# NOTE: box expands to end of content area
scrollable = Gtk.ScrolledWindow(
propagate_natural_height=True, max_content_height=500
)
box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL)
textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.WORD,
editable=False,
left_margin=10,
right_margin=10,
top_margin=15,
bottom_margin=10,
)
textview.set_buffer(Gtk.TextBuffer(text=trace))
box.pack_start(textview, EXPAND, FILL, 0)
self.error_details = Gtk.ScrolledWindow(
overlay_scrolling=False,
max_content_height=150,
propagate_natural_height=False,
)
details_box = Gtk.Box(
hexpand=True,
vexpand=True,
margin_right=5,
orientation=Gtk.Orientation.VERTICAL,
)
self.details_buffer = Gtk.TextBuffer()
details_textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.WORD_CHAR,
editable=False,
left_margin=10,
right_margin=10,
top_margin=15,
)
details_textview.set_buffer(self.details_buffer)
details_box.pack_start(details_textview, EXPAND, FILL, 10)
self.error_details.add(details_box)
self.error_notebook = Gtk.Notebook(show_tabs=False, margin_bottom=15)
self.error_notebook.append_page(box, Gtk.Label(label="Error"))
self.error_notebook.append_page(self.error_details, Gtk.Label(label="Details"))
self.error_notebook.connect("switch-page", self._on_page_changed)
scrollable.add(self.error_notebook)
content = self.get_content_area()
content.set_spacing(0)
content.add(scrollable)
def _on_page_changed(
self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int
) -> None:
if child == self.error_details:
GLib.idle_add(self.error_details.set_propagate_natural_height, True)
else:
GLib.idle_add(self.error_details.set_propagate_natural_height, False)
def set_details_buffer(self, text: str) -> None:
self.details_buffer.set_text(text)
self.error_notebook.set_show_tabs(True)

View File

@ -1,10 +1,12 @@
import logging
import re
import textwrap
from typing import TYPE_CHECKING
from importlib import resources
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, CHANGELOG_PATH
from dzgui.strings import changelog as clog
from dzgui.util.strings import missing_changelog
from dzgui.util.format import format_pango
from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin
@ -31,20 +33,39 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
logger.critical(e)
changelog = missing_changelog
# TODO: should long text be wrapped?
self.controller = controller
self.box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10
)
self.add(self.box)
expand_all = Gtk.Button(
label=clog.expand_all, halign=Gtk.Align.START, margin_start=20
)
expand_all.connect("clicked", self._on_expand_all_clicked)
self.box.add(expand_all)
self.expanded = False
self.expanders: list[Gtk.Expander] = []
self.connect("key-press-event", self._on_keypress)
self.connect("key-press-event", self._on_esc_keypress)
changes = self._parse(changelog)
self._generate_nodes(changes)
self.show_all()
def _on_expand_all_clicked(self, button: Gtk.Button) -> None:
self.expanded = not self.expanded
for expander in self.expanders:
# NOTE: simply setting set_expanded() does not trigger activate() signal,
# so margins are not applied
if expander.get_expanded() == self.expanded:
continue
expander.activate()
label = clog.collapse_all if self.expanded else clog.expand_all
button.set_label(label)
def grab_content_area(self) -> None:
self.grab_focus()
@ -65,9 +86,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
text = "\n".join(changes)
formatted = format_pango(text)
container = Gtk.Box(
valign=Gtk.Align.START, halign=Gtk.Align.START
)
container = Gtk.Box(valign=Gtk.Align.START, halign=Gtk.Align.START)
label = Gtk.Label()
label.set_markup(formatted)
container.add(label)
@ -81,6 +100,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
expander.add(container)
expander.connect("activate", self._on_expand, container)
self.box.add(expander)
self.expanders.append(expander)
def _on_expand(self, expander: Gtk.Box, container: Gtk.Box) -> None:
"""
@ -94,6 +114,7 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
container.set_margin_bottom(15)
def _parse(self, changelog: str) -> list[tuple[str, list[str]]]:
release = ""
releases: list[tuple[str, list[str]]] = []
release_notes: list[str] = []
@ -107,5 +128,5 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
release = ""
release = line
continue
release_notes.append(line.rstrip())
release_notes.append(textwrap.fill(line.rstrip(), width=120))
return releases

View File

@ -430,6 +430,7 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
# NOTE: re-check in case file was removed by user between runs
if prefs.paths.config.is_file() is False:
dialog = ExceptionDialog(self.controller, strings.config_not_found)
dialog.show_all()
dialog.run()
raise OSError(f"Config file '{prefs.paths.config}' not found")

View File

@ -11,6 +11,7 @@ from dzgui.util.keys import is_ctrl_mask
from dzgui.util.localize import number
from dzgui.strings.server_mods import checkmark
from dzgui.strings import preconnect
from dzgui.views.dialogs.generic import DebugDialog
from dzgui.views.components.frame import HeadingFrame
from dzgui.views.trees.tree_server_mods import ServerModTreeView
@ -120,6 +121,12 @@ class PreConnectionAssistant(Gtk.Box):
spacing=5,
)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
# NOTE: -d flag
if self.controller.get_prefs().is_debug:
debug = Gtk.Button(label=preconnect.debug, halign=Gtk.Align.START)
debug.connect("clicked", self._on_debug_clicked)
box.add(debug)
for button in self.back, self.ok, self.connect_last:
box.add(button)
self.button_box.add(box)
@ -221,6 +228,12 @@ class PreConnectionAssistant(Gtk.Box):
def _on_connect_last_clicked(self, button: Gtk.Button) -> None:
self.controller.update_and_load_to_menu()
def _on_debug_clicked(self, button: Gtk.Button) -> None:
args = self.controller.get_debug_args()
d = DebugDialog(self.controller, args)
d.show_all()
d.run()
def _on_ok_clicked(self, button: Gtk.Button) -> None:
self.controller.update_and_connect()

View File

@ -1,20 +1,33 @@
import re
import pytest
from importlib import resources
from dzgui.const.constants import APP_NAME_LOWER, CHANGELOG_PATH
from pathlib import Path
@pytest.fixture
def changelog():
path = resources.files(APP_NAME_LOWER).joinpath(CHANGELOG_PATH)
return path
def changelog(request) -> None:
root = request.config.rootpath
changelog = Path(root).joinpath("CHANGELOG.md").read_text()
return changelog
def test_headings(changelog):
with open(changelog, "r") as f:
lines = f.readlines()
for line in lines:
if line.startswith("#"):
pass
# TODO: use regex
pass
def count_hash(line: str) -> int:
cnt = 0
for c in line:
if c == "#":
cnt += 1
return cnt
def test_changelog_prefix(changelog) -> None:
r = r".*(\[.*\]).*"
lines = changelog.splitlines()
sort = sorted(lines)
match = [line for line in sort if line.startswith("#")]
for m in match:
if "Changelog" in m:
assert count_hash(m) == 1
elif re.match(r, m) is not None:
assert count_hash(m) == 2
else:
assert count_hash(m) == 3