diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a1b254..6bc7eb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ - Preconnect warnings/failsafes like filesize - Save filters per server context between sessions - Preboot progress dialog +- Choose to jump into splash screen instead of server ## Changed @@ -52,6 +53,7 @@ - Embed Workshop link in Options menu - Disable overlay scrollbars on server tables - Reduce size of geolocation DB on disk (~100MB) +- Enable LAN page Empty/Full filters on first run of app ## Dropped - Debug mode @@ -67,7 +69,6 @@ ## Unreleased - Load offline mods -- Choose to jump into splash screen instead of server - Local documentation - Raw debug command in context menu diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index d9498eb..3688cdb 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -456,6 +456,5 @@ def get_pretty_version(steam_path: Path, appid: int) -> str | None: vers = get_dayz_version(pe_file_path) dayz_version = dayz_version_to_str(vers) return dayz_version - except Exception as e: - print(e) + except Exception: return None diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index c73ff67..fce937b 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -61,6 +61,8 @@ class Details: @dataclass(slots=True) class Record: + # TODO: investigate whether this enum is still being used; + # can this be frozen? """ The gameport field is manipulated by the RowType.CONN_BY_IP method """ @@ -509,6 +511,10 @@ def fqip_to_record(addr: str) -> Optional[Record]: return Record(r[0], int(r[1]), int(r[2])) +def record_to_fqip(record: Record) -> str: + return f"{record.ip}:{record.gameport}:{record.qport}" + + def response_to_fqip(res: dict) -> str: ip = res["addr"].split(":")[0] gameport = res["gameport"] diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 5bb1736..0fa126d 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -146,6 +146,23 @@ def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> i return proc.returncode +def load_to_menu(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int: + """Loads to the menu screen with the selected mods; used for AFK/pre-joining""" + concat = concat_mods(mods) + client_args = concat_bash_args(client) + params = [ + "-applaunch", + str(appid), + "-nolauncher", + "-nosplash", + "-skipintro", + f"-name={name}", + f"-mod={concat}", + ] + proc = subprocess.run([*client_args, *params]) + return proc.returncode + + def find_user_id(path: Path) -> str | None: resolved_path = path / "config" / "loginusers.vdf" try: diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 0628502..53a8b14 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -409,9 +409,12 @@ class Controller(GObject.GObject): saved_tree = self.get_servers().get_saved() ServerModelManager(self, saved_tree).remove_by_record(record) - def add_to_history(self, record: dict[str, Any]) -> None: + def add_to_history(self, row: dict[str, Any], record: "Record") -> None: tv = self.get_servers().get_recent() - ServerModelManager(self, tv).add_to_history(record) + ServerModelManager(self, tv).add_to_history((row, record)) + + # def append_to_history(self, record: "Record") -> None: + # self.config_man.append_to_history_file(record) def remove_from_history(self, record: "Record") -> None: # NOTE: remove action is only possible from history tree context menu, @@ -508,6 +511,9 @@ class Controller(GObject.GObject): ind = self.config_man.get_start_tab() self.get_servers().notebook.set_current_page(ind) + def update_and_load_to_menu(self, raise_window: bool) -> None: + self.connection_man.update_and_connect(raise_window, menu_only=True) + def update_and_connect(self, raise_window: bool) -> None: self.connection_man.update_and_connect(raise_window) diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 223e197..6bc7eb1 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -36,6 +36,7 @@ - Preconnect warnings/failsafes like filesize - Save filters per server context between sessions - Preboot progress dialog +- Choose to jump into splash screen instead of server ## Changed @@ -51,6 +52,8 @@ - Suppress log messages from imported modules - Embed Workshop link in Options menu - Disable overlay scrollbars on server tables +- Reduce size of geolocation DB on disk (~100MB) +- Enable LAN page Empty/Full filters on first run of app ## Dropped - Debug mode @@ -66,7 +69,6 @@ ## Unreleased - Load offline mods -- Choose to jump into splash screen instead of server - Local documentation - Raw debug command in context menu diff --git a/dzgui/managers/config.py b/dzgui/managers/config.py index e550e19..419f5b0 100644 --- a/dzgui/managers/config.py +++ b/dzgui/managers/config.py @@ -1,6 +1,7 @@ import logging import traceback +from collections import deque from typing import Any, TYPE_CHECKING from dzgui.const.constants import ( @@ -52,11 +53,25 @@ class ConfigManager: ips.append(record) self.update_config(Preferences.IP_LIST, ips) - def update_history_file(self, records: list[Any]) -> None: + def update_history_file(self, fqip: str) -> None: + with open(self.prefs.paths.history, "r") as f: + ips = [line.rstrip() for line in f.readlines()] + + seen = set() + ips.append(fqip) + # NOTE: Preserve linear order of records while deduplicating + unique = deque(maxlen=10) # type: ignore + for i in range(len(ips) - 1, -1, -1): + if ips[i] not in seen: + seen.add(ips[i]) + unique.appendleft(ips[i]) + # NOTE: appendleft will push 11th item off right edge + if len(unique) == 10: + break + with open(self.prefs.paths.history, "w") as f: - for record in records: - addr = f"{record[7]}:{record[8]}" - f.write(f"{addr}\n") + for record in unique: + f.write(f"{record}\n") def remove_saved_server(self, record: str) -> None: ips = self.lookup(Preferences.IP_LIST) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index a03992d..a8f5cb2 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -15,6 +15,7 @@ from dzgui.api.steam import ( enqueue_mod, get_remote_signatures, get_needs_update, + load_to_menu, ) from dzgui.api.mods import ( @@ -74,6 +75,7 @@ class Prerequisites: steam_proc: SteamProcess mods: list[list[str]] game_mode: bool + is_last_server: bool class ConnectionManager: @@ -172,6 +174,8 @@ class ConnectionManager: game_mode = prefs.is_game_mode + is_last = self.is_last_server() + prereqs = Prerequisites( name=info.server_name, appid=info.game_id, @@ -186,11 +190,23 @@ class ConnectionManager: steam_proc=steam_proc, mods=remote_mods, game_mode=game_mode, + is_last_server=is_last, ) func = StoredFunc(self.controller.open_connection_assistant, prereqs) self.thread_man.set_cleanup_func(func, destroy_first=True) + def is_last_server(self) -> bool: + prefs = self.controller.get_prefs() + history = prefs.paths.history + try: + lines = history.read_text().splitlines() + last = lines[-1] + current = Servers.record_to_fqip(self.record) + return last == current + except Exception: + return False + @call_on_thread(dialog.querying) def query_details(self, record: Servers.Record) -> None: details = Servers.get_details(record) @@ -245,11 +261,14 @@ class ConnectionManager: dialog = ExceptionDialog(self.controller, server_timeout) dialog.run() - def _connect_steam(self) -> None: + def _connect_steam(self, menu_only: bool) -> None: addr = f"{self.record.ip}:{self.record.gameport}" playername = self.controller.query_config(Preferences.NAME) client = self.controller.query_config(Preferences.CLIENT) - rc = connect(client, addr, self.appid, playername, self.remote_mod_ids) + if menu_only: + rc = load_to_menu(client, addr, self.appid, playername, self.remote_mod_ids) + else: + rc = connect(client, addr, self.appid, playername, self.remote_mod_ids) if rc != 0: # TODO: log/pop the error func = StoredFunc(self.controller.update_status) @@ -271,10 +290,10 @@ class ConnectionManager: self.thread_man.set_cleanup_func(func) def _add_to_history_and_return(self) -> None: - self.controller.add_to_history(self.history) + self.controller.add_to_history(self.history, self.record) self.controller.open_page(NotebookPage.SERVERS) - def _update_mods(self, raise_window: bool) -> None: + def _update_mods(self, raise_window: bool, menu_only: bool = False) -> None: # NOTE: fast enqueue all mods in auto mode prefs = self.controller.get_prefs() @@ -312,11 +331,11 @@ class ConnectionManager: update_signatures(self.missing_mods, prefs.paths.version) # TODO: just push steam path directly rebuild_symlinks(prefs.paths.config) - self._connect_steam() + self._connect_steam(menu_only) @call_on_thread(waiting_for_mods, show_cancel=True) - def update_and_connect(self, raise_window: bool) -> None: + def update_and_connect(self, raise_window: bool, menu_only: bool = False) -> None: if len(self.missing_mods) > 0: - self._update_mods(raise_window) + self._update_mods(raise_window, menu_only) else: - self._connect_steam() + self._connect_steam(menu_only) diff --git a/dzgui/managers/filter.py b/dzgui/managers/filter.py index 52ef6a0..92adf55 100644 --- a/dzgui/managers/filter.py +++ b/dzgui/managers/filter.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: class FilterManager: - def __init__(self) -> None: + def __init__(self, show_empty: bool = False, show_full: bool = False) -> None: self.map_store = ModelFactory().make_map_store() @@ -17,10 +17,10 @@ class FilterManager: self.default_filters = { strings.filter_1pp: True, strings.filter_day: True, - strings.filter_empty: False, + strings.filter_empty: show_empty, strings.filter_3pp: True, strings.filter_night: True, - strings.filter_full: False, + strings.filter_full: show_full, strings.filter_lowpop: True, strings.filter_nonascii: False, strings.filter_duplicate: False, diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 9639789..378d7dc 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -85,8 +85,9 @@ class ProxyModelManager: if found is False: self.control_model.append(history) - if len(self.control_model) == 11: - del self.control_model[0] + + # if len(self.control_model) == 11: + # del self.control_model[0] self.filter(FilterMode.INITIAL, skip_cache=True) diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index ad75b15..a336409 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -14,6 +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.util.strings import api_warn_msg, dialog from dzgui.views.dialogs.generic import ExceptionDialog @@ -23,7 +24,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk # noqa E402 if TYPE_CHECKING: - from dzgui.api.servers import A2SInfo + from dzgui.api.servers import A2SInfo, Record from dzgui.controllers.mc import Controller from dzgui.model.proxy_model import ProxyModelManager from dzgui.views.trees.tree_servers import ServerTreeView @@ -130,7 +131,7 @@ class ServerModelManager: servers = [] ports = range(1, 256) - failure_func = StoredFunc(self._cleanup_on_failure) + failure_func = StoredFunc(self._cleanup_on_lan_failure) event = threading.Event() with ThreadPoolExecutor() as executor: @@ -225,15 +226,12 @@ class ServerModelManager: ) = None, ) -> None: proxy_man = self._get_proxy_man() - config_man = self.controller.get_config_man() if rows is not None: records = rows else: control_model = proxy_man.get_control() records = control_model - config_man.update_history_file(records) - config_man.update_history_file(records) self._sort_unique_maps(records) proxy = proxy_man.get_proxy_model() self.tv.set_model(proxy) @@ -249,15 +247,20 @@ class ServerModelManager: self.emitter.emit("load_maps", store) # self.emitter.emit("servers_loaded_init") - def add_to_history(self, record: dict[str, Any]) -> None: + # TODO: dataclass for record rows; check for other dict annotations + def add_to_history(self, data: tuple[dict[str, Any], "Record"]) -> None: + row, record = data proxy_man = self._get_proxy_man() - rows = Servers.parse_json([record]) + rows = Servers.parse_json([row]) try: proxy_man.append_row_to_history(rows[0]) + self.update_history() except Exception: self.update_history(rows) - return - self.update_history() + + fqip = Servers.record_to_fqip(record) + config_man = self.controller.get_config_man() + config_man.update_history_file(fqip) def remove_from_history(self, record: Servers.Record) -> None: proxy_man = self._get_proxy_man() @@ -403,6 +406,16 @@ class ServerModelManager: if self.first_iteration: self._update_maps() + def _cleanup_on_lan_failure(self, show_dialog: bool = True) -> None: + if self.preserve_on_fail is False: + self.tv.set_model(None) + filter_man = self.tv.get_filter_man() + filter_man.set_unique_maps([]) + + if show_dialog: + dialog = ExceptionDialog(self.controller, dialogs.load_error_lan) + dialog.run() + def _cleanup_on_failure(self, show_dialog: bool = True) -> None: # TODO: disable map, keyword, and filter widgets if model is None # signal driven (servers_empty, servers_failed_to_load) diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py index d7587a1..db6900a 100644 --- a/dzgui/strings/dialogs.py +++ b/dzgui/strings/dialogs.py @@ -4,3 +4,5 @@ waiting_for_mods = "Waiting for Steam to update mods" fetching_update = "Fetching update" failed_to_update = "Failed to update DZGUI executable" update_success = "Updated successfully. Please exit and relaunch." + +load_error_lan = "Failed to find any servers on your network.\nCheck the server query port or your firewall settings." diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py index 17c8a5e..7b18939 100644 --- a/dzgui/strings/preconnect.py +++ b/dzgui/strings/preconnect.py @@ -1,5 +1,9 @@ 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." +) back = "Back" cancel = "Cancel" warnings = "Warnings" diff --git a/dzgui/util/file.py b/dzgui/util/file.py new file mode 100644 index 0000000..b655336 --- /dev/null +++ b/dzgui/util/file.py @@ -0,0 +1,15 @@ +from datetime import datetime +from pathlib import Path + + +def is_writeable(path: "Path") -> bool: + now = int(datetime.now().timestamp()) + suffix = ".dzgtmp" + file = str(now) + suffix + filepath = Path(str(path) + "_" + file) + try: + filepath.touch() + filepath.unlink() + return True + except OSError: + return False diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 1d76209..bc6904c 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -611,6 +611,7 @@ atomic_buttons = AtomicButton( keys_tooltip="Toggles the keybindings dialog", ) +gtk_theme_missing = "No GTK theme provided" steam_icon_missing = "Steam icon not found in IconTheme" missing_changelog = "Error: failed to read changelog" diff --git a/dzgui/views/base.py b/dzgui/views/base.py index e046b65..f9b7a00 100644 --- a/dzgui/views/base.py +++ b/dzgui/views/base.py @@ -323,8 +323,12 @@ class App(Gtk.Application): MainController = Controller() -theme = Gtk.IconTheme.get_default() -icons = theme.list_icons(None) -if STEAM_ICON not in icons: - logger.warn(strings.steam_icon_missing) - warnings.warn(strings.steam_icon_missing, stacklevel=2) +try: + theme = Gtk.IconTheme.get_default() + icons = theme.list_icons(None) + if STEAM_ICON not in icons: + logger.warn(strings.steam_icon_missing) + warnings.warn(strings.steam_icon_missing, stacklevel=2) +except Exception as e: + logger.warning(e) + warnings.warn(strings.gtk_theme_missing, stacklevel=2) diff --git a/dzgui/views/components/right_panel.py b/dzgui/views/components/right_panel.py index d8d1d21..2e93488 100644 --- a/dzgui/views/components/right_panel.py +++ b/dzgui/views/components/right_panel.py @@ -1,6 +1,7 @@ import logging import os +from pathlib import Path from typing import Literal, TYPE_CHECKING from dzgui.const.constants import ( @@ -13,6 +14,7 @@ from dzgui.const.constants import ( from dzgui.const.enum import ServerTab from dzgui.managers.update import UpdateManager from dzgui.util.clip import copy_clipboard +from dzgui.util.file import is_writeable from dzgui.views.components.buttonbox import ButtonBox from dzgui.views.components.filter_panel import FilterPanel from dzgui.views.components.mod_panel import ModSelectionPanel @@ -87,11 +89,12 @@ class RightPanel(Gtk.Box): ) exe_path = os.getenv("PYAPP") if exe_path 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 - ) + if is_writeable(Path(exe_path)) is True: + 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 + ) self.gutter_box.add(eb) self.pack_start(self.gutter_box, NO_EXPAND, FILL, NO_PADDING) @@ -108,7 +111,7 @@ class RightPanel(Gtk.Box): return self.filters_vbox.set_sensitive(False) # TODO: unless it is lan page - #self.refresh_button.set_sensitive(False) + # self.refresh_button.set_sensitive(False) def _on_lan_page_init(self, emitter: "Emitter") -> None: self.filters_vbox.set_sensitive(False) diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 3f5d0ca..eb9459c 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -106,6 +106,9 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): label=preconnect.back, halign=Gtk.Align.END, hexpand=True ) self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END) + self.connect_last = Gtk.Button( + label=preconnect.connect_last, halign=Gtk.Align.END, tooltip_text=preconnect.connect_last_tooltip + ) # TODO: abstract self.raise_window = Gtk.CheckButton( @@ -126,13 +129,14 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): spacing=5, ) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - for button in self.back, self.ok: + for button in self.back, self.ok, self.connect_last: box.add(button) for el in self.raise_window, box: self.button_box.add(el) self.back.connect("clicked", self._on_back_clicked) self.ok.connect("clicked", self._on_ok_clicked) + self.connect_last.connect("clicked", self._on_connect_last_clicked) self.title = Gtk.Label(label="") add_class(self.title, "preconnect-heading") @@ -213,6 +217,9 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if event.keyval == Gdk.KEY_u: self.ok.emit("clicked") + def _on_connect_last_clicked(self, button: Gtk.Button) -> None: + self.controller.update_and_load_to_menu(self.raise_window.get_active()) + def _on_ok_clicked(self, button: Gtk.Button) -> None: self.controller.update_and_connect(self.raise_window.get_active()) @@ -308,6 +315,11 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): prefix = preconnect.total_mods self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}") + if prereqs.is_last_server: + self.connect_last.show() + else: + self.connect_last.hide() + self._process_warnings(prereqs) if self.tree.is_visible(): self.tree.grab_focus() diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 651dfe5..5bbfa7c 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -47,7 +47,13 @@ class ServerNotebook(Gtk.ScrolledWindow): self.recent = ServerTreeView( controller, ServerTab.RECENT, ContextMenuGroup.RECENT ) - self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN) + self.lan = ServerTreeView( + controller, + ServerTab.LAN, + ContextMenuGroup.SCAN_LAN, + show_empty=True, + show_full=True, + ) tabs = [ (self.browser, server_labels.browser), diff --git a/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index ac2b2e3..12eaf8c 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -38,7 +38,12 @@ QUEUE_CHECK_DELAY = 200 class ServerTreeView(ContextMixin, TreeView): # type: ignore def __init__( - self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup + self, + controller: "Controller", + enum: ServerTab, + menu: ContextMenuGroup, + show_empty: bool = False, + show_full: bool = False, ) -> None: super().__init__(controller, menu=menu) @@ -48,7 +53,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore self.loaded = False - self.filter_man = FilterManager() + self.filter_man = FilterManager(show_empty, show_full) self.proxy_man = ProxyModelManager(self.filter_man) model = self.proxy_man.get_proxy_model() self.set_model(model) diff --git a/pyproject.toml b/pyproject.toml index 6635ddc..41d7062 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux" authors = [ {name = "aclist"} ] -version = "7.0.0b7" +version = "7.0.0b8" license = "GPL-3.0-or-later" license-files = ["LICENSE"] readme = "README.md" diff --git a/scripts/prebuild.py b/scripts/prebuild.py index 239cfbb..b0c1c0b 100644 --- a/scripts/prebuild.py +++ b/scripts/prebuild.py @@ -53,7 +53,8 @@ def untar(file: str, glob: str, renamed: str) -> None: def install_deps() -> None: pip = build_dir.joinpath(f"{CPYTHON_BUILD}/bin/pip") - subprocess.run([pip, "install", root]) + proc = subprocess.run([pip, "install", root]) + return proc.returncode def repackage() -> None: @@ -66,7 +67,9 @@ def repackage() -> None: def rebuild_cpython() -> None: if cpython_dir.is_dir() is False: get_cpython() - install_deps() + code = install_deps() + if code != 0: + return repackage() return get_packaged_version()