From fbbf0d9e66bcc766db3a46e582d8cb6b08b9f1aa Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 7 May 2026 06:30:33 +0900 Subject: [PATCH] feat: mod enqueue logic (WIP) --- dzgui/api/mods.py | 10 ++++ dzgui/api/steam.py | 87 ++++++++++------------------- dzgui/const/enum.py | 2 +- dzgui/controllers/mc.py | 6 +- dzgui/managers/connection.py | 49 +++++++++++----- dzgui/util/bash.py | 1 + dzgui/views/components/statusbar.py | 1 + dzgui/views/pages/preconnect.py | 84 ++++++++++++++++++++++------ 8 files changed, 148 insertions(+), 92 deletions(-) diff --git a/dzgui/api/mods.py b/dzgui/api/mods.py index 8d43e73..30eb2f6 100644 --- a/dzgui/api/mods.py +++ b/dzgui/api/mods.py @@ -160,3 +160,13 @@ def find_stale_mods(config: Path) -> list[int]: unique_mods = set(remote_mods) stale = [mod for mod in local if mod not in unique_mods] return stale + + +def get_mod_dir_size(path: Path) -> int: + size = 0 + for i in Path(path).iterdir(): + if i.is_file(): + size += i.stat().st_size + elif i.is_dir(): + size += get_mod_dir_size(i) + return size diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index fc310a9..55e2b50 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -8,50 +8,12 @@ from pathlib import Path from dzgui.const.constants import APP_NAME from dzgui.const.endpoints import STEAM_PUBLISHED_FILES -from dzgui.util.bash import concat_bash_args logger = logging.getLogger(APP_NAME) -def query_defunct() -> None: - pass - - -# TODO: unimplemented -# cf. -#!/usr/bin/env bash -# query_defunct(){ -# readarray -t modlist <<< "$@" -# local max=${#modlist[@]} -# concat(){ -# for ((i=0;i<$max;i++)); do -# echo "publishedfileids[$i]=${modlist[$i]}&" -# done | awk '{print}' ORS='' -# } -# payload(){ -# echo -e "itemcount=${max}&$(concat)" -# } -# post(){ -# curl -s \ -# -X POST \ -# -H "Content-Type:application/x-www-form-urlencoded"\ -# -d "$(payload)" 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json' -# } -# post | jq -# return -# local result=$(post | jq -r ' -# .[].publishedfiledetails[] -# | select(.result==1) -# | select(.filename|contains("screenshot")|not) -# | "\(.file_size) \(.publishedfileid)"') -# <<< "$result" awk '{print $2}' -# } -# -# query_defunct "3576065083" - - -def concat_mods(mods: list[int]) -> str: +def concat_mods(mods: list[str]) -> str: for mod in mods: mods[mod] = f"@{mod}" return ";".join(mods) @@ -68,22 +30,32 @@ def get_local_signatures(version_file: Path) -> dict[str, int]: return hashes +def enqueue_mod(mod: str, appid: int) -> None: + args = [ + "steam", + f"steam://url/CommunityFilePage/{mod}+workshop_download_item", + str(appid), + mod, + ] + subprocess.Popen(["/usr/bin/env", "bash", *args]) + + def get_needs_update( - version_file: Path, remote_hashes: list[tuple[str, int, str]] -) -> list[tuple[str, int, str]]: + version_file: Path, remote_hashes: list[tuple[str, str, int, int]] +) -> list[tuple[str, str, int, int]]: local_hashes = get_local_signatures(version_file) needs_update: list[tuple[str, str]] = [] - for _id, _hash, size in remote_hashes: + for title, _id, _hash, size in remote_hashes: if _id not in local_hashes: - needs_update.append((_id, _hash, size)) + needs_update.append((title, _id, _hash, size)) elif _hash != local_hashes[_id]: - needs_update.append((_id, _hash, size)) + needs_update.append((title, _id, _hash, size)) else: continue return needs_update -def get_remote_signatures(mods: list[str]) -> list[tuple[str, int]]: +def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]: """ Attempts to continue connecting even if signatures are empty """ @@ -99,24 +71,26 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, int]]: if r.status_code != 200: return [] - hashes: list[tuple[str, int, str]] = [] + hashes: list[tuple[str, int, int]] = [] j = r.json() rows = j["response"]["publishedfiledetails"] for row in rows: + title = row["title"] _id = row["publishedfileid"] time = row["time_updated"] - size = row["file_size"] - hashes.append((_id, time, size)) + size = int(row["file_size"]) + hashes.append((title, _id, time, size)) return hashes -# TEST: set config to name=user, use official server and no mods, -# ensure that formatted string is identical to fixture -def connect(addr: str, appid: int, name: str, mods: list[int]) -> None: - # TODO: get name from configs - # TODO: concat_mods(mods): - # @;@; +# 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(addr: str, appid: int, name: str, mods: list[str]) -> None: + concat = concat_mods(mods) params = [ + "steam", + "-applaunch", + appid, f"-connect={addr}", "-nolauncher", "-nosplash", @@ -124,10 +98,7 @@ def connect(addr: str, appid: int, name: str, mods: list[int]) -> None: f"-name={name}", f"-mod={concat}", ] - # TODO: get steam launch command from configs - # args = concat_bash_args() - # - proc = subprocess.Popen([*args, "-applaunch", appid, *params]) + proc = subprocess.Popen([*params]) # check proc.returncode diff --git a/dzgui/const/enum.py b/dzgui/const/enum.py index be62cb2..aad7698 100644 --- a/dzgui/const/enum.py +++ b/dzgui/const/enum.py @@ -112,7 +112,7 @@ class NotebookPage(EnumWithAttrs): OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False} SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True} THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True} - CONNECTION = {"crumbs": "Connect", "statusbar": False} + CONNECTION = {"crumbs": "Connect", "statusbar": True} class RowType(EnumWithAttrs): diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 36ea9c1..2bc6b32 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -20,6 +20,7 @@ from dzgui.controllers.emitter import Emitter from dzgui.managers.config import ConfigManager from dzgui.managers.connection import ConnectionManager from dzgui.managers.contextmenu import ContextMenuManager +from dzgui.managers.mods import ModManager from dzgui.managers.notes import NoteManager from dzgui.model.servers import ServerModelManager from dzgui.util.diag import write_diagnostic @@ -215,8 +216,6 @@ class Controller(GObject.GObject): open_user_workshop(uid, client) def load_mods(self) -> None: - from dzgui.managers.mods import ModManager - self.mod_man = ModManager(self) def uncolorize_mods(self) -> None: @@ -473,3 +472,6 @@ class Controller(GObject.GObject): def update_and_connect(self) -> None: self.connection_man.update_and_connect() + + def update_status(self, mod: str, mark_finished: bool = False) -> None: + self.mediator.preconnect.update_mod(mod, mark_finished) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 26f2a69..ce8ed9b 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -1,5 +1,6 @@ import logging import shutil +import time from dataclasses import dataclass from packaging.version import Version @@ -8,9 +9,9 @@ from typing import TYPE_CHECKING import dzgui.api.pefile as PeFile import dzgui.api.servers as Servers -from dzgui.api.steam import get_remote_signatures, get_needs_update +from dzgui.api.steam import enqueue_mod, get_remote_signatures, get_needs_update -from dzgui.api.mods import get_local_mod_ids +from dzgui.api.mods import get_mod_dir_size, get_local_mod_ids, get_local_mod_path from dzgui.const.constants import ( APP_NAME, APPID_DAYZ, @@ -29,7 +30,7 @@ from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk # noqa E402 +from gi.repository import Gtk, GLib # noqa E402 if TYPE_CHECKING: from dzgui.api.servers import A2SInfo, Record @@ -62,6 +63,7 @@ class ConnectionManager: self.appid: int self.record: Record + self.workshop: Path self.remote_mod_ids: list[str] = [] self.missing_mods: list[str] = [] @@ -101,6 +103,7 @@ class ConnectionManager: free_mib = 0.0 steam_path = Path(self.controller.query_config(Preferences.DEFAULT)) + self.workshop = get_local_mod_path(steam_path) local_version = PeFile.get_pretty_version(steam_path, info.game_id) if local_version is None: local_version = "0.0.0" @@ -124,13 +127,12 @@ class ConnectionManager: 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_size = sum(row[2] for row in self.missing_mods) required_mib = format_mib(required_size) free_mib = format_mib(required_size) dayz_running = is_dayz_running() steam_running = is_steam_running() - # TODO: is dayz downloading prereqs = Prerequisites( name=info.server_name, @@ -207,17 +209,38 @@ class ConnectionManager: print(self.record.ip) print(self.record.gameport) print(self.appid) - # TODO: convert mod ids to symlink hashes + # TODO: add to history file and list store # steam api, concat mods - # TODO: custom threading with glib idle callback + @call_on_thread("Waiting for Steam to update mods") 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 + # NOTE: fast enqueue all mods in auto mode + for title, mod, stamp, size in self.missing_mods: + # TODO: check for cancel event + enqueue_mod(mod, self.appid) + time.sleep(2) + + for title, mod, stamp, size in self.missing_mods: + mod_path = self.workshop / mod + + while mod_path.is_dir() is False: + time.sleep(1) + while True: + # NOTE: mods will finish at the same time + # TODO: check for cancel event + cur_size = get_mod_dir_size(mod_path) + if cur_size == size: + break + time.sleep(1) + + # TODO: update tree checkmarks + func = StoredFunc(self.controller.update_status, "All mods updated.") + self.thread_man.set_cleanup_func(func) + # TODO: after downloading mods, create all symlinks (and clone) + # GLib.idle_add(self._mark_finished) + + # def _mark_finished(self) -> None: + # self.controller.update_status("All mods updated.", mark_finished=True) def update_and_connect(self) -> None: if len(self.missing_mods) > 0: diff --git a/dzgui/util/bash.py b/dzgui/util/bash.py index 879e433..9c2a73d 100644 --- a/dzgui/util/bash.py +++ b/dzgui/util/bash.py @@ -1,4 +1,5 @@ import shlex + def concat_bash_args(command: str) -> list[str]: return shlex.split(command) diff --git a/dzgui/views/components/statusbar.py b/dzgui/views/components/statusbar.py index 4bf04d1..ebb3976 100644 --- a/dzgui/views/components/statusbar.py +++ b/dzgui/views/components/statusbar.py @@ -76,6 +76,7 @@ class Statusbar(Gtk.Grid): NotebookPage.THANKS, NotebookPage.CHANGELOG, NotebookPage.LOG, + NotebookPage.CONNECTION, ): self.set_by_context(enum, esc_to_return) return diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index f6cdaea..c0a8e36 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -5,6 +5,7 @@ from dzgui.const.constants import ( ERROR, WARNING, ) +from dzgui.const.enum import NotebookPage from dzgui.util.css import add_class from dzgui.util.localize import number from dzgui.strings import preconnect @@ -99,20 +100,33 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.controller.register_widget("preconnect", self) - 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 + self.back = Gtk.Button( + label=preconnect.back, halign=Gtk.Align.END, hexpand=True ) self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END) + # TODO: abstract + self.raise_window = Gtk.CheckButton( + label="Foreground DZGUI while downloading", + halign=Gtk.Align.END, + hexpand=True, + valign=Gtk.Align.END, + visible=False, + has_tooltip=True, + sensitive=False, + tooltip_text="This option is available if wmctrl or xdotool is installed on the system", + ) self.button_box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, + orientation=Gtk.Orientation.VERTICAL, valign=Gtk.Align.END, vexpand=True, spacing=5, ) - for button in self.back, self.cancel, self.ok: - self.button_box.add(button) + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + for button in self.back, self.ok: + 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) @@ -128,6 +142,21 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): margin_top=10, margin_bottom=10, ) + self.spinner = Gtk.Spinner() + self.progress_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15) + self.progress_box.add(self.mod_count) + self.progress_box.add(self.spinner) + self.cancel = Gtk.Button( + label=preconnect.cancel, + halign=Gtk.Align.START, + hexpand=True, + margin_bottom=5, + margin_top=5, + ) + self.progress_box.add(self.cancel) + self.cancel.connect("clicked", self._on_cancel_clicked) + self.cancel.set_visible(False) + # TODO: live count of remaining downloads # "Steam is downloading: {mod_name}" # mention whether manual or auto mod is active @@ -138,7 +167,7 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.tree_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.tree_box.add(self.scrolled) - self.tree_box.add(self.mod_count) + self.tree_box.add(self.progress_box) # TODO: strings self.mods_placeholder = Placeholder("This server has no mods.") @@ -180,12 +209,18 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self.grab_focus() widgets = ( self.tree_frame, - self.mod_count, + self.progress_box, self.error_placeholder, self.warning_placeholder, ) for widget in widgets: widget.set_visible(True) + self.raise_window.set_visible(False) + + # TODO: enable button if wmctrl or xdotool is available + # TODO: check this at boot time and pass in via connection manager + + self.cancel.set_visible(False) self.ok.set_sensitive(True) self.ok.set_label(preconnect.update_mods) @@ -193,18 +228,24 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if event.keyval == Gdk.KEY_Escape: self.back.emit("clicked") + def _on_cancel_clicked(self, button: Gtk.Button) -> None: + print("user canceled") + 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 + # sets some kind of global event listener + if self.mod_count.get_visible(): + # TODO: set ready mode + # TODO: strings + self.mod_count.set_label("Enqueuing downloads") + self.spinner.start() + self.cancel.set_visible(True) + self.ok.set_label(preconnect.connect) 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) + self.controller.open_page(NotebookPage.SERVERS) def _process_warnings(self, prereqs: "Prerequisites") -> None: warnings: list[str] = [] @@ -256,16 +297,17 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): if total_mods < 1: self.scrolled.set_visible(False) - self.mod_count.set_visible(False) + self.progress_box.set_visible(False) self.mods_placeholder.set_visible(True) self.ok.set_label(preconnect.connect) else: self.scrolled.set_visible(True) - self.mod_count.set_visible(True) + self.progress_box.set_visible(True) + self.raise_window.set_visible(True) self.mods_placeholder.set_visible(False) - # TODO: print no. of mods that need updating - suffix = "" + # TODO: strings + suffix = "All mods are up to date." if prereqs.required_space > 0: pretty = number(prereqs.required_space) suffix = f" Need to download {pretty} MiB of mod updates." @@ -274,6 +316,12 @@ class PreConnectionAssistant(Gtk.ScrolledWindow): self._process_warnings(prereqs) + def update_mod(self, text: str, mark_finished: bool = False) -> None: + self.mod_count.set_label(text) + if mark_finished: + self.cancel.set_visible(False) + self.spinner.stop() + def add_errors(self, errors: list[str]) -> None: self.error_tree.extend(errors)