Compare commits

...
Author SHA1 Message Date
aclist c94e4e73db chore: add strings file 2026-11-10 04:34:38 +09:00
aclist 8da264686a chore: clear typehinting errors 2026-11-10 04:33:54 +09:00
aclist 598973104c change: reference dataclass values in enum 2026-11-10 04:30:40 +09:00
aclist 15d4b71350 Merge pull request #533 from aclist/docs/more-attribution
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
chore: add remarks to README
2026-11-10 03:08:12 +09:00
aclist 16d3df618a chore: add remarks to README 2026-11-10 03:07:30 +09:00
aclist 22dd8fc67f Merge pull request #525 from aclist/change/get-git-root
change: get git root
2026-11-10 02:44:51 +09:00
aclist 03fa707305 chore: remove unused imports 2026-11-09 11:04:56 +09:00
aclist 2479cbd41b change: parse repository root 2026-11-09 11:03:50 +09:00
aclist 370edbe01b Merge pull request #522 from aclist/prerelease/7.0.0b30
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
Prerelease/7.0.0b30
2026-11-06 23:39:31 +09:00
aclist 0c41654645 feat: add screenshot dumper
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-11-06 19:18:35 +09:00
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) Canceled after 0s
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 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 12daf041ff chore: add tests 2026-11-02 12:17:50 +09:00
aclist 2cc26b6573 fix: prevent toggling when panel is insensitive 2026-11-01 23:47:06 +09:00
aclist 78144e9127 chore: update tests 2026-10-31 00:26:51 +09:00
aclist 33b8dfba70 change: simplify logic 2026-10-31 00:26:27 +09:00
aclist abf0060cac feat: import ipaddress 2026-10-31 00:26:08 +09:00
aclist 95daa9163b change: update paths 2026-10-31 00:25:32 +09:00
aclist ff068e5396 feat: marshal geo DB 2026-10-31 00:23:27 +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
47 changed files with 781 additions and 362 deletions

No files matched your search

