Compare commits

..
1 Commits
Author SHA1 Message Date
aclist c2cf996f92 Merge fa8792ba69 into f839001097 2026-06-20 10:45:45 +00:00
82 changed files with 490 additions and 1803 deletions

No files matched your search

+28 -31
View File
@@ -1,7 +1,6 @@
# Changelog # Changelog
## [7.0.0] Unreleased ## Added
### Added
- Setup wizard - Setup wizard
- Changelog text wrapping and formatting - Changelog text wrapping and formatting
- Changelog ships with source - Changelog ships with source
@@ -42,10 +41,8 @@
- Choose to jump into splash screen instead of server - Choose to jump into splash screen instead of server
- Collapsible connection panel - Collapsible connection panel
- Play offline (load mods directly) - Play offline (load mods directly)
- Generate Steam shortcuts and cover art
- Warn user if background downloads are disabled
### Changed ## Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
- Reduce padding on keys button - Reduce padding on keys button
- Boldface breadcrumbs - Boldface breadcrumbs
@@ -62,58 +59,58 @@
- Enable LAN page Empty/Full filters on first run of app - Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client - Propagate subscribed mods to Steam client
### Dropped ## Dropped
- Debug mode - Debug mode
- Branch switching - Branch switching
- Manual mod install mode (describe rationale) - Manual mod install mode (describe rationale)
- Force update mods - Force update mods
### Fixed ## Fixed
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
- Center server title text on server dialogs - Center server title text on server dialogs
- Rare segfaults when changing maps (threading) - Rare segfaults when changing maps (threading)
- Moved dialogs out of threads - Moved dialogs out of threads
### Unreleased ## Unreleased
- Load offline mods - Load offline mods
- Local documentation - Local documentation
- Raw debug command in context menu - Raw debug command in context menu
### Developer-facing ## Developer-facing
- Add pyproject.toml file - Add pyproject.toml file
- Show deprecation warnings - Show deprecation warnings
- Options -> Dev page - Options -> Dev page
## [6.0.5] 2026-05-17 ## [6.0.5] 2026-05-17
### Changed ## Changed
- Update symlink hash logic to align with DZGUI 7 beta - Update symlink hash logic to align with DZGUI 7 beta
- Update A2S helper to latest commit - Update A2S helper to latest commit
## [6.0.4] 2026-04-13 ## [6.0.4] 2026-04-13
### Fixed ## Fixed
- Update A2S module checksums to support servers with missing description field - Update A2S module checksums to support servers with missing description field
## [6.0.3] 2026-04-12 ## [6.0.3] 2026-04-12
### Fixed ## Fixed
- Use GLibUnix namespace where available when setting up SIGINT callback - Use GLibUnix namespace where available when setting up SIGINT callback
## [6.0.2] 2026-01-26 ## [6.0.2] 2026-01-26
### Fixed ## Fixed
- Explicitly use Python 3.13 when calling subprocesses - Explicitly use Python 3.13 when calling subprocesses
- Raise error correctly from module - Raise error correctly from module
- Do not spawn dialogs from inside of thread when fetching prereqs - Do not spawn dialogs from inside of thread when fetching prereqs
### Changed ## Changed
- Optimize time complexity of startup checks - Optimize time complexity of startup checks
- Optimize coordinate calculation by using local records first - Optimize coordinate calculation by using local records first
- Rename some internal functions - Rename some internal functions
- Wrap entire startup process in dialog - Wrap entire startup process in dialog
## [6.0.1] 2026-01-16 ## [6.0.1] 2026-01-16
### Fixed ## Fixed
- Explicitly use Python 3.13 when calling subprocesses - Explicitly use Python 3.13 when calling subprocesses
## [6.0.0] 2026-01-14 ## [6.0.0] 2026-01-14
### Added ## Added
- Support DayZ Experimental - Support DayZ Experimental
- Ping display on server tables - Ping display on server tables
- Save descriptive text notes on a per-server basis - Save descriptive text notes on a per-server basis
@@ -139,7 +136,7 @@
- Commandline usage help text (GaryBlackbourne) - Commandline usage help text (GaryBlackbourne)
- More descriptive help dialogs when initial dependencies are missing - More descriptive help dialogs when initial dependencies are missing
### Fixed ## Fixed
- Script failing to start when remote endpoints are unavailable (GaryBlackbourne) - Script failing to start when remote endpoints are unavailable (GaryBlackbourne)
- Key stickiness when quickly navigating through entries in tables - Key stickiness when quickly navigating through entries in tables
- Servers returning malformed A2S_INFO blocking server browser from loading - Servers returning malformed A2S_INFO blocking server browser from loading
@@ -178,7 +175,7 @@
- Path discovery during first-time setup when parsing filepaths with whitespaces - Path discovery during first-time setup when parsing filepaths with whitespaces
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it - First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
### Changed ## Changed
- Require Python 3.13 - Require Python 3.13
- Reduced global API cooldown from 60s to 30s - Reduced global API cooldown from 60s to 30s
- Clarify dialog messages when DayZ path could not be found - Clarify dialog messages when DayZ path could not be found
@@ -190,50 +187,50 @@
- Rewrote distance calculation module (GaryBlackbourne) - Rewrote distance calculation module (GaryBlackbourne)
- Changed preferred client setting from radio toggle to combobox - Changed preferred client setting from radio toggle to combobox
### Dropped ## Dropped
- Ping readout in statusbar - Ping readout in statusbar
- Extraneous information from right statusbar - Extraneous information from right statusbar
## [5.8.3] 2026-01-04 ## [5.8.3] 2026-01-04
### Fixed ## Fixed
- Normalize checksum numbers and dates - Normalize checksum numbers and dates
## [5.8.2] 2026-01-04 ## [5.8.2] 2026-01-04
### Fixed ## Fixed
- Fix regression in symlink routine introduced by previous hotfix - Fix regression in symlink routine introduced by previous hotfix
## [5.8.1] 2025-12-27 ## [5.8.1] 2025-12-27
### Fixed ## Fixed
- Path discovery during first-time setup when parsing filepaths with whitespaces - Path discovery during first-time setup when parsing filepaths with whitespaces
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it - First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
## [5.8.0] 2025-07-06 ## [5.8.0] 2025-07-06
### Added ## Added
- Filter servers by official/unofficial status - Filter servers by official/unofficial status
- Automatically fetch geolocation records - Automatically fetch geolocation records
### Changed ## Changed
- Updated internal versioning of helper files - Updated internal versioning of helper files
### Fixed ## Fixed
- Corrected erroneous 2024 date in prior changelog entries - Corrected erroneous 2024 date in prior changelog entries
## [5.7.0] 2025-04-17 ## [5.7.0] 2025-04-17
### Added ## Added
- Save application dimensions when quitting and restore on subsequent boot - Save application dimensions when quitting and restore on subsequent boot
### Fixed ## Fixed
- Issues with the window exceeding the bounds of the screen when displaying some table contexts - Issues with the window exceeding the bounds of the screen when displaying some table contexts
### Changed ## Changed
- More direct server ping query method that should return marginally more accurate times - More direct server ping query method that should return marginally more accurate times
## [5.6.7] 2025-04-04 ## [5.6.7] 2025-04-04
### Dropped ## Dropped
- Removed extraneous pre-boot API checks that could cause error messages to be printed if the user had not set up an API key yet - Removed extraneous pre-boot API checks that could cause error messages to be printed if the user had not set up an API key yet
## [5.6.6] 2025-03-16 ## [5.6.6] 2025-03-16
### Changed ## Changed
- Update IP database records for 2025-03 - Update IP database records for 2025-03
## [5.6.5] 2025-03-04 ## [5.6.5] 2025-03-04
### Fixed ## Fixed
- Livonia server results being dropped from batch queries - Livonia server results being dropped from batch queries
## [5.6.4] 2025-02-10 ## [5.6.4] 2025-02-10
-69
View File
@@ -1,69 +0,0 @@
# Introduction
Thank you for your interest in DZGUI!
This guide goes over development conventions and best practices for contributors.
If you are a developer, you can skip to the end.
# Requesting help
If you encounter a problem with DZGUI, you can submit tickets on the GitHub
[issue tracker](https://github.com/aclist/dztui/issues) under the
`troubleshooting` tag.
# How can I help the project?
There are several ways to help this project.
1. Report bugs that you find
2. Request features that you would like
3. Contribute features and fixes to the codebase
4. Contribute documentation to the project
Before making any contribution, please read the `CODE_OF_CONDUCT.md` and act
accordingly.
## Submitting a ticket
Navigate to the GitHub [issue tracker](https://github.com/aclist/dztui/issues).
From there, follow the onscreen prompts. You will be asked questions such as:
- What version are you using?
- What distribution are you using?
- What is the issue that you found?
- How can we reproduce the issue?
You can also attach screenshots, logs, or other data that can help us.
## Requesting a feature
You can also request features via the same issue tracker. It is good practice to
first search for your idea to see if a similar one has already been posted.
If not, open a ticket where you describe your feature and its possible benefits.
Please note that this is a community project, so it takes time for us to develop
features. Putting in a feature request does not mean that it will be implemented,
but we will do our best to support as many cool ideas as possible.
## Contributing code or documentation
If you would like to assist with an issue on the issue tracker or
contribute a new change, please follow the guidelines below.
Fork this repository and check out the code up to the `dzgui7` (development) branch.
The following naming conventions apply for PRs:
- fix/<your-fix> - patch/hotfix branches
- feat/<your-feature> - feature branches
- doc/<your-doc-branch> - documentation branches
- infra/<your-infra-branch> - infrastructure branches
Implement your changes and test them locally. If they work, you may
open a merge request, and then we review your changes. If everything is OK, it
will be merged to `dzgui7`. After sufficient changes are consolidated into a new release,
this branch will be tagged to a certain point in time and a binary release published.
It is recommended to follow
[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), as this
integrates well with tooling and helps the project review your code.
Please see DEVELOPERS.md for further details.
+7 -2
View File
@@ -24,8 +24,13 @@ Please refer to the documentation for installation and setup instructions:
Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https://creativecommons.org/licenses/by/4.0/) Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https://creativecommons.org/licenses/by/4.0/)
Executable versions of DZGUI published as release binaries ship with various runtime dependencies and the Python interpreter built in. This tool uses [python-a2s](https://github.com/Yepoleb/python-a2s) and [dayzquery](https://github.com/Yepoleb/dayzquery) as submodules; licenses for these submodules can be found in the LICENSES file
Users wishing to review the licenses to these components can inspect the `LICENSE` file located in the release tarball. of the project root.
Both the geolocation records and submodules listed above are not shipped with the source code, but are retrieved and assembled at runtime.
Finally, executable versions of DZGUI shipped as release binaries are thin wrappers around the Python interpreter, and also retrieve and assemble the above dependencies at runtime on the end-user's
machine, rather than using pre-compiled source code. Users wishing to review these dependencies can inspect the 'pyproject.toml' manifest in the project root.
## Disclaimer ## Disclaimer
File renamed without changes.
File renamed without changes.
-19
View File
@@ -1,19 +0,0 @@
Copyright (c) 2015 Rossen Georgiev <rossen@rgp.io>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File renamed without changes.
File renamed without changes.
+1 -1
View File
@@ -26,7 +26,7 @@ You can install it via the following set of commands:
.. code:: console .. code:: console
sudo apt update sudo apt update
sudo apt install libgirepository-2.0-0 sudo apt install libgirepository-2.0
Next steps Next steps
--------------- ---------------
+15 -38
View File
@@ -1,18 +1,12 @@
import logging
import re import re
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path
from typing import Any from typing import Any
from warnings import deprecated from warnings import deprecated
from dzgui.const.constants import APP_NAME
logger = logging.getLogger(APP_NAME) @deprecated("Use dzgui.api.steam.unsubscribe()")
class WorkshopACF:
def __init__(self, file: str) -> None:
class ACF:
def __init__(self, file: Path) -> None:
super().__init__() super().__init__()
self.dict: dict[str, Any] self.dict: dict[str, Any]
@@ -21,10 +15,9 @@ class ACF:
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return self.dict return self.dict
def load(self, file: Path) -> None: def load(self, file: str) -> None:
delimiter = r"\t\t" delimiter = r"\t\t"
lines = [] lines = []
# TODO: illegal file handling
with open(file, "r", encoding="utf-8") as f: with open(file, "r", encoding="utf-8") as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
@@ -34,14 +27,6 @@ class ACF:
lines.append(els) lines.append(els)
self.dict = self.parse(iter(lines)) self.dict = self.parse(iter(lines))
@classmethod
def enquote(cls, s: str) -> str:
return f'"{s}"'
@classmethod
def dequote(cls, s: str) -> str:
return s.rstrip('"').lstrip('"')
def parse(self, lines: Iterator[list[str]]) -> dict[str, str]: def parse(self, lines: Iterator[list[str]]) -> dict[str, str]:
acf: dict[str, Any] = {} acf: dict[str, Any] = {}
try: try:
@@ -69,23 +54,6 @@ class ACF:
except StopIteration: except StopIteration:
return acf return acf
def get_allows_downloads(self) -> int | None:
try:
# TODO: consider coercing strs to ints on initial import
flag = int(self.dict["AppState"]["AllowOtherDownloadsWhileRunning"])
return flag
except Exception as e:
logger.critical(e)
return None
@deprecated(
"Serializing workshop files is no longer supported, use dzgui.api.steam.unsubscribe"
)
class WorkshopACF(ACF):
def __init__(self, file: Path) -> None:
super().__init__(file)
def unpack(self, d: dict, lines: list[Any] = []) -> str: def unpack(self, d: dict, lines: list[Any] = []) -> str:
t1 = "AppWorkshop" t1 = "AppWorkshop"
t2 = ("WorkshopItemsInstalled", "WorkshopItemDetails") t2 = ("WorkshopItemsInstalled", "WorkshopItemDetails")
@@ -110,9 +78,18 @@ class WorkshopACF(ACF):
s += line + "\n" s += line + "\n"
return s return s
def to_file(self, file: Path) -> None: def to_file(self, file: str) -> None:
s = self.unpack(self.dict) s = self.unpack(self.dict)
file.write_text(s) with open(file, "w") as f:
f.write(s)
@classmethod
def enquote(cls, s: str) -> str:
return f'"{s}"'
@classmethod
def dequote(cls, s: str) -> str:
return s.rstrip('"').lstrip('"')
def delete(self, modid: int) -> None: def delete(self, modid: int) -> None:
del self.dict["AppWorkshop"]["WorkshopItemsInstalled"][modid] del self.dict["AppWorkshop"]["WorkshopItemsInstalled"][modid]
+10 -19
View File
@@ -7,12 +7,13 @@ from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from dzgui.api.steam import get_app_path import dzgui.api.pefile as PeFile
from dzgui.api.servers import get_rules, fqip_to_record from dzgui.api.servers import get_rules, fqip_to_record
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
APPID_DAYZ, APPID_DAYZ,
DAYZ_COMMUNITY_ROOT, DAYZ_COMMUNITY_ROOT,
LIBRARYFOLDERS_PATH,
WORKSHOP_PATH, WORKSHOP_PATH,
) )
@@ -39,7 +40,7 @@ def get_local_mod_ids(steam_path: Path) -> list[int]:
def get_local_mod_path(steam_path: Path) -> Path: def get_local_mod_path(steam_path: Path) -> Path:
p = get_app_path(steam_path, APPID_DAYZ) p = PeFile.get_app_path(steam_path / Path(LIBRARYFOLDERS_PATH), APPID_DAYZ)
workshop_path = p / WORKSHOP_PATH workshop_path = p / WORKSHOP_PATH
return workshop_path return workshop_path
@@ -48,7 +49,6 @@ def get_local_mods(workshop_path: Path) -> list[Path]:
mods = [file for file in workshop_path.iterdir() if file.is_dir()] mods = [file for file in workshop_path.iterdir() if file.is_dir()]
return mods return mods
def is_mission(path: Path) -> bool: def is_mission(path: Path) -> bool:
# TODO: parse integrity of other files # TODO: parse integrity of other files
parent = path.parent.name parent = path.parent.name
@@ -57,10 +57,9 @@ def is_mission(path: Path) -> bool:
file = path / "init.c" file = path / "init.c"
return file.exists() return file.exists()
def tokenize(mod: Path) -> dict[str, Any] | None: def tokenize(mod: Path) -> dict[str, Any] | None:
file = mod.joinpath("meta.cpp") file = mod.joinpath("meta.cpp")
delimiter = r"\s*=\s*" delimiter=r"\s*=\s*"
modmeta = {} modmeta = {}
try: try:
with open(file, "r", encoding="utf-8") as f: with open(file, "r", encoding="utf-8") as f:
@@ -77,7 +76,6 @@ def tokenize(mod: Path) -> dict[str, Any] | None:
logger.critical(e) logger.critical(e)
return None return None
def get_mod_size(path: Path) -> float: def get_mod_size(path: Path) -> float:
s = 0 s = 0
for f in path.rglob("*"): for f in path.rglob("*"):
@@ -125,27 +123,20 @@ def _hash(uid: str, use_custom: bool = False) -> str:
return prefix + md5.hexdigest()[:8] return prefix + md5.hexdigest()[:8]
def remove_stale_signatures( def remove_stale_signatures(config: Path, versions: Path) -> None:
config: Path, versions: Path, ids: list[int] | None = None
) -> None:
if versions.is_file() is False: if versions.is_file() is False:
logger.warning("Creating new version signatures file") logger.warning("Creating new version signatures file")
versions.touch() versions.touch()
return return
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path) steam_path = Path(path)
if ids is None: ids = get_local_mod_ids(steam_path)
local_ids = get_local_mod_ids(steam_path)
with open(versions, "r") as f: with open(versions, "r") as f:
lines = f.readlines() lines = f.readlines()
for line in lines:
if ids is None: uid = int(line.split(",")[0])
lines = [line for line in lines if int(line.split(",")[0]) in local_ids] if uid not in ids:
else: lines.remove(line)
lines = [line for line in lines if int(line.split(",")[0]) not in ids]
with open(versions, "w") as f: with open(versions, "w") as f:
for line in lines: for line in lines:
f.write(line) f.write(line)
+49 -2
View File
@@ -1,3 +1,4 @@
import json
import struct import struct
from dataclasses import dataclass from dataclasses import dataclass
@@ -11,8 +12,9 @@ from dzgui.const.constants import (
APPNAME_DAYZ, APPNAME_DAYZ,
APPNAME_DAYZ_EXP, APPNAME_DAYZ_EXP,
DAYZ_BINARY, DAYZ_BINARY,
LIBRARYFOLDERS_PATH,
) )
from dzgui.api.steam import get_app_path from dzgui.api.steam import vdf2json
# https://learn.microsoft.com/en-us/windows/win32/debug/pe-format # https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
endian = "<" endian = "<"
@@ -255,6 +257,25 @@ class PeFileError(Exception):
pass pass
class AppNotInstalledError(Exception):
"""App not present in user's libraryfolders"""
pass
class AppMovedError(Exception):
"""VDF points to a nonexistent location on disk"""
pass
class VDFLoadError(Exception):
"""Malformed VDF or JSON conversion"""
pass
def parse_version_number(data: BinaryIO) -> FileVersion: def parse_version_number(data: BinaryIO) -> FileVersion:
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo # https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
@@ -398,11 +419,37 @@ def get_pefile_path(steam_path: Path, appid: int) -> Path:
name = identifier[appid] name = identifier[appid]
binary = DAYZ_BINARY binary = DAYZ_BINARY
app_path = get_app_path(steam_path, appid) app_path = get_app_path(steam_path / LIBRARYFOLDERS_PATH, appid)
pe_path = app_path / f"steamapps/common/{name}/{binary}" pe_path = app_path / f"steamapps/common/{name}/{binary}"
return pe_path return pe_path
def get_app_path(folders_path: Path, appid: int) -> Path:
app_path = None
try:
j = json.loads(vdf2json(folders_path))
except Exception:
raise VDFLoadError("Failed to parse libraryfolders")
for obj in j["libraryfolders"]:
if str(appid) in j["libraryfolders"][obj]["apps"]:
app_path = j["libraryfolders"][obj]["path"]
if Path(app_path).exists():
break
if app_path is None:
raise AppNotInstalledError(
f"Failed to find a libraryfolder for the appid {appid}"
)
if Path(app_path).exists() is False:
raise AppMovedError(
f"The location '{app_path}' pointed to by '{appid}' no longer exists and may have been changed on the disk."
)
return Path(app_path)
def get_pretty_version(steam_path: Path, appid: int) -> str | None: def get_pretty_version(steam_path: Path, appid: int) -> str | None:
try: try:
pe_file_path = get_pefile_path(steam_path, appid) pe_file_path = get_pefile_path(steam_path, appid)
+1 -2
View File
@@ -289,14 +289,13 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] |
def get_details(record: Record) -> Details: def get_details(record: Record) -> Details:
# TODO: consolidate try/except statements
ip = record.ip ip = record.ip
qport = record.qport qport = record.qport
default_str = strings.none_provided default_str = strings.none_provided
try: try:
info = a2s.info((ip, qport)) info = a2s.info((ip, qport))
except (TimeoutError, a2s.exceptions.BrokenMessageError): except TimeoutError:
return Details(None, default_str, False) return Details(None, default_str, False)
try: try:
rules = dayzquery.dayz_rules((ip, qport)) rules = dayzquery.dayz_rules((ip, qport))
-173
View File
@@ -1,173 +0,0 @@
import binascii
import ctypes
import logging
import shutil
import vdf # type: ignore
from dataclasses import dataclass
from importlib import resources
from pathlib import Path
from typing import Any
from dzgui.api.steam import find_user_id_32
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH
from dzgui.util.dirs import copy_dzgui_to_xdg_data
logger = logging.getLogger(APP_NAME)
@dataclass(slots=True, frozen=True)
class ShortcutMetadata:
appname: str
appid: int
exe_path: str
start_dir: str
icon: str
class Shortcuts:
def __init__(self, steam_path: Path) -> None:
self.user_config_path: Path
self.shortcuts_path = self.find_shortcuts_path(steam_path)
self._load_shortcuts(self.shortcuts_path)
@classmethod
def gen_bpid(cls, uid: str) -> int:
u = uid.encode()
encoded = binascii.crc32(u)
return encoded | 0x80000000
@classmethod
def gen_signed_appid(cls, appid: int) -> int:
return ctypes.c_int(appid).value
@classmethod
def gen_exe_uid(cls, appname: str, exe: Path) -> str:
wrapped_exe = f'"{exe}"'
return appname + wrapped_exe
def get_shortcuts(self) -> Any:
return self.shortcuts
def _load_shortcuts(self, path: Path) -> None:
try:
with open(path, "rb") as f:
shortcuts = vdf.binary_load(f)
self.shortcuts = shortcuts
logger.debug("Loaded shortcuts file")
except Exception as e:
logger.critical(e)
raise e
def find_shortcuts_path(self, steam_path: Path) -> Path:
uid = find_user_id_32(steam_path)
self.user_config_path = steam_path.joinpath(f"userdata/{uid}/config")
return self.user_config_path.joinpath("shortcuts.vdf")
def add_shortcut(
self, appname: str, start_dir: Path, exe_path: Path, icon: Path
) -> None:
uid = self.gen_exe_uid(appname, exe_path)
self.bpid = self.gen_bpid(uid)
self.signed_appid = self.gen_signed_appid(self.bpid)
meta = ShortcutMetadata(
appname, self.signed_appid, str(exe_path), str(start_dir), str(icon)
)
entry = self._create_entry(meta)
self._insert_at_last_index(entry)
def add_grid_images(self, images: Path) -> None:
"""
{BPID}_hero.png: hero (splash)
{BPID}p.ping: portrait boxart in library
{BPID}_logo.png: logo overlay on top of hero
{BPID}.png: legacy Big Picture/landscape image
"""
grid_path = self.user_config_path.joinpath("grid")
for img in ("_hero", "p", "_logo"):
filename = str(self.bpid) + img
dest = grid_path.joinpath(filename).with_suffix(".png")
b = images.joinpath(img).with_suffix(".png").read_bytes()
dest.write_bytes(b)
# NOTE: legacy Big Picture header
b = images.joinpath("_hero.png").read_bytes()
dest = grid_path.joinpath(str(self.bpid)).with_suffix(".png")
dest.write_bytes(b)
def _insert_at_last_index(self, entry: dict[str, Any]) -> None:
try:
last = list(self.shortcuts["shortcuts"].keys())[-1]
n = int(last) + 1
except Exception:
n = 0
self.shortcuts["shortcuts"][str(n)] = entry
@classmethod
def _create_entry(cls, meta: ShortcutMetadata) -> dict[str, Any]:
"""
https://developer.valvesoftware.com/wiki/Add_Non-Steam_Game
https://developer.valvesoftware.com/wiki/Steam_Library_Shortcuts
Wiki variously lists keys with title case and lowercase, but keys actually do
not seem to be case-sensitive. Some entries generated by Steam do not match the wiki
Keys are entered into the dictionary in a linear insertion order
# TODO: try to replicate key case sensitivity as it is created by Steam with generic shortcut
appid: signed int CRC
exe: absolute path to the executable, must be wrapped in literal quotes
StartDir: directory the executable starts in, generally the parent
"""
NEW_ENTRY: dict[str, Any] = {}
NEW_ENTRY["appid"] = meta.appid
NEW_ENTRY["appname"] = meta.appname
NEW_ENTRY["exe"] = f'"{meta.exe_path}"'
NEW_ENTRY["StartDir"] = f"{meta.start_dir}"
NEW_ENTRY["icon"] = meta.icon
NEW_ENTRY["ShortcutPath"] = ""
NEW_ENTRY["LaunchOptions"] = ""
NEW_ENTRY["IsHidden"] = 0
NEW_ENTRY["AllowDesktopConfig"] = 1
NEW_ENTRY["AllowOverlay"] = 1
NEW_ENTRY["openvr"] = 0
NEW_ENTRY["Devkit"] = 0
NEW_ENTRY["DevkitGameID"] = ""
NEW_ENTRY["DevkitOverrideAppID"] = 0
NEW_ENTRY["LastPlayTime"] = 0
NEW_ENTRY["FlatpakAppID"] = ""
NEW_ENTRY["sortas"] = ""
NEW_ENTRY["tags"] = {}
return NEW_ENTRY
def save_shortcuts(self) -> None:
try:
backup = self.shortcuts_path.with_suffix(".vdf.bak")
shutil.copy(self.shortcuts_path, backup)
with open(self.shortcuts_path, "wb") as f:
vdf.binary_dump(self.shortcuts, f)
except Exception as e:
logger.critical(e)
def add_steam_shortcut(steam_path: Path, exe_path: Path) -> None:
try:
start_dir = exe_path.parent
traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH)
images = Path(str(traversable))
icon = images.joinpath("icon.png")
shortcuts = Shortcuts(Path(steam_path))
shortcuts.add_shortcut(APP_NAME, start_dir, exe_path, icon)
shortcuts.save_shortcuts()
shortcuts.add_grid_images(images)
copy_dzgui_to_xdg_data(exe_path)
logger.debug("Finished creating Steam shortcut")
except Exception as e:
logger.critical(e)
+66 -206
View File
@@ -1,57 +1,34 @@
import json
import logging import logging
import os import os
import psutil
import requests import requests
import subprocess import subprocess
import vdf # type: ignore from typing import Union
from pathlib import Path
from typing import Any, Union
from warnings import deprecated from warnings import deprecated
from dzgui.api.acf import ACF from shlex import shlex
from pathlib import Path
from dzgui.init.prereqs import has_steam_client from dzgui.init.prereqs import has_steam_client
from dzgui.api.mods import _hash
from dzgui.const.constants import ( from dzgui.const.constants import (
APPID_DAYZ, APPID_DAYZ,
APPID_DAYZ_EXP,
APP_NAME, APP_NAME,
DAYZ_BINARY,
DEBIAN_STEAM_PATH, DEBIAN_STEAM_PATH,
DEFAULT_STEAM_PATH, DEFAULT_STEAM_PATH,
FLATPAK_STEAM_PATH, FLATPAK_STEAM_PATH,
LIBRARYFOLDERS_PATH,
UBUNTU_STEAM_PATH, UBUNTU_STEAM_PATH,
REQUEST_TIMEOUT, REQUEST_TIMEOUT,
VDF_PATH, VDF_PATH,
) )
from dzgui.const.endpoints import ( from dzgui.const.endpoints import SUB_ENDPOINT, STEAM_PUBLISHED_FILES, UNSUB_ENDPOINT
APP_DETAILS,
SUB_ENDPOINT,
STEAM_PUBLISHED_FILES,
UNSUB_ENDPOINT,
)
from dzgui.strings import wizard from dzgui.strings import wizard
from dzgui.util.bash import concat_bash_args from dzgui.util.bash import concat_bash_args
from dzgui.util.strings import unknown
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
class AppNotInstalledError(Exception):
"""App not present in user's libraryfolders"""
pass
class AppMovedError(Exception):
"""VDF points to a nonexistent location on disk"""
pass
class VDFLoadError(Exception):
"""Malformed VDF or JSON conversion"""
pass
def get_steam_paths() -> list[tuple[Path, str]]: def get_steam_paths() -> list[tuple[Path, str]]:
paths = [] paths = []
if has_steam_client(): if has_steam_client():
@@ -81,9 +58,6 @@ def get_steam_paths() -> list[tuple[Path, str]]:
def concat_mods(mods: list[str]) -> str: def concat_mods(mods: list[str]) -> str:
# TODO: circular import workaround
from dzgui.api.mods import _hash
hashes = [] hashes = []
for mod in mods: for mod in mods:
md5sum = _hash(mod) md5sum = _hash(mod)
@@ -207,22 +181,55 @@ def launch_offline(
def find_user_id(path: Path) -> str | None: def find_user_id(path: Path) -> str | None:
resolved_path = path / "config" / "loginusers.vdf" resolved_path = path / "config" / "loginusers.vdf"
try: try:
with open(resolved_path, "r") as f: vdf = vdf2json(resolved_path)
v = vdf.load(f) j = json.loads(vdf)
for user in v["users"]: for user in j["users"]:
if v["users"][user]["MostRecent"] == "1": if j["users"][user]["MostRecent"] == "1":
return str(user) return str(user)
return None return None
except Exception as e: except Exception as e:
logger.warn(e) logger.warn(e)
return None return None
def find_user_id_32(path: Path) -> int: def vdf2json(path: Path) -> str:
uid = find_user_id(path) def _istr(indent: int, string: str) -> str:
if uid is None: return (indent * " ") + string
raise ValueError("Failed to parse a valid Steam32 ID")
return int(uid) & 0xFFFFFFFF jbuf = "{\n"
indent = 1
with open(path, "r") as f:
st = f.read()
lex = shlex(st)
while True:
tok = lex.get_token()
if not tok:
return jbuf + "}\n"
if tok == "}":
indent -= 1
jbuf += _istr(indent, "}")
ntok = lex.get_token()
if ntok is not None:
lex.push_token(ntok)
if ntok and ntok != "}":
jbuf += ","
jbuf += "\n"
else:
ntok = lex.get_token()
if ntok == "{":
jbuf += _istr(indent, tok + ": {\n")
indent += 1
else:
if ntok is not None:
jbuf += _istr(indent, tok + ": " + ntok)
ntok = lex.get_token()
if ntok is not None:
lex.push_token(ntok)
if ntok != "}":
jbuf += ","
jbuf += "\n"
def update_workshop(key: str, mod: int, endpoint: str) -> None: def update_workshop(key: str, mod: int, endpoint: str) -> None:
@@ -248,168 +255,21 @@ def unsubscribe(key: str, mod: int) -> None:
update_workshop(key, mod, UNSUB_ENDPOINT) update_workshop(key, mod, UNSUB_ENDPOINT)
def gen_shortcut() -> None:
# TODO:
"""
during setup, prompt user to select matching user id from loginusers
show user account name, select outer steam id
steam/userdata/<steamid_32>/config/shortcuts.vdf
"""
# STEAMID_MAGIC = 76561197960265728
# STEAMID_64 - STEAMID_MAGIC = STEAMID32
# or get right-most 32 bits
# STEAMID_64 & 0xFFFFFFFF
pass
@deprecated("Use subscribe()") @deprecated("Use subscribe()")
def enqueue_mod(client: str, mod: str, appid: int) -> None: def enqueue_mod(client: str, mod: str, appid: int) -> None:
client_args = concat_bash_args(client) client_args = concat_bash_args(client)
subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod]) subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod])
@deprecated("Cf. https://github.com/ValveSoftware/steam-for-linux/issues/9672")
def get_registry() -> Any | None:
home = os.getenv("HOME")
try:
with open(f"{home}/.steam/registry.vdf") as f:
registry = vdf.load(f)
return registry
except Exception as e:
logger.critical(e)
return None
def _is_dayz_running() -> bool:
registry = get_registry()
if registry is None:
return False
apps = registry["Registry"]["HKCU"]["Software"]["Valve"]["Steam"]["apps"].items()
for app in apps:
k, v = app
if k in (APPID_DAYZ, APPID_DAYZ_EXP):
try:
state = v["Running"]
# NOTE: 0 denotes False
return bool(int(state))
except Exception as e:
logger.critical(e)
return False
return False
def _get_running_app() -> int | None:
registry = get_registry()
if registry is None:
return None
try:
return int(
registry["Registry"]["HKCU"]["Software"]["Valve"]["Steam"]["RunningAppID"]
)
except Exception:
return None
def get_running_app() -> int | None:
PROC_NAME = "steam"
SUBPROC_NAME = "reaper"
FLAG = "AppId"
# NOTE: may cause conflicts if multiple apps are running
try:
for proc in psutil.process_iter():
if proc.name() == PROC_NAME:
subprocs = proc.children()
filtered = (proc for proc in subprocs if proc.name() == SUBPROC_NAME)
try:
proc = next(filtered)
except StopIteration:
return None
args = proc.cmdline()
appid = (row for row in args if FLAG in row)
try:
return int(next(appid).split("=")[1])
except StopIteration:
return None
except Exception as e:
logger.debug(e)
return None
def get_app_allows_downloads(path: Path, appid: int) -> bool:
root_path = get_app_path(path, appid)
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
flag = ACF(acf).get_allows_downloads()
match flag:
# NOTE: adheres to global client setting
case 0:
return get_client_allows_downloads(path)
# NOTE: always allow
case 1:
return True
# NOTE: never allow
case 2:
return False
# NOTE: no other known values at this time, fallback (assume permits)
case _:
return True
def get_config(path: Path) -> Path:
return path.joinpath("config/config.vdf")
def get_client_allows_downloads(path: Path) -> bool:
config = get_config(path)
try:
with open(config) as f:
settings = vdf.load(f)
# NOTE: "1" denotes "allow"
allow = bool(
int(
settings["InstallConfigStore"]["Software"]["Valve"]["Steam"][
"AllowDownloadsDuringGameplay"
]
)
)
return allow
except Exception as e:
logger.critical(e)
return True
def is_dayz_running() -> bool:
"""Subprocesses spawned from Steam will not show up in regular process tree"""
procs = []
substring = DAYZ_BINARY
for proc in psutil.process_iter():
try:
procs.append(proc.cmdline())
except Exception as e:
logger.warning(e)
continue
return any(substring in item for sublist in procs for item in sublist)
def get_app_path(folders_path: Path, appid: int) -> Path:
app_path = None
try:
with open(folders_path / LIBRARYFOLDERS_PATH) as f:
folders = vdf.load(f)
except Exception:
raise VDFLoadError("Failed to parse libraryfolders")
for obj in folders["libraryfolders"]:
if str(appid) in folders["libraryfolders"][obj]["apps"]:
app_path = folders["libraryfolders"][obj]["path"]
if Path(app_path).exists():
break
if app_path is None:
raise AppNotInstalledError(
f"Failed to find a libraryfolder for the appid {appid}"
)
if Path(app_path).exists() is False:
raise AppMovedError(
f"The location '{app_path}' pointed to by '{appid}' no longer exists and may have been changed on the disk."
)
return Path(app_path)
def get_app_name(appid: int) -> str:
payload = {"appids": [appid]}
res = requests.get(APP_DETAILS, params=payload)
if res.status_code != 200:
return unknown
try:
return str(res.json()[str(appid)]["data"]["name"])
except Exception as e:
logger.debug(e)
return unknown
-156
View File
@@ -1,156 +0,0 @@
import logging
import os
import shutil
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import Preferences
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.migrate import (
has_new_config,
copy_state_files,
migrate_cols_file,
)
from dzgui.init.flock import lock_acquire
from dzgui.init.prereqs import has_steam_client
from dzgui.strings import boot
# from dzgui.util.map_count import get_map_count
from dzgui.util.deck import is_steam_deck, is_game_mode
from dzgui.util.dirs import make_parents
from dzgui.util.localize import set_locale
from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS
from dzgui.util.strings import init
from dzgui.views.base import App
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
logger = logging.getLogger(APP_NAME)
# TODO: profile load time
def setup_logger(log_path: "Path") -> None:
# TODO: put in consts?
_format = (
"%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s"
)
fh = logging.FileHandler(log_path)
formatter = logging.Formatter(_format)
fh.setFormatter(formatter)
fh.setLevel(logging.DEBUG)
_filter = RedactionFilter(patterns=REDACTION_PATTERNS)
fh.addFilter(_filter)
logger.setLevel(logging.DEBUG)
logger.addHandler(fh)
def copy_bare_configs(config: "Path", resolution: "Path") -> tuple[bool, bool]:
# NOTE: temporary workaround for #375
conf = "config.json"
state = [
"dzg.history",
"dzg.versions",
"dzg.res.json",
"dzg.filters.json",
"dzg.columns.json",
"dzg.notes.json",
"ips.csv",
".month",
]
config_changed = False
state_changed = False
if config.parent.exists() is False:
new_file = config
old_file = config.parent.parent / conf
if old_file.is_file():
try:
make_parents(new_file)
shutil.copy(old_file, new_file)
config_changed = True
except Exception as e:
logger.critical(e)
if resolution.parent.exists() is False:
state_path = resolution.parent
for state_file in state:
old_file = state_path.parent / state_file
if old_file.is_file():
try:
new_file = state_path / state_file
make_parents(new_file)
shutil.copy(old_file, new_file)
state_changed = True
except Exception as e:
logger.critical(e)
return (config_changed, state_changed)
def load_gui(version: str, is_debug: bool) -> None:
lock = lock_acquire() # noqa
set_locale()
# NOTE: consider aborting this check if steam deck
xdg_paths = get_xdg_paths()
XDG = parse_filepaths(xdg_paths)
copy_bare_configs(XDG.config, XDG.resolution)
if XDG.resolution.parent.is_dir() is False:
make_parents(XDG.resolution)
if XDG.debug.is_file() is False:
make_parents(XDG.debug)
_is_steam_deck = is_steam_deck()
_is_game_mode = is_game_mode() if _is_steam_deck else False
if _is_game_mode:
# NOTE: this may no longer be necessary on newer versions of SteamOS
del os.environ["GTK_IM_MODULE"]
if has_new_config(XDG.config) is False:
migrate_cols_file(XDG.columns)
copy_state_files(xdg_paths["XDG_STATE_HOME"])
# TODO: add logging inside wizard
wizard = SetupWizard(_is_steam_deck, XDG)
if not wizard.is_setup_complete():
return
setup_logger(XDG.debug)
with open(XDG.debug, "w") as f:
f.truncate(0)
if _is_steam_deck is False:
# TODO: sudo escalation dialog
# count = get_map_count()
# TODO: move into module
if has_steam_client() is False:
EarlyAlertDialog(init.requires_steam)
bootwin = BootWindow(XDG, version)
local_coords, latest_release = bootwin.get_results()
use_miles = lookup(XDG.config, Preferences.DIST)
prefs = UserPrefs(
is_steam_deck=_is_steam_deck,
is_game_mode=_is_game_mode,
is_debug=is_debug,
coords=local_coords,
version=version,
paths=XDG,
latest_release=latest_release,
use_miles=use_miles,
)
print(boot.all_ok)
App(prefs)
-6
View File
@@ -3,8 +3,6 @@ import shlex
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from dzgui.const.boilerplate import config_boilerplate
""" """
Convert legacy dztuirc to config.json Convert legacy dztuirc to config.json
@@ -91,8 +89,4 @@ def rc2json(file: Path) -> str:
keys["ip_list"] = ips keys["ip_list"] = ips
keys["use_miles"] = False keys["use_miles"] = False
keys["start_tab"] = 0 keys["start_tab"] = 0
# NOTE: workaround for DZGUI 6 logic (see #375)
for key in config_boilerplate.keys():
if key not in keys:
keys[key] = config_boilerplate[key]
return json.dumps(keys, indent=2) return json.dumps(keys, indent=2)
+5 -30
View File
@@ -1,43 +1,18 @@
import stat
import textwrap import textwrap
from pathlib import Path from pathlib import Path
from dzgui.const.constants import APP_NAME
from dzgui.util.dirs import copy_dzgui_to_xdg_data, find_icon_resource, make_parents
def get_share_path(exe_path: Path) -> Path:
return exe_path.parent.parent
def write_desktop_file(exe_path: Path) -> Path:
icon = find_icon_resource()
def write_desktop_file(share_path: Path) -> None:
template = f"""\ template = f"""\
[Desktop Entry] [Desktop Entry]
Version=1.0 Version=1.0
Type=Application Type=Application
Terminal=false Terminal=false
Exec={exe_path} Exec={share_path}/launch.sh
Name=DZGUI Name=DZGUI
Comment=dzgui Comment=dzgui
Icon={icon} Icon={share_path}/dzgui
Categories=Game""" Categories=Game"""
copy_dzgui_to_xdg_data(exe_path) file = share_path / "dzgui.desktop"
file.write_text(textwrap.dedent(template))
share_path = get_share_path(exe_path)
desktop_file = share_path.joinpath("applications/dzgui.desktop")
make_parents(desktop_file)
desktop_file.write_text(textwrap.dedent(template))
desktop_file.chmod(desktop_file.stat().st_mode | stat.S_IEXEC)
return desktop_file
def write_desktop_shortcut(desktop_file: Path) -> None:
# NOTE: necessarily depends on the above (UI blocks creation without XDG entry first)
link = Path.home().joinpath(f"Desktop/{APP_NAME}.desktop")
if link.exists():
link.unlink()
link.symlink_to(desktop_file)
+6 -12
View File
@@ -18,7 +18,6 @@ class Xdg:
debug: Path debug: Path
ips: Path ips: Path
filters: Path filters: Path
shortcut: Path
def is_writeable(path_str: str) -> bool: def is_writeable(path_str: str) -> bool:
@@ -30,9 +29,9 @@ def is_writeable(path_str: str) -> bool:
if not path.exists(): if not path.exists():
try: try:
path.mkdir(parents=True) path.mkdir(parents=True)
path.unlink()
except OSError: except OSError:
return False return False
path.unlink()
return True return True
try: try:
@@ -56,19 +55,17 @@ def get_xdg_paths() -> dict:
resolved_paths = {} resolved_paths = {}
for path in xdg_paths: for path in xdg_paths:
real_path = os.environ.get(path) rp = os.environ.get(path)
if real_path is not None and is_writeable(real_path): if rp is not None and is_writeable(rp):
new_path = Path(real_path) resolved_paths[path] = Path(rp)
else: else:
new_path = xdg_paths[path] resolved_paths[path] = xdg_paths[path] / APP_NAME_LOWER
resolved_paths[path] = new_path / APP_NAME_LOWER
return resolved_paths return resolved_paths
def parse_filepaths(xdg: dict) -> Xdg: def parse_filepaths(xdg: dict) -> Xdg:
config = xdg["XDG_CONFIG_HOME"] config = xdg["XDG_CONFIG_HOME"]
state = xdg["XDG_STATE_HOME"] state = xdg["XDG_STATE_HOME"]
share = xdg["XDG_DATA_HOME"]
config = config / "config.json" config = config / "config.json"
@@ -83,8 +80,6 @@ def parse_filepaths(xdg: dict) -> Xdg:
ips = state / "ips.csv" ips = state / "ips.csv"
filters = state / "dzg.filters.json" filters = state / "dzg.filters.json"
shortcut = share / APP_NAME_LOWER
return Xdg( return Xdg(
config, config,
columns, columns,
@@ -95,6 +90,5 @@ def parse_filepaths(xdg: dict) -> Xdg:
system, system,
debug, debug,
ips, ips,
filters, filters
shortcut,
) )
+1 -3
View File
@@ -72,9 +72,7 @@ LEGACY_IPS_PATH = ".local/share/dzgui/helpers/ips.csv"
DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG" DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG"
SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG" SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG"
CHANGELOG_PATH = "data/CHANGELOG.md" CHANGELOG_PATH = "data/CHANGELOG.md"
IMAGES_PATH = "data/images" HERO_PATH = "data/images/hero.png"
HERO_PATH = "data/images/_hero.png"
CSS_PATH = "data/app.css" CSS_PATH = "data/app.css"
VDF_PATH = "steamapps/libraryfolders.vdf" VDF_PATH = "steamapps/libraryfolders.vdf"
DEFAULT_STEAM_PATH = ".local/share/Steam" DEFAULT_STEAM_PATH = ".local/share/Steam"
-1
View File
@@ -5,7 +5,6 @@ STEAM_PUBLISHED_FILES = (
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1" STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1"
SUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Subscribe/v1" SUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Subscribe/v1"
UNSUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Unsubscribe/v1" UNSUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Unsubscribe/v1"
APP_DETAILS = "https://store.steampowered.com/api/appdetails?"
BM_SERVERS = "https://api.battlemetrics.com/servers?" BM_SERVERS = "https://api.battlemetrics.com/servers?"
GITHUB = "https://github.com/aclist" GITHUB = "https://github.com/aclist"
+2 -2
View File
@@ -81,8 +81,8 @@ class Emitter(GObject.GObject):
def distcalc_started(self) -> None: def distcalc_started(self) -> None:
pass pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool, int)) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
def mod_page_toggled(self, state: bool, mods: int) -> None: def mod_page_toggled(self, state: bool) -> None:
pass pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,)) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
+1
View File
@@ -205,6 +205,7 @@ class Controller(GObject.GObject):
self.mediator.grid.notebook.settings.populate_settings() self.mediator.grid.notebook.settings.populate_settings()
except Exception as e: except Exception as e:
msg = format_exception(e) msg = format_exception(e)
print(msg)
logger.critical(msg) logger.critical(msg)
return return
case ButtonType.MODS: case ButtonType.MODS:
+28 -31
View File
@@ -1,7 +1,6 @@
# Changelog # Changelog
## [7.0.0] Unreleased ## Added
### Added
- Setup wizard - Setup wizard
- Changelog text wrapping and formatting - Changelog text wrapping and formatting
- Changelog ships with source - Changelog ships with source
@@ -42,10 +41,8 @@
- Choose to jump into splash screen instead of server - Choose to jump into splash screen instead of server
- Collapsible connection panel - Collapsible connection panel
- Play offline (load mods directly) - Play offline (load mods directly)
- Generate Steam shortcuts and cover art
- Warn user if background downloads are disabled
### Changed ## Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
- Reduce padding on keys button - Reduce padding on keys button
- Boldface breadcrumbs - Boldface breadcrumbs
@@ -62,58 +59,58 @@
- Enable LAN page Empty/Full filters on first run of app - Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client - Propagate subscribed mods to Steam client
### Dropped ## Dropped
- Debug mode - Debug mode
- Branch switching - Branch switching
- Manual mod install mode (describe rationale) - Manual mod install mode (describe rationale)
- Force update mods - Force update mods
### Fixed ## Fixed
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
- Center server title text on server dialogs - Center server title text on server dialogs
- Rare segfaults when changing maps (threading) - Rare segfaults when changing maps (threading)
- Moved dialogs out of threads - Moved dialogs out of threads
### Unreleased ## Unreleased
- Load offline mods - Load offline mods
- Local documentation - Local documentation
- Raw debug command in context menu - Raw debug command in context menu
### Developer-facing ## Developer-facing
- Add pyproject.toml file - Add pyproject.toml file
- Show deprecation warnings - Show deprecation warnings
- Options -> Dev page - Options -> Dev page
## [6.0.5] 2026-05-17 ## [6.0.5] 2026-05-17
### Changed ## Changed
- Update symlink hash logic to align with DZGUI 7 beta - Update symlink hash logic to align with DZGUI 7 beta
- Update A2S helper to latest commit - Update A2S helper to latest commit
## [6.0.4] 2026-04-13 ## [6.0.4] 2026-04-13
### Fixed ## Fixed
- Update A2S module checksums to support servers with missing description field - Update A2S module checksums to support servers with missing description field
## [6.0.3] 2026-04-12 ## [6.0.3] 2026-04-12
### Fixed ## Fixed
- Use GLibUnix namespace where available when setting up SIGINT callback - Use GLibUnix namespace where available when setting up SIGINT callback
## [6.0.2] 2026-01-26 ## [6.0.2] 2026-01-26
### Fixed ## Fixed
- Explicitly use Python 3.13 when calling subprocesses - Explicitly use Python 3.13 when calling subprocesses
- Raise error correctly from module - Raise error correctly from module
- Do not spawn dialogs from inside of thread when fetching prereqs - Do not spawn dialogs from inside of thread when fetching prereqs
### Changed ## Changed
- Optimize time complexity of startup checks - Optimize time complexity of startup checks
- Optimize coordinate calculation by using local records first - Optimize coordinate calculation by using local records first
- Rename some internal functions - Rename some internal functions
- Wrap entire startup process in dialog - Wrap entire startup process in dialog
## [6.0.1] 2026-01-16 ## [6.0.1] 2026-01-16
### Fixed ## Fixed
- Explicitly use Python 3.13 when calling subprocesses - Explicitly use Python 3.13 when calling subprocesses
## [6.0.0] 2026-01-14 ## [6.0.0] 2026-01-14
### Added ## Added
- Support DayZ Experimental - Support DayZ Experimental
- Ping display on server tables - Ping display on server tables
- Save descriptive text notes on a per-server basis - Save descriptive text notes on a per-server basis
@@ -139,7 +136,7 @@
- Commandline usage help text (GaryBlackbourne) - Commandline usage help text (GaryBlackbourne)
- More descriptive help dialogs when initial dependencies are missing - More descriptive help dialogs when initial dependencies are missing
### Fixed ## Fixed
- Script failing to start when remote endpoints are unavailable (GaryBlackbourne) - Script failing to start when remote endpoints are unavailable (GaryBlackbourne)
- Key stickiness when quickly navigating through entries in tables - Key stickiness when quickly navigating through entries in tables
- Servers returning malformed A2S_INFO blocking server browser from loading - Servers returning malformed A2S_INFO blocking server browser from loading
@@ -178,7 +175,7 @@
- Path discovery during first-time setup when parsing filepaths with whitespaces - Path discovery during first-time setup when parsing filepaths with whitespaces
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it - First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
### Changed ## Changed
- Require Python 3.13 - Require Python 3.13
- Reduced global API cooldown from 60s to 30s - Reduced global API cooldown from 60s to 30s
- Clarify dialog messages when DayZ path could not be found - Clarify dialog messages when DayZ path could not be found
@@ -190,50 +187,50 @@
- Rewrote distance calculation module (GaryBlackbourne) - Rewrote distance calculation module (GaryBlackbourne)
- Changed preferred client setting from radio toggle to combobox - Changed preferred client setting from radio toggle to combobox
### Dropped ## Dropped
- Ping readout in statusbar - Ping readout in statusbar
- Extraneous information from right statusbar - Extraneous information from right statusbar
## [5.8.3] 2026-01-04 ## [5.8.3] 2026-01-04
### Fixed ## Fixed
- Normalize checksum numbers and dates - Normalize checksum numbers and dates
## [5.8.2] 2026-01-04 ## [5.8.2] 2026-01-04
### Fixed ## Fixed
- Fix regression in symlink routine introduced by previous hotfix - Fix regression in symlink routine introduced by previous hotfix
## [5.8.1] 2025-12-27 ## [5.8.1] 2025-12-27
### Fixed ## Fixed
- Path discovery during first-time setup when parsing filepaths with whitespaces - Path discovery during first-time setup when parsing filepaths with whitespaces
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it - First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
## [5.8.0] 2025-07-06 ## [5.8.0] 2025-07-06
### Added ## Added
- Filter servers by official/unofficial status - Filter servers by official/unofficial status
- Automatically fetch geolocation records - Automatically fetch geolocation records
### Changed ## Changed
- Updated internal versioning of helper files - Updated internal versioning of helper files
### Fixed ## Fixed
- Corrected erroneous 2024 date in prior changelog entries - Corrected erroneous 2024 date in prior changelog entries
## [5.7.0] 2025-04-17 ## [5.7.0] 2025-04-17
### Added ## Added
- Save application dimensions when quitting and restore on subsequent boot - Save application dimensions when quitting and restore on subsequent boot
### Fixed ## Fixed
- Issues with the window exceeding the bounds of the screen when displaying some table contexts - Issues with the window exceeding the bounds of the screen when displaying some table contexts
### Changed ## Changed
- More direct server ping query method that should return marginally more accurate times - More direct server ping query method that should return marginally more accurate times
## [5.6.7] 2025-04-04 ## [5.6.7] 2025-04-04
### Dropped ## Dropped
- Removed extraneous pre-boot API checks that could cause error messages to be printed if the user had not set up an API key yet - Removed extraneous pre-boot API checks that could cause error messages to be printed if the user had not set up an API key yet
## [5.6.6] 2025-03-16 ## [5.6.6] 2025-03-16
### Changed ## Changed
- Update IP database records for 2025-03 - Update IP database records for 2025-03
## [5.6.5] 2025-03-04 ## [5.6.5] 2025-03-04
### Fixed ## Fixed
- Livonia server results being dropped from batch queries - Livonia server results being dropped from batch queries
## [5.6.4] 2025-02-10 ## [5.6.4] 2025-02-10
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3 -3
View File
@@ -2,11 +2,11 @@ import logging
from pathlib import Path from pathlib import Path
from dzgui.api.steam import get_app_path
from dzgui.config.query import lookup from dzgui.config.query import lookup
from dzgui.const.constants import APPID_DAYZ, APP_NAME from dzgui.const.constants import APPID_DAYZ, APP_NAME, LIBRARYFOLDERS_PATH
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
import dzgui.api.pefile as PeFile
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
@@ -14,7 +14,7 @@ logger = logging.getLogger(APP_NAME)
def is_dayz_installed(config: Path) -> None: def is_dayz_installed(config: Path) -> None:
try: try:
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
get_app_path(Path(path), APPID_DAYZ) PeFile.get_app_path(Path(path) / LIBRARYFOLDERS_PATH, APPID_DAYZ)
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
raise e raise e
+5 -4
View File
@@ -1,12 +1,11 @@
import platform import platform
import sys import sys
from ctypes.util import find_library from pathlib import Path
from warnings import deprecated from warnings import deprecated
LIB = "libgirepository-2.0" LIB = "libgirepository-2.0"
@deprecated("currently unused") @deprecated("currently unused")
def is_debian() -> bool: def is_debian() -> bool:
""" """
@@ -40,10 +39,12 @@ def is_debian() -> bool:
def has_libgi() -> bool: def has_libgi() -> bool:
return bool(find_library("girepository-2.0")) parent_dir = Path("/usr/lib64")
exists = any(LIB in subdir.name for subdir in parent_dir.iterdir())
return exists
def test_libgi_missing() -> None: def test_missing_lib() -> bool:
msg = f"System is missing the required library '{LIB}'. Install it via your system package manager." msg = f"System is missing the required library '{LIB}'. Install it via your system package manager."
if has_libgi() is False: if has_libgi() is False:
print(msg) print(msg)
+37 -3
View File
@@ -1,10 +1,11 @@
import psutil import psutil
import subprocess import subprocess
import shutil import shutil
import logging
from warnings import deprecated
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, DAYZ_BINARY,
STEAM_CMD, STEAM_CMD,
FLATPAK_APPID, FLATPAK_APPID,
FLATPAK_CMD, FLATPAK_CMD,
@@ -12,7 +13,18 @@ from dzgui.const.constants import (
FLATPAK_SANDBOX, FLATPAK_SANDBOX,
) )
logger = logging.getLogger(APP_NAME)
# TODO: move to util.proc
def is_dayz_running() -> bool:
"""Subprocesses spawned from Steam will not show up in regular process tree"""
procs = []
substring = DAYZ_BINARY
for proc in psutil.process_iter():
try:
procs.append(proc.cmdline())
except Exception:
continue
return any(substring in item for sublist in procs for item in sublist)
def is_steam_running(cmd: str) -> bool: def is_steam_running(cmd: str) -> bool:
@@ -49,3 +61,25 @@ def has_cmd(cmd: str) -> bool:
if shutil.which(cmd) is not None: if shutil.which(cmd) is not None:
return True return True
return False return False
@deprecated("dropped in favor of Gtk native methods")
def foreground(cmd: str, pid: int) -> None:
if cmd == "wmctrl":
proc = subprocess.run(["wmctrl", "-ilp"], capture_output=True, text=True)
lines = proc.stdout.splitlines()
for line in lines:
els = line.split(" ")
if str(pid) in els:
wid = els[0]
break
subprocess.run(["wmctrl", "-ia", wid])
elif cmd == "xdotool":
args = [cmd, "search", "--pid", str(pid)]
proc = subprocess.run([*args], capture_output=True, text=True)
lines = proc.stdout.splitlines()
## NOTE: some forked subprocesses may fail, so skip over them
for line in lines:
subprocess.run(
["xdotool", "windowactivate", line], stderr=subprocess.DEVNULL
)
+103 -6
View File
@@ -1,11 +1,40 @@
import argparse import argparse
import logging
import os
import sys import sys
import warnings import warnings
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME from dzgui.const.constants import APP_NAME
from dzgui.init.libgi import test_libgi_missing from dzgui.const.enum import Preferences
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.flock import lock_acquire
from dzgui.init.migrate import (
has_new_config,
copy_state_files,
migrate_cols_file,
)
from dzgui.init.prefix import get_version from dzgui.init.prefix import get_version
from dzgui.util.strings import flags from dzgui.init.prereqs import has_steam_client
from dzgui.strings import boot
# 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.strings import init, flags
from dzgui.views.base import App
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
logger = logging.getLogger(APP_NAME)
parser = argparse.ArgumentParser(description=flags.description) parser = argparse.ArgumentParser(description=flags.description)
parser.add_argument("-v", "--version", action="store_true", help=flags.version) parser.add_argument("-v", "--version", action="store_true", help=flags.version)
@@ -14,6 +43,11 @@ parser.add_argument("-d", "--debug", action="store_true", help=flags.debug)
args = parser.parse_args() args = parser.parse_args()
# TODO: profile load time
def make_parents(path: "Path") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def uninstall() -> None: def uninstall() -> None:
# TODO: uninstall data files (-u) # TODO: uninstall data files (-u)
# -u removes state, log, freedesktop # -u removes state, log, freedesktop
@@ -23,7 +57,21 @@ def uninstall() -> None:
pass pass
def setup_logger(log_path: "Path") -> None:
_format = (
"%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s"
)
fh = logging.FileHandler(log_path)
formatter = logging.Formatter(_format)
fh.setFormatter(formatter)
fh.setLevel(logging.DEBUG)
logger.setLevel(logging.DEBUG)
logger.addHandler(fh)
def main() -> None: def main() -> None:
lock = lock_acquire() # noqa
if args.version is True: if args.version is True:
print(get_version()) print(get_version())
sys.exit(0) sys.exit(0)
@@ -34,11 +82,60 @@ def main() -> None:
warnings.filterwarnings("default", category=DeprecationWarning) warnings.filterwarnings("default", category=DeprecationWarning)
version = get_version() version = get_version()
set_locale()
print(f"{APP_NAME} {version}") print(f"{APP_NAME} {version}")
test_libgi_missing()
# NOTE: only import ligbirepository modules after the above check # NOTE: consider aborting this check if steam deck
from dzgui.app_init import load_gui xdg_paths = get_xdg_paths()
XDG = parse_filepaths(xdg_paths)
load_gui(version, args.debug) if XDG.resolution.parent.is_dir() is False:
make_parents(XDG.resolution)
if XDG.debug.is_file() is False:
make_parents(XDG.debug)
_is_steam_deck = is_steam_deck()
_is_game_mode = is_game_mode() if _is_steam_deck else False
if _is_game_mode:
# NOTE: this may no longer be necessary on newer versions of SteamOS
del os.environ["GTK_IM_MODULE"]
if has_new_config(XDG.config) is False:
migrate_cols_file(XDG.columns)
copy_state_files(xdg_paths["XDG_STATE_HOME"])
# TODO: add logging inside wizard
SetupWizard(_is_steam_deck, XDG.config)
# NOTE: implies that setup wizard failed or was closed
if has_new_config(XDG.config) is False:
return
setup_logger(XDG.debug)
with open(XDG.debug, "w") as f:
f.truncate(0)
if _is_steam_deck is False:
# TODO: sudo escalation dialog
# count = get_map_count()
# TODO: move into module
if has_steam_client() is False:
EarlyAlertDialog(init.requires_steam)
bootwin = BootWindow(XDG, version)
local_coords, latest_release = bootwin.get_results()
use_miles = lookup(XDG.config, Preferences.DIST)
prefs = UserPrefs(
is_steam_deck=_is_steam_deck,
is_game_mode=_is_game_mode,
is_debug=args.debug,
coords=local_coords,
version=version,
paths=XDG,
latest_release=latest_release,
use_miles=use_miles,
)
print(boot.all_ok)
App(prefs)
+2 -5
View File
@@ -55,11 +55,8 @@ class ConfigManager:
self.update_config(Preferences.IP_LIST, ips) self.update_config(Preferences.IP_LIST, ips)
def update_history_file(self, fqip: str) -> None: def update_history_file(self, fqip: str) -> None:
try: with open(self.prefs.paths.history, "r") as f:
with open(self.prefs.paths.history, "r") as f: ips = [line.rstrip() for line in f.readlines()]
ips = [line.rstrip() for line in f.readlines()]
except FileNotFoundError:
ips = []
seen = set() seen = set()
ips.append(fqip) ips.append(fqip)
+16 -42
View File
@@ -12,11 +12,8 @@ import dzgui.api.servers as Servers
from dzgui.api.steam import ( from dzgui.api.steam import (
connect, connect,
get_app_allows_downloads,
get_app_name,
get_needs_update,
get_remote_signatures, get_remote_signatures,
get_running_app, get_needs_update,
load_to_menu, load_to_menu,
subscribe, subscribe,
) )
@@ -35,15 +32,10 @@ from dzgui.const.constants import (
APPNAME_DAYZ, APPNAME_DAYZ,
APPNAME_DAYZ_EXP_HUMAN, APPNAME_DAYZ_EXP_HUMAN,
) )
from dzgui.api.steam import is_dayz_running
from dzgui.const.enum import NotebookPage, Preferences from dzgui.const.enum import NotebookPage, Preferences
from dzgui.init.proc import is_steam_running from dzgui.init.proc import is_dayz_running, is_steam_running
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings.dialogs import ( from dzgui.strings.dialogs import waiting_for_launch, waiting_for_mods, waiting_for_directories
waiting_for_launch,
waiting_for_mods,
waiting_for_directories,
)
from dzgui.strings.server_mods import checkmark, resync from dzgui.strings.server_mods import checkmark, resync
from dzgui.util.format import format_mib from dzgui.util.format import format_mib
from dzgui.util.strings import dialog, server_timeout from dzgui.util.strings import dialog, server_timeout
@@ -85,7 +77,6 @@ class Prerequisites:
mods: list[list[str]] mods: list[list[str]]
game_mode: bool game_mode: bool
is_last_server: bool is_last_server: bool
allows_downloads: tuple[bool, str]
class ConnectionManager: class ConnectionManager:
@@ -153,14 +144,21 @@ class ConnectionManager:
remote_mods: list[list[str]] = [] remote_mods: list[list[str]] = []
if res.is_modded(): if res.is_modded():
try: try:
remote_mods, missing_mods = self._query_modlist(record) remote_mods = self._query_modlist(record)
self.missing_mods = missing_mods
self.remote_mod_ids = [mod[1] for mod in remote_mods] self.remote_mod_ids = [mod[1] for mod in remote_mods]
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
self.thread_man.set_cleanup_func(failure_func, destroy_first=True) self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
return return
hashes = get_remote_signatures(self.remote_mod_ids)
version_file = prefs.paths.version
self.missing_mods = get_needs_update(version_file, hashes)
for mod in remote_mods:
if any(mod[1] in tuple for tuple in self.missing_mods):
mod[2] = resync
if local_version is not None: if local_version is not None:
pefile_path = PeFile.get_pefile_path(steam_path, info.game_id) pefile_path = PeFile.get_pefile_path(steam_path, info.game_id)
total, used, free = shutil.disk_usage(pefile_path) total, used, free = shutil.disk_usage(pefile_path)
@@ -171,15 +169,6 @@ class ConnectionManager:
dayz_running = is_dayz_running() dayz_running = is_dayz_running()
running_app = get_running_app()
if running_app is not None:
allows_dl = get_app_allows_downloads(steam_path, running_app)
running_app_name = get_app_name(running_app)
allows_downloads = (allows_dl, running_app_name)
else:
allows_downloads = (True, "")
client_name = self.controller.get_steam_client_name() client_name = self.controller.get_steam_client_name()
client = self.controller.query_config(Preferences.CLIENT) client = self.controller.query_config(Preferences.CLIENT)
running = is_steam_running(client) running = is_steam_running(client)
@@ -205,7 +194,6 @@ class ConnectionManager:
mods=remote_mods, mods=remote_mods,
game_mode=game_mode, game_mode=game_mode,
is_last_server=is_last, is_last_server=is_last,
allows_downloads=allows_downloads,
) )
func = StoredFunc(self.controller.open_connection_assistant, prereqs) func = StoredFunc(self.controller.open_connection_assistant, prereqs)
@@ -234,13 +222,10 @@ class ConnectionManager:
StoredFunc(self._present_details_dialog, details), destroy_first=True StoredFunc(self._present_details_dialog, details), destroy_first=True
) )
def _query_modlist( def _query_modlist(self, record: Servers.Record) -> list[list[str]]:
self, record: Servers.Record
) -> tuple[list[list[str]], list[tuple[str, str, int, int]]]:
mods = Servers.get_rules(record) mods = Servers.get_rules(record)
steam_path = self.controller.query_config(Preferences.DEFAULT) steam_path = self.controller.query_config(Preferences.DEFAULT)
local = get_local_mod_ids(Path(steam_path)) local = get_local_mod_ids(steam_path)
alpha_mods: list[list[str]] = [ alpha_mods: list[list[str]] = [
[ [
mod.name, mod.name,
@@ -250,23 +235,12 @@ class ConnectionManager:
for mod in mods for mod in mods
] ]
alpha_mods.sort(key=lambda x: x[0]) alpha_mods.sort(key=lambda x: x[0])
return alpha_mods
prefs = self.controller.get_prefs()
version_file = prefs.paths.version
remote_mod_ids = [mod[1] for mod in alpha_mods]
hashes = get_remote_signatures(remote_mod_ids)
missing_mods = get_needs_update(version_file, hashes)
for mod in alpha_mods:
if any(mod[1] in tuple for tuple in missing_mods):
mod[2] = resync
return alpha_mods, missing_mods
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def query_modlist_and_present(self, record: Servers.Record) -> None: def query_modlist_and_present(self, record: Servers.Record) -> None:
try: try:
mods, missing_mods = self._query_modlist(record) mods = self._query_modlist(record)
except Exception: except Exception:
self.thread_man.set_cleanup_func( self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True StoredFunc(self._server_timeout), destroy_first=True
-1
View File
@@ -80,7 +80,6 @@ class FilterManager:
if maps is None: if maps is None:
return return
if len(maps) < 1: if len(maps) < 1:
self.reinit_map_store()
return return
self.reinit_map_store() self.reinit_map_store()
+8 -17
View File
@@ -23,7 +23,6 @@ from dzgui.model.model_factory import FastInsertListStore, ModelFactory
from dzgui.strings import dialogs from dzgui.strings import dialogs
from dzgui.util.format import format_mods from dzgui.util.format import format_mods
from dzgui.util.strings import server_timeout from dzgui.util.strings import server_timeout
from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.generic import ExceptionDialog
@@ -60,20 +59,16 @@ class ModManager:
@call_on_thread(dialogs.fetching_mods) @call_on_thread(dialogs.fetching_mods)
def load_mods(self) -> None: def load_mods(self) -> None:
mods = get_delimited_mods(Path(self.path)) mods = get_delimited_mods(self.path)
if len(mods) < 1: if len(mods) < 1:
msg = self.format_mod_statusbar() msg = self.format_mod_statusbar()
func = StoredFunc(lambda: self.emitter.emit("mods_updated", msg, 0)) func = StoredFunc(lambda: self.emitter.emit("mods_updated", msg, 0))
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
return return
prefs = self.controller.get_prefs()
rebuild_symlinks(prefs.paths.config)
self.store = ModelFactory().make_mod_store() self.store = ModelFactory().make_mod_store()
self.store.extend(mods) self.store.extend(mods)
func = StoredFunc(self._on_mods_loaded) func = StoredFunc(self._on_mods_loaded)
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
def set_store(self, store: "FastInsertListStore") -> None: def set_store(self, store: "FastInsertListStore") -> None:
@@ -98,7 +93,6 @@ class ModManager:
continue continue
mod, _iter = res mod, _iter = res
mods.append((mod, _iter)) mods.append((mod, _iter))
self.thread_man.set_job_count(len(mods))
self.unsub_all_mods(mods) self.unsub_all_mods(mods)
def get_mod_from_tree_path( def get_mod_from_tree_path(
@@ -115,10 +109,10 @@ class ModManager:
def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None: def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
for mod, _iter in mods: for mod, _iter in mods:
self.unsub_atomic_mod(mod) self.unsub_atomic_mod(mod)
self.thread_man.increment_dialog()
time.sleep(API_RATE_LIMIT) time.sleep(API_RATE_LIMIT)
func = StoredFunc(self._on_mods_unsubbed, mods) iters = [_iter for mod, _iter in mods]
func = StoredFunc(self._on_mods_unsubbed, iters)
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
def unsub_atomic_mod(self, mod: str) -> None: def unsub_atomic_mod(self, mod: str) -> None:
@@ -141,25 +135,22 @@ class ModManager:
app_path_exp = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP) app_path_exp = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP)
symlink = app_path_exp / md5 symlink = app_path_exp / md5
symlink.unlink() symlink.unlink()
except Exception: except PeFile.AppNotInstalledError:
pass pass
time.sleep(API_RATE_LIMIT) time.sleep(API_RATE_LIMIT)
def _on_mods_unsubbed(self, mod_iter: list[tuple[str, Gtk.TreeIter]]) -> None: def _on_mods_unsubbed(self, iters: list[Gtk.TreeIter]) -> None:
mods = [int(mod) for mod, _iter in mod_iter]
iters = [_iter for mod, _iter in mod_iter]
if self.store is None: if self.store is None:
return return
for _iter in iters: for _iter in iters:
self.store.remove(_iter) self.store.remove(_iter)
# TODO: process config path in called function remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version)
remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version, mods)
model = self.treeview.get_model() model = self.treeview.get_model()
if model is None: if model is None:
return return
total_mods = len(model) mods = len(model)
msg = self.format_mod_statusbar() msg = self.format_mod_statusbar()
self.emitter.emit("mods_updated", msg, total_mods) self.emitter.emit("mods_updated", msg, mods)
def uncolorize_mods(self) -> None: def uncolorize_mods(self) -> None:
model = self.treeview.get_model() model = self.treeview.get_model()
+3 -2
View File
@@ -5,9 +5,10 @@ from typing import Callable, TYPE_CHECKING, Union
import dzgui.api.pefile as PeFile import dzgui.api.pefile as PeFile
from dzgui.api.mods import is_mission, get_custom_mods from dzgui.api.mods import is_mission, get_custom_mods
from dzgui.api.steam import is_dayz_running, launch_offline from dzgui.api.steam import launch_offline
from dzgui.const.constants import APP_NAME, APPID_DAYZ_EXP from dzgui.const.constants import APP_NAME, APPID_DAYZ_EXP
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.init.proc import is_dayz_running
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
from dzgui.strings import dialogs from dzgui.strings import dialogs
@@ -76,7 +77,7 @@ class OfflineManager:
window.set_sensitive(True) window.set_sensitive(True)
self.emitter.emit("custom_mods_loaded", store, folder) self.emitter.emit("custom_mods_loaded", store, folder)
@call_on_thread(dialogs.waiting_for_launch, show_cancel=True) @call_on_thread(dialogs.waiting_for_launch)
def launch( def launch(
self, self,
appid: int, appid: int,
+4 -1
View File
@@ -2,6 +2,7 @@ from dataclasses import dataclass
from typing import Any, Sequence, TYPE_CHECKING from typing import Any, Sequence, TYPE_CHECKING
from dzgui.const.enum import HELP_MENU_ROWS from dzgui.const.enum import HELP_MENU_ROWS
from dzgui.util.redact import redact_log
from dzgui.util.strings import delimiter from dzgui.util.strings import delimiter
import gi import gi
@@ -133,7 +134,9 @@ class ModelFactory:
with open(path, "r") as f: with open(path, "r") as f:
lines = [line.split(delimiter) for line in f.read().splitlines()] lines = [line.split(delimiter) for line in f.read().splitlines()]
for record in lines: for record in lines:
store.append(record) # NOTE: strips PII and API keys
clean = redact_log(record)
store.append(clean)
return store return store
def new_model_from_class(self, cls: type) -> FastInsertListStore: def new_model_from_class(self, cls: type) -> FastInsertListStore:
+1 -3
View File
@@ -395,10 +395,8 @@ class ServerModelManager:
self.first_iteration = False self.first_iteration = False
def _cleanup_on_success(self) -> None: def _cleanup_on_success(self) -> None:
proxy = self.proxy_man.get_proxy_model() proxy = self._get_proxy_man().get_proxy_model()
self.tv.set_model(proxy) self.tv.set_model(proxy)
if self.preserve_on_fail is True:
self.proxy_man.wipe_cache()
self.emitter.emit("servers_loaded", self.enum) self.emitter.emit("servers_loaded", self.enum)
if self.first_iteration: if self.first_iteration:
-19
View File
@@ -64,25 +64,6 @@ radio_mi = "mi (miles)"
label_dist = "Distance display" label_dist = "Distance display"
label_client = "Steam client" label_client = "Steam client"
### Shortcuts page
heading_shortcuts = "Create shortcuts"
blurb_shortcuts = (
"You can optionally create shortcuts to facilitate launching DZGUI faster."
)
button_shortcuts = "Create shortcuts"
checkbox_steam_shortcut = (
"Add DZGUI to Steam library",
(
"This creates a standalone copy of DZGUI and adds it to Steam. "
"You <b>must restart Steam</b> for the shortcut to appear. This will not replace or update existing shortcuts. "
"If you update DZGUI when it is launched via Steam, updates will be applied to the Steam version."
),
)
checkbox_start_menu = (
"Add a start menu shortcut",
"Depends on local window manager settings. Will work on Steam Deck.",
)
checkbox_desktop_shortcut = ("Add a desktop shortcut", "Depends on the option above.")
### Completion page ### Completion page
heading_completion = "Setup complete" heading_completion = "Setup complete"
+7 -14
View File
@@ -1,4 +1,3 @@
import logging
import os import os
import platform import platform
@@ -10,9 +9,7 @@ from dzgui.const.constants import APP_NAME
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.config.query import lookup from dzgui.config.query import lookup
from dzgui.init.prefix import get_version from dzgui.init.prefix import get_version
from dzgui.util.redact import redact_home from dzgui.util.redact import redact
logger = logging.getLogger(APP_NAME)
def get_cpu_model() -> str: def get_cpu_model() -> str:
@@ -41,19 +38,15 @@ def print_mods(mods: list[int]) -> str:
def write_diagnostic(config: Path, outfile: Path) -> None: def write_diagnostic(config: Path, outfile: Path) -> None:
# TODO: test availability on other distros
date = datetime.now().isoformat() date = datetime.now().isoformat()
try: distro = platform.freedesktop_os_release()["ID_LIKE"]
# FIXME: ID_LIKE not available on CachyOS
distro = platform.freedesktop_os_release()["ID_LIKE"]
except Exception as e:
logger.warn(e)
distro = "Unknown"
kernel = os.uname().release kernel = os.uname().release
cpu = get_cpu_model() cpu = get_cpu_model()
version = get_version() version = get_version()
branch = lookup(config, Preferences.BRANCH)
debug = lookup(config, Preferences.DEBUG) debug = lookup(config, Preferences.DEBUG)
install = lookup(config, Preferences.INSTALL) install = lookup(config, Preferences.INSTALL)
default = lookup(config, Preferences.DEFAULT) default = lookup(config, Preferences.DEFAULT)
@@ -61,8 +54,8 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
steam_path = Path(default) steam_path = Path(default)
workshop_path = get_local_mod_path(steam_path) workshop_path = get_local_mod_path(steam_path)
steam_redacted = redact_home(default) steam_redacted = redact(default)
workshop_redacted = redact_home(str(workshop_path)) workshop_redacted = redact(str(workshop_path))
mods = get_local_mod_ids(steam_path) mods = get_local_mod_ids(steam_path)
mods_pretty = print_mods(mods) mods_pretty = print_mods(mods)
@@ -72,7 +65,7 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
# FIXME: extraneous newlines in lists of mods # FIXME: extraneous newlines in lists of mods
template = f"""\ template = f"""\
{APP_NAME} version {version} {APP_NAME} version {version} ({branch})
Date: {date} Date: {date}
=============================== ===============================
Distribution: {distro} Distribution: {distro}
-32
View File
@@ -1,32 +0,0 @@
import logging
import os
import shutil
from importlib import resources
from pathlib import Path
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH
logger = logging.getLogger(APP_NAME)
def make_parents(path: "Path") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def copy_dzgui_to_xdg_data(exe_path: Path) -> None:
dzgui = os.getenv("PYAPP")
if dzgui is None:
return
try:
make_parents(exe_path)
shutil.copy(dzgui, exe_path)
except Exception as e:
logger.critical(e)
def find_icon_resource() -> "Path":
traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH)
images = Path(str(traversable))
icon = images.joinpath("icon.png")
return icon
+16 -25
View File
@@ -1,29 +1,20 @@
import logging
import re import re
from typing import Literal
api_filter = r"(.*&key=)([^&]*)(.*)" def redact(text: str) -> str:
home_filter = r"(/home/)([^\s'\/]*)(.*)" r = r"(/home/)([^/])*"
REDACTED = r"\1REDACTED\3" cleaned = re.sub(r, r"/home/REDACTED", text)
REDACTION_PATTERNS = [api_filter, home_filter]
def redact_home(text: str) -> str:
pat = re.compile(home_filter)
cleaned = pat.sub(REDACTED, text)
return cleaned return cleaned
def redact_log(record: list) -> list[str]:
class RedactionFilter(logging.Filter): """
def __init__(self, patterns: list[str] | None = None) -> None: requests library includes Steam API key in URL params
super().__init__() """
self._patterns = [re.compile(pat) for pat in (patterns or [])] clean = []
for item in record:
def filter(self, record: logging.LogRecord) -> Literal[True]: if "&key=" in item:
for pattern in self._patterns: pat = r"(.*&key=)(\S+)(.*)"
try: scrubbed = re.sub(pat, r"\1REDACTED\3", item)
record.msg = pattern.sub(REDACTED, record.msg) clean.append(scrubbed)
except TypeError: else:
exception_text = f"{type(record.msg).__name__}: {record.msg}" clean.append(item)
record.msg = pattern.sub(REDACTED, exception_text) return clean
return True
+3 -2
View File
@@ -130,6 +130,7 @@ servers = {
"Enter/space/double-click": "connect to server", "Enter/space/double-click": "connect to server",
"Right-click/Ctrl-l": "additional context menus", "Right-click/Ctrl-l": "additional context menus",
"Ctrl-r": "refresh players", "Ctrl-r": "refresh players",
"Ctrl-p": "refresh ping",
"Ctrl-f": "jump to keyword search field", "Ctrl-f": "jump to keyword search field",
"Ctrl-m": "jump to maps field", "Ctrl-m": "jump to maps field",
"Ctrl-d": "select default LAN port", "Ctrl-d": "select default LAN port",
@@ -147,8 +148,8 @@ servers = {
vim = { vim = {
"j": "Move down a row/scroll up", "j": "Move down a row/scroll up",
"k": "Move up a row/scroll down", "k": "Move up a row/scroll down",
"h": "Jump to main area from sidebar", "l": "Jump to main area from sidebar",
"l": "Jump to sidebar from main area", "h": "Jump to sidebar from main area",
"g": "Jump to first row/top of page", "g": "Jump to first row/top of page",
"G": "Jump to last row/bottom of page", "G": "Jump to last row/bottom of page",
} }
-2
View File
@@ -141,8 +141,6 @@ class RefreshButton(IconTextButton):
def _on_refresh_clicked(self, button: Self) -> None: def _on_refresh_clicked(self, button: Self) -> None:
"""Spawned in a thread""" """Spawned in a thread"""
if self.controller.get_active_treeview().is_loaded() is False:
return
self.is_clicked = True self.is_clicked = True
self.controller.refresh_tree() self.controller.refresh_tree()
# TODO: get server tab enum # TODO: get server tab enum
+4 -8
View File
@@ -80,18 +80,14 @@ class ModSelectionPanel(Gtk.Box):
self.highlight_stale_button.set_sensitive(True) self.highlight_stale_button.set_sensitive(True)
def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None: def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None:
self._toggle_panel_sensitivity(mods) if mods < 1:
self.main_panel.set_sensitive(False)
def _toggle_panel_sensitivity(self, mods: int) -> None: self.stale_panel.set_sensitive(False)
state = bool(mods)
for el in self.main_panel, self.stale_panel:
el.set_sensitive(state)
def _on_mods_highlighted(self, emitter: "Emitter") -> None: def _on_mods_highlighted(self, emitter: "Emitter") -> None:
self.swap_sensitive(True) self.swap_sensitive(True)
def _on_mod_page_toggled(self, emitter: "Emitter", state: bool, mods: int) -> None: def _on_mod_page_toggled(self, emitter: "Emitter", state: bool) -> None:
self._toggle_panel_sensitivity(mods)
self.set_visible(state) self.set_visible(state)
def swap_sensitive(self, state: bool) -> None: def swap_sensitive(self, state: bool) -> None:
+14 -134
View File
@@ -1,13 +1,11 @@
import os
import textwrap import textwrap
from enum import Enum from enum import Enum
from importlib import resources from importlib import resources
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Self, TYPE_CHECKING from typing import Any, Callable, Self
from dzgui.api.probe import test_steam_api, test_bm_api from dzgui.api.probe import test_steam_api, test_bm_api
from dzgui.api.shortcuts import add_steam_shortcut
from dzgui.api.steam import get_steam_paths from dzgui.api.steam import get_steam_paths
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
@@ -17,9 +15,6 @@ from dzgui.const.constants import (
) )
from dzgui.const.boilerplate import config_boilerplate from dzgui.const.boilerplate import config_boilerplate
from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP
from dzgui.const.enum import Preferences
from dzgui.config import freedesktop
from dzgui.config.query import lookup
from dzgui.init.migrate import migrate_legacy_conf from dzgui.init.migrate import migrate_legacy_conf
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import wizard from dzgui.strings import wizard
@@ -35,9 +30,6 @@ import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402 from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402
if TYPE_CHECKING:
from dzgui.config.xdg import Xdg
class PageNum(Enum): class PageNum(Enum):
INTRO = 1 INTRO = 1
@@ -46,8 +38,7 @@ class PageNum(Enum):
STEAM_API = 4 STEAM_API = 4
BM_API = 5 BM_API = 5
USER_PREFS = 6 USER_PREFS = 6
SHORTCUTS = 7 FINAL = 7
FINAL = 8
class DescriptionArea(Gtk.Box): class DescriptionArea(Gtk.Box):
@@ -67,7 +58,7 @@ class Progress(Gtk.ProgressBar):
class ScrolledWizardPage(Gtk.ScrolledWindow): class ScrolledWizardPage(Gtk.ScrolledWindow):
def __init__(self, enum: PageNum, heading: str, description: str): def __init__(self, enum: PageNum, heading: str, description: str):
super().__init__(overlay_scrolling=False) super().__init__()
self.enum = enum self.enum = enum
self.page_type: Gtk.AssistantPageType self.page_type: Gtk.AssistantPageType
@@ -150,10 +141,6 @@ class NotificationFrame(Gtk.Frame):
if error: if error:
add_class(self, "error-frame") add_class(self, "error-frame")
def set_text(self, text: str) -> None:
wrapped = textwrap.fill(text, width=80)
self.label.set_markup(wrapped)
class APIValidationPage(ScrolledWizardPage): class APIValidationPage(ScrolledWizardPage):
def __init__( def __init__(
@@ -352,7 +339,6 @@ class ConfigMigrationPage(ScrolledWizardPage):
def _on_import_clicked(self, button: Gtk.Button) -> None: def _on_import_clicked(self, button: Gtk.Button) -> None:
self.grid.set_sensitive(False) self.grid.set_sensitive(False)
try: try:
# TODO: this could be deferred to the final page (prevents accidental destruction of dialog via ESC)
migrate_legacy_conf(self.config) migrate_legacy_conf(self.config)
self.migrated = True self.migrated = True
self.success_box.set_visible(True) self.success_box.set_visible(True)
@@ -437,27 +423,24 @@ class CompletionPage(ScrolledWizardPage):
class Assistant(Gtk.Assistant): class Assistant(Gtk.Assistant):
def __init__(self, is_deck: bool, XDG: "Xdg"): def __init__(self, is_deck: bool, config: Path):
super().__init__() super().__init__()
if is_deck: if is_deck:
self.fullscreen() self.fullscreen()
else: else:
self.set_default_size(1500, 900) self.set_default_size(1500, 900)
self.config_path = XDG.config self.config_path = config
self.config_values: dict[str, Any] = config_boilerplate self.config_values: dict[str, Any] = config_boilerplate
self.setup_complete = False
self.page1 = IntroductionPage() self.page1 = IntroductionPage()
self.page2 = ConfigMigrationPage(XDG.config) self.page2 = ConfigMigrationPage(config)
self.page3 = SteamPathPage() self.page3 = SteamPathPage()
self.page4 = SteamValidationPage() self.page4 = SteamValidationPage()
self.page5 = BMValidationPage() self.page5 = BMValidationPage()
self.page6 = PreferencesPage() self.page6 = PreferencesPage()
self.page7 = ShortcutCreationPage(XDG.shortcut) self.page7 = CompletionPage()
self.page8 = CompletionPage()
self.set_forward_page_func(self._advance_page) self.set_forward_page_func(self._advance_page)
@@ -475,16 +458,9 @@ class Assistant(Gtk.Assistant):
self.page5, self.page5,
self.page6, self.page6,
self.page7, self.page7,
self.page8,
): ):
# NOTE: skip config migration page if no legacy config file # NOTE: skip config migration page if no legacy config file
if ( if page == self.page2 and self.has_legacy_config is False:
isinstance(page, ConfigMigrationPage)
and self.has_legacy_config is False
):
continue
# NOTE: disabled for now on system-provided packages
if isinstance(page, ShortcutCreationPage) and os.getenv("PYAPP") is None:
continue continue
self._add_page(page, page.get_page_type()) self._add_page(page, page.get_page_type())
@@ -499,15 +475,13 @@ class Assistant(Gtk.Assistant):
def _advance_page(self, index: int) -> int: def _advance_page(self, index: int) -> int:
page = self.get_nth_page(index) page = self.get_nth_page(index)
# TODO: use enums/isinstance # TODO: use enums
match page: match page:
case self.page1: case self.page1:
pass pass
case self.page2: case self.page2:
if self.page2.is_migrated(): if self.page2.is_migrated():
steam_path = lookup(self.config_path, Preferences.DEFAULT) return self.get_n_pages() - 1
self.page7.set_steam_path(steam_path)
return self.get_n_pages() - 2
case self.page3: case self.page3:
self.config_values["default_steam_path"] = page.get_path_from_radio() self.config_values["default_steam_path"] = page.get_path_from_radio()
case self.page4: case self.page4:
@@ -521,17 +495,10 @@ class Assistant(Gtk.Assistant):
self.config_values["use_miles"] = use_miles self.config_values["use_miles"] = use_miles
self.config_values["client"] = client self.config_values["client"] = client
self.write_config() self.write_config()
self.page7.set_steam_path(self.config_values["default_steam_path"])
case self.page7:
self.page7.create_shortcuts()
self.setup_complete = True
case _: case _:
raise AttributeError("Trying to advance a non-canonical page") raise AttributeError("Trying to advance a non-canonical page")
return index + 1 return index + 1
def is_setup_complete(self) -> bool:
return self.setup_complete
def destroy_and_quit(self, widget: Self) -> None: def destroy_and_quit(self, widget: Self) -> None:
self.destroy() self.destroy()
Gtk.main_quit() Gtk.main_quit()
@@ -576,90 +543,6 @@ class Assistant(Gtk.Assistant):
EMITTER.emit("step_pending") EMITTER.emit("step_pending")
class CheckboxWithLabel(Gtk.Box):
def __init__(self, text: str, blurb_text: str) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5)
self.button = Gtk.CheckButton(label=text)
self.button.set_active(True)
label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=20)
wrapped = textwrap.fill(blurb_text, width=100)
label.set_markup(f"- {wrapped}")
for el in self.button, label:
self.add(el)
def get_checkbox(self) -> Gtk.CheckButton:
return self.button
def get_active(self) -> bool:
return self.button.get_active()
def set_active(self, state: bool) -> None:
self.button.set_active(state)
class ShortcutCreationPage(ScrolledWizardPage):
def __init__(self, shortcut: Path) -> None:
super().__init__(
enum=PageNum.SHORTCUTS,
heading=wizard.heading_shortcuts,
description=wizard.blurb_shortcuts,
)
self.steam_path: Path
self.shortcut_path = shortcut
self.checks_area = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=20
)
self.page_type = Gtk.AssistantPageType.INTRO
label, blurb = wizard.checkbox_steam_shortcut
self.steam_checkbox = CheckboxWithLabel(label, blurb)
label, blurb = wizard.checkbox_start_menu
self.start_menu_checkbox = CheckboxWithLabel(label, blurb)
cb = self.start_menu_checkbox.get_checkbox()
cb.connect("toggled", self._on_start_menu_toggled)
label, blurb = wizard.checkbox_desktop_shortcut
self.desktop_checkbox = CheckboxWithLabel(label, blurb)
for el in (
self.steam_checkbox,
self.start_menu_checkbox,
self.desktop_checkbox,
):
self.checks_area.add(el)
self.add_start(self.checks_area)
self.show_all()
self.connect("map", self._on_map)
def _on_start_menu_toggled(self, button: Gtk.CheckButton) -> None:
state = button.get_active()
if not state:
self.desktop_checkbox.set_active(state)
self.desktop_checkbox.set_sensitive(state)
def _on_map(self, page: "ScrolledWizardPage") -> None:
EMITTER.emit("step_complete")
def set_steam_path(self, path: Path) -> None:
self.steam_path = path
def create_shortcuts(self) -> None:
# NOTE: best-effort, permissive even on failure (page is already marked as complete)
if self.steam_checkbox.get_active():
add_steam_shortcut(self.steam_path, self.shortcut_path)
if self.start_menu_checkbox.get_active():
desktop_file = freedesktop.write_desktop_file(self.shortcut_path)
if self.desktop_checkbox.get_active():
freedesktop.write_desktop_shortcut(desktop_file)
class SteamPathPage(ScrolledWizardPage): class SteamPathPage(ScrolledWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
@@ -716,20 +599,17 @@ class SteamPathPage(ScrolledWizardPage):
class SetupWizard(Gtk.Application): class SetupWizard(Gtk.Application):
def __init__(self, is_deck: bool, XDG: "Xdg") -> None: def __init__(self, is_deck: bool, config: Path) -> None:
super().__init__() super().__init__()
GLib.set_prgname(APP_NAME) GLib.set_prgname(APP_NAME)
self.win = Window(is_deck, XDG) Window(is_deck, config)
Gtk.main() Gtk.main()
def is_setup_complete(self) -> int:
return self.win.assistant.is_setup_complete()
class Window(Gtk.Window): class Window(Gtk.Window):
def __init__(self, is_deck: bool, XDG: "Xdg") -> None: def __init__(self, is_deck: bool, config: Path) -> None:
super().__init__(title=APP_NAME, icon_name=APP_NAME) super().__init__(title=APP_NAME, icon_name=APP_NAME)
self.assistant = Assistant(is_deck, XDG) Assistant(is_deck, config)
class Emitter(GObject.GObject): class Emitter(GObject.GObject):
+5 -54
View File
@@ -1,5 +1,4 @@
import logging import logging
import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from importlib import resources from importlib import resources
@@ -33,63 +32,15 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
# TODO: should long text be wrapped? # TODO: should long text be wrapped?
self.controller = controller self.controller = controller
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10) formatted = format_pango(changelog)
self.changelog_label = Gtk.Label(valign=Gtk.Align.START, margin=15)
self.changelog_label.set_markup(formatted)
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.box.add(self.changelog_label)
self.add(self.box) self.add(self.box)
self.connect("key-press-event", self._on_keypress) self.connect("key-press-event", self._on_keypress)
self.connect("key-press-event", self._on_esc_keypress) self.connect("key-press-event", self._on_esc_keypress)
changes = self._parse(changelog)
self._generate_nodes(changes)
self.show_all()
def grab_content_area(self) -> None: def grab_content_area(self) -> None:
self.grab_focus() self.grab_focus()
def _parse_version(self, version: str) -> tuple[str, str]:
pattern = r"(^##\s\[)(.+)(\] )(.+$)"
match = re.match(pattern, version)
if match:
version = match.group(2)
date = match.group(4)
return version, date
raise ValueError("Changelog parse error")
def _generate_nodes(self, releases: list[tuple[str, list[str]]]) -> None:
for header, changes in releases:
for ind in (0, -1):
if changes[ind] == "":
del changes[ind]
text = "\n".join(changes)
formatted = format_pango(text)
label = Gtk.Label(valign=Gtk.Align.START, margin=15, halign=Gtk.Align.START)
label.set_markup(formatted)
version, date = self._parse_version(header)
button_text = f"{version} ({date})"
button = Gtk.Button(label=button_text, margin_start=5)
expander = Gtk.Expander()
expander.set_label_widget(button)
expander.add(label)
self.box.add(expander)
def _parse(self, changelog: str) -> list[tuple[str, list[str]]]:
release = ""
releases: list[tuple[str, list[str]]] = []
release_notes: list[str] = []
for line in changelog.splitlines():
if line.startswith("# "):
continue
if line.startswith("## "):
if release != "":
releases.append((release, release_notes))
release_notes = []
release = ""
release = line
continue
release_notes.append(line.rstrip())
return releases
+2 -7
View File
@@ -64,15 +64,10 @@ class Mods(Gtk.Box):
self.controller.open_offline() self.controller.open_offline()
def _on_unmap(self, widget: Self) -> None: def _on_unmap(self, widget: Self) -> None:
self.emitter.emit("mod_page_toggled", False, 0) self.emitter.emit("mod_page_toggled", False)
def _on_map(self, widget: Self) -> None: def _on_map(self, widget: Self) -> None:
model = self.tree.get_model() self.emitter.emit("mod_page_toggled", True)
if model is None:
mods = 0
else:
mods = len(model)
self.emitter.emit("mod_page_toggled", True, mods)
# TODO: delegation # TODO: delegation
# NOTE: handles going back from offline mods page # NOTE: handles going back from offline mods page
# more generic cache restoration method # more generic cache restoration method
-1
View File
@@ -288,7 +288,6 @@ class Options(Gtk.Box):
def _on_api_change_failed(self, emitter: "Emitter") -> None: def _on_api_change_failed(self, emitter: "Emitter") -> None:
self.old_entry.set_text(self.old_text) self.old_entry.set_text(self.old_text)
# TODO: use popover
dialog = ExceptionDialog(self.controller, errors.api_validation_error) dialog = ExceptionDialog(self.controller, errors.api_validation_error)
dialog.run() dialog.run()
+2 -11
View File
@@ -272,15 +272,6 @@ class PreConnectionAssistant(Gtk.Box):
"It looks like DayZ is already running in the background. Exit DayZ before connecting." "It looks like DayZ is already running in the background. Exit DayZ before connecting."
) )
allows_dl, running_app = prereqs.allows_downloads
if allows_dl is False:
msg = (
f"The game '{running_app}' is currently running in Steam, but background downloads are not enabled.\n"
"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."
)
warnings.append(msg)
self.add_warnings(warnings) self.add_warnings(warnings)
self.add_errors(errors) self.add_errors(errors)
@@ -316,12 +307,12 @@ class PreConnectionAssistant(Gtk.Box):
self.mod_count.set_text(msg) self.mod_count.set_text(msg)
if prereqs.required_space != 0: if prereqs.required_space != 0:
self.ok.set_label(preconnect.connect)
pretty = number(prereqs.required_space) pretty = number(prereqs.required_space)
suffix = f" Need to download {pretty} MiB of mod updates." suffix = f" Need to download {pretty} MiB of mod updates."
prefix = preconnect.total_mods prefix = preconnect.total_mods
self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}") self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}")
else:
self.ok.set_label(preconnect.connect)
if prereqs.is_last_server: if prereqs.is_last_server:
self.connect_last.show() self.connect_last.show()
+2 -5
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [ authors = [
{name = "aclist"} {name = "aclist"}
] ]
version = "7.0.0b16" version = "7.0.0b13"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
license-files = ["LICENSE"] license-files = ["LICENSE"]
readme = "README.md" readme = "README.md"
@@ -27,7 +27,6 @@ dependencies = [
"psutil==7.1.3", "psutil==7.1.3",
"python-a2s", "python-a2s",
"requests==2.32.5", "requests==2.32.5",
"vdf==3.4"
] ]
[project.scripts] [project.scripts]
@@ -76,7 +75,7 @@ build-backend = "setuptools.build_meta"
line-length = 88 line-length = 88
indent-width = 4 indent-width = 4
extend-exclude = ["*lib/a2s"] extend-exclude = ["*lib/a2s"]
lint.unfixable = ["F401", "I001"] lint.unfixable = ["F401"]
[tool.ruff.format] [tool.ruff.format]
quote-style = "double" quote-style = "double"
@@ -108,10 +107,8 @@ config-settings-package = { pygobject-stubs = { config = "Gtk3,Gdk3,Soup2" } }
[tool.pytest.ini_options] [tool.pytest.ini_options]
markers = [ markers = [
"config: config file keys/values", "config: config file keys/values",
"mods: tests mod metadata/link creation",
"pefile: validate PE files", "pefile: validate PE files",
"post_install: requires a completed installation", "post_install: requires a completed installation",
"redact: log redaction mechanisms",
"slow: long-running tests", "slow: long-running tests",
"webtest: checks remote endpoints" "webtest: checks remote endpoints"
] ]
-1
View File
@@ -12,7 +12,6 @@ This file includes the combined licenses to the following components:
- psutil - psutil
- PyGObject - PyGObject
- python-a2s - python-a2s
- vdf
When shipped as a binary installer, these components are collectively bundled When shipped as a binary installer, these components are collectively bundled
as a turnkey application and used to run the DZGUI utility.\n\n as a turnkey application and used to run the DZGUI utility.\n\n
-13
View File
@@ -1,13 +0,0 @@
"InstallConfigStore"
{
"Software"
{
"Valve"
{
"Steam"
{
"AllowDownloadsDuringGameplay" "1"
}
}
}
}
-13
View File
@@ -1,13 +0,0 @@
"InstallConfigStore"
{
"Software"
{
"Valve"
{
"Steam"
{
"AllowDownloadsDuringGameplay" "0"
}
}
}
}
Binary file not shown.
-53
View File
@@ -1,53 +0,0 @@
"AppState"
{
"appid" "111"
"Universe" "1"
"name" "TEST APP"
"StateFlags" "4"
"installdir" "TESTAPP"
"LastUpdated" "0"
"LastPlayed" "0"
"SizeOnDisk" "0"
"StagingSize" "0"
"buildid" "0"
"LastOwner" "0"
"DownloadType" "2"
"UpdateResult" "0"
"BytesToDownload" "0"
"BytesDownloaded" "0"
"BytesToStage" "0"
"BytesStaged" "0"
"TargetBuildID" "0"
"AutoUpdateBehavior" "0"
"AllowOtherDownloadsWhileRunning" "1"
"ScheduledAutoUpdate" "0"
"FullValidateAfterNextUpdate" "1"
"InstalledDepots"
{
"1110"
{
"manifest" "0"
"size" "0"
}
}
"SharedDepots"
{
"228983" "228980"
"228985" "228980"
"228988" "228980"
"228990" "228980"
"229004" "228980"
}
"UserConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
"MountedConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
}
-53
View File
@@ -1,53 +0,0 @@
"AppState"
{
"appid" "222"
"Universe" "1"
"name" "TEST APP"
"StateFlags" "4"
"installdir" "TESTAPP"
"LastUpdated" "0"
"LastPlayed" "0"
"SizeOnDisk" "0"
"StagingSize" "0"
"buildid" "0"
"LastOwner" "0"
"DownloadType" "2"
"UpdateResult" "0"
"BytesToDownload" "0"
"BytesDownloaded" "0"
"BytesToStage" "0"
"BytesStaged" "0"
"TargetBuildID" "0"
"AutoUpdateBehavior" "0"
"AllowOtherDownloadsWhileRunning" "2"
"ScheduledAutoUpdate" "0"
"FullValidateAfterNextUpdate" "1"
"InstalledDepots"
{
"2220"
{
"manifest" "0"
"size" "0"
}
}
"SharedDepots"
{
"228983" "228980"
"228985" "228980"
"228988" "228980"
"228990" "228980"
"229004" "228980"
}
"UserConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
"MountedConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
}
-53
View File
@@ -1,53 +0,0 @@
"AppState"
{
"appid" "333"
"Universe" "1"
"name" "TEST APP"
"StateFlags" "4"
"installdir" "TESTAPP"
"LastUpdated" "0"
"LastPlayed" "0"
"SizeOnDisk" "0"
"StagingSize" "0"
"buildid" "0"
"LastOwner" "0"
"DownloadType" "2"
"UpdateResult" "0"
"BytesToDownload" "0"
"BytesDownloaded" "0"
"BytesToStage" "0"
"BytesStaged" "0"
"TargetBuildID" "0"
"AutoUpdateBehavior" "0"
"AllowOtherDownloadsWhileRunning" "0"
"ScheduledAutoUpdate" "0"
"FullValidateAfterNextUpdate" "1"
"InstalledDepots"
{
"3330"
{
"manifest" "0"
"size" "0"
}
}
"SharedDepots"
{
"228983" "228980"
"228985" "228980"
"228988" "228980"
"228990" "228980"
"229004" "228980"
}
"UserConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
"MountedConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3
View File
@@ -0,0 +1,3 @@
from dzgui.api.mods import tokenize
print(tokenize("meta4.cpp"))
-8
View File
@@ -1,8 +0,0 @@
1559212036,1771519119
1564026768,1770917948
2545327648,1780174455
2276010135,1756744083
1654462998,1780501511
3410710885,1752864732
2918418331,1780413260
3739934289,1780855136
-42
View File
@@ -1,42 +0,0 @@
#Path to DayZ installation
steam_path="/tmp"
#Battlemetrics API key
api_key=""
#Favorited server IP:PORT array
ip_list=(
)
#Favorite server to fast-connect to (limit one)
fav_server=""
#Favorite server label (human readable)
fav_label=""
#Custom player name (optional, required by some servers)
name=""
#Set to 1 to perform dry-run and print launch options
debug="1"
#Toggle stable/testing branch
branch="testing"
#Steam API key
steam_api=""
#Auto-install mods
auto_install="2"
#Automod staging directory
staging_dir="/tmp"
#Path to default Steam client
default_steam_path="/tmp"
#Preferred Steam launch command (for Flatpak support)
preferred_client="steam"
#DZGUI source path
src_path=""
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-60
View File
@@ -1,60 +0,0 @@
import pytest
from _pytest.monkeypatch import MonkeyPatch
from pathlib import Path
from dzgui.api.steam import get_app_allows_downloads
from tests.fixtures import fixture_path
pytestmark = pytest.mark.apitest
def mock_path(p: Path, appid: int) -> Path:
return Path(fixture_path("api"))
def mock_config_allows(p: Path) -> Path:
return Path(fixture_path("api/client_allows_downloads.vdf"))
def mock_config_disallows(p: Path) -> Path:
return Path(fixture_path("api/client_disallows_downloads.vdf"))
def mock_config_missing(p: Path) -> Path:
return Path(fixture_path(""))
@pytest.fixture
def client_allows() -> Path:
return Path(fixture_path("api/client_allows_downloads.vdf"))
@pytest.fixture
def steam_path() -> Path:
return Path(fixture_path("api"))
@pytest.fixture(scope="module", autouse=True)
def patch_api() -> None:
mp = MonkeyPatch()
mp.setattr("dzgui.api.steam.get_app_path", mock_path)
yield
mp.undo()
def test_app_allows_downloads(steam_path) -> None:
assert get_app_allows_downloads(steam_path, 111) is True
def test_app_disallows_downloads(steam_path):
assert get_app_allows_downloads(steam_path, 222) is False
def test_app_delegates_downloads(monkeypatch, steam_path, client_allows):
monkeypatch.setattr("dzgui.api.steam.get_config", mock_config_disallows)
assert get_app_allows_downloads(steam_path, 333) is False
monkeypatch.setattr("dzgui.api.steam.get_config", mock_config_allows)
assert get_app_allows_downloads(steam_path, 333) is True
monkeypatch.setattr("dzgui.api.steam.get_config", mock_config_missing)
assert get_app_allows_downloads(steam_path, 333) is True
-84
View File
@@ -1,84 +0,0 @@
import pytest
import tempfile
from dzgui.app_init import copy_bare_configs
from dzgui.config.xdg import get_xdg_paths, parse_filepaths
CONF_STRING = "DZGUI_CONF\n"
STATE_STRING = "DZGUI_STATE\n"
@pytest.fixture
def state_files():
state = [
"dzg.history",
"dzg.versions",
"dzg.res.json",
"dzg.filters.json",
"dzg.columns.json",
"dzg.notes.json",
"ips.csv",
".month",
]
return state
@pytest.fixture
def xdg_paths(monkeypatch):
routes = {
"XDG_CONFIG_HOME": "",
"XDG_STATE_HOME": "",
"XDG_DATA_HOME": "",
"XDG_CACHE_HOME": "",
}
for route in routes:
tmp = tempfile.TemporaryDirectory(delete=False)
routes[route] = tmp.name
for k, v in routes.items():
monkeypatch.setenv(k, v)
env = get_xdg_paths()
return parse_filepaths(env)
@pytest.mark.mods
def test_config_file_import(xdg_paths, state_files):
# NOTE: write bare files in root
tmp_conf = xdg_paths.config.parent.parent
tmp_conf_file = tmp_conf / xdg_paths.config.name
tmp_conf_file.write_text(CONF_STRING)
tmp_state = xdg_paths.resolution.parent.parent
for file in state_files:
tmp_state.joinpath(file).write_text(STATE_STRING)
# NOTE: function should move files into "dzgui" subdirectory
config_changed, state_changed = copy_bare_configs(
xdg_paths.config, xdg_paths.resolution
)
assert xdg_paths.config.read_text() == CONF_STRING
for file in state_files:
expected = xdg_paths.resolution.parent.joinpath(file)
assert expected.read_text() == STATE_STRING
assert config_changed is True
assert state_changed is True
@pytest.mark.mods
def test_config_file_no_import(xdg_paths, state_files):
for subdir in xdg_paths.config.parent, xdg_paths.resolution.parent:
subdir.mkdir()
tmp_conf_file = xdg_paths.config
tmp_conf_file.write_text(CONF_STRING)
for file in state_files:
xdg_paths.resolution.parent.joinpath(file).write_text(STATE_STRING)
config_changed, state_changed = copy_bare_configs(
xdg_paths.config, xdg_paths.resolution
)
assert config_changed is False
assert state_changed is False
+5 -12
View File
@@ -1,7 +1,6 @@
import json import json
import pytest import pytest
from dzgui.const.boilerplate import config_boilerplate
from dzgui.config.query import get_config from dzgui.config.query import get_config
from dzgui.config.xdg import get_xdg_paths, parse_filepaths from dzgui.config.xdg import get_xdg_paths, parse_filepaths
from dzgui.config import convert from dzgui.config import convert
@@ -12,11 +11,7 @@ pytestmark = pytest.mark.config
@pytest.fixture @pytest.fixture
def legacy_config(): def legacy_config():
return fixture_path("dztuirc_1") return fixture_path("dztuirc_one")
@pytest.fixture
def unset_values():
return fixture_path("dztuirc_3")
@pytest.fixture @pytest.fixture
@@ -65,8 +60,8 @@ def test_contains_invalid_values(keys, config):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"fixture, expect", "fixture, expect",
[ [
("dztuirc_1", (False, True, True)), ("dztuirc_one", (False, True, True)),
("dztuirc_2", (False, False, False)), ("dztuirc_two", (False, False, False)),
], ],
) )
def test_bool_conversion(fixture, expect): def test_bool_conversion(fixture, expect):
@@ -91,7 +86,5 @@ def test_key_conversion(legacy_config):
for key in keys: for key in keys:
assert key not in j assert key not in j
def test_missing_values(unset_values):
j = convert.rc2json(unset_values) # TODO: test that when a config file is created from scratch, it contains all values
j = json.loads(j)
assert j.keys() == config_boilerplate.keys()
-54
View File
@@ -1,54 +0,0 @@
import logging
import pytest
from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS
class RecordsListHandler(logging.Handler):
def __init__(self) -> None:
super().__init__()
self.records_list = []
def emit(self, record: logging.LogRecord) -> None:
self.records_list.append(record)
def pop(self) -> None:
return self.records_list[-1].msg
@pytest.mark.redact
@pytest.mark.parametrize(
"log_error, expect",
[
("/home/SENSITIVE_USERNAME/subdir", "/home/REDACTED/subdir"),
(
"https://url.com/?api&key=SENSITIVE_KEY&results=10",
"https://url.com/?api&key=REDACTED&results=10",
),
(
"https://url.com/?api&key=SENSITIVE_KEY",
"https://url.com/?api&key=REDACTED",
),
(
"Error in directory: '/home/SENSITIVE_USERNAME/'",
"Error in directory: '/home/REDACTED/'",
),
(
"Error in directory: '/home/SENSITIVE_USERNAME'",
"Error in directory: '/home/REDACTED'",
),
],
)
def test_log_redaction(log_error: str, expect: str) -> None:
logger = logging.getLogger("TEST")
handler = RecordsListHandler()
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
_filter = RedactionFilter(patterns=REDACTION_PATTERNS)
logger.addFilter(_filter)
logger.critical(log_error)
redacted = handler.pop()
assert expect == redacted
-52
View File
@@ -1,52 +0,0 @@
import pytest
import shutil
import tempfile
from pathlib import Path
from typing import TYPE_CHECKING
import dzgui.api.mods
import dzgui.config.query
from tests.fixtures import fixture_path
if TYPE_CHECKING:
from dzgui.const.enums import Preferences
pytestmark = pytest.mark.mods
@pytest.fixture
def versions() -> str:
return fixture_path("dzg.versions")
@pytest.fixture
def tmp() -> str:
with tempfile.NamedTemporaryFile(delete=False) as f:
tmp = f.name
return tmp
def mock_local_ids(path: Path) -> list[int]:
return [3410710885, 3739934289]
def mock_lookup(path: Path, prefs: "Preferences") -> str:
return ""
def test_signatures(monkeypatch, tmp: str, versions: str) -> None:
ids = [1559212036, 1654462998]
shutil.copyfile(versions, tmp)
path = Path(tmp)
monkeypatch.setattr("dzgui.api.mods.lookup", mock_lookup)
dzgui.api.mods.remove_stale_signatures(path, path, ids)
with open(tmp, "r") as f:
lines = f.readlines()
assert ids not in lines
def test_signatures_with_no_ids(monkeypatch, tmp: str, versions: str) -> None:
shutil.copyfile(versions, tmp)
path = Path(tmp)
monkeypatch.setattr("dzgui.api.mods.get_local_mod_ids", mock_local_ids)
monkeypatch.setattr("dzgui.api.mods.lookup", mock_lookup)
dzgui.api.mods.remove_stale_signatures(path, path)
with open(tmp, "r") as f:
lines = f.readlines()
assert lines == ["3410710885,1752864732\n", "3739934289,1780855136\n"]
+11 -7
View File
@@ -1,13 +1,17 @@
import pytest import pytest
from pathlib import Path
from dzgui.api.mods import tokenize from dzgui.api.mods import tokenize
from tests.fixtures import fixture_path from tests.fixtures import fixture_path
@pytest.mark.mods @pytest.mark.mods
@pytest.mark.parametrize("i", range(1, 7)) @pytest.mark.parametrize("fixture", [
def test_modmeta(i: int) -> None: fixture_path("cpp/meta1.cpp"),
fixture = fixture_path(f"cpp/mod{i}") fixture_path("cpp/meta2.cpp"),
path = Path(fixture) fixture_path("cpp/meta3.cpp"),
meta = tokenize(path) fixture_path("cpp/meta4.cpp"),
fixture_path("cpp/meta5.cpp"),
fixture_path("cpp/meta6.cpp"),
]
)
def test_modmeta(fixture: str) -> None:
meta = tokenize(fixture)
assert meta["name"] == "ModName" assert meta["name"] == "ModName"
+13 -27
View File
@@ -1,22 +1,21 @@
import pytest import pytest
from pathlib import Path from pathlib import Path
import dzgui.api.pefile as PeFile import dzgui.api.pefile as PeFile
from dzgui.api.steam import ( from dzgui.api.pefile import VDFLoadError, AppNotInstalledError, AppMovedError
VDFLoadError,
AppNotInstalledError,
AppMovedError,
get_app_path
)
from dzgui.config.query import get_config from dzgui.config.query import get_config
from dzgui.config.xdg import get_xdg_paths, parse_filepaths from dzgui.config.xdg import get_xdg_paths, parse_filepaths
from dzgui.const.constants import APPID_DAYZ, LIBRARYFOLDERS_PATH from dzgui.const.constants import APPID_DAYZ
from tests.fixtures import fixture_path from tests.fixtures import fixture_path
pytestmark = pytest.mark.apitest pytestmark = pytest.mark.pefile
@pytest.fixture
def second_drive():
return fixture_path("in_library_on_second_drive.vdf")
@pytest.fixture @pytest.fixture
@@ -27,8 +26,6 @@ def default_steam_path():
return conf["default_steam_path"] return conf["default_steam_path"]
# TODO: split into separate test file
# FIXME: use fixtures
def test_pefile_length(default_steam_path): def test_pefile_length(default_steam_path):
try: try:
pe_file_path = PeFile.get_pefile_path(Path(default_steam_path), APPID_DAYZ) pe_file_path = PeFile.get_pefile_path(Path(default_steam_path), APPID_DAYZ)
@@ -55,26 +52,15 @@ def test_invalid_pefile_path():
("malformed.vdf", VDFLoadError), ("malformed.vdf", VDFLoadError),
], ],
) )
def test_not_in_library(monkeypatch, fixture, exception): def test_not_in_library(fixture, exception):
import dzgui.api.steam as steam fixture = fixture_path(fixture)
monkeypatch.setattr(steam, "LIBRARYFOLDERS_PATH", fixture)
folder_path = Path(fixture_path("api"))
with pytest.raises(exception): with pytest.raises(exception):
try: try:
get_app_path(folder_path, APPID_DAYZ) PeFile.get_app_path(fixture, APPID_DAYZ)
except Exception as e: except Exception as e:
raise e raise e
@pytest.fixture def test_on_second_drive(second_drive):
def second_drive(): path = PeFile.get_app_path(second_drive, APPID_DAYZ)
return "in_library_on_second_drive.vdf"
def test_on_second_drive(monkeypatch, second_drive):
import dzgui.api.steam as steam
monkeypatch.setattr(steam, "LIBRARYFOLDERS_PATH", second_drive)
folder_path = Path(fixture_path("api"))
path = get_app_path(folder_path, APPID_DAYZ)
assert path == Path("/tmp") assert path == Path("/tmp")
+1
View File
@@ -20,6 +20,7 @@ def test_steam(config):
key = config["steam_api"] key = config["steam_api"]
assert probe.test_steam_api(key) assert probe.test_steam_api(key)
def test_bm(config): def test_bm(config):
key = config["bm_api"] key = config["bm_api"]
assert probe.test_bm_api(key) assert probe.test_bm_api(key)
-67
View File
@@ -1,67 +0,0 @@
import pytest
import tempfile
from pathlib import Path
from _pytest.monkeypatch import MonkeyPatch
from dzgui.api.shortcuts import Shortcuts
from tests.fixtures import fixture_path
pytestmark = pytest.mark.apitest
def mock_find_shortcuts(self, steam_path: Path) -> Path:
return Path(fixture_path("api/no_shortcuts.vdf"))
@pytest.fixture(scope="module", autouse=True)
def patch_api() -> None:
mp = MonkeyPatch()
mp.setattr("dzgui.api.shortcuts.Shortcuts.find_shortcuts_path", mock_find_shortcuts)
yield
mp.undo()
def test_no_shortcuts(monkeypatch) -> None:
s = Shortcuts(Path(""))
assert len(s.shortcuts["shortcuts"]) == 0
@pytest.fixture
def dummy_app() -> None:
d = {
"appname": "TEST APP",
"StartDir": "TEST_DIR",
"exe": "TEST_DIR/TEST_EXE.EXE",
"icon": "IMAGES_DIR/TEST_IMAGE.PNG",
}
return d
def test_wrap_exe(dummy_app) -> None:
s = Shortcuts(Path(""))
s.add_shortcut(*dummy_app.values())
assert s.shortcuts["shortcuts"]["0"]["exe"][0] == '"'
assert s.shortcuts["shortcuts"]["0"]["exe"][-1] == '"'
def test_add_shortcut(dummy_app) -> None:
s = Shortcuts(Path(""))
s.add_shortcut(*dummy_app.values())
new = s.shortcuts["shortcuts"]
ind = str(len(new) - 1)
for k, v in dummy_app.items():
if k == "exe":
v = f'"{v}"'
assert new[ind][k] == v
def test_save_shortcut(dummy_app) -> None:
s = Shortcuts(Path(""))
s.add_shortcut(*dummy_app.values())
with tempfile.NamedTemporaryFile() as f:
tmp = Path(f.name)
s.shortcuts_path = tmp
s.save_shortcuts()
s._load_shortcuts(tmp)
assert len(s.shortcuts["shortcuts"]) == 1