Compare commits

..
1 Commits
Author SHA1 Message Date
aclist a1f851430d Merge aa43b017d1 into e92df8c062 2026-07-09 03:37:28 +00:00
70 changed files with 413 additions and 1234 deletions

No files matched your search

+28 -34
View File
@@ -1,7 +1,6 @@
# Changelog
## [7.0.0] Unreleased
### Added
## Added
- Setup wizard
- Changelog text wrapping and formatting
- Changelog ships with source
@@ -42,11 +41,8 @@
- Choose to jump into splash screen instead of server
- Collapsible connection panel
- Play offline (load mods directly)
- Generate Steam shortcuts and cover art
- Warn user if background downloads are disabled
- Dynamic copy button
### Changed
## Changed
- Conform to PEP 440 versioning for beta versions
- Reduce padding on keys button
- Boldface breadcrumbs
@@ -63,60 +59,58 @@
- Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client
### Dropped
- Battlemetrics API support
## Dropped
- Debug mode
- Branch switching
- Manual mod install mode (describe rationale)
- Force update mods
### Fixed
## Fixed
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
- Center server title text on server dialogs
- Rare segfaults when changing maps (threading)
- Moved dialogs out of threads
- History table not being in lock-step with state file
### Unreleased
## Unreleased
- Load offline mods
- Local documentation
- Raw debug command in context menu
### Developer-facing
## Developer-facing
- Add pyproject.toml file
- Show deprecation warnings
- Options -> Dev page
## [6.0.5] 2026-05-17
### Changed
## Changed
- Update symlink hash logic to align with DZGUI 7 beta
- Update A2S helper to latest commit
## [6.0.4] 2026-04-13
### Fixed
## Fixed
- Update A2S module checksums to support servers with missing description field
## [6.0.3] 2026-04-12
### Fixed
## Fixed
- Use GLibUnix namespace where available when setting up SIGINT callback
## [6.0.2] 2026-01-26
### Fixed
## Fixed
- Explicitly use Python 3.13 when calling subprocesses
- Raise error correctly from module
- Do not spawn dialogs from inside of thread when fetching prereqs
### Changed
## Changed
- Optimize time complexity of startup checks
- Optimize coordinate calculation by using local records first
- Rename some internal functions
- Wrap entire startup process in dialog
## [6.0.1] 2026-01-16
### Fixed
## Fixed
- Explicitly use Python 3.13 when calling subprocesses
## [6.0.0] 2026-01-14
### Added
## Added
- Support DayZ Experimental
- Ping display on server tables
- Save descriptive text notes on a per-server basis
@@ -142,7 +136,7 @@
- Commandline usage help text (GaryBlackbourne)
- More descriptive help dialogs when initial dependencies are missing
### Fixed
## Fixed
- Script failing to start when remote endpoints are unavailable (GaryBlackbourne)
- Key stickiness when quickly navigating through entries in tables
- Servers returning malformed A2S_INFO blocking server browser from loading
@@ -181,7 +175,7 @@
- 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
### Changed
## Changed
- Require Python 3.13
- Reduced global API cooldown from 60s to 30s
- Clarify dialog messages when DayZ path could not be found
@@ -193,50 +187,50 @@
- Rewrote distance calculation module (GaryBlackbourne)
- Changed preferred client setting from radio toggle to combobox
### Dropped
## Dropped
- Ping readout in statusbar
- Extraneous information from right statusbar
## [5.8.3] 2026-01-04
### Fixed
## Fixed
- Normalize checksum numbers and dates
## [5.8.2] 2026-01-04
### Fixed
## Fixed
- Fix regression in symlink routine introduced by previous hotfix
## [5.8.1] 2025-12-27
### Fixed
## Fixed
- 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
## [5.8.0] 2025-07-06
### Added
## Added
- Filter servers by official/unofficial status
- Automatically fetch geolocation records
### Changed
## Changed
- Updated internal versioning of helper files
### Fixed
## Fixed
- Corrected erroneous 2024 date in prior changelog entries
## [5.7.0] 2025-04-17
### Added
## Added
- 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
### Changed
## Changed
- More direct server ping query method that should return marginally more accurate times
## [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
## [5.6.6] 2025-03-16
### Changed
## Changed
- Update IP database records for 2025-03
## [5.6.5] 2025-03-04
### Fixed
## Fixed
- Livonia server results being dropped from batch queries
## [5.6.4] 2025-02-10
+2 -2
View File
@@ -8,7 +8,7 @@ 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
(issue tracker)[https://github.com/aclist/dztui/issues] under the
`troubleshooting` tag.
# How can I help the project?
@@ -24,7 +24,7 @@ accordingly.
## Submitting a ticket
Navigate to the GitHub [issue tracker](https://github.com/aclist/dztui/issues).
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?
+1
View File
@@ -86,6 +86,7 @@ class WorkshopACF(ACF):
def __init__(self, file: Path) -> None:
super().__init__(file)
# TODO: break into workshop parser and generic acf parser
def unpack(self, d: dict, lines: list[Any] = []) -> str:
t1 = "AppWorkshop"
t2 = ("WorkshopItemsInstalled", "WorkshopItemDetails")
+40
View File
@@ -0,0 +1,40 @@
import logging
import requests
from typing import Any, Optional, TYPE_CHECKING
from dzgui.const.constants import APP_NAME
from dzgui.const.endpoints import BM_SERVERS
logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from dzgui.api.servers import Record
def get_attributes(key: str, uid: int) -> Any:
# TODO: handle if key is not set
# TODO: tests for malformed IDs/values
hdr = {"Authorization": "Bearer " + key}
payload: dict[str, str] = {
"filter[game]": "dayz",
"sort": "-players",
"filter[ids][whitelist]": str(uid),
}
res = requests.get(BM_SERVERS, params=payload, headers=hdr)
res.raise_for_status()
j = res.json()["data"][0]["attributes"]
return j
def map_id_to_record(key: str, uid: int) -> Optional["Record"]:
from dzgui.api.servers import Record
try:
record = get_attributes(key, uid)
ip = record["ip"]
port = int(record["port"])
qport = int(record["portQuery"])
return Record(ip, port, qport)
except Exception:
return None
+15
View File
@@ -41,6 +41,21 @@ def test_ipdb() -> bool:
return False
def test_bm_api(key: str) -> bool:
payload: dict[str, str] = {
"filter[game]": "dayz",
}
hdr = {"Authorization": "Bearer " + key}
try:
res = requests.get(
endpoints.BM_SERVERS, params=payload, headers=hdr, timeout=REQUEST_TIMEOUT
)
return is_remote_up(res)
except Exception as e:
logger.critical(e)
return False
def is_remote_up(res: "Response") -> bool:
if res.status_code == 200:
return True
+11
View File
@@ -11,6 +11,7 @@ import threading
from dataclasses import dataclass
from typing import Any, Optional, TYPE_CHECKING, Union
from dzgui.api.bm import map_id_to_record
from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT
from dzgui.const.endpoints import STEAM_SERVERS
from dzgui.util.format import format_exception
@@ -477,6 +478,16 @@ def query_by_ip(addr: str) -> A2SInfo:
return query_by_record(record, update_gameport=True)
def query_by_id(server_id: int, key: str) -> A2SInfo:
"""
Used with numeric Battlemetrics IDs
"""
record = map_id_to_record(key, server_id)
if record is None:
return A2SInfo(Record("0", 0, 0), None)
return query_by_record(record)
def query_by_record(record: Record, update_gameport: bool = False) -> A2SInfo:
try:
info = a2s.info((record.ip, record.qport), 3.0)
-189
View File
@@ -1,189 +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
from dzgui.util.strings import unknown
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 find_appname_by_unsigned_id(self, appid: int) -> str:
# NOTE: bitmask signed int back to 32-bit CRC
# varying client versions treat case sensitivity differently
try:
for key in self.shortcuts["shortcuts"].keys():
if self.shortcuts["shortcuts"][key]["appid"] & 0xFFFFFFFF == appid:
try:
return str(self.shortcuts["shortcuts"][key]["appname"])
except Exception as e:
logger.debug(e)
try:
return str(self.shortcuts["shortcuts"][key]["AppName"])
except Exception as e:
logger.debug(e)
return unknown
except Exception as e:
logger.debug(e)
return unknown
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
Key case is not internally consistent and varies between client versions
Most keys use Pascal case, but some do not
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)
+45 -60
View File
@@ -15,7 +15,6 @@ from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
APP_NAME,
DAYZ_BINARY,
DEBIAN_STEAM_PATH,
DEFAULT_STEAM_PATH,
FLATPAK_STEAM_PATH,
@@ -32,6 +31,7 @@ from dzgui.const.endpoints import (
)
from dzgui.strings import wizard
from dzgui.util.bash import concat_bash_args
from dzgui.util.strings import unknown
logger = logging.getLogger(APP_NAME)
@@ -205,35 +205,21 @@ def launch_offline(
proc = subprocess.run([*client_args, *params])
return proc.returncode
def find_loginusers(path: Path) -> Path:
return path / "config" / "loginusers.vdf"
def find_user_id(path: Path) -> str | None:
resolved_path = find_loginusers(path)
resolved_path = path / "config" / "loginusers.vdf"
try:
with open(resolved_path, "r") as f:
v = vdf.load(f)
# NOTE: beta client
# /package/beta
if len(v["users"]) == 1:
return str(list(v["users"].keys())[0])
for user in v["users"]:
if v["users"][user]["MostRecent"] == "1":
return str(user)
return None
except Exception as e:
logger.debug(e)
logger.warn(e)
return None
def find_user_id_32(path: Path) -> int:
uid = find_user_id(path)
if uid is None:
raise ValueError("Failed to parse a valid Steam32 ID")
return int(uid) & 0xFFFFFFFF
def update_workshop(key: str, mod: int, endpoint: str) -> None:
payload: dict[str, Union[int, str]] = {
"publishedfileid": mod,
@@ -257,6 +243,20 @@ def unsubscribe(key: str, mod: int) -> None:
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()")
def enqueue_mod(client: str, mod: str, appid: int) -> None:
client_args = concat_bash_args(client)
@@ -305,38 +305,35 @@ def _get_running_app() -> int | None:
return None
# TODO: write tests
# TODO: consider moving to proc module
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)
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
return None
# TODO: write tests
def get_app_allows_downloads(path: Path, appid: int) -> bool:
try:
root_path = get_app_path(path, appid)
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
flag = ACF(acf).get_allows_downloads()
except AppNotInstalledError:
flag = 0
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:
@@ -347,17 +344,13 @@ def get_app_allows_downloads(path: Path, appid: int) -> bool:
# NOTE: never allow
case 2:
return False
# NOTE: no other known values at this time, fallback (assume permits)
# NOTE: no other known values at this time
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)
config = path.joinpath("config/config.vdf")
try:
with open(config) as f:
settings = vdf.load(f)
@@ -376,16 +369,8 @@ def get_client_allows_downloads(path: Path) -> bool:
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)
appid = get_running_app()
return appid in (APPID_DAYZ, APPID_DAYZ_EXP)
def get_app_path(folders_path: Path, appid: int) -> Path:
@@ -415,13 +400,13 @@ def get_app_path(folders_path: Path, appid: int) -> Path:
return Path(app_path)
def get_app_name(appid: int) -> str | None:
def get_app_name(appid: int) -> str:
payload = {"appids": [appid]}
res = requests.get(APP_DETAILS, params=payload)
if res.status_code != 200:
return None
return unknown
try:
return str(res.json()[str(appid)]["data"]["name"])
except Exception as e:
logger.debug(e)
return None
return unknown
+7 -4
View File
@@ -20,7 +20,6 @@ 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
@@ -35,7 +34,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(APP_NAME)
# TODO: profile load time
def make_parents(path: "Path") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def setup_logger(log_path: "Path") -> None:
@@ -122,10 +124,11 @@ def load_gui(version: str, is_debug: bool) -> None:
migrate_cols_file(XDG.columns)
copy_state_files(xdg_paths["XDG_STATE_HOME"])
# TODO: add logging inside wizard
wizard = SetupWizard(_is_steam_deck, XDG)
SetupWizard(_is_steam_deck, XDG.config)
if not wizard.is_setup_complete():
return
# 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:
+2 -1
View File
@@ -42,7 +42,6 @@ def rc2json(file: Path) -> str:
toggles = ["fullscreen"]
deprecated = [
"api_key",
"staging_dir",
"src_path",
"steam_path",
@@ -67,6 +66,8 @@ def rc2json(file: Path) -> str:
value = str2bool(ntok)
elif tok == "preferred_client":
tok = "client"
elif tok == "api_key":
tok = "bm_api"
elif tok == "ip_list":
while True:
ntok = lex.get_token()
+6 -31
View File
@@ -1,43 +1,18 @@
import stat
import textwrap
from pathlib import Path
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"""\
[Desktop Entry]
Version=1.0
Type=Application
Terminal=false
Exec={exe_path}
Exec={share_path}/launch.sh
Name=DZGUI
Comment=DayZ server browser and mod manager
Icon={icon}
Comment=dzgui
Icon={share_path}/dzgui
Categories=Game"""
copy_dzgui_to_xdg_data(exe_path)
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("Desktop/dzgui.desktop")
make_parents(link)
if link.exists():
link.unlink()
link.symlink_to(desktop_file)
file = share_path / "dzgui.desktop"
file.write_text(textwrap.dedent(template))
+1 -6
View File
@@ -18,7 +18,6 @@ class Xdg:
debug: Path
ips: Path
filters: Path
shortcut: Path
def is_writeable(path_str: str) -> bool:
@@ -68,7 +67,6 @@ def get_xdg_paths() -> dict:
def parse_filepaths(xdg: dict) -> Xdg:
config = xdg["XDG_CONFIG_HOME"]
state = xdg["XDG_STATE_HOME"]
share = xdg["XDG_DATA_HOME"]
config = config / "config.json"
@@ -83,8 +81,6 @@ def parse_filepaths(xdg: dict) -> Xdg:
ips = state / "ips.csv"
filters = state / "dzg.filters.json"
shortcut = share / APP_NAME_LOWER
return Xdg(
config,
columns,
@@ -95,6 +91,5 @@ def parse_filepaths(xdg: dict) -> Xdg:
system,
debug,
ips,
filters,
shortcut,
filters
)
+1
View File
@@ -2,6 +2,7 @@
Generic defaults used when initializing a config file from scratch
"""
config_boilerplate = {
"bm_api": "",
"fav_server": "",
"fav_label": "",
"name": "",
+1 -3
View File
@@ -72,9 +72,7 @@ LEGACY_IPS_PATH = ".local/share/dzgui/helpers/ips.csv"
DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG"
SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG"
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"
VDF_PATH = "steamapps/libraryfolders.vdf"
DEFAULT_STEAM_PATH = ".local/share/Steam"
+3
View File
@@ -7,6 +7,7 @@ SUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Subscribe/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?"
GITHUB = "https://github.com/aclist"
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
CODEBERG_RELEASES = "https://codeberg.org/api/v1/repos/aclist/dzgui/releases/latest"
@@ -26,3 +27,5 @@ GITHUB_ISSUES = "https://github.com/aclist/dzgui/issues"
FORUM = "https://old.reddit.com/r/dzgui"
SPONSORS = "https://github.com/sponsors/aclist"
STEAM_API_SETUP = "https://steamcommunity.com/dev/apikey"
BM_API_SETUP = "https://www.battlemetrics.com/developers"
BM_BROWSE = "https://www.battlemetrics.com/servers/dayz"
+3
View File
@@ -61,6 +61,9 @@ class Preferences(EnumWithAttrs):
STEAM = {
"key": "steam_api",
}
BM = {
"key": "bm_api",
}
CLIENT = {
"key": "client",
}
+9 -3
View File
@@ -413,7 +413,7 @@ class Controller(GObject.GObject):
def add_by_str(self, addr: str) -> None:
saved_tree = self.get_servers().get_saved()
ServerModelManager(self, saved_tree).add_by_ip(addr)
ServerModelManager(self, saved_tree).add_by_str(addr)
def add_by_record(self, record: "Record") -> None:
saved_tree = self.get_servers().get_saved()
@@ -437,8 +437,14 @@ class Controller(GObject.GObject):
ServerModelManager(self, tv).remove_from_history(record)
def connect_by_str(self, addr: str) -> None:
self.connection_man = ConnectionManager(self)
self.connection_man.connect_by_ip(addr)
if addr.isdigit():
config_man = self.get_config_man()
key = config_man.lookup(Preferences.BM)
self.connection_man = ConnectionManager(self)
self.connection_man.connect_by_id(int(addr), key)
else:
self.connection_man = ConnectionManager(self)
self.connection_man.connect_by_ip(addr)
def connect_by_record(self, record: "Record") -> None:
self.connection_man = ConnectionManager(self)
+28 -31
View File
@@ -1,7 +1,6 @@
# Changelog
## [7.0.0] Unreleased
### Added
## Added
- Setup wizard
- Changelog text wrapping and formatting
- Changelog ships with source
@@ -42,10 +41,8 @@
- Choose to jump into splash screen instead of server
- Collapsible connection panel
- 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
- Reduce padding on keys button
- Boldface breadcrumbs
@@ -62,58 +59,58 @@
- Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client
### Dropped
## Dropped
- Debug mode
- Branch switching
- Manual mod install mode (describe rationale)
- Force update mods
### Fixed
## Fixed
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
- Center server title text on server dialogs
- Rare segfaults when changing maps (threading)
- Moved dialogs out of threads
### Unreleased
## Unreleased
- Load offline mods
- Local documentation
- Raw debug command in context menu
### Developer-facing
## Developer-facing
- Add pyproject.toml file
- Show deprecation warnings
- Options -> Dev page
## [6.0.5] 2026-05-17
### Changed
## Changed
- Update symlink hash logic to align with DZGUI 7 beta
- Update A2S helper to latest commit
## [6.0.4] 2026-04-13
### Fixed
## Fixed
- Update A2S module checksums to support servers with missing description field
## [6.0.3] 2026-04-12
### Fixed
## Fixed
- Use GLibUnix namespace where available when setting up SIGINT callback
## [6.0.2] 2026-01-26
### Fixed
## Fixed
- Explicitly use Python 3.13 when calling subprocesses
- Raise error correctly from module
- Do not spawn dialogs from inside of thread when fetching prereqs
### Changed
## Changed
- Optimize time complexity of startup checks
- Optimize coordinate calculation by using local records first
- Rename some internal functions
- Wrap entire startup process in dialog
## [6.0.1] 2026-01-16
### Fixed
## Fixed
- Explicitly use Python 3.13 when calling subprocesses
## [6.0.0] 2026-01-14
### Added
## Added
- Support DayZ Experimental
- Ping display on server tables
- Save descriptive text notes on a per-server basis
@@ -139,7 +136,7 @@
- Commandline usage help text (GaryBlackbourne)
- More descriptive help dialogs when initial dependencies are missing
### Fixed
## Fixed
- Script failing to start when remote endpoints are unavailable (GaryBlackbourne)
- Key stickiness when quickly navigating through entries in tables
- 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
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
### Changed
## Changed
- Require Python 3.13
- Reduced global API cooldown from 60s to 30s
- Clarify dialog messages when DayZ path could not be found
@@ -190,50 +187,50 @@
- Rewrote distance calculation module (GaryBlackbourne)
- Changed preferred client setting from radio toggle to combobox
### Dropped
## Dropped
- Ping readout in statusbar
- Extraneous information from right statusbar
## [5.8.3] 2026-01-04
### Fixed
## Fixed
- Normalize checksum numbers and dates
## [5.8.2] 2026-01-04
### Fixed
## Fixed
- Fix regression in symlink routine introduced by previous hotfix
## [5.8.1] 2025-12-27
### Fixed
## Fixed
- 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
## [5.8.0] 2025-07-06
### Added
## Added
- Filter servers by official/unofficial status
- Automatically fetch geolocation records
### Changed
## Changed
- Updated internal versioning of helper files
### Fixed
## Fixed
- Corrected erroneous 2024 date in prior changelog entries
## [5.7.0] 2025-04-17
### Added
## Added
- 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
### Changed
## Changed
- More direct server ping query method that should return marginally more accurate times
## [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
## [5.6.6] 2025-03-16
### Changed
## Changed
- Update IP database records for 2025-03
## [5.6.5] 2025-03-04
### Fixed
## Fixed
- Livonia server results being dropped from batch queries
## [5.6.4] 2025-02-10
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3 -1
View File
@@ -12,7 +12,7 @@ from dzgui.const.constants import (
WINDOW_DEFAULT_X,
WINDOW_DEFAULT_Y,
)
from dzgui.api.probe import test_steam_api
from dzgui.api.probe import test_steam_api, test_bm_api
from dzgui.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.views.dialogs.generic import ExceptionDialog
@@ -102,6 +102,8 @@ class ConfigManager:
def update_api_key(self, key: Preferences, text: str) -> None:
if key is Preferences.STEAM:
res = test_steam_api(text)
else:
res = test_bm_api(text)
if res is True:
self.update_config(key, text)
else:
+7 -22
View File
@@ -9,15 +9,11 @@ from typing import TYPE_CHECKING
import dzgui.api.pefile as PeFile
import dzgui.api.servers as Servers
from dzgui.api.shortcuts import Shortcuts
from dzgui.api.steam import (
connect,
get_app_allows_downloads,
get_app_name,
get_needs_update,
get_remote_signatures,
get_running_app,
get_needs_update,
load_to_menu,
subscribe,
)
@@ -86,7 +82,6 @@ class Prerequisites:
mods: list[list[str]]
game_mode: bool
is_last_server: bool
allows_downloads: tuple[bool, str]
class ConnectionManager:
@@ -103,6 +98,11 @@ class ConnectionManager:
self.remote_mod_ids: list[str] = []
self.missing_mods: list[tuple[str, str, int, int]] = []
@call_on_thread(dialog.querying)
def connect_by_id(self, _id: int, key: str) -> None:
res = Servers.query_by_id(_id, key)
self._prepare_connection(res)
@call_on_thread(dialog.querying)
def connect_by_ip(self, addr: str) -> None:
res = Servers.query_by_ip(addr)
@@ -167,20 +167,6 @@ class ConnectionManager:
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)
# NOTE: for out-of-range appid, assume NSG.
# dzgui.api.steam.get_app_name() will return None
if running_app_name is None:
s = Shortcuts(steam_path)
# NOTE: returns "Unknown" if unparseable
running_app_name = s.find_appname_by_unsigned_id(running_app)
allows_downloads = (allows_dl, running_app_name)
else:
allows_downloads = (True, "")
client_name = self.controller.get_steam_client_name()
client = self.controller.query_config(Preferences.CLIENT)
running = is_steam_running(client)
@@ -206,7 +192,6 @@ class ConnectionManager:
mods=remote_mods,
game_mode=game_mode,
is_last_server=is_last,
allows_downloads=allows_downloads,
)
func = StoredFunc(self.controller.open_connection_assistant, prereqs)
@@ -240,7 +225,7 @@ class ConnectionManager:
) -> tuple[list[list[str]], list[tuple[str, str, int, int]]]:
mods = Servers.get_rules(record)
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]] = [
[
+1 -1
View File
@@ -76,7 +76,7 @@ class OfflineManager:
window.set_sensitive(True)
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(
self,
appid: int,
+4 -3
View File
@@ -1,6 +1,7 @@
import re
from typing import Any, TYPE_CHECKING, Union
from warnings import deprecated
from dzgui.const.enum import FilterMode
from dzgui.model.model_factory import ModelFactory
@@ -85,8 +86,8 @@ class ProxyModelManager:
if found is False:
self.control_model.append(history)
if len(self.control_model) == 11:
del self.control_model[0]
# if len(self.control_model) == 11:
# del self.control_model[0]
self.filter(FilterMode.INITIAL, skip_cache=True)
@@ -258,7 +259,7 @@ class ProxyModelManager:
def set_cache(self, filters: tuple[str], filtered_rows: list[tuple]) -> None:
self.filter_cache[filters] = filtered_rows
# @deprecated("Currently unused")
@deprecated("Currently unused")
# def convert_model_to_list(self, model: "FastInsertListStore") -> list:
# return [[el for el in row] for row in model]
+27 -20
View File
@@ -15,7 +15,7 @@ from dzgui.const.constants import (
from dzgui.const.enum import FilterMode, Preferences, ServerTab
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import dialogs
from dzgui.util.strings import server_timeout, dialog
from dzgui.util.strings import api_warn_msg, dialog
from dzgui.views.dialogs.generic import ExceptionDialog
import gi
@@ -175,29 +175,29 @@ class ServerModelManager:
# TODO: wrap except
if self.controller.get_exit_event().is_set():
return
res = future.result(timeout=API_TIMEOUT)
self.thread_man.increment_dialog()
"""
Collects futures in linear order after completion
for lock-step representation of the state file
"""
for future in futures:
res = future.result(timeout=API_TIMEOUT)
# NOTE: failing entries are culled
if res is None:
# TODO: log which servers failed
# query_direct() currently does not return enough information
continue
servers.append(res)
if len(servers) == 0:
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
# NOTE: failing entries are culled
if res is None:
# TODO: log which servers failed
continue
servers.append(res)
if len(servers) == 0:
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
parsed = Servers.parse_json(servers)
self._push_data(parsed)
@call_on_thread(dialog.querying)
def add_by_id(self, _id: str) -> None:
config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.BM)
res = Servers.query_by_id(int(_id), key)
self._parse_single_record(res)
@call_on_thread(dialog.querying)
def add_by_ip(self, addr: str) -> None:
res = Servers.query_by_ip(addr)
@@ -241,6 +241,7 @@ class ServerModelManager:
self.first_iteration = False
self.emitter.emit("load_maps", store)
# self.emitter.emit("servers_loaded_init")
# TODO: dataclass for record rows; check for other dict annotations
def add_to_history(self, data: tuple[dict[str, Any], "Record"]) -> None:
@@ -262,6 +263,12 @@ class ServerModelManager:
proxy_man.remove_row_from_control(record)
self.update_history()
def add_by_str(self, addr: str) -> None:
if addr.isdigit():
self.add_by_id(addr)
else:
self.add_by_ip(addr)
@call_on_thread(dialog.querying)
def update_playercount(
self, treeiter: Gtk.TreeIter, record: Servers.Record
@@ -419,7 +426,7 @@ class ServerModelManager:
# TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty"
# customize statusbar and dialog accordingly
if show_dialog:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog = ExceptionDialog(self.controller, api_warn_msg)
dialog.run()
def _push_data(self, data: list[Any]) -> None:
+3 -2
View File
@@ -5,9 +5,10 @@ add_tooltip="Add to Saved Servers"
connect_button="Connect"
connect_tooltip="Connect to this server"
connect_entry_tooltip=(
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016"
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n"
"- Battlemetrics: numeric server ID\ne.g. 123456"
)
connect_entry_placeholder="Enter IP (IP:Query port)"
connect_entry_placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)"
fav_heading="Favorite server"
favs_empty="None set. Right click a server and select 'Set favorite' to set."
-3
View File
@@ -1,7 +1,4 @@
heading = "Play offline"
heading_disclaimer = (
"Not all mod combinations will function offline. Use at your own discretion."
)
mission_button = "Select mission folder"
mission_frame = "Mission"
+5 -19
View File
@@ -46,6 +46,11 @@ You must set up a Steam Web API key in order to browse the global server list.
\nIf you don't have one already, it can be set up via the page below.
\nPlease refer to the DZGUI documentation for more instructions.
"""
heading_bm_api = "Battlemetrics Web API key"
blurb_bm_api = """A Battlemetrics key is <b>optional</b>, but allows you to add/search for servers\n
by numeric ID on the web. For example, in the URL https://www.battlemetrics.net/servers/dayz/24819107,\n
the ID would be <b>24819107</b>.
"""
### PreferencesPage
heading_prefs = "User preferences"
@@ -59,25 +64,6 @@ radio_mi = "mi (miles)"
label_dist = "Distance display"
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
heading_completion = "Setup complete"
+1 -1
View File
@@ -41,10 +41,10 @@ def print_mods(mods: list[int]) -> str:
def write_diagnostic(config: Path, outfile: Path) -> None:
# TODO: test availability on other distros
date = datetime.now().isoformat()
try:
# FIXME: ID_LIKE not available on CachyOS
distro = platform.freedesktop_os_release()["ID_LIKE"]
except Exception as e:
logger.warn(e)
-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
+1 -2
View File
@@ -4,9 +4,8 @@ from typing import Literal
api_filter = r"(.*&key=)([^&]*)(.*)"
home_filter = r"(/home/)([^\s'\/]*)(.*)"
user_filter = r"(.*Steam/userdata/)([^/]*)(.*)"
REDACTED = r"\1REDACTED\3"
REDACTION_PATTERNS = [api_filter, home_filter, user_filter]
REDACTION_PATTERNS = [api_filter, home_filter]
def redact_home(text: str) -> str:
+15 -5
View File
@@ -100,7 +100,10 @@ steam_missing = "Local Steam installation is not set, possibly malformed config
build_corrupted = (
"Steam settings or DayZ installation may be corrupted. Try restarting Steam."
)
server_timeout = "Timed out when querying server(s). Check IP or try again later."
api_warn_msg = """No servers returned. Please wait and try again.
This usually indicates a transitory network timeout. If this issue persists, your API key or network may be malfunctioning.
"""
server_timeout = "Timed out when querying server, check IP or try again later."
server_error = (
"Error while contacting server, possibly timed out. Please wait and try again."
)
@@ -283,8 +286,11 @@ class Thanks:
class Options:
header: str
steam_web: str
bm_web: str
enter_steam: str
enter_bm: str
steam_placeholder: str
bm_placeholder: str
name_placeholder: str
last_used: str
always_fs: str
@@ -305,7 +311,7 @@ class Options:
force_eventbox: str
self_update: str
no_self_update: str
api_key: str
api_keys: str
prefs: str
mods: str
version: str
@@ -372,8 +378,11 @@ thanks = Thanks(
options = Options(
header="Options",
steam_web="Steam API page",
bm_web="Battlemetrics API page",
enter_steam="Enter your Steam API key",
enter_bm="Enter your Battlemetrics API key",
steam_placeholder="Steam API key",
bm_placeholder="Battlemetrics API key",
name_placeholder="Identifies you to other players in-game",
last_used="Last used dimensions",
always_fs="Always fullscreen",
@@ -402,7 +411,7 @@ options = Options(
"In-app updates are disabled when DZGUI is "
"installed globally (e.g., via package manager)."
),
api_key="API Key",
api_keys="API Keys",
prefs="Preferences",
mods="Mods",
version="Version",
@@ -540,9 +549,10 @@ connect_panel = ConnectPanel(
connect="Connect",
add="Add",
add_con="Add/connect",
placeholder="Enter IP (IP:Query port)",
placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)",
entry_tooltip=(
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016"
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n"
"- Battlemetrics: numeric server ID\ne.g. 123456"
),
add_tooltip="Add to Saved Servers",
connect_tooltip="Connect to this server",
+1 -18
View File
@@ -1,4 +1,4 @@
from typing import Callable, Literal, Self, TYPE_CHECKING, Union
from typing import Callable, Self, TYPE_CHECKING, Union
from dzgui.util.clip import copy_clipboard
from dzgui.util.format import pluralize
@@ -66,15 +66,6 @@ class IconButton(Gtk.Button):
# self.set_image_position(Gtk.PositionType.RIGHT)
self.set_focus_on_click(False)
def swap_icon(self, icon: str) -> None:
start = self.icon.get_margin_start()
end = self.icon.get_margin_end()
alt_icon = Icon(icon, margin_start=start, margin_end=end)
self.set_image(alt_icon)
def revert_icon(self) -> None:
self.set_image(self.icon)
class IconTextButton(IconButton):
def __init__(
@@ -105,18 +96,11 @@ class ClipboardButton(IconTextButton):
self.controller = controller
self.connect("clicked", self._on_button_clicked, func)
# TODO: strings
self.set_tooltip_text("Copy to clipboard")
def _on_button_clicked(self, button: Self, func: Callable) -> None:
def revert() -> Literal[False]:
self.revert_icon()
return False
self.swap_icon("object-select-symbolic")
data = func()
copy_clipboard(data)
GLib.timeout_add(600, revert)
# TODO: determine when controller would be passed to this button or drop
@@ -124,7 +108,6 @@ class CopyIpButton(ClipboardButton):
def __init__(self, controller: Union["Controller", None], func: Callable) -> None:
super().__init__(controller, func)
# TODO: strings
self.set_tooltip_text("Copy IP to clipboard")
+7 -5
View File
@@ -26,12 +26,15 @@ def validate_port(text: str) -> bool:
return False
def validate_ip_truthy(text: str) -> bool:
def validate_ip_or_id(text: str) -> bool:
try:
validate_ip(text)
return True
except Exception:
return False
if text.isdigit():
return True
else:
return False
class ValidatedEntry(Gtk.Entry):
@@ -121,7 +124,7 @@ class IpEntry(ValidatedEntry):
def __init__(self, controller: "Controller") -> None:
super().__init__(
controller,
func=validate_ip_truthy,
func=validate_ip_or_id,
placeholder_text=connect_panel.placeholder,
tooltip_text=connect_panel.entry_tooltip,
)
@@ -159,8 +162,7 @@ class APIEntry(Gtk.Box):
self.entry.connect("icon-release", self._on_icon_release)
self.entry.connect("activate", self._on_field_activated)
# TODO: strings
self.submit = Gtk.Button(label="Validate")
self.submit = Gtk.Button(label="Submit")
self.submit.set_sensitive(False)
self.submit.connect("clicked", self._on_submit)
-3
View File
@@ -30,9 +30,6 @@ class InfoEventBox(Gtk.EventBox):
self.connect("leave-notify-event", self._on_leave_tooltip)
self.add(box)
def set_icon_yalign(self, offset: float) -> None:
self.icon.set_property("yalign", offset)
def _on_enter_tooltip(
self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing
) -> None:
-1
View File
@@ -131,7 +131,6 @@ class RightPanel(Gtk.Box):
return
self.copying = True
version = self.version_label.get_text()
# TODO: strings
self.version_label.set_text("Copied!")
copy_clipboard(version)
GLib.timeout_add_seconds(1, revert)
+1 -1
View File
@@ -41,7 +41,7 @@ class BootDialog(Gtk.Dialog):
def __init__(self, parent: "BootWindow", xdg: "Xdg", version: str) -> None:
super().__init__(
title=dialog_header,
transient_for=parent,
parent=parent,
modal=True,
)
+1 -1
View File
@@ -13,7 +13,7 @@ class FilePicker(Gtk.FileChooserDialog):
super().__init__(
title=picker.title,
action=Gtk.FileChooserAction.SAVE,
transient_for=parent,
parent=parent,
resizable=True,
)
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL)
+6 -9
View File
@@ -111,22 +111,19 @@ class WaitDialog(GenericDialog):
self.connect("delete-event", lambda widget, event: True)
content = self.get_content_area()
spinner = Gtk.Spinner()
self.prog = Gtk.ProgressBar()
spinner_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.CENTER
)
spinner = Gtk.Spinner()
spinner_box.add(spinner)
content.pack_end(self.cancel, NO_EXPAND, NO_FILL, 0)
content.pack_end(spinner, NO_EXPAND, NO_FILL, 0)
if self.jobs > 1:
content.pack_start(self.prog, NO_EXPAND, NO_FILL, 0)
content.pack_end(self.prog, NO_EXPAND, NO_FILL, 0)
else:
content.pack_start(spinner_box, NO_EXPAND, NO_FILL, 0)
spinner.start()
if show_cancel is True:
content.pack_start(self.cancel, NO_EXPAND, NO_FILL, 0)
if show_cancel is False:
self.connect("realize", lambda _: self.cancel.set_visible(False))
def update_text(self, msg: str) -> None:
self.format_secondary_text(msg)
+51 -153
View File
@@ -1,13 +1,11 @@
import os
import textwrap
from enum import Enum
from importlib import resources
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
from dzgui.api.shortcuts import add_steam_shortcut
from dzgui.api.probe import test_steam_api, test_bm_api
from dzgui.api.steam import get_steam_paths
from dzgui.const.constants import (
APP_NAME,
@@ -16,10 +14,7 @@ from dzgui.const.constants import (
LEGACY_CONFIG_PATH,
)
from dzgui.const.boilerplate import config_boilerplate
from dzgui.const.endpoints import STEAM_API_SETUP
from dzgui.const.enum import Preferences
from dzgui.config import freedesktop
from dzgui.config.query import lookup
from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP
from dzgui.init.migrate import migrate_legacy_conf
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import wizard
@@ -35,27 +30,17 @@ import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402
if TYPE_CHECKING:
from dzgui.config.xdg import Xdg
class PageNum(Enum):
INTRO = 1
HAS_CONFIG = 2
STEAM_PATH = 3
STEAM_API = 4
USER_PREFS = 5
SHORTCUTS = 6
BM_API = 5
USER_PREFS = 6
FINAL = 7
class OptionalPageMixin:
"""Marks optional pages as advanceable"""
def _on_map(self, page: "ScrolledWizardPage") -> None:
EMITTER.emit("step_complete")
class DescriptionArea(Gtk.Box):
def __init__(self, text: str):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
@@ -73,7 +58,7 @@ class Progress(Gtk.ProgressBar):
class ScrolledWizardPage(Gtk.ScrolledWindow):
def __init__(self, enum: PageNum, heading: str, description: str):
super().__init__(overlay_scrolling=False)
super().__init__()
self.enum = enum
self.page_type: Gtk.AssistantPageType
@@ -97,7 +82,6 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
margin_top=50,
spacing=20,
)
self.add(self.box)
self.prog = Progress()
self.box.pack_end(self.prog, expand=False, fill=False, padding=0)
@@ -157,10 +141,6 @@ class NotificationFrame(Gtk.Frame):
if error:
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):
def __init__(
@@ -222,6 +202,23 @@ class APIValidationPage(ScrolledWizardPage):
self.spinner.stop()
class BMValidationPage(APIValidationPage):
def __init__(self) -> None:
super().__init__(
enum=PageNum.BM_API,
heading=wizard.heading_bm_api,
description=wizard.blurb_bm_api,
link=BM_API_SETUP,
func=self._validate,
)
@call_on_thread("", show_dialog=False)
def _validate(self, key: str) -> None:
is_valid = test_bm_api(key.strip())
cleanup = StoredFunc(self._cleanup, is_valid, key)
self.thread_man.set_cleanup_func(cleanup)
class SteamValidationPage(APIValidationPage):
def __init__(self) -> None:
super().__init__(
@@ -342,7 +339,6 @@ class ConfigMigrationPage(ScrolledWizardPage):
def _on_import_clicked(self, button: Gtk.Button) -> None:
self.grid.set_sensitive(False)
try:
# TODO: this could be deferred to the final page (prevents accidental destruction of dialog via ESC)
migrate_legacy_conf(self.config)
self.migrated = True
self.success_box.set_visible(True)
@@ -427,27 +423,23 @@ class CompletionPage(ScrolledWizardPage):
class Assistant(Gtk.Assistant):
def __init__(self, is_deck: bool, XDG: "Xdg"):
def __init__(self, is_deck: bool, config: Path):
super().__init__()
if is_deck:
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
self.set_default_size(1085, 670)
self.fullscreen()
else:
self.set_default_size(1500, 900)
self.is_binary = False if os.getenv("PYAPP") is None else True
self.config_path = XDG.config
self.config_path = config
self.config_values: dict[str, Any] = config_boilerplate
self.setup_complete = False
self.page1 = IntroductionPage()
self.page2 = ConfigMigrationPage(XDG.config)
self.page2 = ConfigMigrationPage(config)
self.page3 = SteamPathPage()
self.page4 = SteamValidationPage()
self.page5 = PreferencesPage()
self.page6 = ShortcutCreationPage(XDG.shortcut)
self.page5 = BMValidationPage()
self.page6 = PreferencesPage()
self.page7 = CompletionPage()
self.set_forward_page_func(self._advance_page)
@@ -468,13 +460,7 @@ class Assistant(Gtk.Assistant):
self.page7,
):
# NOTE: skip config migration page if no legacy config file
if (
isinstance(page, ConfigMigrationPage)
and self.has_legacy_config is False
):
continue
# NOTE: disabled for now on system-provided packages
if isinstance(page, ShortcutCreationPage) and not self.is_binary:
if page == self.page2 and self.has_legacy_config is False:
continue
self._add_page(page, page.get_page_type())
@@ -489,38 +475,30 @@ class Assistant(Gtk.Assistant):
def _advance_page(self, index: int) -> int:
page = self.get_nth_page(index)
# TODO: use enums
match page:
case IntroductionPage():
case self.page1:
pass
case ConfigMigrationPage():
if page.is_migrated():
steam_path = lookup(self.config_path, Preferences.DEFAULT)
self.page6.set_steam_path(steam_path)
offset = 1 if not self.is_binary else 2
self.setup_complete = True
return self.get_n_pages() - offset
case SteamPathPage():
case self.page2:
if self.page2.is_migrated():
return self.get_n_pages() - 1
case self.page3:
self.config_values["default_steam_path"] = page.get_path_from_radio()
case SteamValidationPage():
case self.page4:
self.config_values["steam_api"] = page.get_api_key()
case PreferencesPage():
# NOTE: collects config values before advancing to last page
name, use_miles, client = self.page5.get_prefs()
case self.page5:
self.config_values["bm_api"] = page.get_api_key()
# NOTE: collects config values before advancing to last page
case self.page6:
name, use_miles, client = self.page6.get_prefs()
self.config_values["name"] = name
self.config_values["use_miles"] = use_miles
self.config_values["client"] = client
self.write_config()
self.page6.set_steam_path(self.config_values["default_steam_path"])
self.setup_complete = True
case ShortcutCreationPage():
page.create_shortcuts()
case _:
raise AttributeError("Trying to advance a non-canonical page")
return index + 1
def is_setup_complete(self) -> bool:
return self.setup_complete
def destroy_and_quit(self, widget: Self) -> None:
self.destroy()
Gtk.main_quit()
@@ -557,91 +535,14 @@ class Assistant(Gtk.Assistant):
bar.set_fraction(fraction)
bar.set_text(f"{page_num}/{total}")
if not isinstance(page, IntroductionPage):
# NOTE: disable forward action
# TODO: use page enums
if page == self.page5:
return
if page != self.page1:
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(OptionalPageMixin, ScrolledWizardPage): # type: ignore
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 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):
def __init__(self) -> None:
super().__init__(
@@ -698,20 +599,17 @@ class SteamPathPage(ScrolledWizardPage):
class SetupWizard(Gtk.Application):
def __init__(self, is_deck: bool, XDG: "Xdg") -> None:
def __init__(self, is_deck: bool, config: Path) -> None:
super().__init__()
GLib.set_prgname(APP_NAME)
self.win = Window(is_deck, XDG)
Window(is_deck, config)
Gtk.main()
def is_setup_complete(self) -> int:
return self.win.assistant.is_setup_complete()
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)
self.assistant = Assistant(is_deck, XDG)
Assistant(is_deck, config)
class Emitter(GObject.GObject):
+5 -54
View File
@@ -1,5 +1,4 @@
import logging
import re
from typing import TYPE_CHECKING
from importlib import resources
@@ -33,63 +32,15 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
# TODO: should long text be wrapped?
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.connect("key-press-event", self._on_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:
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
+4 -14
View File
@@ -41,18 +41,9 @@ class FolderError(Enum):
FOLDER_CHANGED = 3
class PageHeading(Gtk.Box):
def __init__(self, label: str, controller: "Controller") -> None:
super().__init__(
halign=Gtk.Align.CENTER, orientation=Gtk.Orientation.HORIZONTAL
)
self.label = Gtk.Label(label=label)
self.eb = InfoEventBox(offline.heading_disclaimer, controller)
self.eb.set_icon_yalign(0.7)
for el in self.label, self.eb:
self.add(el)
class PageHeading(Gtk.Label):
def __init__(self, label: str) -> None:
super().__init__(label=label, halign=Gtk.Align.CENTER)
css.add_class(self, "page-heading")
@@ -443,8 +434,7 @@ class OfflineLoader(Gtk.Box):
self.emitter = controller.get_emitter()
self.offline_man = OfflineManager(controller)
heading = PageHeading(offline.heading, controller)
self.add(heading)
self.add(PageHeading(offline.heading))
self.local_frame = ModFrame(self, controller, offline.local_frame)
self.custom_frame = CustomModFrame(self, controller, offline.custom_frame)
+40 -7
View File
@@ -15,7 +15,7 @@ from dzgui.const.constants import (
VIEW_CONCEAL,
VIEW_REVEAL,
)
from dzgui.const.endpoints import STEAM_API_SETUP
from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP
from dzgui.const.enum import Preferences, ServerTab
from dzgui.strings import errors, options
from dzgui.util import strings, css, open_links
@@ -66,15 +66,23 @@ class Options(Gtk.Box):
self.add(label)
self.steam_entry: Gtk.Entry
self.bm_entry: Gtk.Entry
self.steam = WebButton(label=strings.options.steam_web)
self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP)
self.bm = WebButton(label=strings.options.bm_web)
self.bm.connect("clicked", self._on_link_button_clicked, BM_API_SETUP)
self.steam_box = self._make_submit_field(
strings.options.enter_steam, Preferences.STEAM, True
)
self.bm_box = self._make_submit_field(
strings.options.enter_bm, Preferences.BM, True
)
api_rows = [
[LeftLabel(strings.options.steam_placeholder), self.steam_box, self.steam],
[LeftLabel(strings.options.steam_placeholder), self.steam_box],
[LeftLabel(strings.options.bm_placeholder), self.bm_box],
]
self.player_box = self._make_submit_field(
@@ -136,6 +144,18 @@ class Options(Gtk.Box):
api_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
api_grid = self._make_grid(api_rows)
api_box.add(api_grid)
api_links_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
margin_start=5,
margin_end=5,
margin_top=5,
margin_bottom=10,
homogeneous=True,
spacing=10,
)
api_links_box.add(self.steam)
api_links_box.add(self.bm)
api_box.add(api_links_box)
prefs_grid = self._make_grid(pref_rows)
version_grid = self._make_grid(version_rows)
@@ -156,7 +176,7 @@ class Options(Gtk.Box):
grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT)
for pair in [
(api_box, strings.options.api_key),
(api_box, strings.options.api_keys),
(prefs_grid, strings.options.prefs),
(version_grid, strings.options.version),
]:
@@ -175,11 +195,13 @@ class Options(Gtk.Box):
return str(model[ind][0])
def block_text_entry(self) -> None:
self.steam_entry.set_position(-1)
self.steam_entry.set_can_focus(False)
for entry in self.steam_entry, self.bm_entry:
entry.set_position(-1)
entry.set_can_focus(False)
def unblock_text_entry(self) -> None:
self.steam_entry.set_can_focus(True)
for entry in self.steam_entry, self.bm_entry:
entry.set_can_focus(True)
def _on_developers_clicked(self, button: Gtk.Button) -> None:
self.controller.show_developers_page()
@@ -212,6 +234,8 @@ class Options(Gtk.Box):
if context == Preferences.STEAM:
self.steam_entry = entry
else:
self.bm_entry = entry
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
box.add(entry)
@@ -258,7 +282,7 @@ class Options(Gtk.Box):
case Preferences.NAME:
value = entry.get_text().strip()
self.controller.update_config(enum, value)
case Preferences.STEAM:
case Preferences.BM | Preferences.STEAM:
text = "".join(entry.get_text().split())
self.controller.update_api_key(enum, text)
@@ -274,6 +298,9 @@ class Options(Gtk.Box):
def revert(self, mode: Preferences) -> None:
if mode == Preferences.STEAM:
self.steam_entry.set_text(self.old_steam)
else:
self.bm_entry.set_text(self.old_bm)
pass
def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None:
_iter = combo.get_active_iter()
@@ -309,6 +336,8 @@ class Options(Gtk.Box):
old = self.old_name
case Preferences.STEAM:
old = self.old_steam
case Preferences.BM:
old = self.old_bm
if text == old:
return False
return True
@@ -372,13 +401,16 @@ class Options(Gtk.Box):
name = self.controller.query_config(Preferences.NAME)
default_steam_path = self.controller.query_config(Preferences.DEFAULT)
steam = self.controller.query_config(Preferences.STEAM)
bm = self.controller.query_config(Preferences.BM)
steam_path = Path(default_steam_path)
self.old_steam = steam
self.old_bm = bm
self.old_name = name
self.steam_entry.set_text(steam)
self.bm_entry.set_text(bm)
p = self.player_box.get_children()[0]
if hasattr(p, "set_text"):
p.set_text(name)
@@ -396,6 +428,7 @@ class Options(Gtk.Box):
for field in (
[name, self.player_box],
[steam, self.steam_box],
[bm, self.bm_box],
):
if field[0] == "":
field[1].get_children()[1].set_sensitive(False)
-9
View File
@@ -272,15 +272,6 @@ class PreConnectionAssistant(Gtk.Box):
"It looks like DayZ is already running in the background. Exit DayZ before connecting."
)
allows_dl, running_app = prereqs.allows_downloads
if len(prereqs.mods) > 0 and 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_errors(errors)
+3 -2
View File
@@ -1,6 +1,7 @@
import logging
from typing import Any, Optional, TYPE_CHECKING
from warnings import deprecated
from dzgui.const.constants import APP_NAME, SEPARATOR
from dzgui.util.keys import is_ctrl_mask, is_navkey
@@ -172,7 +173,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
(model, pathlist) = sels
return (model, pathlist)
# @deprecated("Currently unused")
@deprecated("Currently unused")
# def get_mpath(self) -> Optional[Gtk.TreePath]:
# (model, pathlist) = self.get_model_and_pathlist()
# if len(pathlist) < 1:
@@ -194,7 +195,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
return True
return False
# @deprecated("unused")
@deprecated("unused")
# def get_selected_row(self) -> Optional[Gtk.TreeModelRow]:
# ind = self.get_selected_row_index()
# model = self.get_model()
+1 -1
View File
@@ -53,7 +53,7 @@ class LogTreeView(ContextMixin, TreeView): # type: ignore
model = ModelFactory().new_model_from_logfile(filepath)
_filter = model.filter_new()
_filter.set_visible_func(self._filter_rows)
sortable = Gtk.TreeModelSort.new_with_model(_filter)
sortable = Gtk.TreeModelSort(_filter)
self.set_model(sortable)
_filter.refilter()
path = Gtk.TreePath.new_from_indices([0])
+3 -5
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [
{name = "aclist"}
]
version = "7.0.0b20"
version = "7.0.0b15"
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
readme = "README.md"
@@ -76,7 +76,7 @@ build-backend = "setuptools.build_meta"
line-length = 88
indent-width = 4
extend-exclude = ["*lib/a2s"]
lint.unfixable = ["F401", "I001"]
lint.unfixable = ["F401"]
[tool.ruff.format]
quote-style = "double"
@@ -107,13 +107,11 @@ config-settings-package = { pygobject-stubs = { config = "Gtk3,Gdk3,Soup2" } }
[tool.pytest.ini_options]
markers = [
"apitest: checks remote endpoints",
"config: config file keys/values",
"realconfig: depends on actual generated local config file",
"mods: tests mod metadata/link creation",
"pefile: validate PE files",
"post_install: requires a completed installation",
"redact: log redaction mechanisms",
"slow: long-running tests",
"webtest: depends on remote endpoint",
"webtest: checks remote endpoints"
]
-1
View File
@@ -12,7 +12,6 @@ This file includes the combined licenses to the following components:
- psutil
- PyGObject
- python-a2s
- vdf
When shipped as a binary installer, these components are collectively bundled
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"
}
}
}
}
-13
View File
@@ -1,13 +0,0 @@
"users"
{
"0"
{
"AccountName" "STEAMUSER"
"PersonaName" "STEAMUSER"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "0"
}
}
-14
View File
@@ -1,14 +0,0 @@
"users"
{
"0"
{
"AccountName" "STEAMUSER"
"PersonaName" "STEAMUSER"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "1"
"Timestamp" "0"
}
}
@@ -1,25 +0,0 @@
"users"
{
"0"
{
"AccountName" "STEAMUSER"
"PersonaName" "STEAMUSER"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "0"
"Timestamp" "0"
}
"1"
{
"AccountName" "STEAMUSER2"
"PersonaName" "STEAMUSER2"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "1"
"Timestamp" "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.
-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
+4 -6
View File
@@ -7,12 +7,13 @@ from dzgui.config.xdg import get_xdg_paths, parse_filepaths
from dzgui.config import convert
from tests.fixtures import fixture_path
pytestmark = pytest.mark.config
@pytest.fixture
def legacy_config():
return fixture_path("dztuirc_1")
@pytest.fixture
def unset_values():
return fixture_path("dztuirc_3")
@@ -21,6 +22,7 @@ def unset_values():
@pytest.fixture
def keys():
return [
"bm_api",
"fav_server",
"fav_label",
"name",
@@ -36,7 +38,6 @@ def keys():
# TODO: use a static fixture instead of system config
@pytest.fixture
@pytest.mark.realconfig
def config():
paths = get_xdg_paths()
xdg = parse_filepaths(paths)
@@ -44,21 +45,18 @@ def config():
@pytest.mark.post_install
@pytest.mark.realconfig
def test_invalid_config_value(config):
with pytest.raises(Exception):
assert config["foo"] is None
@pytest.mark.post_install
@pytest.mark.realconfig
def test_default_config_values(keys, config):
for key in keys:
assert config[key] is not None
@pytest.mark.post_install
@pytest.mark.realconfig
def test_contains_invalid_values(keys, config):
for key in config:
assert key in keys
@@ -83,6 +81,7 @@ def test_key_conversion(legacy_config):
j = convert.rc2json(legacy_config)
j = json.loads(j)
keys = [
"api_key",
"staging_dir",
"src_path",
"steam_path",
@@ -92,7 +91,6 @@ def test_key_conversion(legacy_config):
for key in keys:
assert key not in j
def test_missing_values(unset_values):
j = convert.rc2json(unset_values)
j = json.loads(j)
-20
View File
@@ -1,24 +1,6 @@
import pytest
from dzgui.api.servers import validate_ip
from dzgui.views.components.entry import validate_ip_truthy
@pytest.mark.parametrize(
"ip",
[
("foo", False),
("999", False),
("192.168.1.101", False),
("192.168.1.101:", False),
(":1", False),
("192.168.1.101:27016", True),
],
)
def test_ip_entry(ip) -> None:
string, state = ip
assert validate_ip_truthy(string) is state
def test_ip_validation() -> None:
ip = "192.168.1.1:100"
@@ -33,7 +15,6 @@ def test_invalid_port() -> None:
with pytest.raises(Exception):
validate_ip(ip)
@pytest.mark.parametrize("port", [-1, 65536])
def test_port_out_of_range(port: int) -> None:
ip = f"192.168.1.1:{port}"
@@ -46,7 +27,6 @@ def test_invalid_socket() -> None:
with pytest.raises(Exception):
validate_ip(ip)
# TODO: ?
def test_ipdb() -> None:
pass
-8
View File
@@ -37,14 +37,6 @@ class RecordsListHandler(logging.Handler):
"Error in directory: '/home/SENSITIVE_USERNAME'",
"Error in directory: '/home/REDACTED'",
),
(
"User directory: /home/user/.local/share/Steam/userdata/999999/grid",
"User directory: /home/REDACTED/.local/share/Steam/userdata/REDACTED/grid",
),
(
"User directory: /drive/.local/share/Steam/userdata/999999",
"User directory: /drive/.local/share/Steam/userdata/REDACTED",
),
],
)
def test_log_redaction(log_error: str, expect: str) -> None:
-25
View File
@@ -1,25 +0,0 @@
import pytest
from pathlib import Path
from dzgui.api.steam import find_user_id
from tests.fixtures import fixture_path
pytestmark = pytest.mark.apitest
@pytest.mark.parametrize(
"fixture, expect",
[
("api/loginusers_legacy_client.vdf", 0),
("api/loginusers_legacy_client_multiple.vdf", 1),
("api/loginusers_beta_client.vdf", 0),
],
)
def test_loginusers(monkeypatch, fixture: str, expect: int) -> None:
def mock_loginusers(path: Path) -> Path:
return fixture_path(fixture)
monkeypatch.setattr("dzgui.api.steam.find_loginusers", mock_loginusers)
uid = find_user_id(Path(""))
assert int(uid) == expect
+12 -26
View File
@@ -1,14 +1,8 @@
import pytest
from pathlib import Path
import dzgui.api.pefile as PeFile
from dzgui.api.steam import (
VDFLoadError,
AppNotInstalledError,
AppMovedError,
get_app_path
)
from dzgui.api.steam import VDFLoadError, AppNotInstalledError, AppMovedError, get_app_path
from dzgui.config.query import get_config
from dzgui.config.xdg import get_xdg_paths, parse_filepaths
@@ -16,7 +10,12 @@ from dzgui.const.constants import APPID_DAYZ
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
@@ -27,8 +26,6 @@ def default_steam_path():
return conf["default_steam_path"]
# TODO: split into separate test file
# FIXME: use fixtures
def test_pefile_length(default_steam_path):
try:
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),
],
)
def test_not_in_library(monkeypatch, fixture, exception):
import dzgui.api.steam as steam
monkeypatch.setattr(steam, "LIBRARYFOLDERS_PATH", fixture)
folder_path = Path(fixture_path("api"))
def test_not_in_library(fixture, exception):
fixture = fixture_path(fixture)
with pytest.raises(exception):
try:
get_app_path(folder_path, APPID_DAYZ)
get_app_path(fixture, APPID_DAYZ)
except Exception as e:
raise e
@pytest.fixture
def second_drive():
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)
def test_on_second_drive(second_drive):
path = get_app_path(second_drive, APPID_DAYZ)
assert path == Path("/tmp")
+4 -2
View File
@@ -13,11 +13,13 @@ def config():
xdg = parse_filepaths(paths)
return get_config(xdg.config)
def test_ipdb():
assert probe.test_ipdb()
def test_steam(config):
key = config["steam_api"]
assert probe.test_steam_api(key)
def test_bm(config):
key = config["bm_api"]
assert probe.test_bm_api(key)
-91
View File
@@ -1,91 +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: dict[str, str]) -> 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: dict[str, str]) -> 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: dict[str, str]) -> 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
def test_shortcut_crc(dummy_app: dict[str, str]) -> None:
s = Shortcuts(Path(""))
s.add_shortcut(*dummy_app.values())
for key in s.shortcuts["shortcuts"].keys():
entry = s.shortcuts["shortcuts"][key]
name = entry["AppName"]
exe = entry["Exe"]
uid = name + exe
bpid = s.gen_bpid(uid)
assert entry["appid"] & 0xFFFFFFFF == bpid
def test_reverse_crc(dummy_app: dict[str, str]) -> None:
s = Shortcuts(Path(""))
s.add_shortcut(*dummy_app.values())
for key in s.shortcuts["shortcuts"].keys():
entry = s.shortcuts["shortcuts"][key]
name = entry["AppName"]
exe = entry["Exe"]
uid = name + exe
bpid = s.gen_bpid(uid)
assert s.find_appname_by_unsigned_id(bpid) == name