diff --git a/dzgui/api/pefile.py b/dzgui/api/pefile.py index 7c86987..15e028f 100644 --- a/dzgui/api/pefile.py +++ b/dzgui/api/pefile.py @@ -255,16 +255,28 @@ class PeFileError(Exception): pass + def parse_version_number(data: BinaryIO) -> FileVersion: # https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo - minor = struct.unpack("> 16 & 0xFFFF - major = struct.unpack("> 0 & 0xFFFF - build = struct.unpack("> 0 & 0xFFFF - # NOTE: 0-pad revision number - revision = str(struct.unpack("> 16 & 0xFFFF) - offset = 3 - len(revision) - padded = "0" * offset + revision - return FileVersion(major, minor, build, padded) + """ + Note: structure differs from standard VS_FIXEDFILEINFO + Major: lower 16 bits of first DWORD + Minor: upper 16 bits of first DWORD + Build: lower 16 bits of third DWORD + Revision: upper 16 bits of second DWORD, zero-padded + Lower 16 bits of revision DWORD appear unused. + """ + major_minor = struct.unpack("> 16) & 0xFFFF + build = build_dword & 0xFFFF + revision = (revision_pair >> 16) & 0xFFFF + revision = f"{revision:03d}" + + return FileVersion(major, minor, build, revision) def seek_to_hex(address: str, data: BinaryIO) -> None: diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 6347230..8c331e4 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -346,7 +346,7 @@ def get_details(record: Record) -> Details | None: dlc = strings.dlc_none case 2: dlc = strings.dlc_frostline - case 3: + case 4: dlc = strings.dlc_badlands case _: dlc = strings.unspecified diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 5e914ae..7253d1c 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -1,13 +1,14 @@ import logging import os import psutil +import re import requests import subprocess import vdf # type: ignore from pathlib import Path from requests.exceptions import HTTPError -from typing import Any, Union +from typing import Any, Generator, Union from warnings import deprecated from dzgui.api.acf import ACF @@ -20,6 +21,7 @@ from dzgui.const.constants import ( DAYZ_LAUNCHER, DEBIAN_STEAM_PATH, DEFAULT_STEAM_PATH, + DOWNLOADS_PATH, FLATPAK_STEAM_PATH, LIBRARYFOLDERS_PATH, UBUNTU_STEAM_PATH, @@ -245,9 +247,9 @@ def find_user_id(path: Path) -> str | None: last_user = str(next(iter(users))) if len(users) == 1: return last_user - last_stamp = int(users[last_user]["Timestamp"]) + last_stamp = int(users[last_user]["timestamp"]) for user in users: - stamp = int(users[user]["Timestamp"]) + stamp = int(users[user]["timestamp"]) if stamp > last_stamp: last_stamp = stamp last_user = user @@ -439,6 +441,22 @@ def is_dayz_running() -> bool: return any(s in item for sublist in procs for item in sublist for s in substring) +def get_pending_downloads(steam_path: Path, appid: int) -> Generator[Path, None, None]: + root_path = get_app_path(steam_path, appid) + downloads_dir = root_path / DOWNLOADS_PATH + files = downloads_dir.iterdir() + return files + + +def is_dayz_downloading(steam_path: Path, appid: int) -> bool: + files = get_pending_downloads(steam_path, appid) + reg = re.compile(rf"^state_(?:{APPID_DAYZ_EXP}|{APPID_DAYZ})_.*\.patch$") + pending = [f for f in files if reg.match(f.name)] + if len(pending) > 0: + return True + return False + + def get_app_path(folders_path: Path, appid: int) -> Path: app_path = None diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 686dece..a1fa7a5 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -15,6 +15,7 @@ DAYZ_BINARY = "DayZ_x64.exe" DAYZ_LAUNCHER = "DayZLauncher.exe" LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf" +DOWNLOADS_PATH = "steamapps/downloading" WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ) STEAM_CMD = "steam" diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 574f622..89c35f0 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -14,6 +14,7 @@ from dzgui.api.shortcuts import Shortcuts from dzgui.api.steam import ( connect, connect_debug, + is_dayz_downloading, get_app_allows_downloads, get_app_name, get_needs_update, @@ -84,6 +85,7 @@ class Prerequisites: available_space: float passworded: bool dayz_running: bool + download_paused: bool steam_proc: SteamProcess mods: list[list[str]] game_mode: bool @@ -169,6 +171,7 @@ class ConnectionManager: free_mib = format_mib(free) dayz_running = is_dayz_running() + download_paused = is_dayz_downloading(steam_path, self.appid) running_app = get_running_app() if running_app is not None: @@ -210,6 +213,7 @@ class ConnectionManager: available_space=free_mib, passworded=info.password_protected, dayz_running=dayz_running, + download_paused=download_paused, steam_proc=steam_proc, mods=remote_mods, game_mode=game_mode, diff --git a/dzgui/managers/filter.py b/dzgui/managers/filter.py index 7dd2a21..4151bc5 100644 --- a/dzgui/managers/filter.py +++ b/dzgui/managers/filter.py @@ -22,11 +22,12 @@ class FilterManager: strings.filter_night: True, strings.filter_full: show_full, strings.filter_official: True, - strings.filter_nonascii: False, + strings.filter_modded: True, strings.filter_lowpop: True, strings.filter_unofficial: True, + strings.filter_unmodded: True, strings.filter_duplicate: False, - strings.filter_modded: True, + strings.filter_nonascii: False, } # TODO: strings @@ -43,6 +44,7 @@ class FilterManager: strings.filter_unofficial: "Third-party servers", strings.filter_duplicate: "Duplicates of existing servers (usually spoofed)", strings.filter_modded: "Servers with one or more mods", + strings.filter_unmodded: "Servers with no mods", } self.active_keyword = "" diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index ce49e85..c892110 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -243,6 +243,8 @@ class ProxyModelManager: rows = [row for row in rows if (row[4] / row[5] * 100) > 30] case strings.filter_modded: rows = [row for row in rows if not row[11]] + case strings.filter_unmodded: + rows = [row for row in rows if row[11]] return rows def filter_toggle_on(self, filters: tuple[str]) -> list: diff --git a/dzgui/strings/preconnect.py b/dzgui/strings/preconnect.py index cd54196..f42d06d 100644 --- a/dzgui/strings/preconnect.py +++ b/dzgui/strings/preconnect.py @@ -47,3 +47,7 @@ running_app = ( "Either stop the game first, or update your global Steam settings or the game's local settings.\n" "Otherwise, mods may be queued for download but never update." ) +downloads_paused = ( + "It looks like DayZ may have a pending update on Steam that was paused.\n" + "To enqueue mods for download, unpause any pending DayZ update and allow it to complete." +) diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index 1207e4d..30a7fc6 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -156,6 +156,7 @@ filter_duplicate = "Duplicate" filter_official = "Official" filter_unofficial = "Unofficial" filter_modded = "Modded" +filter_unmodded = "Unmodded" # maps all_maps = "All maps" diff --git a/dzgui/views/pages/preconnect.py b/dzgui/views/pages/preconnect.py index 38bf90e..b737ff0 100644 --- a/dzgui/views/pages/preconnect.py +++ b/dzgui/views/pages/preconnect.py @@ -279,6 +279,8 @@ class PreConnectionAssistant(Gtk.Box): warnings.append(preconnect.protected_server) if prereqs.dayz_running is True: warnings.append(preconnect.dayz_running) + if len(prereqs.mods) > 0 and prereqs.download_paused: + warnings.append(preconnect.downloads_paused) allows_dl, running_app = prereqs.allows_downloads if len(prereqs.mods) > 0 and allows_dl is False: diff --git a/pyproject.toml b/pyproject.toml index bf60d09..1d52faa 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.0b26" +version = "7.0.0b28" license = "GPL-3.0-or-later" license-files = ["LICENSE"] readme = "README.md" diff --git a/tests/fixtures/api/loginusers_beta_client.vdf b/tests/fixtures/api/loginusers_beta_client.vdf index 4ba1ef7..6d19dc2 100644 --- a/tests/fixtures/api/loginusers_beta_client.vdf +++ b/tests/fixtures/api/loginusers_beta_client.vdf @@ -8,6 +8,6 @@ "WantsOfflineMode" "0" "SkipOfflineModeWarning" "0" "AutoLogin" "1" - "Timestamp" "0" + "timestamp" "0" } } diff --git a/tests/fixtures/api/loginusers_beta_client_multiple.vdf b/tests/fixtures/api/loginusers_beta_client_multiple.vdf index bc56e12..d80cbc2 100644 --- a/tests/fixtures/api/loginusers_beta_client_multiple.vdf +++ b/tests/fixtures/api/loginusers_beta_client_multiple.vdf @@ -8,7 +8,7 @@ "WantsOfflineMode" "0" "SkipOfflineModeWarning" "0" "AutoLogin" "1" - "Timestamp" "1787904411" + "timestamp" "1787904411" } "1" { @@ -18,6 +18,6 @@ "WantsOfflineMode" "0" "SkipOfflineModeWarning" "0" "AutoLogin" "1" - "Timestamp" "1787904647" + "timestamp" "1787904647" } } diff --git a/tests/fixtures/api/loginusers_beta_client_multiple_2.vdf b/tests/fixtures/api/loginusers_beta_client_multiple_2.vdf index 7975bde..a1a0661 100644 --- a/tests/fixtures/api/loginusers_beta_client_multiple_2.vdf +++ b/tests/fixtures/api/loginusers_beta_client_multiple_2.vdf @@ -8,7 +8,7 @@ "WantsOfflineMode" "0" "SkipOfflineModeWarning" "0" "AutoLogin" "1" - "Timestamp" "1797904411" + "timestamp" "1797904411" } "1" { @@ -18,6 +18,6 @@ "WantsOfflineMode" "0" "SkipOfflineModeWarning" "0" "AutoLogin" "1" - "Timestamp" "1787904647" + "timestamp" "1787904647" } } diff --git a/tests/fixtures/api/loginusers_legacy_client.vdf b/tests/fixtures/api/loginusers_legacy_client.vdf index 383febd..cb4530b 100644 --- a/tests/fixtures/api/loginusers_legacy_client.vdf +++ b/tests/fixtures/api/loginusers_legacy_client.vdf @@ -9,6 +9,6 @@ "SkipOfflineModeWarning" "0" "AllowAutoLogin" "1" "MostRecent" "1" - "Timestamp" "0" + "timestamp" "0" } } diff --git a/tests/fixtures/api/loginusers_legacy_client_multiple.vdf b/tests/fixtures/api/loginusers_legacy_client_multiple.vdf index 8a4a3b2..a2061cb 100644 --- a/tests/fixtures/api/loginusers_legacy_client_multiple.vdf +++ b/tests/fixtures/api/loginusers_legacy_client_multiple.vdf @@ -9,7 +9,7 @@ "SkipOfflineModeWarning" "0" "AllowAutoLogin" "1" "MostRecent" "0" - "Timestamp" "0" + "timestamp" "0" } "1" { @@ -20,6 +20,6 @@ "SkipOfflineModeWarning" "0" "AllowAutoLogin" "1" "MostRecent" "1" - "Timestamp" "10" + "timestamp" "10" } } diff --git a/tests/test_colorscheme.py b/tests/test_colorscheme.py index fd02526..a8818dc 100644 --- a/tests/test_colorscheme.py +++ b/tests/test_colorscheme.py @@ -10,7 +10,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk, Gio, GLib # noqa -class TestWidget(ColorAwareApp, Gtk.Button): +class DummyWidget(ColorAwareApp, Gtk.Button): def __init__(self) -> None: super().__init__() @@ -22,11 +22,10 @@ class TestWidget(ColorAwareApp, Gtk.Button): @pytest.fixture -def widget() -> TestWidget: - return TestWidget() +def widget() -> DummyWidget: + return DummyWidget() -@pytest.mark.integration @pytest.mark.parametrize("key, expect", [("default", False), ("prefer-dark", True)]) def test_light_mode(widget, key: str, expect: bool) -> None: local_pref = subprocess.run( diff --git a/tests/test_loginusers.py b/tests/test_loginusers.py index 6665e79..db96c56 100644 --- a/tests/test_loginusers.py +++ b/tests/test_loginusers.py @@ -3,6 +3,7 @@ import pytest from pathlib import Path from dzgui.api.steam import find_user_id +from dzgui.const.constants import DEFAULT_STEAM_PATH from tests.fixtures import fixture_path pytestmark = pytest.mark.apitest @@ -25,3 +26,11 @@ def test_loginusers(monkeypatch, fixture: str, expect: int) -> None: monkeypatch.setattr("dzgui.api.steam.find_loginusers", mock_loginusers) uid = find_user_id(Path("")) assert int(uid) == expect + + +@pytest.mark.realconfig +def test_live_loginusers() -> None: + """Added on 2026-10-17 following changes to VDF keys""" + steam = Path.home().joinpath(DEFAULT_STEAM_PATH) + uid = find_user_id(steam) + assert uid is not None diff --git a/tests/test_paused_downloads.py b/tests/test_paused_downloads.py new file mode 100644 index 0000000..86bbb70 --- /dev/null +++ b/tests/test_paused_downloads.py @@ -0,0 +1,27 @@ +import pytest +from pathlib import Path + +from dzgui.api.steam import is_dayz_downloading + + +@pytest.mark.parametrize( + "file_list, expect", + [ + (["state_221100_1111.patch"], True), + (["state_730_1111.patch"], False), + (["state_1024020_1111.patch"], True), + (["221100"], False), + (["221100", "state_221100_1111.patch"], True), + (["state_221100_1111.patch", "state_1024020_1111.patch"], True), + (["state_221100_1111"], False), + ], +) +def test_dayz_patch_pending(monkeypatch, file_list, expect) -> None: + def mock_get_pending_downloads(steam_path: Path, appid: int) -> None: + files = [Path(file) for file in file_list] + return files + + monkeypatch.setattr( + "dzgui.api.steam.get_pending_downloads", mock_get_pending_downloads + ) + assert is_dayz_downloading(Path("/tmp"), Path("/tmp")) == expect