Compare commits

...
Author SHA1 Message Date
aclist 903f8fc31d chore: update example.png 2026-11-05 22:19:19 +09:00
aclist 6b583f68d6 chore: bump version 2026-11-05 22:04:05 +09:00
aclist 3b3ccd0de6 chore: update changelog 2026-11-05 22:03:31 +09:00
aclist 70aacaa866 chore: update debounce comments 2026-11-05 22:03:03 +09:00
aclist 7582ab4d3b Merge pull request #513 from aclist/feat/sqlite
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
feat: sqlite
2026-11-05 21:56:27 +09:00
aclist 225a2a148e Merge pull request #511 from aclist/feat/validate-mod
feat: validate mod
2026-11-05 21:38:44 +09:00
aclist 9117655ebd Merge pull request #518 from aclist/fix/catch-record-timeout
fix: handle query timeout on add/remove actions
2026-11-05 21:30:29 +09:00
aclist fdf92dca40 Merge pull request #521 from aclist/feat/last-updated
feat: last updated (patch)
2026-11-05 21:27:57 +09:00
aclist a97766855d chore: dclear typehinting errors
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-11-05 21:27:07 +09:00
aclist 3097701e98 change: drop explicit import 2026-11-05 21:26:37 +09:00
aclist 895e08d228 fix: error handling 2026-11-05 21:26:20 +09:00
aclist 08a6f698a5 Merge pull request #514 from aclist/feat/last-updated
feat: last updated timestamp
2026-11-05 21:21:41 +09:00
aclist 78be8c93b7 Merge pull request #516 from aclist/chore/test-list-store
chore: add tests
2026-11-05 21:17:05 +09:00
aclist 8a2889d15d Merge pull request #515 from aclist/fix/insensitive-filters
fix: prevent toggling when panel is insensitive
2026-11-05 21:16:19 +09:00
aclist 1a33f71716 Merge pull request #520 from aclist/feat/unmanned-screenshot
feat: unmanned screenshot
2026-11-05 21:15:02 +09:00
aclist ae13dc9a89 chore: add newline 2026-11-05 21:14:27 +09:00
aclist d5706f969c change: use base class type 2026-11-05 21:09:42 +09:00
aclist 790e34b20c chore: clear typehinting errors 2026-11-05 21:09:10 +09:00
aclist 35230ae03f feat: capture composited screenshot 2026-11-05 21:08:52 +09:00
aclist 596d3a59ac fix: handle query timeout on add/remove actions 2026-11-04 17:50:45 +09:00
aclist e7cef215c6 drop: resolved comment
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-11-02 12:19:10 +09:00
aclist 12daf041ff chore: add tests 2026-11-02 12:17:50 +09:00
aclist 90ef6cbdbb chore: add tests 2026-11-02 12:17:04 +09:00
aclist 2cc26b6573 fix: prevent toggling when panel is insensitive 2026-11-01 23:47:06 +09:00
aclist 0ec1c157f4 chore: clear typehinting errors 2026-11-01 23:45:01 +09:00
aclist 0cf6439538 chore: remove unused imports 2026-11-01 23:43:12 +09:00
aclist e3c1ef59ee feat: get last updated timestamp 2026-11-01 23:42:06 +09:00
aclist 80680b86b0 chore: update test decorator 2026-10-28 15:46:00 +09:00
aclist a4ac357c8f feat: validate atomic mods 2026-10-28 15:44:30 +09:00
aclist 728b8b8242 Merge pull request #509 from aclist/fix/marks-on-fixtures
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: do not apply marks to fixtures
2026-10-26 20:07:47 +09:00
aclist 3b51392d39 fix: do not apply marks to fixtures 2026-10-26 20:06:56 +09:00
aclist 159e2da355 Merge pull request #508 from aclist/prerelease/7.0.0b29-patch-4
chore: update test
2026-10-26 19:42:25 +09:00
aclist b83a333cea chore: update test 2026-10-26 19:42:03 +09:00
30 changed files with 449 additions and 44 deletions

No files matched your search

