diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 8da3a24..6c5f6e0 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -88,6 +88,17 @@ class A2SInfo: qport = self.record.qport return source_info_to_dict(ip, qport, self.info) + def is_modded(self) -> bool: + if self.info is None: + raise ValueError("Cannot call this method on Nonetype") + try: + kw = self.info.keywords.split(",") + state = True if "mod" in kw else False + return state + except Exception as e: + logger.warning(e) + raise e + def get_netmask() -> str: hostname = os.uname()[1] diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 7fb7c72..fc310a9 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -84,6 +84,9 @@ def get_needs_update( def get_remote_signatures(mods: list[str]) -> list[tuple[str, int]]: + """ + Attempts to continue connecting even if signatures are empty + """ payload: dict[str, str] = {} payload["itemcount"] = len(mods) for i, mod in enumerate(mods): diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 45c5871..36ea9c1 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -36,9 +36,9 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: - from dayzquery import DayzMod - from dzgui.api.servers import A2SInfo, Record + from dzgui.api.servers import Record from dzgui.const.enum import ServerTab + from dzgui.managers.connection import Prerequisites from dzgui.managers.filter import FilterManager from dzgui.util.dist import Haversine from dzgui.views.base import Notebook, Grid, OuterWindow @@ -88,6 +88,7 @@ class Controller(GObject.GObject): self.pending_jobs = 1 self.exit_event = threading.Event() + self.connection_man: ConnectionManager def get_emitter(self) -> Emitter: return self.emitter @@ -104,6 +105,7 @@ class Controller(GObject.GObject): def set_prefs(self, prefs: UserPrefs) -> None: self.config_man = ConfigManager(prefs, self) self.notes_man = NoteManager(self, prefs.paths.notes) + self.prefs = prefs def query_config(self, key: Preferences) -> Any: @@ -393,12 +395,15 @@ class Controller(GObject.GObject): if addr.isdigit(): config_man = self.get_config_man() key = config_man.lookup(Preferences.BM) - ConnectionManager(self).connect_by_id(int(addr), key) + self.connection_man = ConnectionManager(self) + self.connection_man.connect_by_id(int(addr), key) else: - ConnectionManager(self).connect_by_ip(addr) + self.connection_man = ConnectionManager(self) + self.connection_man.connect_by_ip(addr) def connect_by_record(self, record: "Record") -> None: - ConnectionManager(self).connect_by_record(record) + self.connection_man = ConnectionManager(self) + self.connection_man.connect_by_record(record) def get_details(self, record: "Record") -> None: ConnectionManager(self).query_details(record) @@ -458,10 +463,13 @@ class Controller(GObject.GObject): def set_exit_event(self) -> None: self.exit_event.set() - def open_connection_assistant(self, res: "A2SInfo", mods: list["DayzMod"]) -> None: + def open_connection_assistant(self, prereqs: "Prerequisites") -> None: self.open_page(NotebookPage.CONNECTION) - self.mediator.preconnect.populate(res, mods) + self.mediator.preconnect.populate(prereqs) def set_start_tab(self) -> None: ind = self.config_man.get_start_tab() self.get_servers().notebook.set_current_page(ind) + + def update_and_connect(self) -> None: + self.connection_man.update_and_connect() diff --git a/dzgui/init/proc.py b/dzgui/init/proc.py index 957205c..a3e6c1f 100644 --- a/dzgui/init/proc.py +++ b/dzgui/init/proc.py @@ -6,14 +6,19 @@ from dzgui.views.dialogs.early_alert import EarlyAlertDialog from dzgui.util.strings import init -def is_dayz_running() -> None: +# TODO: simplify +def is_dayz_running(dialog: bool = False) -> None: + if dialog is False: + return is_running(DAYZ_BINARY) if is_running(DAYZ_BINARY) is True: EarlyAlertDialog(init.is_dayz_running) sys.exit(1) -def is_steam_running() -> None: +def is_steam_running(dialog: bool = False) -> None: # TODO: check proc name of flatpak steam + if dialog is False: + return is_running(STEAM_CMD) if is_running(STEAM_CMD) is False: EarlyAlertDialog(init.is_steam_running) sys.exit(1) diff --git a/dzgui/main.py b/dzgui/main.py index 5d50098..da6ead1 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -130,8 +130,8 @@ def main() -> None: EarlyAlertDialog(init.requires_steam) is_dayz_installed(XDG.config) - is_dayz_running() - is_steam_running() + is_dayz_running(dialog=True) + is_steam_running(dialog=True) # NOTE: clear versions file of unlinked mods rebuild_symlinks(XDG.config) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index fa00f8e..26f2a69 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,6 +1,8 @@ import logging import shutil +from dataclasses import dataclass +from packaging.version import Version from pathlib import Path from typing import TYPE_CHECKING @@ -9,9 +11,17 @@ import dzgui.api.servers as Servers from dzgui.api.steam import get_remote_signatures, get_needs_update from dzgui.api.mods import get_local_mod_ids -from dzgui.const.constants import APP_NAME +from dzgui.const.constants import ( + APP_NAME, + APPID_DAYZ, + APPID_DAYZ_EXP, + APPNAME_DAYZ, + APPNAME_DAYZ_EXP, +) from dzgui.const.enum import Preferences +from dzgui.init.proc import is_dayz_running, is_steam_running from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager +from dzgui.util.format import format_mib from dzgui.util.strings import dialog, server_timeout, checkmark from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog @@ -22,18 +32,40 @@ 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 logger = logging.getLogger(APP_NAME) +@dataclass(slots=True, frozen=True) +class Prerequisites: + name: str + appid: int + local_version: Version + remote_version: Version + build: str + binary_missing: bool + required_space: float + available_space: float + passworded: bool + dayz_running: bool + steam_running: bool + mods: list[str] + + class ConnectionManager: def __init__(self, controller: "Controller") -> None: self.controller = controller self.thread_man = ThreadingManager(controller) + self.appid: int + self.record: Record + + self.remote_mod_ids: list[str] = [] + self.missing_mods: list[str] = [] + @call_on_thread(dialog.querying) def connect_by_id(self, _id: int, key: str) -> None: res = Servers.query_by_id(_id, key) @@ -56,52 +88,66 @@ class ConnectionManager: return record = res.get_record() - try: - remote_mods = self._query_modlist(record) - remote_mod_ids = [mod[1] for mod in remote_mods] - except Exception as e: - print(e) - self.thread_man.set_cleanup_func(failure_func, destroy_first=True) - return + info = res.get_info() + + # NOTE: store metadata for later connection + self.appid = info.game_id + self.record = record + + builds = {APPID_DAYZ: APPNAME_DAYZ, APPID_DAYZ_EXP: APPNAME_DAYZ_EXP} + build = builds[self.appid] + binary_missing = False + required_mib = 0.0 + free_mib = 0.0 steam_path = Path(self.controller.query_config(Preferences.DEFAULT)) + local_version = PeFile.get_pretty_version(steam_path, info.game_id) + if local_version is None: + local_version = "0.0.0" + binary_missing = True - hashes = get_remote_signatures(remote_mod_ids) - version_file = self.controller.get_prefs().paths.version - needs_update = get_needs_update(version_file, hashes) + remote_mods: list[str, str, str] = [] + if res.is_modded(): + try: + remote_mods = self._query_modlist(record) + self.remote_mod_ids = [mod[1] for mod in remote_mods] + except Exception as e: + logger.warning(e) + self.thread_man.set_cleanup_func(failure_func, destroy_first=True) + return - # TODO: store mods that need update in class object for referencing later - # TODO: store remote destination to connect to + hashes = get_remote_signatures(self.remote_mod_ids) + version_file = self.controller.get_prefs().paths.version + self.missing_mods = get_needs_update(version_file, hashes) - # missing mods should be the totality of all mods with no signature - # missing = get_missing_mods(local_mod_ids, remote_mod_ids) - # print(missing) - # TODO: when downloading mods, create symlinks if missing + if local_version is not None: + pefile_path = PeFile.get_pefile_path(steam_path, info.game_id) + total, used, free = shutil.disk_usage(pefile_path) + if len(self.missing_mods) > 0: + required_size = sum(int(row[2]) for row in self.missing_mods) + required_mib = format_mib(required_size) + free_mib = format_mib(required_size) - # TODO: get missing mod sizes, warn if not enough space - info = res.get_info() - try: - dayz_path = PeFile.get_pefile_path(steam_path, info.game_id) - # TODO: handle missing path; do not calculate size if appid is missing - total, used, free = shutil.disk_usage(dayz_path) - if len(needs_update) > 0: - # TODO: generic mib function - required_size = sum(int(row[2]) for row in needs_update) - required_mib = round(required_size / (1024**2), 3) - free_mib = round(free / (1024**2), 3) - print(required_mib) - print(free_mib) - except Exception: - # TODO: if this fails, need to show missing build warning, not failure func - # build up list of warnings/errors - # logger.warning(e) - self.thread_man.set_cleanup_func(failure_func, destroy_first=True) + dayz_running = is_dayz_running() + steam_running = is_steam_running() + # TODO: is dayz downloading - # TODO: number separator func - # TODO: pack a final PreReq struct with pre-processed values + prereqs = Prerequisites( + name=info.server_name, + appid=info.game_id, + local_version=Version(local_version), + remote_version=Version(info.version), + build=build, + binary_missing=binary_missing, + required_space=required_mib, + available_space=free_mib, + passworded=info.password_protected, + dayz_running=dayz_running, + steam_running=steam_running, + mods=remote_mods, + ) - # TODO: connection assistant only receives user-facing warnings and list of mods - func = StoredFunc(self.controller.open_connection_assistant, res, remote_mods) + func = StoredFunc(self.controller.open_connection_assistant, prereqs) self.thread_man.set_cleanup_func(func, destroy_first=True) @call_on_thread(dialog.querying) @@ -157,10 +203,24 @@ class ConnectionManager: dialog = ExceptionDialog(self.controller, server_timeout) dialog.run() + def connect(self) -> None: + print(self.record.ip) + print(self.record.gameport) + print(self.appid) + # TODO: convert mod ids to symlink hashes + # steam api, concat mods + + # TODO: custom threading with glib idle callback def update_mods(self) -> None: + print(self.missing_mods) + # TODO: when downloading mods, create symlinks if missing + # TODO: pack a final PreReq struct with pre-processed values # self.needs_update + # then connect pass - def connect(self) -> None: - # steam api, concat mods - pass + def update_and_connect(self) -> None: + if len(self.missing_mods) > 0: + self.update_mods() + else: + self.connect() diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py index b2a522d..ea87fec 100644 --- a/dzgui/strings/preconnect.py +++ b/dzgui/strings/preconnect.py @@ -1,4 +1,5 @@ update_mods = "Update mods and connect" +connect = "Connect" back = "Back" cancel = "Cancel" warnings = "Warnings" diff --git a/dzgui/util/format.py b/dzgui/util/format.py index 937c6d4..871e9a7 100644 --- a/dzgui/util/format.py +++ b/dzgui/util/format.py @@ -51,6 +51,10 @@ def format_mods(size: int, mods: int) -> str: return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}" +def format_mib(bits: int) -> float: + return round(bits / (1024**2), 3) + + def format_server_mods(mods: int) -> str: plural = pluralize("mods", mods) return f"Found {mods:n} {plural}. {workshop}" diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index b9a0870..8dc5792 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -1,16 +1,12 @@ from dataclasses import dataclass -from typing import Self, TYPE_CHECKING +from typing import Self, Sequence, TYPE_CHECKING from dzgui.const.constants import ( - APPID_DAYZ, - APPID_DAYZ_EXP, - APPNAME_DAYZ, - APPNAME_DAYZ_EXP, ERROR, WARNING, ) -from dayzquery import DayzMod from dzgui.util.css import add_class +from dzgui.util.localize import number from dzgui.strings import preconnect from dzgui.views.components.frame import HeadingFrame from dzgui.views.trees.tree_server_mods import ServerModTreeView @@ -22,7 +18,7 @@ from gi.repository import Gdk, Gtk # type: ignore # noqa E402 if TYPE_CHECKING: - from dzgui.api.servers import A2SInfo + from dzgui.managers.connection import Prerequisites from dzgui.controllers.mc import Controller @@ -41,6 +37,17 @@ class Errors: no_dayz: bool +class Placeholder(Gtk.Label): + def __init__(self, text: str) -> None: + super().__init__( + label=text, + halign=Gtk.Align.START, + valign=Gtk.Align.START, + margin_start=10, + margin_bottom=5, + ) + + class MaskedTree(Gtk.TreeView): def __init__(self, icon: str) -> None: super().__init__(headers_visible=False, can_focus=False) @@ -64,7 +71,12 @@ class MaskedTree(Gtk.TreeView): self.append_column(text_column) add_class(self, "masked-tree") - def append(self, items: list[str]) -> None: + def append(self, item: Sequence[str]) -> None: + if len(item) > 1: + raise ValueError("This method only accepts one item") + self.store.append([self.icon, item]) + + def extend(self, items: list[str]) -> None: self.store.clear() for item in items: self.store.append([self.icon, item]) @@ -87,11 +99,11 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.controller.register_widget("preconnect", self) - # TODO: dynamic button text if no mods needed self.back = Gtk.Button(label=preconnect.back, halign=Gtk.Align.START) self.cancel = Gtk.Button( label=preconnect.cancel, halign=Gtk.Align.END, sensitive=False, hexpand=True ) + # TODO: dynamic button text if no mods needed self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END) self.button_box = Gtk.Box( @@ -111,7 +123,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.tree = ServerModTreeView(self.controller) self.mod_count = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=5) - # TODO: live count of remaining downloads # "Steam is downloading: {mod_name}" # mention whether manual or auto mod is active @@ -124,16 +135,30 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.tree_box.add(self.scrolled) self.tree_box.add(self.mod_count) + # TODO: strings + self.mods_placeholder = Placeholder("This server has no mods.") + self.tree_box.add(self.mods_placeholder) + self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods) # TODO: abstract into components + self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.warning_tree = MaskedTree(WARNING) - self.warning_frame = HeadingFrame(self.warning_tree, preconnect.warnings) + # TODO: strings + self.warning_placeholder = Placeholder("No warnings.") + self.warning_box.add(self.warning_tree) + self.warning_box.add(self.warning_placeholder) + self.warning_frame = HeadingFrame(self.warning_box, preconnect.warnings) + self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.error_tree = MaskedTree(ERROR) - self.error_frame = HeadingFrame(self.error_tree, preconnect.errors) + # TODO: strings + self.error_placeholder = Placeholder("No errors.") + self.error_box.add(self.error_tree) + self.error_box.add(self.error_placeholder) + self.error_frame = HeadingFrame(self.error_box, preconnect.errors) - self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) self.box.add(self.title) self.box.add(self.tree_frame) self.box.add(self.warning_frame) @@ -146,8 +171,15 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.connect("map", self._on_map) def _on_map(self, widget: Self) -> None: - self.tree_frame.set_visible(True) - self.mod_count.set_visible(True) + widgets = ( + self.tree_frame, + self.mod_count, + self.error_placeholder, + self.warning_placeholder, + ) + for widget in widgets: + widget.set_visible(True) + self.ok.set_sensitive(True) def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None: if event.keyval == Gdk.KEY_Escape: @@ -155,59 +187,84 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): def _on_ok_clicked(self, button: Gtk.Button) -> None: # TODO: update mod store in place with spinner/toast + # no dialog # TODO: cancel mod downloads + # TODO: add to history file and list store + # TODO: concat mods + self.controller.update_and_connect() pass def _on_back_clicked(self, button: Gtk.Button) -> None: page = self.controller.get_prior_page() self.controller.open_page(page) - def populate(self, res: "A2SInfo", mods: list["DayzMod"]) -> None: + def _process_warnings(self, prereqs: "Prerequisites") -> None: + warnings: list[str] = [] + errors: list[str] = [] + + if prereqs.binary_missing: + errors.append( + f"Remote server is running the build '{prereqs.build}', but it is not installed" + ) + elif prereqs.local_version != prereqs.remote_version: + print("versions do not match") + errors.append( + f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'" + ) + if prereqs.required_space > prereqs.available_space: + required_pretty = number(prereqs.required_space) + available_pretty = number(prereqs.available_space) + errors.append( + f"Need to update {required_pretty} MiB of mods, but installation path only has {available_pretty} MiB" + ) + if prereqs.passworded: + warnings.append( + "Protected: you will be prompted for a password when connecting to this server" + ) + if prereqs.dayz_running is True: + warnings.append( + "It looks like DayZ is already running in the background. Exit DayZ before connecting" + ) + if prereqs.steam_running is False: + warnings.append( + "It looks like Steam is not running. Launch Steam before connecting" + ) + + self.add_warnings(warnings) + self.add_errors(errors) + + if len(warnings) > 0: + self.warning_placeholder.set_visible(False) + if len(errors) > 0: + self.error_placeholder.set_visible(False) + self.ok.set_sensitive(False) + + def populate(self, prereqs: "Prerequisites") -> None: + mods = prereqs.mods self.tree.populate(mods) - total = len(mods) + total_mods = len(mods) - self._set_warnings() - - info = res.get_info() - name = info.server_name + name = prereqs.name self.title.set_text(name) - if total < 1: - self.tree_frame.set_visible(False) + + if total_mods < 1: + self.scrolled.set_visible(False) self.mod_count.set_visible(False) - return + self.mods_placeholder.set_visible(True) + self.ok.set_label(preconnect.connect) else: - self.tree.set_visible(True) + self.scrolled.set_visible(True) self.mod_count.set_visible(True) + self.mods_placeholder.set_visible(False) + # TODO: print no. of mods that need updating prefix = preconnect.total_mods - self.mod_count.set_text(f"{prefix}{str(total)}") + self.mod_count.set_text(f"{prefix}{str(total_mods)}") - """ - blocking warning types: - - build mismatch - - version mismatch - - not enough drive space - passing warning types: - - dayz is running - - steam is not running - - server has password - """ - # TODO: if errors > 1, disable buttons + self._process_warnings(prereqs) - def _set_warnings(self) -> None: - self.warning_tree.append(["Password protected", "Some other error", "Error 3"]) - self.error_tree.append(["Password protected", "Some other error", "Error 3"]) - pass + def add_errors(self, errors: list[str]) -> None: + self.error_tree.extend(errors) - def download_mods(self) -> None: - pass - - def connect_server(self) -> None: - # TODO: add to history file and list store - # TODO: concat mods - """ - spawn dialog in thread - watch for subprocess - return to prior page when finished - """ - pass + def add_warnings(self, warnings: list[str]) -> None: + self.warning_tree.extend(warnings)