+7
View File
@@ -53,6 +53,10 @@
- Expand/collapse changelog versions
- Support local light/dark mode changes on system theme
- 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
- Conform to PEP 440 versioning for beta versions
@@ -74,6 +78,7 @@
- Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
- Use SQLite DB for geolocation records
### Dropped
- Battlemetrics API support
@@ -92,6 +97,8 @@
- Moved dialogs out of threads
- History table not being in lock-step with state file
- 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
- Load offline mods
+2
View File
@@ -37,6 +37,8 @@ Users wishing to review the licenses to these components can inspect the `LICENS
Steam and the Steam logo are trademarks and/or registered trademarks of Valve Corporation in the U.S. and/or other countries.
DayZ is a trademark and brand of Bohemia Interactive a.s.
## Disclaimer
DZGUI is beta-quality software and is provided as-is.
+10 -3
View File
@@ -12,6 +12,7 @@ from typing import Any, Generator, Union
from warnings import deprecated
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_flatpak_client, has_steam_client
from dzgui.const.constants import (
@@ -316,10 +317,9 @@ def unsubscribe(key: str, mod: int) -> None:
update_workshop(key, mod, UNSUB_ENDPOINT)
@deprecated("Use subscribe()")
def enqueue_mod(client: str, mod: str, appid: int) -> None:
def validate_mod(client: str, mod: str) -> None:
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")
@@ -520,3 +520,10 @@ def get_app_name(appid: int) -> str | None:
except Exception as e:
logger.debug(e)
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)
def load_gui(version: str, is_debug: bool) -> None:
def build_gui(version: str, is_debug: bool) -> App | None:
lock = lock_acquire() # noqa
set_locale()
@@ -151,7 +151,7 @@ def load_gui(version: str, is_debug: bool) -> None:
wizard = SetupWizard(_is_steam_deck, XDG, xdg_paths)
if not wizard.is_setup_complete():
return
return None
setup_logger(XDG.debug)
with open(XDG.debug, "w") as f:
@@ -186,4 +186,4 @@ def load_gui(version: str, is_debug: bool) -> None:
print(boot.all_ok)
load_steam_icon()
app = App(prefs)
app.run(None)
return app
+102 -3
View File
@@ -3,6 +3,9 @@ import logging
import re
import requests
import shutil
import socket
import sqlite3
import time
from pathlib import Path
@@ -22,15 +25,16 @@ def find_download_url(url: str) -> str | None:
return str(urls[0])
def get_ipdb(ips_path: Path) -> None:
def get_ipdb(db_path: Path) -> None:
try:
url = find_download_url(DB_IP)
if url is None:
return
ips_path = db_path.with_suffix(".csv")
month_file = db_path.parent / ".month"
date = find_date(url)
month_file = ips_path.parent / ".month"
if ips_path.exists() and month_file.exists():
if db_path.exists() and month_file.exists():
old_date = month_file.read_text().rstrip("\n")
if old_date == date:
logger.info(f"IP DB date matches: {date}")
@@ -45,6 +49,9 @@ def get_ipdb(ips_path: Path) -> None:
logger.info("Stripping IPv6 records")
strip_ipv6(ips_path)
create_table(db_path, ips_path)
ips_path.unlink()
with open(month_file, "w") as f:
f.write(date)
logger.info(f"Wrote {date} to {month_file}")
@@ -108,3 +115,95 @@ def strip_ipv6(path: Path) -> None:
out.write(merged)
path.unlink()
alt_path.rename(path)
def ip_to_int(ip: str) -> int:
return int.from_bytes(socket.inet_aton(ip), "big")
def marshal_rows(ip_file: Path) -> list[tuple[int, int, str, str]]:
YIELD = 0.003
SLEEP = 0.001
with open(ip_file, "r") as f:
rows: list[tuple[int, int, str, str]] = []
# NOTE: yield to GIL periodically to reduce load on spinner
last_yield = time.monotonic()
for row in f:
start, end, lat, lon = row.rstrip("\n").split(",")
int_start = ip_to_int(start)
int_end = ip_to_int(end)
rows.append((int_start, int_end, lat, lon))
now = time.monotonic()
if (now - last_yield) >= YIELD:
time.sleep(SLEEP)
last_yield = time.monotonic()
return rows
def create_table(db_file: Path, ip_file: Path) -> None:
create = """
CREATE TABLE IF NOT EXISTS ips (
start_range INTEGER PRIMARY KEY,
end_range INTEGER,
latitude REAL,
longitude REAL
) WITHOUT ROWID;
"""
insert = """
INSERT INTO ips
(start_range, end_range, latitude, longitude)
values(?, ?, ?, ?)
"""
if db_file.exists():
db_file.unlink()
conn = sqlite3.connect(":memory:")
c = conn.cursor()
try:
c.execute(create)
rows = marshal_rows(ip_file)
c.executemany(insert, rows)
logger.info(f"Inserted {len(rows)} IP records into database")
conn.commit()
with sqlite3.connect(db_file) as dest:
conn.backup(dest)
logger.info(f"Serialized IP records to database: '{db_file}'")
conn.close()
except Exception as e:
logger.critical(e)
def read_from_table(db_path: Path, ip: str) -> tuple[float, float]:
if not db_path.exists():
raise FileNotFoundError(f"No file found at the location '{db_path}")
conn = sqlite3.connect(db_path)
c = conn.cursor()
int_ip = ip_to_int(ip)
query = """
SELECT end_range, latitude, longitude
FROM ips
WHERE start_range <= ?
ORDER BY start_range DESC
LIMIT 1
"""
index = """
CREATE INDEX IF NOT EXISTS start_index
ON ips(start_range);
"""
try:
c.execute(index)
conn.commit()
c.execute(query, (int_ip,))
row = c.fetchone()
end, lat, lon = row
conn.close()
return lat, lon
except Exception as e:
logger.critical(e)
raise e
+2 -1
View File
@@ -5,6 +5,7 @@ from dzgui.util._json import read_json
from dzgui.const.enum import Preferences
class ConfigFileError(Exception):
pass
@@ -39,4 +40,4 @@ def get_favorites(path: Path) -> list[str]:
def enum_to_key(enum: Preferences) -> str:
return str(enum.dict["key"])
return str(enum.key)
+3 -3
View File
@@ -22,7 +22,7 @@ class Xdg:
version: Path
system: Path
debug: Path
ips: Path
db: Path
filters: Path
shortcut: Path
@@ -88,7 +88,7 @@ def parse_filepaths(xdg: dict) -> Xdg:
resolution = state / "dzg.res.json"
history = state / "dzg.history"
versions = state / "dzg.versions"
ips = state / "ips.csv"
db = state / "ips.db"
filters = state / "dzg.filters.json"
shortcut = share / APP_NAME_LOWER
@@ -102,7 +102,7 @@ def parse_filepaths(xdg: dict) -> Xdg:
versions,
system,
debug,
ips,
db,
filters,
shortcut,
)
+1
View File
@@ -18,6 +18,7 @@ COORDS_API = "http://ip-api.com/json"
# steam URIs
WORKSHOP = "steam://url/CommunityFilePage/"
OPEN_URL = "steam://openurl/"
VALIDATE = "steam://validate/"
# user facing
GITHUB_PAGES = "https://aclist.github.io/dzgui/index"
+224 -201
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Any, Self
from dzgui.util import strings
from dzgui.strings import rows
class Port(Enum):
@@ -46,124 +48,61 @@ class FilterMode(Enum):
TOGGLE_ON = 3
class EnumWithAttrs(Enum):
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
value = len(cls.__members__) + 1
obj = object.__new__(cls)
obj._value_ = value
return obj
def __init__(self, d: dict):
self.dict = d
@dataclass(slots=True, frozen=True)
class ModButtonMeta:
label: str
tooltip: str
class Preferences(EnumWithAttrs):
STEAM = {
"key": "steam_api",
}
CLIENT = {
"key": "client",
}
WINDOW = {
"key": "fullscreen",
}
DIST = {
"key": "use_miles",
}
NAME = {
"key": "name",
}
DEFAULT = {
"key": "default_steam_path",
}
FAV_LBL = {
"key": "fav_label",
}
FAV_SRV = {
"key": "fav_server",
}
IP_LIST = {
"key": "ip_list",
}
START_TAB = {
"key": "start_tab",
}
@dataclass(slots=True, frozen=True)
class ButtonTypeMeta:
label: str
tooltip: str
opens: NotebookPage | None
class NotebookPage(EnumWithAttrs):
OFFLINE = {"crumbs": strings.crumbs.offline, "statusbar": True}
CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": True}
DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False}
HELP = {"crumbs": strings.crumbs._help, "statusbar": True}
KEYS = {"crumbs": strings.crumbs.keys, "statusbar": True}
LOG = {"crumbs": strings.crumbs.log, "statusbar": True}
MODS = {"crumbs": strings.crumbs.mods, "statusbar": True}
OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False}
SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True}
THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True}
CONNECTION = {"crumbs": "Connect", "statusbar": True}
@dataclass(slots=True, frozen=True)
class ConfigKey:
key: str
class RowType(EnumWithAttrs):
# TODO: strings
CHANGELOG = {
"label": "View changelog",
"tooltip": "Opens the DZGUI changelog",
}
SHOW_LOG = {
"label": "Show debug log",
"tooltip": "Read the DZGUI log generated since startup",
}
SEPARATOR = {
"label": "SEPARATOR",
"tooltip": "",
}
DUMP_LOG = {
"label": "Output system info to log file",
"tooltip": "Dump diagnostic data for troubleshooting",
}
DOCS = {
"label": "Documentation/help files (GitHub) ⧉",
"tooltip": "Open the DZGUI documentation in a browser",
}
DOCS_FALLBACK = {
"label": "Documentation/help files (Codeberg mirror) ⧉",
"tooltip": "Open the DZGUI documentation in a browser",
}
BUGS = {
"label": "Report a bug (GitHub) ⧉",
"tooltip": "Open the DZGUI issue tracker in a browser",
}
FORUM = {
"label": "DZGUI Subreddit ⧉",
"tooltip": "Open the DZGUI discussion forum in a browser",
}
SPONSOR = {
"label": "Sponsor (GitHub) ⧉",
"tooltip": "Sponsor development of the DZGUI project",
}
THANKS = {
"label": "Special thanks",
"tooltip": "A list of contributors, testers, and sponsors",
}
@dataclass(slots=True, frozen=True)
class NotebookMeta:
crumbs: str
has_statusbar: bool
# TODO: rename to ContextMenuItem
class ContextMenu(EnumWithAttrs):
ADD_NOTE = {"label": strings.add_note}
ADD_SERVER = {"label": strings.add}
CONNECT = {"label": strings.connect}
COPY_LOG_CLIPBOARD = {"label": strings.copy_log}
COPY_SERVER_IP = {"label": strings.copy_ip}
COPY_SERVER_NAME = {"label": strings.copy_name}
UNSUB_MOD = {"label": strings.unsub_mod}
OPEN_WORKSHOP = {"label": strings.open_workshop}
REFRESH_PLAYERS = {"label": strings.refresh_players}
REMOVE_HISTORY = {"label": strings.remove_history}
REMOVE_SERVER = {"label": strings.remove}
SET_FAV = {"label": strings.add_fav}
SHOW_DETAILS = {"label": strings.show_details}
SHOW_MODS = {"label": strings.show_mods}
@dataclass(slots=True, frozen=True)
class RowMeta:
label: str
tooltip: str
@dataclass(slots=True, frozen=True)
class ContextMenuMeta:
label: str
class ContextMenuItem(Enum):
ADD_NOTE = ContextMenuMeta(strings.add_note)
ADD_SERVER = ContextMenuMeta(strings.add)
CONNECT = ContextMenuMeta(strings.connect)
COPY_LOG_CLIPBOARD = ContextMenuMeta(strings.copy_log)
COPY_SERVER_IP = ContextMenuMeta(strings.copy_ip)
COPY_SERVER_NAME = ContextMenuMeta(strings.copy_name)
UNSUB_MOD = ContextMenuMeta(strings.unsub_mod)
VALIDATE_MOD = ContextMenuMeta(strings.validate_mod)
OPEN_WORKSHOP = ContextMenuMeta(strings.open_workshop)
REFRESH_PLAYERS = ContextMenuMeta(strings.refresh_players)
REMOVE_HISTORY = ContextMenuMeta(strings.remove_history)
REMOVE_SERVER = ContextMenuMeta(strings.remove)
SET_FAV = ContextMenuMeta(strings.add_fav)
SHOW_DETAILS = ContextMenuMeta(strings.show_details)
SHOW_MODS = ContextMenuMeta(strings.show_mods)
@property
def label(self) -> str:
return self.value.label
class ContextMenuGroup(Enum):
@@ -171,109 +110,193 @@ class ContextMenuGroup(Enum):
Groupings of context menu items
"""
SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,)
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.UNSUB_MOD)
SERVER_MOD = (ContextMenuItem.OPEN_WORKSHOP,)
MOD = (
ContextMenuItem.OPEN_WORKSHOP,
ContextMenuItem.UNSUB_MOD,
ContextMenuItem.VALIDATE_MOD,
)
MOD_OFFLINE = (None,)
LOG = (ContextMenu.COPY_LOG_CLIPBOARD,)
LOG = (ContextMenuItem.COPY_LOG_CLIPBOARD,)
SERVER_BROWSER = (
ContextMenu.CONNECT,
ContextMenu.ADD_SERVER,
ContextMenu.SET_FAV,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
ContextMenuItem.CONNECT,
ContextMenuItem.ADD_SERVER,
ContextMenuItem.SET_FAV,
ContextMenuItem.COPY_SERVER_NAME,
ContextMenuItem.COPY_SERVER_IP,
ContextMenuItem.ADD_NOTE,
ContextMenuItem.SHOW_MODS,
ContextMenuItem.SHOW_DETAILS,
ContextMenuItem.REFRESH_PLAYERS,
)
SCAN_LAN = (
ContextMenu.CONNECT,
ContextMenu.SET_FAV,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
ContextMenuItem.CONNECT,
ContextMenuItem.SET_FAV,
ContextMenuItem.COPY_SERVER_NAME,
ContextMenuItem.COPY_SERVER_IP,
ContextMenuItem.ADD_NOTE,
ContextMenuItem.SHOW_MODS,
ContextMenuItem.SHOW_DETAILS,
ContextMenuItem.REFRESH_PLAYERS,
)
SAVED = (
ContextMenu.CONNECT,
ContextMenu.SET_FAV,
ContextMenu.REMOVE_SERVER,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
ContextMenuItem.CONNECT,
ContextMenuItem.SET_FAV,
ContextMenuItem.REMOVE_SERVER,
ContextMenuItem.COPY_SERVER_NAME,
ContextMenuItem.COPY_SERVER_IP,
ContextMenuItem.ADD_NOTE,
ContextMenuItem.SHOW_MODS,
ContextMenuItem.SHOW_DETAILS,
ContextMenuItem.REFRESH_PLAYERS,
)
RECENT = (
ContextMenu.CONNECT,
ContextMenu.ADD_SERVER,
ContextMenu.SET_FAV,
ContextMenu.REMOVE_HISTORY,
ContextMenu.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP,
ContextMenu.ADD_NOTE,
ContextMenu.SHOW_MODS,
ContextMenu.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS,
ContextMenuItem.CONNECT,
ContextMenuItem.ADD_SERVER,
ContextMenuItem.SET_FAV,
ContextMenuItem.REMOVE_HISTORY,
ContextMenuItem.COPY_SERVER_NAME,
ContextMenuItem.COPY_SERVER_IP,
ContextMenuItem.ADD_NOTE,
ContextMenuItem.SHOW_MODS,
ContextMenuItem.SHOW_DETAILS,
ContextMenuItem.REFRESH_PLAYERS,
)
class ModButton(EnumWithAttrs):
SELECT_ALL = {
"label": strings.mod_panel.select_all,
"tooltip": strings.mod_panel.select_all_tooltip,
}
UNSELECT_ALL = {
"label": strings.mod_panel.unselect_all,
"tooltip": strings.mod_panel.unselect_all_tooltip,
}
HIGHLIGHT_STALE = {
"label": strings.mod_panel.highlight_stale,
"tooltip": strings.mod_panel.highlight_stale_tooltip,
}
UNHIGHLIGHT_STALE = {
"label": strings.mod_panel.unhighlight_stale,
"tooltip": strings.mod_panel.unhighlight_stale_tooltip,
}
UNSUB_SELECTED = {
"label": strings.mod_panel.unsub_selected,
"tooltip": strings.mod_panel.unsub_selected_tooltip,
}
SELECT_STALE = {
"label": strings.mod_panel.select_stale,
"tooltip": strings.mod_panel.select_stale_tooltip,
}
class Preferences(Enum):
STEAM = ConfigKey("steam_api")
CLIENT = ConfigKey("client")
WINDOW = ConfigKey("fullscreen")
DIST = ConfigKey("use_miles")
NAME = ConfigKey("name")
DEFAULT = ConfigKey("default_steam_path")
FAV_LBL = ConfigKey("fav_label")
FAV_SRV = ConfigKey("fav_server")
IP_LIST = ConfigKey("ip_list")
START_TAB = ConfigKey("start_tab")
@property
def key(self) -> str:
return self.value.key
class ButtonType(EnumWithAttrs):
SERVERS = {
"label": strings.buttons.servers_label,
"tooltip": strings.buttons.servers_tooltip,
"opens": NotebookPage.SERVERS,
}
MODS = {
"label": strings.buttons.mods_label,
"tooltip": strings.buttons.mods_tooltip,
"opens": NotebookPage.MODS,
}
OPTIONS = {
"label": strings.buttons.options_label,
"tooltip": strings.buttons.options_tooltip,
"opens": NotebookPage.OPTIONS,
}
HELP = {
"label": strings.buttons.help_label,
"tooltip": strings.buttons.help_tooltip,
"opens": NotebookPage.HELP,
}
EXIT = {
"label": strings.buttons.exit_label,
"tooltip": strings.buttons.exit_tooltip,
"opens": None,
}
class NotebookPage(Enum):
OFFLINE = NotebookMeta(strings.crumbs.offline, True)
CHANGELOG = NotebookMeta(strings.crumbs.changelog, True)
DEVELOPERS = NotebookMeta(strings.crumbs.developers, False)
HELP = NotebookMeta(strings.crumbs._help, True)
KEYS = NotebookMeta(strings.crumbs.keys, True)
LOG = NotebookMeta(strings.crumbs.log, True)
MODS = NotebookMeta(strings.crumbs.mods, True)
OPTIONS = NotebookMeta(strings.crumbs.options, False)
SERVERS = NotebookMeta(strings.crumbs.servers, True)
THANKS = NotebookMeta(strings.crumbs.thanks, True)
CONNECTION = NotebookMeta(strings.crumbs.connect, True)
@property
def crumbs(self) -> str:
return self.value.crumbs
@property
def has_statusbar(self) -> bool:
return self.value.has_statusbar
class RowType(Enum):
CHANGELOG = RowMeta(rows.changelog_label, rows.changelog_status)
SHOW_LOG = RowMeta(rows.log_label, rows.log_status)
SEPARATOR = RowMeta(rows.separator_label, rows.separator_status)
DUMP_LOG = RowMeta(rows.system_log_label, rows.system_log_status)
DOCS = RowMeta(rows.docs_label, rows.docs_status)
DOCS_FALLBACK = RowMeta(rows.docs_fallback_label, rows.docs_fallback_status)
BUGS = RowMeta(rows.bugs_label, rows.bugs_status)
FORUM = RowMeta(rows.forum_label, rows.forum_status)
SPONSOR = RowMeta(rows.sponsor_label, rows.sponsor_status)
THANKS = RowMeta(rows.thanks_label, rows.thanks_status)
@property
def label(self) -> str:
return self.value.label
@property
def tooltip(self) -> str:
return self.value.tooltip
class ModButton(Enum):
SELECT_ALL = ModButtonMeta(
strings.mod_panel.select_all, strings.mod_panel.select_all_tooltip
)
UNSELECT_ALL = ModButtonMeta(
strings.mod_panel.unselect_all, strings.mod_panel.unselect_all_tooltip
)
HIGHLIGHT_STALE = ModButtonMeta(
strings.mod_panel.highlight_stale, strings.mod_panel.highlight_stale_tooltip
)
UNHIGHLIGHT_STALE = ModButtonMeta(
strings.mod_panel.unhighlight_stale, strings.mod_panel.unhighlight_stale_tooltip
)
UNSUB_SELECTED = ModButtonMeta(
strings.mod_panel.unsub_selected,
strings.mod_panel.unsub_selected_tooltip,
)
SELECT_STALE = ModButtonMeta(
strings.mod_panel.select_stale,
strings.mod_panel.select_stale_tooltip,
)
VALIDATE = ModButtonMeta(
strings.mod_panel.validate,
strings.mod_panel.validate_tooltip,
)
@property
def label(self) -> str:
return self.value.label
@property
def tooltip(self) -> str:
return self.value.tooltip
class ButtonType(Enum):
SERVERS = ButtonTypeMeta(
strings.buttons.servers_label,
strings.buttons.servers_tooltip,
NotebookPage.SERVERS,
)
MODS = ButtonTypeMeta(
strings.buttons.mods_label,
strings.buttons.mods_tooltip,
NotebookPage.MODS,
)
OPTIONS = ButtonTypeMeta(
strings.buttons.options_label,
strings.buttons.options_tooltip,
NotebookPage.OPTIONS,
)
HELP = ButtonTypeMeta(
strings.buttons.help_label,
strings.buttons.help_tooltip,
NotebookPage.HELP,
)
EXIT = ButtonTypeMeta(
strings.buttons.exit_label,
strings.buttons.exit_tooltip,
None,
)
@property
def label(self) -> str:
return self.value.label
@property
def tooltip(self) -> str:
return self.value.tooltip
@property
def opens(self) -> NotebookPage | None:
return self.value.opens
HELP_MENU_ROWS = (
+18 -7
View File
@@ -8,7 +8,7 @@ import dzgui.util._json as JSON # noqa
from dzgui.const.enum import (
ButtonType,
ContextMenu,
ContextMenuItem,
FilterMode,
NotebookPage,
Preferences,
@@ -102,6 +102,16 @@ class Controller(GObject.GObject):
except AttributeError:
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:
return self.prefs
@@ -186,7 +196,9 @@ class Controller(GObject.GObject):
def show_developers_page(self) -> None:
self.open_page(NotebookPage.DEVELOPERS)
def open_page(self, page: NotebookPage) -> None:
def open_page(self, page: NotebookPage | None) -> None:
if page is None:
return
self.mediator.grid.notebook.set_page_by_enum(page)
def open_page_by_button(self, button: "ContextualButton") -> None:
@@ -252,7 +264,7 @@ class Controller(GObject.GObject):
logger.critical(e)
return
def menu_action(self, action: ContextMenu, tree: "ServerTreeView") -> None:
def menu_action(self, action: ContextMenuItem, tree: "ServerTreeView") -> None:
context_man = ContextMenuManager(tree, self)
context_man.process(action)
@@ -264,7 +276,7 @@ class Controller(GObject.GObject):
except Exception as e:
dialog = ExceptionDialog(self, str(e))
dialog.show_all()
dialog.run()
dialog.run() # type: ignore[no-untyped-call]
def select_colorized(self) -> None:
mod_man = self.mediator.modtreeview.get_mod_man()
@@ -298,7 +310,7 @@ class Controller(GObject.GObject):
except Exception as e:
dialog = ExceptionDialog(self, str(e))
dialog.show_all()
dialog.run()
dialog.run() # type: ignore[no-untyped-call]
def update_steam_api_key(self, text: str) -> None:
self.config_man.update_steam_api_key(text)
@@ -355,12 +367,11 @@ class Controller(GObject.GObject):
filter_man = self.get_filter_man()
return filter_man.get_map_store()
def get_enabled_filters(self) -> dict:
def get_enabled_filters(self) -> dict[str, bool]:
filter_man = self.get_filter_man()
return filter_man.get_filters()
# FIXME: wipe maps store when changing tabs if model is none
def has_server_model(self) -> bool:
treeview = self.get_active_treeview()
proxy_man = treeview.get_proxy_man()
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 361 KiB

+2 -2
View File
@@ -10,10 +10,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(APP_NAME)
def get_local_coords(path: "Path") -> Coords | None:
def get_local_coords(db_path: "Path") -> Coords | None:
try:
my_ip = get_local_ip()
return get_coords(path, my_ip)
return get_coords(db_path, my_ip)
except Exception as e:
logger.warn(e)
return None
+5 -2
View File
@@ -42,6 +42,9 @@ def main() -> None:
test_libgi_missing()
# 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)
+1 -1
View File
@@ -124,7 +124,7 @@ class ConfigManager:
return False
def enum_to_key(self, enum: Preferences) -> str:
return str(enum.dict["key"])
return str(enum.key)
def toggle_config(self, key: Preferences) -> None:
try:
+23 -19
View File
@@ -2,7 +2,7 @@ import logging
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import ContextMenu, Preferences
from dzgui.const.enum import ContextMenuItem, Preferences
from dzgui.util.clip import copy_clipboard
from dzgui.util.open_links import open_workshop_page
@@ -34,7 +34,7 @@ class ContextMenuManager:
self.controller = controller
self.treeview = treeview
def process(self, action: ContextMenu) -> None:
def process(self, action: ContextMenuItem) -> None:
if isinstance(self.treeview, ServerTreeView):
record = self.treeview.get_record()
if record is None:
@@ -42,14 +42,14 @@ class ContextMenuManager:
match action:
# UNTHREADED
case ContextMenu.ADD_NOTE:
case ContextMenuItem.ADD_NOTE:
dialog = NoteDialog(self.controller)
dialog.run()
case ContextMenu.COPY_SERVER_IP:
dialog.run() # type: ignore[no-untyped-call]
case ContextMenuItem.COPY_SERVER_IP:
self.copy_server_ip()
case ContextMenu.COPY_SERVER_NAME:
case ContextMenuItem.COPY_SERVER_NAME:
self.copy_server_name()
case ContextMenu.SET_FAV:
case ContextMenuItem.SET_FAV:
name = self.treeview.get_value_at_index(0)
# NOTE: fully qualified ip
fqip = self.treeview.get_record_string()
@@ -58,36 +58,40 @@ class ContextMenuManager:
self.controller.set_fav(name, fqip, simple)
# THREADED
case ContextMenu.ADD_SERVER:
case ContextMenuItem.ADD_SERVER:
self.controller.add_by_record(record)
case ContextMenu.CONNECT:
case ContextMenuItem.CONNECT:
self.controller.connect_by_record(record)
case ContextMenu.REFRESH_PLAYERS:
case ContextMenuItem.REFRESH_PLAYERS:
self.controller.refresh_players(record)
case ContextMenu.REMOVE_HISTORY:
case ContextMenuItem.REMOVE_HISTORY:
self.controller.remove_from_history(record)
case ContextMenu.REMOVE_SERVER:
case ContextMenuItem.REMOVE_SERVER:
self.controller.remove_by_record(record)
case ContextMenu.SHOW_DETAILS:
case ContextMenuItem.SHOW_DETAILS:
self.controller.get_details(record)
case ContextMenu.SHOW_MODS:
case ContextMenuItem.SHOW_MODS:
self.controller.get_modlist(record)
if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)):
match action:
case ContextMenu.UNSUB_MOD:
self.controller.unsub_mods(self.treeview)
case ContextMenu.OPEN_WORKSHOP:
case ContextMenuItem.OPEN_WORKSHOP:
self.open_mod_page()
case ContextMenuItem.UNSUB_MOD:
self.controller.unsub_mods(self.treeview)
case ContextMenuItem.VALIDATE_MOD:
self.controller.validate_mods(self.treeview)
case _:
pass
if isinstance(self.treeview, LogTreeView):
match action:
case ContextMenu.COPY_LOG_CLIPBOARD:
case ContextMenuItem.COPY_LOG_CLIPBOARD:
self.copy_log()
if isinstance(self.treeview, ServerModTreeView):
match action:
case ContextMenu.OPEN_WORKSHOP:
case ContextMenuItem.OPEN_WORKSHOP:
self.open_mod_page()
def copy_server_ip(self) -> None:
+26 -2
View File
@@ -4,7 +4,7 @@ import time
from pathlib import Path
from typing import TYPE_CHECKING
from dzgui.api.steam import unsubscribe
from dzgui.api.steam import unsubscribe, validate_mod
from dzgui.api.mods import (
get_delimited_mods,
find_stale_mods,
@@ -85,7 +85,7 @@ class ModManager:
total_mods = len(self.store)
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()
model, pathlist = sel.get_selected_rows()
# NOTE: reverse when multiple selection
@@ -96,6 +96,15 @@ class ModManager:
continue
mod, _iter = res
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.unsub_all_mods(mods)
@@ -109,6 +118,21 @@ class ModManager:
mod = model.get_value(tree_iter, 2)
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)
def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
for mod, _iter in mods:
+1 -1
View File
@@ -147,7 +147,7 @@ class ModelFactory:
def make_help_store(self) -> FastInsertListStore:
store = self.new_model_from_class(MenuCols)
rows = [[row.dict["label"], row] for row in HELP_MENU_ROWS]
rows = [[row.label, row] for row in HELP_MENU_ROWS]
store.extend(rows)
return store
+30 -7
View File
@@ -201,19 +201,43 @@ class ServerModelManager:
@call_on_thread(dialog.querying)
def add_by_ip(self, addr: str) -> None:
res = Servers.query_by_ip(addr)
self._parse_single_record(res)
self.preserve_on_fail = True
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)
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
res = Servers.query_by_record(record)
self._parse_single_record(res)
try:
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)
def remove_by_record(self, record: Servers.Record) -> None:
res = Servers.query_by_record(record)
self._parse_single_record(res, delete=True)
self.preserve_on_fail = 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(
self,
@@ -280,7 +304,6 @@ class ServerModelManager:
proxy_man.update_playercount(self.playercount)
def _parse_single_record(self, response: "A2SInfo", delete: bool = False) -> None:
self.preserve_on_fail = True
try:
row = response.as_row()
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."
fetching_mods = "Fetching mod metadata"
deleting_mods = "Unsubscribing mods"
validating_mods = "Validating mods"
scanning_mods = "Scanning mods"
parsing_mods = "Parsing mods"
+29
View File
@@ -0,0 +1,29 @@
changelog_label = "View changelog"
changelog_status = "Opens the DZGUI changelog"
log_label = "Show debug log"
log_status = "Read the DZGUI log generated since startup"
separator_label = "SEPARATOR"
separator_status = ""
system_log_label = "Output system info to log file"
system_log_status = "Dump diagnostic data for troubleshooting"
docs_label = "Documentation/help files (GitHub) ⧉"
docs_status = "Open the DZGUI documentation in a browser"
docs_fallback_label = "Documentation/help files (Codeberg mirror) ⧉"
docs_fallback_status = "Open the DZGUI documentation in a browser"
bugs_label = "Report a bug (GitHub) ⧉"
bugs_status = "Open the DZGUI issue tracker in a browser"
forum_label = "DZGUI Subreddit ⧉"
forum_status = "Open the DZGUI discussion forum in a browser"
sponsor_label = "Sponsor (GitHub) ⧉"
sponsor_status = "Sponsor development of the DZGUI project"
thanks_label = "Special thanks"
thanks_status = "A list of contributors, testers, and sponsors"
+3 -2
View File
@@ -80,8 +80,9 @@ class CalcDist:
if local is None:
return None
try:
remote = get_coords(prefs.paths.ips, remote_ip)
except Exception:
remote = get_coords(prefs.paths.db, remote_ip)
except Exception as e:
logger.critical(e)
return None
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
+14 -45
View File
@@ -1,4 +1,5 @@
import a2s
from ipaddress import ip_address
import requests
import shutil
import subprocess
@@ -7,6 +8,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
from dzgui.api.servers import Record
from dzgui.config.ipdb import read_from_table
from dzgui.const.constants import REQUEST_TIMEOUT
from dzgui.const.endpoints import IP_ECHO
@@ -33,54 +35,21 @@ def ip2list(ip: str) -> list:
return [int(digit) for digit in ip.split(".")]
# TODO: tests
def is_reserved(digits: list) -> bool:
reserved = [0, 10, 127]
if digits[0] in reserved:
return True
if digits[0] == 192 and digits[1] == 168:
return True
return False
def is_usable(ip: str) -> bool:
address = ip_address(ip)
if address.is_reserved or address.is_private:
return False
return True
def get_coords(ips: "Path", ip: str) -> Coords:
split = ip2list(ip)
if is_reserved(split):
def get_coords(db_path: "Path", ip: str) -> Coords:
if not is_usable(ip):
raise ReservedIPError(f"{ip} is a reserved IP address.")
prefix = f"^{split[0]}.{split[1]}."
if shutil.which("rg") is not None:
args = ["/usr/bin/rg", prefix, str(ips)]
else:
args = ["/usr/bin/grep", "-E", prefix, str(ips)]
proc = subprocess.run(args, capture_output=True, text=True)
if proc.returncode != 0:
raise GeolocationError("Failed to split records")
ip_list = proc.stdout.splitlines()
for address in ip_list:
fields = address.split(",")
upper = fields[1]
upper_digits = ip2list(upper)
if ip == upper:
return Coords(float(fields[-2]), float(fields[-1]))
if split[2] > upper_digits[2]:
continue
if split[2] == upper_digits[2]:
if split[3] > upper_digits[3]:
continue
if split[2] <= upper_digits[2]:
return Coords(float(fields[-2]), float(fields[-1]))
raise GeolocationError("No matching records found")
try:
lat, lon = read_from_table(db_path, ip)
return Coords(lat, lon)
except Exception:
raise GeolocationError("No matching records found")
def get_local_ip() -> str:
+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
+7
View File
@@ -31,6 +31,7 @@ show_details = "Show server details"
refresh_players = "Refresh player count"
open_workshop = "Open in Steam Workshop"
unsub_mod = "Unsubscribe mod"
validate_mod = "Validate mod"
copy_name = "Copy name to clipboard"
copy_ip = "Copy IP to clipboard"
copy_log = "Copy record(s) to clipboard"
@@ -200,6 +201,8 @@ class ModPanelStrings:
clear_highlights: str
select_stale: str
select_stale_tooltip: str
validate: str
validate_tooltip: str
@dataclass(slots=True, frozen=True)
@@ -249,6 +252,7 @@ class Crumbs:
developers: str
default: str
offline: str
connect: str
@dataclass(slots=True, frozen=True)
@@ -319,6 +323,8 @@ mod_panel = ModPanelStrings(
clear_highlights="Clears highlights and reverts the table to a default state",
select_stale="Select stale",
select_stale_tooltip="Only selects highlighted stale mods",
validate="Validate selected",
validate_tooltip="Validates selected mods and updates them if necessary",
)
@@ -434,6 +440,7 @@ crumbs = Crumbs(
developers="Options > Developers",
default="Servers > ",
offline="Mods > Play offline",
connect="Connect",
)
no_mods = "No local mods found."
+20 -2
View File
@@ -1,7 +1,7 @@
import logging
import signal
from typing import TYPE_CHECKING, Literal, Self
from typing import Any, Literal, Self, TYPE_CHECKING
from dzgui.const.constants import (
APP_NAME,
@@ -47,7 +47,7 @@ logger = logging.getLogger(APP_NAME)
class OuterWindow(Gtk.ApplicationWindow):
def __init__(self, app: Gtk.Application) -> None:
def __init__(self, app: "App") -> None:
super().__init__(
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.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:
self.halt_proc_and_quit()
@@ -291,6 +300,14 @@ class App(ColorAwareApp, Gtk.Application): # type: ignore
self._setup_signals()
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:
self.win = OuterWindow(app)
@@ -331,4 +348,5 @@ class App(ColorAwareApp, Gtk.Application): # type: ignore
) -> None:
self.win.halt_proc_and_quit()
MainController = Controller()
+4 -4
View File
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
class ContextualButton(Gtk.Button):
def __init__(
self, label: str, opens: "NotebookPage", tooltip: str, context: ButtonType
self, label: str, opens: "NotebookPage" | None, tooltip: str, context: ButtonType
) -> None:
super().__init__(
label=label,
@@ -51,9 +51,9 @@ class ButtonBox(Gtk.Box):
for side_button in ButtonType:
button = ContextualButton(
label=side_button.dict["label"],
opens=side_button.dict["opens"],
tooltip=side_button.dict["tooltip"],
label=side_button.label,
opens=side_button.opens,
tooltip=side_button.tooltip,
context=side_button,
)
+1 -1
View File
@@ -43,7 +43,7 @@ class Breadcrumbs(Gtk.Label):
label = page.get_current_tab_text()
self.set_server_crumbs(label)
else:
crumbs = enum.dict["crumbs"]
crumbs = enum.crumbs
self.set_crumbs(crumbs)
def set_server_crumbs(self, suffix: str) -> None:
+2
View File
@@ -313,6 +313,8 @@ class FilterPanel(Gtk.Box):
return True
def toggle_check(self, digit: int) -> None:
if not self.button_grid.is_sensitive():
return
checks = self.button_grid.get_checkboxes()
check = checks[digit]
state = check.get_active()
+5 -2
View File
@@ -20,8 +20,8 @@ class EnumeratedModButton(Gtk.Button):
super().__init__(margin_start=10, margin_end=10, focus_on_click=False)
self.enum = enum
self.set_label(enum.dict["label"])
self.set_tooltip_text(enum.dict["tooltip"])
self.set_label(enum.label)
self.set_tooltip_text(enum.tooltip)
class ModPanelButton(EnumeratedModButton):
@@ -50,6 +50,7 @@ class ModSelectionPanel(Gtk.Box):
ModButton.SELECT_ALL,
ModButton.UNSELECT_ALL,
ModButton.UNSUB_SELECTED,
ModButton.VALIDATE,
)
for button in buttons:
b = EnumeratedModButton(button)
@@ -114,3 +115,5 @@ class ModSelectionPanel(Gtk.Box):
self.swap_sensitive(False)
case ModButton.SELECT_STALE:
self.controller.select_colorized()
case ModButton.VALIDATE:
self.controller.validate_mods()
+2 -2
View File
@@ -60,7 +60,7 @@ class Statusbar(Gtk.Grid):
return
enum = notebook.get_page_by_enum()
show_statusbar = enum.dict["statusbar"]
show_statusbar = enum.has_statusbar
if show_statusbar is False:
self.set_by_context(enum, "")
@@ -152,5 +152,5 @@ class Statusbar(Gtk.Grid):
row = tree.get_value_at_index(1)
if self.controller.loaded is False:
return
tooltip = row.dict["tooltip"]
tooltip = row.tooltip
self.set_by_context(NotebookPage.HELP, tooltip)
+2 -2
View File
@@ -132,9 +132,9 @@ class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
preboot.signatures,
False,
),
(StoredFunc(get_ipdb, self.xdg.ips), preboot.geo, False),
(StoredFunc(get_ipdb, self.xdg.db), preboot.geo, False),
# TODO: handle IP DB failure and use coords fallback
(StoredFunc(get_local_coords, self.xdg.ips), preboot.coords, True),
(StoredFunc(get_local_coords, self.xdg.db), preboot.coords, True),
(StoredFunc(check_updates, self.version), preboot.updates, True),
]
self.results: list[Any] = []
+9 -13
View File
@@ -1,7 +1,7 @@
import logging
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import ContextMenu
from dzgui.const.enum import ContextMenuItem
from dzgui.util.keys import is_ctrl_mask, is_navkey
from dzgui.util.strings import edit_note
from dzgui.views.trees.tree_base import TreeView
@@ -15,11 +15,7 @@ logger = logging.getLogger(APP_NAME)
class ContextMixin(TreeView):
def present_menu(
self,
widget: Gtk.Widget,
event: Gdk.EventButton | Gdk.EventKey,
) -> bool:
def present_menu(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
if self.menu is None:
return False
@@ -62,17 +58,17 @@ class ContextMixin(TreeView):
self.context_menu.select_first(False)
return True
def _process_dynamic_row(self, row: ContextMenu) -> Gtk.MenuItem:
if row == ContextMenu.ADD_SERVER and self.is_in_favs(): # type: ignore
row = ContextMenu.REMOVE_SERVER
def _process_dynamic_row(self, row: ContextMenuItem) -> Gtk.MenuItem:
if row == ContextMenuItem.ADD_SERVER and self.is_in_favs(): # type: ignore
row = ContextMenuItem.REMOVE_SERVER
item = Gtk.MenuItem(label=row.dict["label"])
item = Gtk.MenuItem(label=row.label)
item.connect("activate", self._on_menu_click, row)
if row == ContextMenu.SHOW_MODS:
if row == ContextMenuItem.SHOW_MODS:
item.set_sensitive(self.is_modded()) # type: ignore
if row == ContextMenu.ADD_NOTE:
if row == ContextMenuItem.ADD_NOTE:
if self.controller.has_note():
item.set_label(edit_note)
@@ -96,7 +92,7 @@ class ContextMixin(TreeView):
except AttributeError:
return False
def _on_menu_click(self, widget: Gtk.MenuItem, enum: ContextMenu) -> None:
def _on_menu_click(self, widget: Gtk.MenuItem, enum: ContextMenuItem) -> None:
self.controller.menu_action(enum, self) # type: ignore
def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None:
+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:
"""
Suppresses spamming on keydown
TODO: explain this better
Adds debounce to key press/release events to prevent spamming
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()
if model is None:
+1 -1
View File
@@ -77,4 +77,4 @@ class MenuTreeView(TreeView):
raise ValueError("Trying to call a method on non-existent model")
_iter = self.get_focused_row_iter()
rowtype = model.get_value(_iter, 1)
return str(rowtype.dict["tooltip"])
return str(rowtype.tooltip)
+3 -3
View File
@@ -7,7 +7,7 @@ from warnings import deprecated
from dzgui.api.servers import ping, Record
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import ContextMenu, ContextMenuGroup, ServerTab
from dzgui.const.enum import ContextMenuItem, ContextMenuGroup, ServerTab
from dzgui.managers.filter import FilterManager
from dzgui.model.proxy_model import ProxyModelManager
from dzgui.util import strings
@@ -250,9 +250,9 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
if self.enum is ServerTab.LAN:
self.emitter.emit("request_custom_port_focus")
case Gdk.KEY_c:
self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self)
self.controller.menu_action(ContextMenuItem.COPY_SERVER_IP, self)
case Gdk.KEY_r:
self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self)
self.controller.menu_action(ContextMenuItem.REFRESH_PLAYERS, self)
else:
match event.keyval:
case Gdk.KEY_l | Gdk.KEY_Right:
+1 -1
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [
{name = "aclist"}
]
version = "7.0.0b29"
version = "7.0.0b30"
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
readme = "README.md"
+2 -2
View File
@@ -1,4 +1,4 @@
from pathlib import Path
from shared import get_git_root
BOILERPLATE = """
This file includes the combined licenses to the following components:
@@ -40,7 +40,7 @@ class CombinedLicense:
def concat_license() -> str:
combined = CombinedLicense()
root = Path(__file__).resolve().parents[1]
root = get_git_root()
licenses = root.joinpath("docs/licenses")
main_license = root.joinpath(LICENSE_FILE)
+5 -4
View File
@@ -1,10 +1,11 @@
import shutil
import subprocess
from pathlib import Path
par = Path(__file__).parent.parent
clog = par / "CHANGELOG.md"
target = par / "dzgui/data/CHANGELOG.md"
from shared import get_git_root
root = get_git_root()
clog = root.joinpath("CHANGELOG.md")
target = root.joinpath("dzgui/data/CHANGELOG.md")
shutil.copy(clog, target)
+2 -2
View File
@@ -1,9 +1,9 @@
import tomllib
from pathlib import Path
from shared import get_git_root
root = Path(__file__).resolve().parents[1]
def get_entrypoint() -> None:
root = get_git_root()
p = root.joinpath("pyproject.toml")
with open(p, "rb") as f:
t = tomllib.load(f)
+3 -2
View File
@@ -3,14 +3,15 @@ import requests
import subprocess
import tarfile
from pathlib import Path
from urllib.parse import unquote
from shared import get_git_root
BUILD = "3.13.9"
DATE = "20251014"
CPYTHON_BUILD = f"cpython-{BUILD}"
root = Path(__file__).resolve().parents[1]
root = get_git_root()
build_dir = root.joinpath("build")
cpython_dir = build_dir.joinpath(CPYTHON_BUILD)
+2 -1
View File
@@ -9,6 +9,7 @@ from pathlib import Path
from concat_licenses import concat_license
from parse_toml import get_entrypoint
from prebuild import get_pyapp, rebuild_cpython
from shared import get_git_root
def update_uname(info: tarfile.TarInfo) -> tarfile.TarInfo:
@@ -17,7 +18,7 @@ def update_uname(info: tarfile.TarInfo) -> tarfile.TarInfo:
return info
root = Path(__file__).resolve().parents[1]
root = get_git_root()
dist_dir = root.joinpath("dist")
build_dir = root.joinpath("build")
+14
View File
@@ -0,0 +1,14 @@
from pathlib import Path
from dzgui.app_init import build_gui
from shared import get_git_root
def get_image_path() -> Path:
return get_git_root().joinpath("dzgui/data/images/example.png")
p = get_image_path()
app = build_gui("", False)
app.set_automation_param("screenshot_path", p)
app.run(None)
+11
View File
@@ -0,0 +1,11 @@
import subprocess
from pathlib import Path
def get_git_root() -> Path:
proc = subprocess.run(
["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True
)
if proc.returncode != 0:
raise OSError("Failed to parse git root")
return Path(proc.stdout.rstrip())
-1
View File
@@ -36,7 +36,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)
+5 -3
View File
@@ -1,7 +1,7 @@
import configparser
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.util.flatpak import apply_overrides
@@ -38,6 +38,8 @@ def test_session_bus(override, 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(";")
assert all(str(path) in paths for path in expect.values())
assert all(str(path) in paths for path in expect)
+44 -7
View File
@@ -1,16 +1,40 @@
from dzgui.config.ipdb import get_ipdb
from pathlib import Path
import os
import sqlite3
import pytest
import tempfile
from pathlib import Path
from dzgui.const.endpoints import DB_IP
from dzgui.config.ipdb import (
create_table,
find_download_url,
serialize,
strip_ipv6,
unzip,
)
def get_db_len(path: Path) -> int:
conn = sqlite3.connect(path)
c = conn.cursor()
c.execute("SELECT COUNT(*) from ips")
db_len = c.fetchone()[0]
return db_len
@pytest.fixture
def csv() -> Path:
csv = Path(tempfile.NamedTemporaryFile().name)
url = find_download_url(DB_IP)
tmp = serialize(url)
unzip(tmp, csv)
strip_ipv6(csv)
return csv
@pytest.mark.slow
def test_stripping():
d = tempfile.TemporaryDirectory()
path = Path(d.name).joinpath("ips.csv")
get_ipdb(path)
with open(path, "rb") as f:
def test_stripping(csv):
with open(csv, "rb") as f:
first = f.readline().decode().rstrip()
try:
f.seek(-2, os.SEEK_END)
@@ -25,3 +49,16 @@ def test_stripping():
assert first_els[0] == "0.0.0.0"
assert last_els[0] == "224.0.0.0"
@pytest.mark.slow
def test_row_parity(csv) -> None:
rows = csv.read_text().splitlines()
db = Path(tempfile.NamedTemporaryFile().name)
create_table(db, csv)
csv_len = len(rows)
db_len = get_db_len(db)
assert csv_len == db_len
+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)