+7
View File
@@ -53,6 +53,10 @@
- Expand/collapse changelog versions - Expand/collapse changelog versions
- Support local light/dark mode changes on system theme - Support local light/dark mode changes on system theme
- Server time calculator - Server time calculator
- Add "Unmodded" filter to servers
- List timestamp of last DayZ client update
- Validate mods via Mod page to pull in updates
- Support for Nasdara (Badlands) DLC
### Changed ### Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
@@ -74,6 +78,7 @@
- Enable LAN page Empty/Full filters on first run of app - Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client - Propagate subscribed mods to Steam client
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow - Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
- Use SQLite DB for geolocation records
### Dropped ### Dropped
- Battlemetrics API support - Battlemetrics API support
@@ -92,6 +97,8 @@
- Moved dialogs out of threads - Moved dialogs out of threads
- History table not being in lock-step with state file - History table not being in lock-step with state file
- Current keyword filter not being applied when refreshing table - Current keyword filter not being applied when refreshing table
- Steam Flatpak cannot run DZGUI as a non-Steam game
- Collisions between native Steam and Steam Flatpak clients
### Unreleased ### Unreleased
- Load offline mods - Load offline mods
+28 -3
View File
@@ -1,15 +1,18 @@
import logging import logging
import re import re
from collections.abc import Iterator from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, TYPE_CHECKING
from warnings import deprecated from warnings import deprecated
from dzgui.const.constants import APP_NAME from dzgui.const.constants import APP_NAME
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from collections.abc import Iterator
class ACF: class ACF:
def __init__(self, file: Path) -> None: def __init__(self, file: Path) -> None:
@@ -42,7 +45,7 @@ class ACF:
def dequote(cls, s: str) -> str: def dequote(cls, s: str) -> str:
return s.rstrip('"').lstrip('"') return s.rstrip('"').lstrip('"')
def parse(self, lines: Iterator[list[str]]) -> dict[str, str]: def parse(self, lines: "Iterator[list[str]]") -> dict[str, str]:
acf: dict[str, Any] = {} acf: dict[str, Any] = {}
try: try:
while True: while True:
@@ -69,6 +72,28 @@ class ACF:
except StopIteration: except StopIteration:
return acf return acf
def search_key(self, key: str) -> str:
return str(self.dict["AppState"][key])
def date_to_str(self, epoch: int) -> str | None:
if epoch == 0:
return None
return datetime.fromtimestamp(epoch).isoformat(" ")
def get_date_by_key(self, key: str) -> str | None:
try:
stamp = int(self.search_key(key))
return self.date_to_str(stamp)
except Exception as e:
logger.critical(e)
return None
def get_last_updated(self) -> str | None:
return self.get_date_by_key("LastUpdated")
def get_last_played(self) -> str | None:
return self.get_date_by_key("LastPlayed")
def get_allows_downloads(self) -> int | None: def get_allows_downloads(self) -> int | None:
try: try:
# TODO: consider coercing strs to ints on initial import # TODO: consider coercing strs to ints on initial import
+30 -3
View File
@@ -12,6 +12,7 @@ from typing import Any, Generator, Union
from warnings import deprecated from warnings import deprecated
from dzgui.api.acf import ACF from dzgui.api.acf import ACF
from dzgui.const.endpoints import VALIDATE
from dzgui.init.prereqs import has_any_client from dzgui.init.prereqs import has_any_client
from dzgui.init.prereqs import has_flatpak_client, has_steam_client from dzgui.init.prereqs import has_flatpak_client, has_steam_client
from dzgui.const.constants import ( from dzgui.const.constants import (
@@ -316,10 +317,9 @@ def unsubscribe(key: str, mod: int) -> None:
update_workshop(key, mod, UNSUB_ENDPOINT) update_workshop(key, mod, UNSUB_ENDPOINT)
@deprecated("Use subscribe()") def validate_mod(client: str, mod: str) -> None:
def enqueue_mod(client: str, mod: str, appid: int) -> None:
client_args = concat_bash_args(client) client_args = concat_bash_args(client)
subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod]) subprocess.Popen([*client_args, "+workshop_download_item", str(APPID_DAYZ), mod])
@deprecated("Cf. https://github.com/ValveSoftware/steam-for-linux/issues/9672") @deprecated("Cf. https://github.com/ValveSoftware/steam-for-linux/issues/9672")
@@ -389,6 +389,26 @@ def get_running_app() -> int | None:
return None return None
def get_last_updated(path: Path, appid: int) -> str | None:
root_path = get_app_path(path, appid)
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
try:
return ACF(acf).get_last_updated()
except Exception as e:
logger.critical(e)
return None
def get_last_played(path: Path, appid: int) -> str | None:
root_path = get_app_path(path, appid)
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
try:
return ACF(acf).get_last_played()
except Exception as e:
logger.critical(e)
return None
def get_app_allows_downloads(path: Path, appid: int) -> bool: def get_app_allows_downloads(path: Path, appid: int) -> bool:
try: try:
root_path = get_app_path(path, appid) root_path = get_app_path(path, appid)
@@ -500,3 +520,10 @@ def get_app_name(appid: int) -> str | None:
except Exception as e: except Exception as e:
logger.debug(e) logger.debug(e)
return None return None
@deprecated("Currently unused")
def validate_dayz(client: str) -> None:
client_args = concat_bash_args(client)
uri = VALIDATE + str(APPID_DAYZ)
subprocess.Popen([*client_args, uri])
+3 -3
View File
@@ -121,7 +121,7 @@ def load_steam_icon() -> None:
logger.critical(e) logger.critical(e)
def load_gui(version: str, is_debug: bool) -> None: def build_gui(version: str, is_debug: bool) -> App | None:
lock = lock_acquire() # noqa lock = lock_acquire() # noqa
set_locale() set_locale()
@@ -151,7 +151,7 @@ def load_gui(version: str, is_debug: bool) -> None:
wizard = SetupWizard(_is_steam_deck, XDG, xdg_paths) wizard = SetupWizard(_is_steam_deck, XDG, xdg_paths)
if not wizard.is_setup_complete(): if not wizard.is_setup_complete():
return return None
setup_logger(XDG.debug) setup_logger(XDG.debug)
with open(XDG.debug, "w") as f: with open(XDG.debug, "w") as f:
@@ -186,4 +186,4 @@ def load_gui(version: str, is_debug: bool) -> None:
print(boot.all_ok) print(boot.all_ok)
load_steam_icon() load_steam_icon()
app = App(prefs) app = App(prefs)
app.run(None) return app
+1
View File
@@ -18,6 +18,7 @@ COORDS_API = "http://ip-api.com/json"
# steam URIs # steam URIs
WORKSHOP = "steam://url/CommunityFilePage/" WORKSHOP = "steam://url/CommunityFilePage/"
OPEN_URL = "steam://openurl/" OPEN_URL = "steam://openurl/"
VALIDATE = "steam://validate/"
# user facing # user facing
GITHUB_PAGES = "https://aclist.github.io/dzgui/index" GITHUB_PAGES = "https://aclist.github.io/dzgui/index"
+6 -1
View File
@@ -157,6 +157,7 @@ class ContextMenu(EnumWithAttrs):
COPY_SERVER_IP = {"label": strings.copy_ip} COPY_SERVER_IP = {"label": strings.copy_ip}
COPY_SERVER_NAME = {"label": strings.copy_name} COPY_SERVER_NAME = {"label": strings.copy_name}
UNSUB_MOD = {"label": strings.unsub_mod} UNSUB_MOD = {"label": strings.unsub_mod}
VALIDATE_MOD = {"label": strings.validate_mod}
OPEN_WORKSHOP = {"label": strings.open_workshop} OPEN_WORKSHOP = {"label": strings.open_workshop}
REFRESH_PLAYERS = {"label": strings.refresh_players} REFRESH_PLAYERS = {"label": strings.refresh_players}
REMOVE_HISTORY = {"label": strings.remove_history} REMOVE_HISTORY = {"label": strings.remove_history}
@@ -172,7 +173,7 @@ class ContextMenuGroup(Enum):
""" """
SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,) SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,)
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.UNSUB_MOD) MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.UNSUB_MOD, ContextMenu.VALIDATE_MOD)
MOD_OFFLINE = (None,) MOD_OFFLINE = (None,)
LOG = (ContextMenu.COPY_LOG_CLIPBOARD,) LOG = (ContextMenu.COPY_LOG_CLIPBOARD,)
SERVER_BROWSER = ( SERVER_BROWSER = (
@@ -246,6 +247,10 @@ class ModButton(EnumWithAttrs):
"label": strings.mod_panel.select_stale, "label": strings.mod_panel.select_stale,
"tooltip": strings.mod_panel.select_stale_tooltip, "tooltip": strings.mod_panel.select_stale_tooltip,
} }
VALIDATE = {
"label": strings.mod_panel.validate,
"tooltip": strings.mod_panel.validate_tooltip,
}
class ButtonType(EnumWithAttrs): class ButtonType(EnumWithAttrs):
+10
View File
@@ -102,6 +102,16 @@ class Controller(GObject.GObject):
except AttributeError: except AttributeError:
logger.critical(f"{attr} is not a valid AppNavigation attribute.") logger.critical(f"{attr} is not a valid AppNavigation attribute.")
def validate_mods(
self, treeview: Union["ModTreeView", "OfflineModTreeView", None] = None
) -> None:
if treeview is None:
view = self.mediator.modtreeview
else:
view = treeview
mod_man = view.get_mod_man()
mod_man.validate_mods()
def get_prefs(self) -> UserPrefs: def get_prefs(self) -> UserPrefs:
return self.prefs return self.prefs
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 361 KiB

