diff --git a/docs/source/kb.rst b/docs/source/kb.rst index afaa68f..7ab8bb2 100644 --- a/docs/source/kb.rst +++ b/docs/source/kb.rst @@ -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:: diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 4ac97bc..668def6 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -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: diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index 28a4cdf..05a9763 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -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", } diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index b8b69fc..9effd3c 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -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) diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index ac559b2..576f809 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -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() diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index c96abc2..7d2047e 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -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) diff --git a/dzgui/managers/mods.py b/dzgui/managers/mods.py index da31385..c04d231 100644 --- a/dzgui/managers/mods.py +++ b/dzgui/managers/mods.py @@ -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: diff --git a/dzgui/managers/update.py b/dzgui/managers/update.py index 3c0f928..ff64fb4 100644 --- a/dzgui/managers/update.py +++ b/dzgui/managers/update.py @@ -64,4 +64,5 @@ class UpdateManager: def _on_update_failure(self, msg: str) -> None: dialog = ExceptionDialog(self.controller, msg) + dialog.show_all() dialog.run() diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index bc5d616..a429ffb 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -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: diff --git a/dzgui/strings/changelog.py b/dzgui/strings/changelog.py new file mode 100644 index 0000000..cc637ea --- /dev/null +++ b/dzgui/strings/changelog.py @@ -0,0 +1,2 @@ +collapse_all = "Collapse all" +expand_all = "Expand all" diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py index 460250d..3ab9a04 100644 --- a/dzgui/strings/dialogs.py +++ b/dzgui/strings/dialogs.py @@ -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" diff --git a/dzgui/strings/kb.py b/dzgui/strings/kb.py new file mode 100644 index 0000000..68ad2e5 --- /dev/null +++ b/dzgui/strings/kb.py @@ -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.""" diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py index 9e0f764..274c4b0 100644 --- a/dzgui/strings/preconnect.py +++ b/dzgui/strings/preconnect.py @@ -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" diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index c9b890c..b504955 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -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) diff --git a/dzgui/views/pages/changelog.py b/dzgui/views/pages/changelog.py index 4d36637..108912c 100644 --- a/dzgui/views/pages/changelog.py +++ b/dzgui/views/pages/changelog.py @@ -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 diff --git a/dzgui/views/pages/options.py b/dzgui/views/pages/options.py index 277c13e..93341ec 100644 --- a/dzgui/views/pages/options.py +++ b/dzgui/views/pages/options.py @@ -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") diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 2e07de0..93b0fb6 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -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() diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 3eae186..e897571 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -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