Compare commits

..
27 Commits
Author SHA1 Message Date
aclist b47cb482f5 Merge pull request #412 from aclist/fix/deck-unfullscreen-dimensions
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
fix: set explicitly safe dimensions for Steam Deck
2026-07-21 16:03:20 +09:00
aclist 152999d728 fix: set explicitly safe dimensions for Steam Deck 2026-07-21 16:01:48 +09:00
aclist cdcb3b794f Merge pull request #411 from aclist/fix/deck-unfullscreen
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
fix: delegate dialog size to system settings on Steam Deck
2026-07-21 15:22:37 +09:00
aclist cd23f403c9 fix: try delegating size to desktop resolution 2026-07-21 15:21:34 +09:00
aclist a40bef8f82 fix: hotfix for Steam OS fullscreen bug 2026-07-21 15:16:42 +09:00
aclist 5c07a77b63 Merge pull request #409 from aclist/fix/beta-client-vdf
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
fix: handle alternate client versions when parsing vdf
2026-07-15 11:29:54 +09:00
aclist 91773c933c chore: bump version 2026-07-15 11:26:01 +09:00
aclist 6a30ed44fc fix: handle alternate client versions when parsing vdf 2026-07-15 11:25:47 +09:00
aclist bbaec35a5f Merge pull request #408 from aclist/fix/conditional-warning
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
fix: make warning conditional when mods > 0
2026-07-14 17:40:09 +09:00
aclist c46477f0a7 fix: make warning conditional when mods > 0 2026-07-14 17:28:46 +09:00
aclist f47446a2e1 Merge pull request #407 from aclist/fix/parse-nsg-ids
fix: parse NSG ids
2026-07-14 17:18:22 +09:00
aclist cdb311cb1f chore: clear typehinting errors 2026-07-14 17:14:11 +09:00
aclist 11cdcb62b2 chore: drop unused import 2026-07-14 17:08:11 +09:00
aclist 1913f69e7b chore: drop unused import 2026-07-14 17:07:56 +09:00
aclist 8c2cea0fdc chore: bump version 2026-07-14 17:07:47 +09:00
aclist 8c29b9b35a fix: parse out-of-band appids as NSGs 2026-07-14 17:06:40 +09:00
aclist d8239f1c34 Merge pull request #405 from aclist/chore/update-licenses
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
chore: list missing library
2026-07-12 12:34:32 +09:00
aclist 8c3ec4512a chore: list missing library 2026-07-12 12:34:00 +09:00
aclist 1a18c8ff02 Merge pull request #404 from aclist/fix/app-path-tests
chore: update tests to reflect get_app_path() changes
2026-07-12 12:23:05 +09:00
aclist 92c23ec609 chore: update tests to reflect get_app_path() changes 2026-07-12 12:22:12 +09:00
aclist e9d4054e76 Merge pull request #403 from aclist/prerelease/7.0.0b16
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
chore: bump version
2026-07-12 10:35:11 +09:00
aclist 1c9e026f70 chore: bump version 2026-07-12 10:34:24 +09:00
aclist eb076b7dcd Merge pull request #402 from aclist/fix/clean-acf
chore: drop comment
2026-07-12 10:32:27 +09:00
aclist 8629e02670 chore: drop comment 2026-07-12 10:30:06 +09:00
aclist 3ba38f30ad Merge pull request #401 from aclist/feat/collapsible-changelog
feat: collapsible changelog
2026-07-12 10:28:18 +09:00
aclist 7c37fd8b6e Merge pull request #400 from aclist/feat/shortcuts
feat: generate Steam shortcut
2026-07-12 10:27:27 +09:00
aclist 27d444ea6f feat: parse changelog chunks 2026-07-11 22:59:28 +09:00
20 changed files with 251 additions and 74 deletions

No files matched your search