+5 -2
View File
@@ -42,6 +42,9 @@ def main() -> None:
test_libgi_missing() test_libgi_missing()
# NOTE: only import ligbirepository modules after the above check # NOTE: only import ligbirepository modules after the above check
from dzgui.app_init import load_gui from dzgui.app_init import build_gui
load_gui(version, args.debug) app = build_gui(version, args.debug)
if app is None:
sys.exit(0)
app.run(None)
+6 -2
View File
@@ -75,10 +75,14 @@ class ContextMenuManager:
if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)): if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)):
match action: match action:
case ContextMenu.UNSUB_MOD:
self.controller.unsub_mods(self.treeview)
case ContextMenu.OPEN_WORKSHOP: case ContextMenu.OPEN_WORKSHOP:
self.open_mod_page() self.open_mod_page()
case ContextMenu.UNSUB_MOD:
self.controller.unsub_mods(self.treeview)
case ContextMenu.VALIDATE_MOD:
self.controller.validate_mods(self.treeview)
case _:
pass
if isinstance(self.treeview, LogTreeView): if isinstance(self.treeview, LogTreeView):
match action: match action:
+26 -2
View File
@@ -4,7 +4,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.api.steam import unsubscribe from dzgui.api.steam import unsubscribe, validate_mod
from dzgui.api.mods import ( from dzgui.api.mods import (
get_delimited_mods, get_delimited_mods,
find_stale_mods, find_stale_mods,
@@ -85,7 +85,7 @@ class ModManager:
total_mods = len(self.store) total_mods = len(self.store)
self.emitter.emit("mods_updated", msg, total_mods) self.emitter.emit("mods_updated", msg, total_mods)
def unsub_mods(self) -> None: def get_selected_mods(self) -> list[tuple[str, Gtk.TreeIter]]:
sel = self.treeview.get_selection() sel = self.treeview.get_selection()
model, pathlist = sel.get_selected_rows() model, pathlist = sel.get_selected_rows()
# NOTE: reverse when multiple selection # NOTE: reverse when multiple selection
@@ -96,6 +96,15 @@ class ModManager:
continue continue
mod, _iter = res mod, _iter = res
mods.append((mod, _iter)) mods.append((mod, _iter))
return mods
def validate_mods(self) -> None:
mods = self.get_selected_mods()
self.thread_man.set_job_count(len(mods))
self.validate_all_mods(mods)
def unsub_mods(self) -> None:
mods = self.get_selected_mods()
self.thread_man.set_job_count(len(mods)) self.thread_man.set_job_count(len(mods))
self.unsub_all_mods(mods) self.unsub_all_mods(mods)
@@ -109,6 +118,21 @@ class ModManager:
mod = model.get_value(tree_iter, 2) mod = model.get_value(tree_iter, 2)
return mod, tree_iter return mod, tree_iter
def validate_atomic_mod(self, mod: str) -> None:
# NOTE: propagation to dayz exp will happen during
# subsequent symlink phase on connect
client = self.controller.query_config(Preferences.CLIENT)
validate_mod(client, mod)
@call_on_thread(dialogs.validating_mods)
def validate_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
for mod, _iter in mods:
self.validate_atomic_mod(mod)
self.thread_man.increment_dialog()
time.sleep(API_RATE_LIMIT)
func = StoredFunc(lambda: None)
self.thread_man.set_cleanup_func(func)
@call_on_thread(dialogs.deleting_mods) @call_on_thread(dialogs.deleting_mods)
def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None: def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
for mod, _iter in mods: for mod, _iter in mods:
+30 -7
View File
@@ -201,19 +201,43 @@ class ServerModelManager:
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def add_by_ip(self, addr: str) -> None: def add_by_ip(self, addr: str) -> None:
res = Servers.query_by_ip(addr) self.preserve_on_fail = True
self._parse_single_record(res) try:
res = Servers.query_by_ip(addr)
self._parse_single_record(res)
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def add_by_record(self, record: Servers.Record) -> None: def add_by_record(self, record: Servers.Record) -> None:
self.preserve_on_fail = True
# NOTE: rationale: a raw Record as shown in server browser may resolve to a different IP # NOTE: rationale: a raw Record as shown in server browser may resolve to a different IP
res = Servers.query_by_record(record) try:
self._parse_single_record(res) res = Servers.query_by_record(record)
self._parse_single_record(res)
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def remove_by_record(self, record: Servers.Record) -> None: def remove_by_record(self, record: Servers.Record) -> None:
res = Servers.query_by_record(record) self.preserve_on_fail = True
self._parse_single_record(res, delete=True) try:
res = Servers.query_by_record(record)
self._parse_single_record(res, delete=True)
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
def update_history( def update_history(
self, self,
@@ -280,7 +304,6 @@ class ServerModelManager:
proxy_man.update_playercount(self.playercount) proxy_man.update_playercount(self.playercount)
def _parse_single_record(self, response: "A2SInfo", delete: bool = False) -> None: def _parse_single_record(self, response: "A2SInfo", delete: bool = False) -> None:
self.preserve_on_fail = True
try: try:
row = response.as_row() row = response.as_row()
except Exception as e: except Exception as e:
+1
View File
@@ -9,6 +9,7 @@ update_success = "Updated successfully. Please exit and relaunch."
load_error_lan = "Failed to find any servers on your network.\nCheck the server query port or your firewall settings." load_error_lan = "Failed to find any servers on your network.\nCheck the server query port or your firewall settings."
fetching_mods = "Fetching mod metadata" fetching_mods = "Fetching mod metadata"
deleting_mods = "Unsubscribing mods" deleting_mods = "Unsubscribing mods"
validating_mods = "Validating mods"
scanning_mods = "Scanning mods" scanning_mods = "Scanning mods"
parsing_mods = "Parsing mods" parsing_mods = "Parsing mods"
+80
View File
@@ -0,0 +1,80 @@
from pathlib import Path
from typing import cast, TYPE_CHECKING
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.views.base import OuterWindow
from dzgui.views.mixins.context_mixin import ContextMixin
from gi.repository import GdkPixbuf
# TODO: randomize/mock PII distance values
class IdleScreenshot:
def __init__(
self, controller: "Controller", window: "OuterWindow", scrot_path: Path
) -> None:
self.window = window
self.controller = controller
self.scrot_path = scrot_path
clock = window.get_frame_clock()
if clock is None:
raise TypeError("Widget not realized, FrameClock not initialized")
clock.connect("after-paint", self.callback)
def callback(self, clock: Gdk.FrameClock) -> None:
GLib.timeout_add(1000, self.check_idle)
def check_idle(self) -> bool:
tv = self.controller.get_active_treeview()
loaded = tv.is_loaded()
if loaded is True:
self.emit_key_press(tv)
GLib.timeout_add(1000, self.save_and_quit)
return True
return False
def emit_key_press(self, widget: "ContextMixin") -> None:
window = widget.get_parent_window()
if window is None:
raise TypeError(f"Failed to find a valid parent window for {widget}")
event = Gdk.EventKey.new(Gdk.EventType.KEY_PRESS)
# NOTE: GTK introspection returns Gdk.Event type
event_key = cast(Gdk.EventKey, event)
event_key.keyval = Gdk.KEY_l
event_key.state = Gdk.ModifierType.CONTROL_MASK
event_key.window = window
event_key.time = Gdk.CURRENT_TIME
event_key.send_event = True
# TODO: suppress errors from synthetic key events
widget.present_menu(widget, event)
def save_and_quit(self) -> None:
self.save_pixbuf()
self.controller.save_res_and_quit()
def save_pixbuf(self) -> None:
pixbuf = self.capture_application()
if pixbuf:
pixbuf.savev(str(self.scrot_path), "png", [], [])
def capture_application(self) -> "GdkPixbuf.Pixbuf":
# NOTE: composited context menu over X11 window
root = Gdk.get_default_root_window()
xwindow = self.window.get_window()
if xwindow is None:
raise ValueError("Failed to get a valid X11 window")
index, x, y = xwindow.get_origin()
width, height = self.window.get_size()
application = Gdk.pixbuf_get_from_window(root, x, y, width, height)
if application is None:
raise ValueError("Failed to get a valid GdkPixbuf")
return application
+5
View File
@@ -31,6 +31,7 @@ show_details = "Show server details"
refresh_players = "Refresh player count" refresh_players = "Refresh player count"
open_workshop = "Open in Steam Workshop" open_workshop = "Open in Steam Workshop"
unsub_mod = "Unsubscribe mod" unsub_mod = "Unsubscribe mod"
validate_mod = "Validate mod"
copy_name = "Copy name to clipboard" copy_name = "Copy name to clipboard"
copy_ip = "Copy IP to clipboard" copy_ip = "Copy IP to clipboard"
copy_log = "Copy record(s) to clipboard" copy_log = "Copy record(s) to clipboard"
@@ -200,6 +201,8 @@ class ModPanelStrings:
clear_highlights: str clear_highlights: str
select_stale: str select_stale: str
select_stale_tooltip: str select_stale_tooltip: str
validate: str
validate_tooltip: str
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
@@ -319,6 +322,8 @@ mod_panel = ModPanelStrings(
clear_highlights="Clears highlights and reverts the table to a default state", clear_highlights="Clears highlights and reverts the table to a default state",
select_stale="Select stale", select_stale="Select stale",
select_stale_tooltip="Only selects highlighted stale mods", select_stale_tooltip="Only selects highlighted stale mods",
validate="Validate selected",
validate_tooltip="Validates selected mods and updates them if necessary",
) )
+20 -2
View File
@@ -1,7 +1,7 @@
import logging import logging
import signal import signal
from typing import TYPE_CHECKING, Literal, Self from typing import Any, Literal, Self, TYPE_CHECKING
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
@@ -47,7 +47,7 @@ logger = logging.getLogger(APP_NAME)
class OuterWindow(Gtk.ApplicationWindow): class OuterWindow(Gtk.ApplicationWindow):
def __init__(self, app: Gtk.Application) -> None: def __init__(self, app: "App") -> None:
super().__init__( super().__init__(
application=app, title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER application=app, title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER
) )
@@ -78,6 +78,15 @@ class OuterWindow(Gtk.ApplicationWindow):
MainController.loaded = True MainController.loaded = True
MainController.populate_model(MainController.get_active_treeview()) MainController.populate_model(MainController.get_active_treeview())
try:
from dzgui.util.save_screenshot import IdleScreenshot
scrot = app.get_automation_param("screenshot_path")
self.fullscreen()
IdleScreenshot(MainController, self, scrot)
except KeyError:
pass
def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None: def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None:
self.halt_proc_and_quit() self.halt_proc_and_quit()
@@ -291,6 +300,14 @@ class App(ColorAwareApp, Gtk.Application): # type: ignore
self._setup_signals() self._setup_signals()
self.connect("activate", self._on_activate) self.connect("activate", self._on_activate)
self.automation_params: dict[str, Any] = {}
def get_automation_param(self, key: str) -> Any:
return self.automation_params[key]
def set_automation_param(self, key: str, value: Any) -> None:
self.automation_params[key] = value
def _on_activate(self, app: Self) -> None: def _on_activate(self, app: Self) -> None:
self.win = OuterWindow(app) self.win = OuterWindow(app)
@@ -331,4 +348,5 @@ class App(ColorAwareApp, Gtk.Application): # type: ignore
) -> None: ) -> None:
self.win.halt_proc_and_quit() self.win.halt_proc_and_quit()
MainController = Controller() MainController = Controller()
+2
View File
@@ -313,6 +313,8 @@ class FilterPanel(Gtk.Box):
return True return True
def toggle_check(self, digit: int) -> None: def toggle_check(self, digit: int) -> None:
if not self.button_grid.is_sensitive():
return
checks = self.button_grid.get_checkboxes() checks = self.button_grid.get_checkboxes()
check = checks[digit] check = checks[digit]
state = check.get_active() state = check.get_active()
+3
View File
@@ -50,6 +50,7 @@ class ModSelectionPanel(Gtk.Box):
ModButton.SELECT_ALL, ModButton.SELECT_ALL,
ModButton.UNSELECT_ALL, ModButton.UNSELECT_ALL,
ModButton.UNSUB_SELECTED, ModButton.UNSUB_SELECTED,
ModButton.VALIDATE,
) )
for button in buttons: for button in buttons:
b = EnumeratedModButton(button) b = EnumeratedModButton(button)
@@ -114,3 +115,5 @@ class ModSelectionPanel(Gtk.Box):
self.swap_sensitive(False) self.swap_sensitive(False)
case ModButton.SELECT_STALE: case ModButton.SELECT_STALE:
self.controller.select_colorized() self.controller.select_colorized()
case ModButton.VALIDATE:
self.controller.validate_mods()
+1 -3
View File
@@ -16,9 +16,7 @@ logger = logging.getLogger(APP_NAME)
class ContextMixin(TreeView): class ContextMixin(TreeView):
def present_menu( def present_menu(
self, self, widget: Gtk.Widget, event: Gdk.Event
widget: Gtk.Widget,
event: Gdk.EventButton | Gdk.EventKey,
) -> bool: ) -> bool:
if self.menu is None: if self.menu is None:
+13 -2
View File
@@ -2,6 +2,7 @@ from pathlib import Path
from typing import Callable, TYPE_CHECKING from typing import Callable, TYPE_CHECKING
from dzgui.api import pefile as PeFile from dzgui.api import pefile as PeFile
from dzgui.api.steam import get_last_updated
from dzgui.config import query from dzgui.config import query
from dzgui.const.constants import ( from dzgui.const.constants import (
@@ -340,8 +341,10 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
[LeftLabel(options.flatpak_label), self.flatpak_toggle], [LeftLabel(options.flatpak_label), self.flatpak_toggle],
) )
self.dayz_version_label = Gtk.Label(label=strings.null) self.dayz_version_label = Gtk.Label(label=strings.null, halign=Gtk.Align.START)
self.dayz_exp_version_label = Gtk.Label(label=strings.null) self.dayz_exp_version_label = Gtk.Label(
label=strings.null, halign=Gtk.Align.START
)
version_rows = [ version_rows = [
[LeftLabel(APPNAME_DAYZ), self.dayz_version_label], [LeftLabel(APPNAME_DAYZ), self.dayz_version_label],
@@ -467,10 +470,18 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ) dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ)
if dayz_version is None: if dayz_version is None:
dayz_version = strings.null dayz_version = strings.null
else:
last_updated = get_last_updated(steam_path, APPID_DAYZ)
if last_updated is not None:
dayz_version += f" (updated at {last_updated})"
dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP) dayz_exp_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP)
if dayz_exp_version is None: if dayz_exp_version is None:
dayz_exp_version = strings.null dayz_exp_version = strings.null
else:
last_updated = get_last_updated(steam_path, APPID_DAYZ_EXP)
if last_updated is not None:
dayz_exp_version += f" (updated at {last_updated})"
self.dayz_version_label.set_text(dayz_version) self.dayz_version_label.set_text(dayz_version)
self.dayz_exp_version_label.set_text(dayz_exp_version) self.dayz_exp_version_label.set_text(dayz_exp_version)
+2 -7
View File
@@ -108,14 +108,9 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
def _on_key_release(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None: def _on_key_release(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None:
""" """
Suppresses spamming on keydown Adds debounce to key press/release events to prevent spamming
TODO: explain this better when rapidly navigating through records
""" """
# TODO: multisel
# if event.keyval is Gdk.KEY_space:
# it = self.get_focused_row_iter()
# self.get_selection().select_iter(it)
# return True
model = self.get_model() model = self.get_model()
if model is None: if model is None:
+1 -1
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [ authors = [
{name = "aclist"} {name = "aclist"}
] ]
version = "7.0.0b29" version = "7.0.0b30"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
license-files = ["LICENSE"] license-files = ["LICENSE"]
readme = "README.md" readme = "README.md"
+1
View File
@@ -3,6 +3,7 @@ from pathlib import Path
root = Path(__file__).resolve().parents[1] root = Path(__file__).resolve().parents[1]
def get_entrypoint() -> None: def get_entrypoint() -> None:
p = root.joinpath("pyproject.toml") p = root.joinpath("pyproject.toml")
with open(p, "rb") as f: with open(p, "rb") as f:
+53
View File
@@ -0,0 +1,53 @@
"AppState"
{
"appid" "444"
"Universe" "1"
"name" "TEST APP"
"StateFlags" "4"
"installdir" "TESTAPP"
"LastUpdated" "1792595373"
"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"
}
}
+53
View File
@@ -0,0 +1,53 @@
"AppState"
{
"appid" "555"
"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"
}
}
+9 -1
View File
@@ -3,7 +3,7 @@ import pytest
from _pytest.monkeypatch import MonkeyPatch from _pytest.monkeypatch import MonkeyPatch
from pathlib import Path from pathlib import Path
from dzgui.api.steam import get_app_allows_downloads from dzgui.api.steam import get_app_allows_downloads, get_last_updated
from tests.fixtures import fixture_path from tests.fixtures import fixture_path
pytestmark = pytest.mark.apitest pytestmark = pytest.mark.apitest
@@ -43,6 +43,14 @@ def patch_api() -> None:
mp.undo() mp.undo()
def test_no_last_update(steam_path) -> None:
assert get_last_updated(steam_path, 555) is None
def test_last_update(steam_path) -> None:
assert get_last_updated(steam_path, 444) == "2026-10-22 00:09:33"
def test_app_allows_downloads(steam_path) -> None: def test_app_allows_downloads(steam_path) -> None:
assert get_app_allows_downloads(steam_path, 111) is True assert get_app_allows_downloads(steam_path, 111) is True
-1
View File
@@ -36,7 +36,6 @@ def keys():
# TODO: use a static fixture instead of system config # TODO: use a static fixture instead of system config
@pytest.fixture @pytest.fixture
@pytest.mark.realconfig
def config(): def config():
paths = get_xdg_paths() paths = get_xdg_paths()
xdg = parse_filepaths(paths) xdg = parse_filepaths(paths)
+5 -3
View File
@@ -1,7 +1,7 @@
import configparser import configparser
import pytest import pytest
from dzgui.const.constants import FLATPAK_APPID, GTK_APP_ID from dzgui.const.constants import FLATPAK_APPID, GTK_APP_ID, TMP_PATH
from dzgui.config.xdg import get_xdg_paths from dzgui.config.xdg import get_xdg_paths
from dzgui.util.flatpak import apply_overrides from dzgui.util.flatpak import apply_overrides
@@ -38,6 +38,8 @@ def test_session_bus(override, config) -> None:
def test_paths(override, xdg_paths, config) -> None: def test_paths(override, xdg_paths, config) -> None:
expect = xdg_paths expect: list[str] = []
expect.append(TMP_PATH)
expect.extend([*xdg_paths.values()])
paths = config[PATHS_KEY][PATHS_SUBKEY].split(";") paths = config[PATHS_KEY][PATHS_SUBKEY].split(";")
assert all(str(path) in paths for path in expect.values()) assert all(str(path) in paths for path in expect)
+47
View File
@@ -0,0 +1,47 @@
import pytest
from dzgui.model.model_factory import FastInsertListStore
@pytest.fixture
def heterogeneous_list() -> None:
return [["a", 0, True], ["b", 1, False]]
@pytest.fixture
def mismatched_length_list() -> None:
return [["a", 0, True], ["b", 1, False, 0]]
@pytest.fixture
def extended_list(heterogeneous_list) -> None:
fs = FastInsertListStore(str, int, bool)
fs.extend(heterogeneous_list)
return fs
@pytest.fixture
def appended_list(heterogeneous_list) -> None:
fs = FastInsertListStore(str, int, bool)
for row in heterogeneous_list:
fs.append(row)
return fs
@pytest.mark.parametrize("target", ["extended_list", "appended_list"])
def test_list_parity(heterogeneous_list, request, target) -> None:
target = request.getfixturevalue(target)
extracted = [[el for el in row] for row in target]
assert heterogeneous_list == extracted
def test_type_disparity(heterogeneous_list) -> None:
with pytest.raises(TypeError):
fs = FastInsertListStore(str, bool, bool)
fs.extend(heterogeneous_list)
def test_length_disparity(mismatched_length_list) -> None:
with pytest.raises(ValueError):
fs = FastInsertListStore(str, int, bool)
fs.extend(mismatched_length_list)