diff --git a/CHANGELOG.md b/CHANGELOG.md index 888263e..4a1b254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ - Preconnect dialog - Preconnect warnings/failsafes like filesize - Save filters per server context between sessions +- Preboot progress dialog ## Changed @@ -50,6 +51,7 @@ - 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) ## Dropped - Debug mode diff --git a/dzgui/config/ipdb.py b/dzgui/config/ipdb.py index 53c89db..a25fbbe 100644 --- a/dzgui/config/ipdb.py +++ b/dzgui/config/ipdb.py @@ -23,40 +23,36 @@ def find_download_url(url: str) -> str | None: def get_ipdb(ips_path: Path) -> None: - url = find_download_url(DB_IP) - if url is None: - return - - date = find_date(url) - month_file = ips_path.parent / ".month" - if ips_path.exists() and month_file.exists(): - old_date = month_file.read_text().rstrip("\n") - if old_date == date: - logger.info(f"IP DB date matches: {date}") + try: + url = find_download_url(DB_IP) + if url is None: return - # TODO: log additional output - logger.info(f"Fetching IPDB for {date} from {url}") - try: + date = find_date(url) + month_file = ips_path.parent / ".month" + if ips_path.exists() and month_file.exists(): + old_date = month_file.read_text().rstrip("\n") + if old_date == date: + logger.info(f"IP DB date matches: {date}") + return + + # TODO: log additional output + logger.info(f"Fetching IPDB for {date} from {url}") tmp = serialize(url) - except Exception as e: - logger.critical(e) - return + logger.info(f"Extracting {tmp}") + unzip(tmp, ips_path) - logger.info(f"Extracting {tmp}") - unzip(tmp, ips_path) - - logger.info("Stripping IPv6 records") - try: + logger.info("Stripping IPv6 records") strip_ipv6(ips_path) + + with open(month_file, "w") as f: + f.write(date) + logger.info(f"Wrote {date} to {month_file}") except Exception as e: + # NOTE: in the event of failure, geolocation calc is simply not performed logger.critical(e) return - with open(month_file, "w") as f: - f.write(date) - logger.info(f"Wrote {date} to {month_file}") - def find_date(url: str) -> str: date = re.sub(r"(dbip-city-lite-)(.*)(.csv.gz)", r"\2", url) @@ -81,19 +77,34 @@ def serialize(url: str) -> Path: return tmp -# TODO: optimize this function -# consider using grep -# cf. grep -vE "^[a-z0-9]{4}:" | grep -v "::" > "$ip_file" def strip_ipv6(path: Path) -> None: - ips = [] - with open(path, "r") as f: - s = f.read() + """Can be IO intensive and cause visual lag on UI frames + running in the main thread even when run in its own thread; + lines are batched into memory-manageable chunks to reduce + disk writes. Raw file can be 8M+ records long, so it is + not read into memory at once. - reg = r"^\d{1,3}\..*" - ips = re.findall(reg, s, re.MULTILINE) - assert ips[0].split(",")[0] == "0.0.0.0" - assert ips[-1].split(",")[0] == "224.0.0.0" - - with open(path, "w") as f: - for ip in ips: - f.write(ip + "\n") + Relative size is reduced by ~100MB by pruning unwanted columns + """ + # NOTE: "^::," is the boundary line between IPv4 and IPv6 + # NOTE: deprecated regex matching (slower by 5s) + # reg = r"^\d{1,3}\..*" + alt_path = path.parent.joinpath("ips_stripped.csv") + merged = "" + its = 0 + with open(path, "r") as f, open(alt_path, "w") as out: + for line in f: + els = line.split(",") + if "." not in els[0]: + break + final = ",".join([els[0], els[1], els[-2], els[-1]]) + merged += final + its += 1 + if its == 500: + out.write(merged) + its = 0 + merged = "" + if its > 0: + out.write(merged) + path.unlink() + alt_path.rename(path) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 6818fef..7620bc8 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -28,6 +28,7 @@ APP_NAME = "DZGUI" APP_NAME_LOWER = "dzgui" APP_NAME_ABBR = "dzg" +HEX_GREEN = "#32CD32" HEX_RED = "#FF0000" HEX_ORANGE = "#FFAC1C" diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 888263e..223e197 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -35,6 +35,7 @@ - Preconnect dialog - Preconnect warnings/failsafes like filesize - Save filters per server context between sessions +- Preboot progress dialog ## Changed diff --git a/dzgui/init/coords.py b/dzgui/init/coords.py index 7ae3afe..009b386 100644 --- a/dzgui/init/coords.py +++ b/dzgui/init/coords.py @@ -13,11 +13,6 @@ logger = logging.getLogger(APP_NAME) def get_local_coords(path: "Path") -> Coords | None: try: my_ip = get_local_ip() - except Exception as e: - logger.warn(e) - return None - - try: return get_coords(path, my_ip) except Exception as e: logger.warn(e) diff --git a/dzgui/main.py b/dzgui/main.py index c5d74d4..45edf9d 100644 --- a/dzgui/main.py +++ b/dzgui/main.py @@ -6,14 +6,11 @@ import warnings from typing import TYPE_CHECKING -from dzgui.api.mods import remove_stale_signatures from dzgui.const.constants import APP_NAME from dzgui.const.enum import Preferences -from dzgui.config.ipdb import get_ipdb from dzgui.config.query import lookup from dzgui.config.userprefs import UserPrefs from dzgui.config.xdg import get_xdg_paths, parse_filepaths -from dzgui.init.coords import get_local_coords from dzgui.init.dayz import is_dayz_installed from dzgui.init.flock import lock_acquire from dzgui.init.migrate import ( @@ -24,17 +21,16 @@ from dzgui.init.migrate import ( from dzgui.init.prefix import get_version from dzgui.init.prereqs import has_steam_client from dzgui.strings import boot -from dzgui.init.update import check_updates # from dzgui.util.map_count import get_map_count from dzgui.util.deck import is_steam_deck, is_game_mode from dzgui.util.localize import set_locale -from dzgui.util.symlink import rebuild_symlinks from dzgui.util.strings import init, flags from dzgui.views.base import App -from dzgui.views.dialogs.wizard import SetupWizard +from dzgui.views.dialogs.boot import BootWindow from dzgui.views.dialogs.early_alert import EarlyAlertDialog +from dzgui.views.dialogs.wizard import SetupWizard if TYPE_CHECKING: from pathlib import Path @@ -130,20 +126,17 @@ def main() -> None: is_dayz_installed(XDG.config) - # TODO: slow running procs here--needs dialog - # NOTE: clears versions file of unlinked mods - rebuild_symlinks(XDG.config) - remove_stale_signatures(XDG.config, XDG.version) + bootwin = BootWindow(XDG, version) + local_coords, latest_release = bootwin.get_results() + + # rebuild_symlinks(XDG.config) + # remove_stale_signatures(XDG.config, XDG.version) + + ## TODO: handle IP DB failure and use coords fallback + # local_coords = get_local_coords(XDG.ips) + # latest_release = check_updates(version) - # TODO: handle IP DB failure and use coords fallback - # TODO: drop this after dialog is complete - print("Fetching geolocation data, may take some time...") - get_ipdb(XDG.ips) - local_coords = get_local_coords(XDG.ips) use_miles = lookup(XDG.config, Preferences.DIST) - - latest_release = check_updates(version) - prefs = UserPrefs( is_steam_deck=_is_steam_deck, is_game_mode=_is_game_mode, diff --git a/dzgui/managers/threading.py b/dzgui/managers/threading.py index 91db2e9..adecb7a 100644 --- a/dzgui/managers/threading.py +++ b/dzgui/managers/threading.py @@ -47,8 +47,8 @@ class StoredFunc: self.func = func self.bindings = sig.bind(*args, **kwargs) - def call(self) -> None: - self.func(*self.bindings.args, *self.bindings.kwargs) + def call(self) -> Any: + return self.func(*self.bindings.args, *self.bindings.kwargs) class ThreadingManager: @@ -113,8 +113,8 @@ class ThreadingManager: func = self.get_cleanup_func() if func is not None: - func.call() self.set_cleanup_func(None) + func.call() if not self.destroy_first: self.destroy_dialog() diff --git a/dzgui/strings/preboot.py b/dzgui/strings/preboot.py new file mode 100644 index 0000000..01a7ad6 --- /dev/null +++ b/dzgui/strings/preboot.py @@ -0,0 +1,5 @@ +symlinks = "Rebuilding symlinks" +signatures = "Updating mod signatures" +geo = "Checking geolocation records" +coords = "Checking local coordinates" +updates = "Checking for updates" diff --git a/dzgui/util/_json.py b/dzgui/util/_json.py index cad0263..a81e979 100644 --- a/dzgui/util/_json.py +++ b/dzgui/util/_json.py @@ -14,6 +14,7 @@ def read_json(path: Path) -> Any: def write_json(data: dict[str, Any], path: Path) -> None: try: + path.parent.mkdir(parents=True, exist_ok=True) j = json.dumps(data, indent=2) path.write_text(j) except Exception as e: diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index d006184..1d76209 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -360,12 +360,14 @@ thanks = Thanks( "bongjutsu", "Deku", "dj3hac", + "finntux", "GaryBlackbourne", "jiriks74", "Johnofwrong", "MatheusLasserr", "nolan-perez", "scandalouss", + "SnackSBR", "StevelDusa", "Thoughtduck216", ], diff --git a/dzgui/views/components/buttons.py b/dzgui/views/components/buttons.py index c4b3bf2..affcc5b 100644 --- a/dzgui/views/components/buttons.py +++ b/dzgui/views/components/buttons.py @@ -1,4 +1,4 @@ -from typing import Callable, Self, TYPE_CHECKING +from typing import Callable, Self, TYPE_CHECKING, Union from dzgui.util.clip import copy_clipboard from dzgui.util.format import pluralize @@ -70,18 +70,26 @@ class LargeIconTextButton(IconButton): class ClipboardButton(IconTextButton): - def __init__(self, controller: "Controller", func: Callable) -> None: + def __init__(self, controller: Union["Controller", None], func: Callable) -> None: super().__init__(CLIPBOARD, atomic_buttons.copy) self.controller = controller self.connect("clicked", self._on_button_clicked, func) - self.set_tooltip_text("Copy IP to clipboard") + self.set_tooltip_text("Copy to clipboard") def _on_button_clicked(self, button: Self, func: Callable) -> None: data = func() copy_clipboard(data) +# TODO: determine when controller would be passed to this button or drop +class CopyIpButton(ClipboardButton): + def __init__(self, controller: Union["Controller", None], func: Callable) -> None: + super().__init__(controller, func) + + self.set_tooltip_text("Copy IP to clipboard") + + class WebButton(IconTextButton): def __init__(self, label: str) -> None: super().__init__(icon=WEB_BROWSER, label=label) diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 552a9bc..9e0a4c8 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -6,7 +6,7 @@ from dzgui.strings import connect_panel from dzgui.util.keys import is_ctrl_mask from dzgui.views.components.buttons import ( AddButton, - ClipboardButton, + CopyIpButton, SteamConnectButton, ) from dzgui.views.components.entry import IpEntry, PortEntry @@ -151,7 +151,7 @@ class FavPanel(Gtk.Frame): self.fav_button = SteamConnectButton() self.fav_button.connect("clicked", self._on_connect_clicked) - self.copy_button = ClipboardButton(self.controller, self.get_fav_ip) + self.copy_button = CopyIpButton(self.controller, self.get_fav_ip) if favorite is None: self.toggle_buttons(False) diff --git a/dzgui/views/dialogs/boot.py b/dzgui/views/dialogs/boot.py new file mode 100644 index 0000000..974239b --- /dev/null +++ b/dzgui/views/dialogs/boot.py @@ -0,0 +1,244 @@ +import sys +from typing import Literal, TYPE_CHECKING + +# import time +from enum import Enum +from typing import Any, Self + +# TODO: import dialog titles +from dzgui.api.mods import remove_stale_signatures as remove_stale +from dzgui.config.ipdb import get_ipdb +from dzgui.const.constants import HEX_GREEN, HEX_RED +from dzgui.init.coords import get_local_coords +from dzgui.init.update import check_updates +from dzgui.const.constants import EXPAND, FILL +from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager +from dzgui.strings import preboot +from dzgui.util.strings import dialog_header +from dzgui.util.symlink import rebuild_symlinks +from dzgui.views.components.buttons import ClipboardButton + + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Gdk # noqa + +if TYPE_CHECKING: + from dzgui.config.xdg import Xdg + from dzgui.util.ip import Coords + + +class Success(Enum): + OK = 1 + FAIL = 2 + + +# TODO: strings for "Running", "Failed", etc. +# TODO: add margins to tree +class BootDialog(Gtk.Dialog): + def __init__(self, parent: "BootWindow", xdg: "Xdg", version: str) -> None: + super().__init__( + title=dialog_header, + parent=parent, + modal=True, + ) + + self.parent = parent + self.set_modal(True) + self.xdg = xdg + self.version = version + + self.thread_man = ThreadingManager(None) + self.set_size_request(700, 500) + + self.store = Gtk.ListStore(str, str, bool, int) + + self.view = Gtk.TreeView(enable_search=False, headers_visible=False) + self.view.set_model(self.store) + + for i, column_title in enumerate(["Task", "State"]): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + column.set_resizable(False) + column.set_sort_column_id(i) + column.set_cell_data_func(renderer, self._format_color, func_data=None) + self.view.append_column(column) + if i == 0: + column.set_fixed_width(300) + + self.spinner_renderer = Gtk.CellRendererSpinner() + self.spinner_renderer.set_property("size", Gtk.IconSize.LARGE_TOOLBAR) + col_bool = Gtk.TreeViewColumn("Spinner", self.spinner_renderer, active=3) + col_bool.set_alignment(0.0) + col_bool.set_cell_data_func( + self.spinner_renderer, self._set_spinner_vis, func_data=None + ) + self.view.append_column(col_bool) + + col_int = Gtk.TreeViewColumn("Int", Gtk.CellRendererText(), text=3) + col_int.set_visible(False) + self.view.append_column(col_int) + + self.view.get_selection().set_mode(Gtk.SelectionMode.NONE) + self.connect("delete-event", self._on_delete) + + self.scrollable_tree = Gtk.ScrolledWindow(overlay_scrolling=False) + self.scrollable_tree.add(self.view) + self.scrollable_tree.set_size_request(700, 400) + + self.error_box = Gtk.Box( + halign=Gtk.Align.CENTER, + orientation=Gtk.Orientation.VERTICAL, + spacing=20, + margin_top=30, + margin_bottom=30, + ) + self.error_label = Gtk.Label() + + # TODO: abstract class + self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text()) + self.button_hbox = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.CENTER, spacing=10 + ) + self.exit_button = Gtk.Button(label="Exit", halign=Gtk.Align.CENTER) + self.exit_button.connect("clicked", lambda _: sys.exit(1)) + self.button_hbox.add(self.copy_button) + self.button_hbox.add(self.exit_button) + + self.error_box.add(self.error_label) + self.error_box.add(self.button_hbox) + + self.content = self.get_content_area() + self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0) + self.content.pack_start(self.error_box, EXPAND, FILL, 0) + self.show_all() + + self.error_box.hide() + + steps = [ + (StoredFunc(rebuild_symlinks, self.xdg.config), preboot.symlinks, False), + ( + StoredFunc(remove_stale, self.xdg.config, self.xdg.version), + preboot.signatures, + False, + ), + (StoredFunc(get_ipdb, self.xdg.ips), preboot.geo, False), + (StoredFunc(get_local_coords, self.xdg.ips), preboot.coords, True), + (StoredFunc(check_updates, self.version), preboot.updates, True), + ] + self.results: list[Any] = [] + self.failed = False + self.steps = iter(steps) + + GLib.timeout_add(100, self.pulse_spinner) + + def pulse_spinner(self) -> Literal[True]: + for row in self.store: + if row[2]: + if row[3] == 150: + row[3] = 0 + else: + row[3] += 1 + self.spinner_renderer.set_property("pulse", row[3]) + return True + + def run(self) -> None: + self.iter_step() + + def _on_delete(self, widget: Self, event: Gdk.Event) -> Literal[True]: + return True + + def iter_step(self) -> None: + if self.failed: + self.error_box.show() + self.error_label.set_text(self.exception) + return + try: + step, label, store_output = next(self.steps) + self.update_task(label) + self.background(step, store_output) + except StopIteration: + self.parent.set_results(self.results) + self.destroy() + + @call_on_thread("", show_dialog=False) + def background(self, func: StoredFunc, store_output: bool) -> None: + try: + if store_output: + res = func.call() + self.results.append(res) + else: + func.call() + callback = StoredFunc(self.update_status, Success.OK) + self.thread_man.set_cleanup_func(callback) + except Exception as e: + self.failed = True + # TODO: generic exception wrapper + self.exception = f"{type(e).__name__}: {e}" + callback = StoredFunc(self.update_status, Success.FAIL) + self.thread_man.set_cleanup_func(callback) + + def update_task(self, task: str) -> None: + self.store.append((task, "Running", True, 0)) + + def update_status(self, state: Success) -> None: + d = {Success.OK: "OK", Success.FAIL: "FAILED"} + msg = d[state] + self.store[len(self.store) - 1][1] = msg + self.iter_step() + + def _set_spinner_vis( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererSpinner, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + data: Any, + ) -> None: + if model[it][1] == "Running": + cell.set_property("visible", True) + else: + cell.set_property("visible", False) + + def _format_color( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + data: Any, + ) -> None: + prop = "foreground" + state = model[it][1] + if column.get_sort_column_id() != 1: + return + if state == "OK": + cell.set_property(prop, HEX_GREEN) + elif state == "FAILED": + cell.set_property(prop, HEX_RED) + else: + cell.set_property(prop, None) + + +class BootWindow(Gtk.Window): + def __init__(self, xdg: "Xdg", version: str) -> None: + super().__init__() + + self.results: list[Any] = [] + + dialog = BootDialog(self, xdg, version) + dialog.run() + dialog.connect("destroy", self._on_destroy) + Gtk.main() + + def set_results(self, res: list[Any]) -> None: + self.results = res + + def get_results(self) -> tuple["Coords", str]: + coords, version_url = self.results + return (coords, version_url) + + def _on_destroy(self, dialog: BootDialog) -> None: + Gtk.main_quit() diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py index 598a551..bdc298c 100644 --- a/dzgui/views/dialogs/wizard.py +++ b/dzgui/views/dialogs/wizard.py @@ -31,8 +31,6 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402 -# TODO: currently unused, corresponds to linear index -# TODO: use of pagenum enum and page_type attribute is redundant class PageNum(Enum): INTRO = 1 HAS_CONFIG = 2 @@ -93,6 +91,9 @@ class ScrolledWizardPage(Gtk.ScrolledWindow): self.connect("map", self._on_map) + def get_enum(self) -> PageNum: + return self.enum + def get_progress_bar(self) -> Progress: return self.prog @@ -474,6 +475,7 @@ class Assistant(Gtk.Assistant): def _advance_page(self, index: int) -> int: page = self.get_nth_page(index) + # TODO: use enums match page: case self.page1: pass @@ -534,6 +536,9 @@ class Assistant(Gtk.Assistant): bar.set_text(f"{page_num}/{total}") # NOTE: disable forward action + # TODO: use page enums + if page == self.page5: + return if page != self.page1: EMITTER.emit("step_pending") diff --git a/dzgui/views/trees/tree_mods.py b/dzgui/views/trees/tree_mods.py index 130708f..e8340d7 100644 --- a/dzgui/views/trees/tree_mods.py +++ b/dzgui/views/trees/tree_mods.py @@ -88,13 +88,12 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore model: Gtk.TreeModel, it: Gtk.TreeIter, data: Any, - ) -> Any: + ) -> None: state = model[it][4] if state is True: cell.set_property("foreground", HEX_RED) else: cell.set_property("foreground", None) - return def _format_float( self, @@ -103,8 +102,7 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore model: Gtk.TreeModel, it: Gtk.TreeIter, data: Any, - ) -> Any: + ) -> None: val = model[it][3] formatted = localize.number(val) cell.set_property("text", formatted) - return diff --git a/pyproject.toml b/pyproject.toml index 63badb5..6635ddc 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.0b6" +version = "7.0.0b7" license = "GPL-3.0-or-later" license-files = ["LICENSE"] readme = "README.md" diff --git a/scripts/release.py b/scripts/release.py index c80315e..2863dcc 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -51,7 +51,11 @@ if proc.returncode == 0: output_exe.rename(release_exe) tarpath = output.joinpath(tarname) with tarfile.open(tarpath, "w:gz") as tar: - tar.add(release_exe, arcname=appname) + info = tar.gettarinfo(release_exe) + info.uname = appname + info.name = appname + with open(release_exe, "rb") as f: + tar.addfile(info, f) print(f"Wrote tarfile to '{tarpath}'") proc = subprocess.run([release_exe, "-v"], capture_output=True, text=True)