+29 -28
View File
@@ -1,6 +1,7 @@
# Changelog
## Added
## [7.0.0] Unreleased
### Added
- Setup wizard
- Changelog text wrapping and formatting
- Changelog ships with source
@@ -44,7 +45,7 @@
- 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
@@ -61,58 +62,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
@@ -138,7 +139,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
@@ -177,7 +178,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
@@ -189,50 +190,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
-1
View File
@@ -86,7 +86,6 @@ 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")
+26 -10
View File
@@ -12,6 +12,7 @@ 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)
@@ -46,6 +47,25 @@ class Shortcuts:
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
@@ -111,22 +131,18 @@ class Shortcuts:
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
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
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["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"] = ""
@@ -134,7 +150,7 @@ class Shortcuts:
NEW_ENTRY["IsHidden"] = 0
NEW_ENTRY["AllowDesktopConfig"] = 1
NEW_ENTRY["AllowOverlay"] = 1
NEW_ENTRY["openvr"] = 0
NEW_ENTRY["OpenVR"] = 0
NEW_ENTRY["Devkit"] = 0
NEW_ENTRY["DevkitGameID"] = ""
NEW_ENTRY["DevkitOverrideAppID"] = 0
+21 -9
View File
@@ -32,23 +32,25 @@ 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)
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
@@ -203,21 +205,28 @@ 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 = path / "config" / "loginusers.vdf"
resolved_path = find_loginusers(path)
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.warn(e)
logger.debug(e)
return None
def find_user_id_32(path: Path) -> int:
uid = find_user_id(path)
if uid is None:
@@ -322,9 +331,12 @@ def get_running_app() -> int | 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()
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
match flag:
# NOTE: adheres to global client setting
case 0:
@@ -403,13 +415,13 @@ def get_app_path(folders_path: Path, appid: int) -> Path:
return Path(app_path)
def get_app_name(appid: int) -> str:
def get_app_name(appid: int) -> str | None:
payload = {"appids": [appid]}
res = requests.get(APP_DETAILS, params=payload)
if res.status_code != 200:
return unknown
return None
try:
return str(res.json()[str(appid)]["data"]["name"])
except Exception as e:
logger.debug(e)
return unknown
return None
+7
View File
@@ -9,6 +9,7 @@ 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,
@@ -175,6 +176,12 @@ class ConnectionManager:
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, "")
+2 -1
View File
@@ -440,7 +440,8 @@ class Assistant(Gtk.Assistant):
def __init__(self, is_deck: bool, XDG: "Xdg"):
super().__init__()
if is_deck:
self.fullscreen()
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
self.set_default_size(1085, 670)
else:
self.set_default_size(1500, 900)
+54 -5
View File
@@ -1,4 +1,5 @@
import logging
import re
from typing import TYPE_CHECKING
from importlib import resources
@@ -32,15 +33,63 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
# TODO: should long text be wrapped?
self.controller = controller
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.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10)
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
+1 -1
View File
@@ -273,7 +273,7 @@ class PreConnectionAssistant(Gtk.Box):
)
allows_dl, running_app = prereqs.allows_downloads
if allows_dl is False:
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"
+2 -2
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [
{name = "aclist"}
]
version = "7.0.0b15"
version = "7.0.0b19"
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"]
lint.unfixable = ["F401", "I001"]
[tool.ruff.format]
quote-style = "double"
+1
View File
@@ -12,6 +12,7 @@ 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
File renamed without changes.
+13
View File
@@ -0,0 +1,13 @@
"users"
{
"0"
{
"AccountName" "STEAMUSER"
"PersonaName" "STEAMUSER"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "0"
}
}
+14
View File
@@ -0,0 +1,14 @@
"users"
{
"0"
{
"AccountName" "STEAMUSER"
"PersonaName" "STEAMUSER"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "1"
"Timestamp" "0"
}
}
@@ -0,0 +1,25 @@
"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"
}
}
File renamed without changes.
File renamed without changes.
+25
View File
@@ -0,0 +1,25 @@
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
+26 -12
View File
@@ -1,8 +1,14 @@
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
@@ -10,12 +16,7 @@ from dzgui.const.constants import APPID_DAYZ
from tests.fixtures import fixture_path
pytestmark = pytest.mark.pefile
@pytest.fixture
def second_drive():
return fixture_path("in_library_on_second_drive.vdf")
pytestmark = pytest.mark.apitest
@pytest.fixture
@@ -26,6 +27,8 @@ 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)
@@ -52,15 +55,26 @@ def test_invalid_pefile_path():
("malformed.vdf", VDFLoadError),
],
)
def test_not_in_library(fixture, exception):
fixture = fixture_path(fixture)
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"))
with pytest.raises(exception):
try:
get_app_path(fixture, APPID_DAYZ)
get_app_path(folder_path, APPID_DAYZ)
except Exception as e:
raise e
def test_on_second_drive(second_drive):
path = get_app_path(second_drive, APPID_DAYZ)
@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)
assert path == Path("/tmp")
+5 -5
View File
@@ -30,9 +30,9 @@ def test_no_shortcuts(monkeypatch) -> None:
@pytest.fixture
def dummy_app() -> None:
d = {
"appname": "TEST APP",
"AppName": "TEST APP",
"StartDir": "TEST_DIR",
"exe": "TEST_DIR/TEST_EXE.EXE",
"Exe": "TEST_DIR/TEST_EXE.EXE",
"icon": "IMAGES_DIR/TEST_IMAGE.PNG",
}
return d
@@ -41,8 +41,8 @@ def dummy_app() -> None:
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] == '"'
assert s.shortcuts["shortcuts"]["0"]["Exe"][0] == '"'
assert s.shortcuts["shortcuts"]["0"]["Exe"][-1] == '"'
def test_add_shortcut(dummy_app) -> None:
@@ -51,7 +51,7 @@ def test_add_shortcut(dummy_app) -> None:
new = s.shortcuts["shortcuts"]
ind = str(len(new) - 1)
for k, v in dummy_app.items():
if k == "exe":
if k == "Exe":
v = f'"{v}"'
assert new[ind][k] == v