Compare commits

..
Author SHA1 Message Date
aclist b301ddaa70 Merge 667203b4f9 into 04e9e5dcf0 2026-08-28 08:36:30 +00:00
110 changed files with 585 additions and 2354 deletions

No files matched your search

-9
View File
@@ -52,11 +52,6 @@
- Filter button tooltips
- 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
@@ -78,7 +73,6 @@
- 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
@@ -96,9 +90,6 @@
- Rare segfaults when changing maps (threading)
- 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
+5 -17
View File
@@ -1,23 +1,15 @@
## What this is
DZGUI is a turnkey server browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI).
DZGUI is a turnkey browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI).
This overcomes certain limitations in the Linux client and helps prepare the game to launch by providing features like:
- Search for and display global and LAN servers via an interactive table
- Supports official, third-party, and modded DayZ servers
- Automatically find and prepare mods being requested by servers
- Add/delete/manage favorite servers by IP or ID
- Quick-connect to favorite/recent servers
- Manage and delete local mods
- And more
## Design philosophy
DZGUI follows a specific set of guidelines, enumerated below:
- Provide a free and open-source tool under GPL
- Do not use undocumented, third-party servers to aggregate data; no central "DZGUI" server
- All connections go to Steam and DayZ servers directly; the tool should function in perpetuity without intermediary endpoints
- Target Linux desktop and Steam Deck specifically; not a cross-platform tool
- Manage local mods and symlinks
- Prepare launch options to pass to Steam
## Setup and usage
@@ -26,7 +18,7 @@ Please refer to the documentation for installation and setup instructions:
- [GitHub](https://aclist.github.io/dzgui/index.html)
- [Codeberg (Mirror)](https://aclist.codeberg.page)
![A screenshot of DZGUI](/dzgui/data/images/example.png)
![A screenshot of DZGUI](/images/example.png)
## Attribution
@@ -35,10 +27,6 @@ Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https
Executable versions of DZGUI published as release binaries ship with various runtime dependencies and the Python interpreter built in.
Users wishing to review the licenses to these components can inspect the `LICENSE` file located in the release tarball.
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.
+4 -30
View File
@@ -1,18 +1,15 @@
import logging
import re
from datetime import datetime
from collections.abc import Iterator
from pathlib import Path
from typing import Any, TYPE_CHECKING
from typing import Any
from warnings import deprecated
from dzgui.const.constants import APP_NAME
logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from collections.abc import Iterator
class ACF:
def __init__(self, file: Path) -> None:
@@ -45,7 +42,7 @@ class ACF:
def dequote(cls, s: str) -> str:
return s.rstrip('"').lstrip('"')
def parse(self, lines: "Iterator[list[str]]") -> dict[str, str]:
def parse(self, lines: Iterator[list[str]]) -> dict[str, str]:
acf: dict[str, Any] = {}
try:
while True:
@@ -72,28 +69,6 @@ class ACF:
except StopIteration:
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:
try:
# TODO: consider coercing strs to ints on initial import
@@ -111,8 +86,7 @@ class WorkshopACF(ACF):
def __init__(self, file: Path) -> None:
super().__init__(file)
def unpack(self, d: dict[str, Any], lines: list[Any] = []) -> str:
self.indent: int
def unpack(self, d: dict, lines: list[Any] = []) -> str:
t1 = "AppWorkshop"
t2 = ("WorkshopItemsInstalled", "WorkshopItemDetails")
for k, v in d.items():
+5 -1
View File
@@ -114,6 +114,10 @@ def get_delimited_mods(steam_path: Path) -> list[Any]:
return parse_mods(mods)
def get_missing_mods(local: list, remote: list) -> list:
return [mod for mod in remote if mod not in local]
def _hash(uid: str, use_custom: bool = False) -> str:
md5 = hashlib.md5()
md5.update(uid.encode("ascii"))
@@ -148,7 +152,7 @@ def remove_stale_signatures(
def find_stale_mods(config: Path) -> list[int]:
def push_record(rec: str) -> list[int]:
def push_record(rec: str) -> list:
record = fqip_to_record(rec)
if record is None:
return []
+8 -20
View File
@@ -255,28 +255,16 @@ class PeFileError(Exception):
pass
def parse_version_number(data: BinaryIO) -> FileVersion:
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
"""
Note: structure differs from standard VS_FIXEDFILEINFO
Major: lower 16 bits of first DWORD
Minor: upper 16 bits of first DWORD
Build: lower 16 bits of third DWORD
Revision: upper 16 bits of second DWORD, zero-padded
Lower 16 bits of revision DWORD appear unused.
"""
major_minor = struct.unpack("<I", data.read(4))[0]
revision_pair = struct.unpack("<I", data.read(4))[0]
build_dword = struct.unpack("<I", data.read(4))[0]
major = major_minor & 0xFFFF
minor = (major_minor >> 16) & 0xFFFF
build = build_dword & 0xFFFF
revision = (revision_pair >> 16) & 0xFFFF
revision = f"{revision:03d}"
return FileVersion(major, minor, build, revision)
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
# NOTE: 0-pad revision number
revision = str(struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF)
offset = 3 - len(revision)
padded = "0" * offset + revision
return FileVersion(major, minor, build, padded)
def seek_to_hex(address: str, data: BinaryIO) -> None:
+30 -27
View File
@@ -54,16 +54,19 @@ class Res:
@dataclass(slots=True, frozen=True)
class Details:
data: list[list[str]]
name: str
data: Union[list, None]
description: str
gametime: str
day_accel: float
night_accel: float
success: bool
@dataclass(slots=True, frozen=True)
@dataclass(slots=True)
class Record:
# TODO: investigate whether this enum is still being used;
# can this be frozen?
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str
gameport: int
qport: int
@@ -111,7 +114,7 @@ def get_netmask() -> str:
# TODO: rename
def test_ip(suffix: int, port: int, event: threading.Event) -> dict[str, Any] | None:
def test_ip(suffix: int, port: int, event: threading.Event) -> dict | None:
if event.is_set():
return None
netmask = get_netmask()
@@ -139,9 +142,7 @@ def sanitize(name: str) -> str:
return name
def parse_json(
json: list[dict[str, Any]],
) -> list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]]:
def parse_json(json: list) -> list:
"""
Server metadata is underspecified and server operators
tend to insert random garbage in the headers. In case
@@ -282,41 +283,43 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] |
info = a2s.info((ip, qport), timeout)
return source_info_to_dict(ip, qport, info)
except Exception as e:
logger.debug(e)
logger.warning(e)
return None
def get_details(record: Record) -> Details | None:
def get_details(record: Record) -> Details:
# TODO: consolidate try/except statements
ip = record.ip
qport = record.qport
default_str = strings.none_provided
try:
info = a2s.info((ip, qport))
except (TimeoutError, a2s.exceptions.BrokenMessageError):
return Details(None, default_str, False)
try:
rules = dayzquery.dayz_rules((ip, qport))
except Exception:
return Details(None, default_str, False)
try:
keywords = info.keywords.split(",")
except Exception as e:
logger.critical(e)
return None
except AttributeError:
return Details(None, default_str, False)
battleye = strings.disabled
if "battleye" in keywords:
battleye = strings.enabled
name = info.server_name
day_accel = 1.0
night_accel = 1.0
gametime = "00:00"
day_accel = 0.0
night_accel = 0.0
for keyword in keywords:
if "etm" in keyword:
day_accel = float(keyword.lstrip("etm"))
day_accel = float(f"{day_accel:g}")
if "entm" in keyword:
if "entm" in keywords:
night_accel = float(keyword.lstrip("entm"))
night_accel = float(f"{night_accel:g}")
time_reg = r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]$)"
if re.match(time_reg, keyword):
gametime = keyword
try:
password = info.password_protected
@@ -348,7 +351,7 @@ def get_details(record: Record) -> Details | None:
dlc = strings.dlc_none
case 2:
dlc = strings.dlc_frostline
case 4:
case 3:
dlc = strings.dlc_badlands
case _:
dlc = strings.unspecified
@@ -382,7 +385,7 @@ def get_details(record: Record) -> Details | None:
["Version", version],
]
return Details(rows, name, description, gametime, day_accel, night_accel)
return Details(rows, description, True)
def ping(ip: str, qport: int) -> int:
@@ -509,14 +512,14 @@ def record_to_fqip(record: Record) -> str:
return f"{record.ip}:{record.gameport}:{record.qport}"
def response_to_fqip(res: dict[str, Any]) -> str:
def response_to_fqip(res: dict) -> str:
ip = res["addr"].split(":")[0]
gameport = res["gameport"]
qport = res["addr"].split(":")[1]
return f"{ip}:{gameport}:{qport}"
def response_to_record(res: dict[str, Any]) -> Record:
def response_to_record(res: dict) -> Record:
ip = res["addr"].split(":")[0]
gameport = res["gameport"]
qport = res["addr"].split(":")[1]
+3 -11
View File
@@ -11,7 +11,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, make_parents
from dzgui.util.dirs import copy_dzgui_to_xdg_data
from dzgui.util.strings import unknown
logger = logging.getLogger(APP_NAME)
@@ -75,8 +75,6 @@ class Shortcuts:
shortcuts = vdf.binary_load(f)
self.shortcuts = shortcuts
logger.debug("Loaded shortcuts file")
except FileNotFoundError:
self.shortcuts = {"shortcuts": {}}
except Exception as e:
logger.critical(e)
raise e
@@ -111,7 +109,6 @@ class Shortcuts:
for img in ("_hero", "p", "_logo"):
filename = str(self.bpid) + img
dest = grid_path.joinpath(filename).with_suffix(".png")
make_parents(dest)
b = images.joinpath(img).with_suffix(".png").read_bytes()
dest.write_bytes(b)
@@ -159,7 +156,6 @@ class Shortcuts:
NEW_ENTRY["DevkitOverrideAppID"] = 0
NEW_ENTRY["LastPlayTime"] = 0
NEW_ENTRY["FlatpakAppID"] = ""
NEW_ENTRY["sortas"] = ""
NEW_ENTRY["tags"] = {}
return NEW_ENTRY
@@ -173,13 +169,9 @@ class Shortcuts:
self.save_shortcuts()
def save_shortcuts(self) -> None:
if self.shortcuts_path.is_file():
try:
backup = self.shortcuts_path.with_suffix(".vdf.bak")
shutil.copy(self.shortcuts_path, backup)
except Exception as e:
logger.critical(e)
try:
backup = self.shortcuts_path.with_suffix(".vdf.bak")
shutil.copy(self.shortcuts_path, backup)
with open(self.shortcuts_path, "wb") as f:
vdf.binary_dump(self.shortcuts, f)
except Exception as e:
+9 -83
View File
@@ -1,20 +1,16 @@
import logging
import os
import psutil
import re
import requests
import subprocess
import vdf # type: ignore
from pathlib import Path
from requests.exceptions import HTTPError
from typing import Any, Generator, Union
from typing import Any, 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.init.prereqs import has_steam_client
from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
@@ -23,11 +19,10 @@ from dzgui.const.constants import (
DAYZ_LAUNCHER,
DEBIAN_STEAM_PATH,
DEFAULT_STEAM_PATH,
DOWNLOADS_PATH,
FLATPAK_STEAM_PATH,
LIBRARYFOLDERS_PATH,
REQUEST_TIMEOUT,
UBUNTU_STEAM_PATH,
REQUEST_TIMEOUT,
VDF_PATH,
)
from dzgui.const.endpoints import (
@@ -37,7 +32,6 @@ from dzgui.const.endpoints import (
UNSUB_ENDPOINT,
)
from dzgui.strings import wizard
from dzgui.strings.errors import api_validation_error, workshop_endpoint_error
from dzgui.util.bash import concat_bash_args
logger = logging.getLogger(APP_NAME)
@@ -63,7 +57,7 @@ class VDFLoadError(Exception):
def get_steam_paths() -> list[tuple[Path, str]]:
paths = []
if has_any_client():
if has_steam_client():
HOME = Path.home()
env = os.environ.get("XDG_DATA_HOME")
if env is not None:
@@ -84,10 +78,6 @@ def get_steam_paths() -> list[tuple[Path, str]]:
}
for path in DEFAULT_PATH, FLATPAK_PATH, UBUNTU_PATH, DEBIAN_PATH:
if path is FLATPAK_PATH and has_flatpak_client() is False:
continue
if path is not FLATPAK_PATH and has_steam_client() is False:
continue
if path.joinpath(VDF_PATH).is_file():
paths.append((path, human[path]))
return paths
@@ -204,7 +194,6 @@ def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int:
"""Loads to the menu screen with the selected mods; used for AFK/pre-joining"""
concat = concat_mods(mods)
client_args = concat_bash_args(client)
logger.debug(f"Setting client arguments to: {client_args}")
params = [
"-applaunch",
str(appid),
@@ -254,9 +243,9 @@ def find_user_id(path: Path) -> str | None:
last_user = str(next(iter(users)))
if len(users) == 1:
return last_user
last_stamp = int(users[last_user]["timestamp"])
last_stamp = int(users[last_user]["Timestamp"])
for user in users:
stamp = int(users[user]["timestamp"])
stamp = int(users[user]["Timestamp"])
if stamp > last_stamp:
last_stamp = stamp
last_user = user
@@ -273,16 +262,6 @@ def find_user_id_32(path: Path) -> int:
return int(uid) & 0xFFFFFFFF
class SteamAPIKeyError(Exception):
def __init__(self, msg: str | None = None) -> None:
super().__init__(msg or api_validation_error)
class SteamWorkshopEndpointError(Exception):
def __init__(self, msg: str | None = None) -> None:
super().__init__(msg or workshop_endpoint_error)
def update_workshop(key: str, mod: int, endpoint: str) -> None:
payload: dict[str, Union[int, str]] = {
"publishedfileid": mod,
@@ -294,19 +273,8 @@ def update_workshop(key: str, mod: int, endpoint: str) -> None:
try:
res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT)
res.raise_for_status()
except HTTPError as e:
logger.critical(e)
match res.status_code:
case 403:
raise SteamAPIKeyError()
case 404:
raise SteamWorkshopEndpointError()
case _:
# TODO: apply redaction filter
raise e
except Exception as e:
logger.critical(e)
raise e
def subscribe(key: str, mod: int) -> None:
@@ -317,9 +285,10 @@ def unsubscribe(key: str, mod: int) -> None:
update_workshop(key, mod, UNSUB_ENDPOINT)
def validate_mod(client: str, mod: str) -> None:
@deprecated("Use subscribe()")
def enqueue_mod(client: str, mod: str, appid: int) -> None:
client_args = concat_bash_args(client)
subprocess.Popen([*client_args, "+workshop_download_item", str(APPID_DAYZ), mod])
subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod])
@deprecated("Cf. https://github.com/ValveSoftware/steam-for-linux/issues/9672")
@@ -389,26 +358,6 @@ def get_running_app() -> int | 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:
try:
root_path = get_app_path(path, appid)
@@ -467,22 +416,6 @@ def is_dayz_running() -> bool:
return any(s in item for sublist in procs for item in sublist for s in substring)
def get_pending_downloads(steam_path: Path, appid: int) -> Generator[Path, None, None]:
root_path = get_app_path(steam_path, appid)
downloads_dir = root_path / DOWNLOADS_PATH
files = downloads_dir.iterdir()
return files
def is_dayz_downloading(steam_path: Path, appid: int) -> bool:
files = get_pending_downloads(steam_path, appid)
reg = re.compile(rf"^state_(?:{APPID_DAYZ_EXP}|{APPID_DAYZ})_.*\.patch$")
pending = [f for f in files if reg.match(f.name)]
if len(pending) > 0:
return True
return False
def get_app_path(folders_path: Path, appid: int) -> Path:
app_path = None
@@ -520,10 +453,3 @@ 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])
+7 -38
View File
@@ -1,18 +1,10 @@
import logging
import os
import shutil
from importlib import resources
from pathlib import Path
from typing import TYPE_CHECKING
from dzgui.const.constants import (
APP_NAME,
APP_NAME_LOWER,
FLATPAK_ROOT_PATH,
ICONS_PATH,
)
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
from dzgui.config.userprefs import UserPrefs
@@ -23,7 +15,7 @@ from dzgui.init.migrate import (
migrate_cols_file,
)
from dzgui.init.flock import lock_acquire
from dzgui.init.prereqs import has_flatpak_client, has_steam_client
from dzgui.init.prereqs import has_steam_client
from dzgui.strings import boot
from dzgui.util.deck import is_steam_deck, is_game_mode
@@ -104,24 +96,7 @@ def copy_bare_configs(config: "Path", resolution: "Path") -> tuple[bool, bool]:
return (config_changed, state_changed)
def load_steam_icon() -> None:
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa
try:
traversable = resources.files(APP_NAME_LOWER).joinpath(ICONS_PATH)
icons = Path(str(traversable))
theme = Gtk.IconTheme.get_default()
paths = theme.get_search_path()
theme.set_search_path(paths + [str(icons)])
except Exception as e:
logger.critical(e)
def build_gui(version: str, is_debug: bool) -> App | None:
def load_gui(version: str, is_debug: bool) -> None:
lock = lock_acquire() # noqa
set_locale()
@@ -148,10 +123,10 @@ def build_gui(version: str, is_debug: bool) -> App | None:
migrate_cols_file(XDG.columns)
copy_state_files(xdg_paths["XDG_STATE_HOME"])
# TODO: add logging inside wizard
wizard = SetupWizard(_is_steam_deck, XDG, xdg_paths)
wizard = SetupWizard(_is_steam_deck, XDG)
if not wizard.is_setup_complete():
return None
return
setup_logger(XDG.debug)
with open(XDG.debug, "w") as f:
@@ -161,18 +136,13 @@ def build_gui(version: str, is_debug: bool) -> App | None:
# TODO: sudo escalation dialog
# count = get_map_count()
# TODO: move into module
if FLATPAK_ROOT_PATH in lookup(XDG.config, Preferences.DEFAULT):
has_client = has_flatpak_client()
else:
has_client = has_steam_client()
if has_client is False:
if has_steam_client() is False:
EarlyAlertDialog(init.requires_steam)
bootwin = BootWindow(XDG, version)
local_coords, latest_release = bootwin.get_results()
use_miles = lookup(XDG.config, Preferences.DIST)
prefs = UserPrefs(
is_steam_deck=_is_steam_deck,
is_game_mode=_is_game_mode,
@@ -184,6 +154,5 @@ def build_gui(version: str, is_debug: bool) -> App | None:
use_miles=use_miles,
)
print(boot.all_ok)
load_steam_icon()
app = App(prefs)
return app
app.run(None)
+3 -102
View File
@@ -3,9 +3,6 @@ import logging
import re
import requests
import shutil
import socket
import sqlite3
import time
from pathlib import Path
@@ -25,16 +22,15 @@ def find_download_url(url: str) -> str | None:
return str(urls[0])
def get_ipdb(db_path: Path) -> None:
def get_ipdb(ips_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)
if db_path.exists() and month_file.exists():
month_file = ips_path.parent / ".month"
if ips_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}")
@@ -49,9 +45,6 @@ def get_ipdb(db_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}")
@@ -115,95 +108,3 @@ 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
+20
View File
@@ -4,6 +4,8 @@ from pathlib import Path
from dzgui.util._json import read_json
from dzgui.const.enum import Preferences
from dzgui.const.constants import STEAM_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX
class ConfigFileError(Exception):
pass
@@ -38,5 +40,23 @@ def get_favorites(path: Path) -> list[str]:
return list(conf["ip_list"])
# def is_in_favs(record: str, path: Path) -> bool:
# favs = get_favorites(path)
# if record in favs:
# return True
# return False
def enum_to_key(enum: Preferences) -> str:
return str(enum.dict["key"])
def get_client_index(client: str) -> int:
if client == STEAM_CMD:
return 0
elif client == FLATPAK_RUN_CMD:
return 1
elif client == FLATPAK_SANDBOX:
return 2
else:
return 0
+7 -15
View File
@@ -3,13 +3,7 @@ import os
from pathlib import Path
from dataclasses import dataclass
from dzgui.const.constants import (
APP_NAME_LOWER,
DEBUG_LOG,
FLATPAK_ROOT_PATH,
SYSTEM_LOG,
)
from dzgui.init.proc import is_sandboxed
from dzgui.const.constants import APP_NAME_LOWER, DEBUG_LOG, SYSTEM_LOG
@dataclass
@@ -22,7 +16,7 @@ class Xdg:
version: Path
system: Path
debug: Path
db: Path
ips: Path
filters: Path
shortcut: Path
@@ -51,7 +45,7 @@ def is_writeable(path_str: str) -> bool:
return True
def get_xdg_paths() -> dict[str, Path]:
def get_xdg_paths() -> dict:
home = Path.home()
xdg_paths = {
"XDG_CONFIG_HOME": home / ".config",
@@ -59,12 +53,10 @@ def get_xdg_paths() -> dict[str, Path]:
"XDG_DATA_HOME": home / ".local" / "share",
"XDG_CACHE_HOME": home / ".cache",
}
resolved_paths = {}
for path in xdg_paths:
real_path = os.environ.get(path)
if real_path is not None and is_sandboxed():
search_string = FLATPAK_ROOT_PATH + "/"
real_path = real_path.replace(search_string, "")
if real_path is not None and is_writeable(real_path):
new_path = Path(real_path)
else:
@@ -73,7 +65,7 @@ def get_xdg_paths() -> dict[str, Path]:
return resolved_paths
def parse_filepaths(xdg: dict[str, Path]) -> Xdg:
def parse_filepaths(xdg: dict) -> Xdg:
config = xdg["XDG_CONFIG_HOME"]
state = xdg["XDG_STATE_HOME"]
share = xdg["XDG_DATA_HOME"]
@@ -88,7 +80,7 @@ def parse_filepaths(xdg: dict[str, Path]) -> Xdg:
resolution = state / "dzg.res.json"
history = state / "dzg.history"
versions = state / "dzg.versions"
db = state / "ips.db"
ips = state / "ips.csv"
filters = state / "dzg.filters.json"
shortcut = share / APP_NAME_LOWER
@@ -102,7 +94,7 @@ def parse_filepaths(xdg: dict[str, Path]) -> Xdg:
versions,
system,
debug,
db,
ips,
filters,
shortcut,
)
+3 -10
View File
@@ -15,7 +15,6 @@ DAYZ_BINARY = "DayZ_x64.exe"
DAYZ_LAUNCHER = "DayZLauncher.exe"
LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf"
DOWNLOADS_PATH = "steamapps/downloading"
WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ)
STEAM_CMD = "steam"
@@ -37,8 +36,7 @@ HEX_GREEN = "#32CD32"
HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C"
# TODO: normalize icon names
CALCULATOR = "accessories-calculator-symbolic"
# FIXME: normalize icon names
CARET_DOWN = "go-down-symbolic"
CARET_UP = "go-up-symbolic"
CLIPBOARD = "edit-copy-symbolic"
@@ -51,13 +49,11 @@ INPUT_KEYBOARD = "input-keyboard-symbolic"
LIST_ADD = "list-add-symbolic"
REFRESH_ICON = "view-refresh-symbolic"
SEARCH_ICON = "system-search-symbolic"
STEAM_ICON = "steam-tray-symbolic"
STEAM_ICON = "steam_tray_mono"
VIEW_CONCEAL = "view-conceal-symbolic"
VIEW_REVEAL = "view-reveal-symbolic"
WARNING = "dialog-warning-symbolic"
WEB_BROWSER = "web-browser-symbolic"
DAY_ICON = "weather-clear-symbolic"
NIGHT_ICON = "weather-clear-night-symbolic"
SEPARATOR = "SEPARATOR"
@@ -80,14 +76,12 @@ DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG"
SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG"
CHANGELOG_PATH = "data/CHANGELOG.md"
IMAGES_PATH = "data/images"
ICONS_PATH = "data/images/gtk"
HERO_PATH = "data/images/_hero.png"
CSS_PATH = "data/app.css"
VDF_PATH = "steamapps/libraryfolders.vdf"
DEFAULT_STEAM_PATH = ".local/share/Steam"
FLATPAK_ROOT_PATH = ".var/app/com.valvesoftware.Steam"
FLATPAK_STEAM_PATH = FLATPAK_ROOT_PATH + "/data/Steam"
FLATPAK_STEAM_PATH = ".var/app/com.valvesoftware.Steam/data/Steam"
UBUNTU_STEAM_PATH = ".steam/steam"
DEBIAN_STEAM_PATH = ".steam/debian-installation"
@@ -96,7 +90,6 @@ LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG")
TMP_PATH = "/tmp"
TMP_TARBALL = "/tmp/dzgui.tar.gz"
TMP_EXE = "/tmp/dzgui/dzgui"
LOCK_FILE = "/tmp/dzgui.flock"
DAYZ_COMMUNITY_ROOT = "Missions"
-1
View File
@@ -18,7 +18,6 @@ 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"
+1 -6
View File
@@ -157,7 +157,6 @@ class ContextMenu(EnumWithAttrs):
COPY_SERVER_IP = {"label": strings.copy_ip}
COPY_SERVER_NAME = {"label": strings.copy_name}
UNSUB_MOD = {"label": strings.unsub_mod}
VALIDATE_MOD = {"label": strings.validate_mod}
OPEN_WORKSHOP = {"label": strings.open_workshop}
REFRESH_PLAYERS = {"label": strings.refresh_players}
REMOVE_HISTORY = {"label": strings.remove_history}
@@ -173,7 +172,7 @@ class ContextMenuGroup(Enum):
"""
SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,)
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.UNSUB_MOD, ContextMenu.VALIDATE_MOD)
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.UNSUB_MOD)
MOD_OFFLINE = (None,)
LOG = (ContextMenu.COPY_LOG_CLIPBOARD,)
SERVER_BROWSER = (
@@ -247,10 +246,6 @@ class ModButton(EnumWithAttrs):
"label": strings.mod_panel.select_stale,
"tooltip": strings.mod_panel.select_stale_tooltip,
}
VALIDATE = {
"label": strings.mod_panel.validate,
"tooltip": strings.mod_panel.validate_tooltip,
}
class ButtonType(EnumWithAttrs):
-4
View File
@@ -111,10 +111,6 @@ class Emitter(GObject.GObject):
def check_toggled(self, label: str, state: bool) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def servers_timed_out(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def servers_loaded(self, tag: "ServerTab") -> None:
pass
+12 -27
View File
@@ -7,12 +7,11 @@ import dzgui.util._json as JSON # noqa
from dzgui.const.enum import (
FilterMode,
Preferences,
NotebookPage,
ButtonType,
ContextMenu,
FilterMode,
NotebookPage,
Preferences,
ServerTab,
)
from dzgui.config.userprefs import UserPrefs
@@ -38,6 +37,7 @@ logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from dzgui.api.servers import Record
from dzgui.const.enum import ServerTab
from dzgui.managers.connection import Prerequisites
from dzgui.managers.filter import FilterManager
from dzgui.util.dist import Haversine
@@ -77,7 +77,7 @@ class AppNavigation:
class Controller(GObject.GObject):
def __init__(self) -> None:
self.dist_cache: dict[str, "Haversine" | None] = {}
self.dist_cache: dict[str, "Haversine"] = {}
self.mediator = AppNavigation()
self.prefs: UserPrefs
@@ -102,16 +102,6 @@ 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
@@ -274,7 +264,7 @@ class Controller(GObject.GObject):
except Exception as e:
dialog = ExceptionDialog(self, str(e))
dialog.show_all()
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
def select_colorized(self) -> None:
mod_man = self.mediator.modtreeview.get_mod_man()
@@ -308,7 +298,7 @@ class Controller(GObject.GObject):
except Exception as e:
dialog = ExceptionDialog(self, str(e))
dialog.show_all()
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
def update_steam_api_key(self, text: str) -> None:
self.config_man.update_steam_api_key(text)
@@ -355,17 +345,17 @@ class Controller(GObject.GObject):
filter_man.set_filter(filt, state)
self.mediator.filters.set_filters(filters)
def get_dist_cache(self) -> dict[str, Union["Haversine", None]]:
def get_dist_cache(self) -> dict[str, "Haversine"]:
return self.dist_cache
def set_dist_cache(self, addr: str, haversine: Union["Haversine", None]) -> None:
def set_dist_cache(self, addr: str, haversine: "Haversine") -> None:
self.dist_cache[addr] = haversine
def get_map_store(self) -> Gtk.ListStore:
filter_man = self.get_filter_man()
return filter_man.get_map_store()
def get_enabled_filters(self) -> dict[str, bool]:
def get_enabled_filters(self) -> dict:
filter_man = self.get_filter_man()
return filter_man.get_filters()
@@ -508,9 +498,6 @@ class Controller(GObject.GObject):
def set_active_keyword(self, keyword: str) -> None:
self.get_filter_man().set_active_keyword(keyword)
ServerModelManager(self, self.get_active_treeview()).refilter(
FilterMode.TOGGLE_ON
)
def get_modtreeview(self) -> "ModTreeView":
return self.mediator.modtreeview
@@ -551,10 +538,8 @@ class Controller(GObject.GObject):
def update_status(self) -> None:
self.mediator.preconnect.mark_finished()
def get_client_name(self) -> str:
# NOTE: only possible values are "Steam" and "Flatpak"
cmd = self.query_config(Preferences.CLIENT)
return str(cmd.split()[0].capitalize())
def get_steam_client_name(self) -> str:
return self.mediator.options.get_client_name()
def present_window(self) -> None:
self.mediator.window.present_with_time(Gdk.CURRENT_TIME)
-2
View File
@@ -52,7 +52,6 @@
- Filter button tooltips
- Expand/collapse changelog versions
- Support local light/dark mode changes on system theme
- Server time calculator
### Changed
- Conform to PEP 440 versioning for beta versions
@@ -91,7 +90,6 @@
- Rare segfaults when changing maps (threading)
- Moved dialogs out of threads
- History table not being in lock-step with state file
- Current keyword filter not being applied when refreshing table
### Unreleased
- Load offline mods
-4
View File
@@ -24,10 +24,6 @@
.left-label {
font-size: 1.3rem;
}
.server-subheading {
font-size: 1.1rem;
font-weight: 700;
}
.details-heading {
font-size: 1.2rem
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 130 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M568 320C568 457 456.8 568 319.6 568C205.8 568 110 491.7 80.6 387.6L175.8 426.9C182.2 459 210.7 483.3 244.7 483.3C283.9 483.3 316.6 450.9 314.9 409.8L399.4 349.6C451.5 350.9 495.2 308.7 495.2 256.1C495.2 204.5 453.2 162.6 401.5 162.6C349.8 162.6 307.8 204.6 307.8 256.1L307.8 257.3L248.6 343C233.1 342.1 217.9 346.4 205.1 355.1L72 300.1C82.2 172.4 189.1 72 319.6 72C456.8 72 568 183 568 320zM227.7 448.3L197.2 435.7C202.8 447.3 212.5 456.5 224.4 461.5C251.3 472.7 282.2 459.9 293.4 433.1C298.8 420.1 298.9 405.8 293.5 392.8C288.1 379.8 278 369.6 265 364.2C252.1 358.8 238.3 359 226.1 363.6L257.6 376.6C277.4 384.8 286.8 407.5 278.5 427.3C270.2 447.2 247.5 456.5 227.7 448.3zM401.5 193.8C435.9 193.8 463.8 221.7 463.8 256.1C463.8 290.5 435.9 318.4 401.5 318.4C367.1 318.4 339.2 290.5 339.2 256.1C339.2 221.7 367.1 193.8 401.5 193.8zM401.6 302.8C427.4 302.8 448.4 281.8 448.4 256C448.4 230.2 427.4 209.2 401.6 209.2C375.8 209.2 354.8 230.2 354.8 256C354.8 281.8 375.8 302.8 401.6 302.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

+2 -2
View File
@@ -10,10 +10,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(APP_NAME)
def get_local_coords(db_path: "Path") -> Coords | None:
def get_local_coords(path: "Path") -> Coords | None:
try:
my_ip = get_local_ip()
return get_coords(db_path, my_ip)
return get_coords(path, my_ip)
except Exception as e:
logger.warn(e)
return None
+2 -9
View File
@@ -4,24 +4,17 @@ from pathlib import Path
from dzgui.api.steam import get_app_path
from dzgui.config.query import lookup
from dzgui.const.constants import APPID_DAYZ, APP_NAME, APPNAME_DAYZ
from dzgui.const.constants import APPID_DAYZ, APP_NAME
from dzgui.const.enum import Preferences
from dzgui.strings.preconnect import resync
logger = logging.getLogger(APP_NAME)
class VDFSyncError(Exception):
pass
def is_dayz_installed(config: Path) -> None:
try:
path = lookup(config, Preferences.DEFAULT)
get_app_path(Path(path), APPID_DAYZ)
except Exception as e:
logger.critical(e)
msg = resync.format(APPNAME_DAYZ)
raise VDFSyncError(msg)
raise e
+2 -3
View File
@@ -5,9 +5,8 @@ import sys
from pathlib import Path
from typing import TextIO
from dzgui.const.constants import LOCK_FILE
from dzgui.util.strings import cannot_acquire_lock
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
from dzgui.util.strings import cannot_acquire_lock
def lock_release(lock: TextIO) -> None:
@@ -16,7 +15,7 @@ def lock_release(lock: TextIO) -> None:
def lock_acquire() -> TextIO:
lockfile = Path(LOCK_FILE)
lockfile = Path("/tmp/dzgui.flock")
lock = open(lockfile, "w")
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+11 -27
View File
@@ -3,39 +3,23 @@ import shutil
import subprocess
from dzgui.const.constants import APP_NAME, FLATPAK_APPID, FLATPAK_CMD, STEAM_CMD
from dzgui.init.proc import is_sandboxed
logger = logging.getLogger(APP_NAME)
def has_flatpak_client() -> bool:
if is_sandboxed():
return True
try:
res = subprocess.run(
[FLATPAK_CMD, "list", "--columns=application"],
capture_output=True,
text=True,
check=True,
)
if FLATPAK_APPID in res.stdout:
return True
else:
return False
except Exception as e:
logger.critical(e)
return False
def has_steam_client() -> bool:
if shutil.which(STEAM_CMD):
return True
return False
def has_any_client() -> bool:
if shutil.which(STEAM_CMD):
return True
if not shutil.which(FLATPAK_CMD):
return False
return has_flatpak_client()
try:
res = subprocess.run(
[FLATPAK_CMD, "list"], capture_output=True, text=True, check=True
)
if FLATPAK_APPID in res.stdout:
return True
else:
return False
except subprocess.CalledProcessError as e:
logger.critical(e)
return False
+15 -41
View File
@@ -1,8 +1,7 @@
import logging
import os
import psutil
import shutil
import subprocess
import shutil
import logging
from dzgui.const.constants import (
APP_NAME,
@@ -11,67 +10,42 @@ from dzgui.const.constants import (
FLATPAK_CMD,
FLATPAK_RUN_CMD,
FLATPAK_SANDBOX,
FLATPAK_ROOT_PATH,
FLATPAK_STEAM_PATH,
)
logger = logging.getLogger(APP_NAME)
def is_steam_running(cmd: str) -> bool:
if cmd in (FLATPAK_RUN_CMD, FLATPAK_SANDBOX):
if cmd == STEAM_CMD:
if has_cmd(STEAM_CMD) is False:
return False
return is_running(STEAM_CMD)
elif cmd == FLATPAK_RUN_CMD or FLATPAK_SANDBOX:
return is_flatpak_steam_running()
elif cmd == STEAM_CMD:
return is_native_steam_running()
else:
raise TypeError("Not a valid Steam client selection")
def is_sandboxed() -> bool:
return bool(os.environ.get("FLATPAK_ID"))
# CHORE: test alternate clients
# TODO: has_flatpak_steam
def is_flatpak_steam_running() -> bool:
if is_sandboxed():
return True
if has_cmd(FLATPAK_CMD) is False:
return False
proc = subprocess.check_output(
[FLATPAK_CMD, "ps", "--columns=application"], text=True
)
proc = subprocess.check_output([FLATPAK_CMD, "ps"], text=True)
lines = proc.splitlines()
if FLATPAK_APPID in lines:
return True
return False
def get_steam_procs() -> list[list[str]]:
return [
p.cmdline()
for p in psutil.process_iter(["pid", "name"])
if STEAM_CMD in p.info["name"]
]
def is_native_steam_running() -> bool:
if has_cmd(STEAM_CMD) is False:
return False
substrings = [FLATPAK_ROOT_PATH, "steam-runtime-launcher-service"]
steam_procs = get_steam_procs()
native_steam_procs = [
proc
for proc in steam_procs
if not any(sub in item for item in proc for sub in substrings)
]
return bool(native_steam_procs)
def is_running(proc: str) -> bool:
for process in psutil.process_iter(["pid", "name"]):
if process.info["name"] == proc:
return True
return False
def has_cmd(cmd: str) -> bool:
if shutil.which(cmd) is not None:
return True
return False
def path_to_steam_cmd(path: str) -> str:
cmd = FLATPAK_RUN_CMD if FLATPAK_STEAM_PATH in path else STEAM_CMD
return cmd
+2 -5
View File
@@ -42,9 +42,6 @@ def main() -> None:
test_libgi_missing()
# NOTE: only import ligbirepository modules after the above check
from dzgui.app_init import build_gui
from dzgui.app_init import load_gui
app = build_gui(version, args.debug)
if app is None:
sys.exit(0)
app.run(None)
load_gui(version, args.debug)
+13 -1
View File
@@ -6,6 +6,9 @@ from typing import Any, TYPE_CHECKING
from dzgui.const.constants import (
APP_NAME,
FLATPAK_RUN_CMD,
FLATPAK_SANDBOX,
STEAM_CMD,
WINDOW_DEFAULT_X,
WINDOW_DEFAULT_Y,
)
@@ -126,6 +129,15 @@ class ConfigManager:
def enum_to_key(self, enum: Preferences) -> str:
return str(enum.dict["key"])
def get_client_index(self, client: str) -> int:
if client == STEAM_CMD:
return 0
if client == FLATPAK_RUN_CMD:
return 1
if client == FLATPAK_SANDBOX:
return 2
return 0
def toggle_config(self, key: Preferences) -> None:
try:
real_key = self.enum_to_key(key)
@@ -162,7 +174,7 @@ class ConfigManager:
trace = traceback.format_exc()
dialog = ExceptionDialog(self.controller, trace)
dialog.show_all()
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None:
columns = tv.get_columns()
+8 -23
View File
@@ -14,7 +14,6 @@ from dzgui.api.shortcuts import Shortcuts
from dzgui.api.steam import (
connect,
connect_debug,
is_dayz_downloading,
get_app_allows_downloads,
get_app_name,
get_needs_update,
@@ -49,7 +48,7 @@ from dzgui.strings.dialogs import (
)
from dzgui.strings import kb
from dzgui.strings.server_mods import checkmark, resync
from dzgui.util.format import format_exception, format_mib
from dzgui.util.format import format_mib
from dzgui.util.strings import dialog, server_timeout
from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.dialogs.generic import ExceptionDialog
@@ -85,7 +84,6 @@ class Prerequisites:
available_space: float
passworded: bool
dayz_running: bool
download_paused: bool
steam_proc: SteamProcess
mods: list[list[str]]
game_mode: bool
@@ -171,7 +169,6 @@ class ConnectionManager:
free_mib = format_mib(free)
dayz_running = is_dayz_running()
download_paused = is_dayz_downloading(steam_path, self.appid)
running_app = get_running_app()
if running_app is not None:
@@ -187,8 +184,8 @@ class ConnectionManager:
else:
allows_downloads = (True, "")
client_name = self.controller.get_steam_client_name()
client = self.controller.query_config(Preferences.CLIENT)
client_name = self.controller.get_client_name()
running = is_steam_running(client)
steam_proc = SteamProcess(client_name, running)
self.client = client
@@ -213,7 +210,6 @@ class ConnectionManager:
available_space=free_mib,
passworded=info.password_protected,
dayz_running=dayz_running,
download_paused=download_paused,
steam_proc=steam_proc,
mods=remote_mods,
game_mode=game_mode,
@@ -239,7 +235,7 @@ class ConnectionManager:
@call_on_thread(dialog.querying)
def query_details(self, record: Servers.Record) -> None:
details = Servers.get_details(record)
if details is None:
if details.success is False:
self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True
)
@@ -298,22 +294,17 @@ class ConnectionManager:
def _present_modlist_dialog(self, mods: list[list[str]]) -> None:
dialog = ServerModDialog(self.controller, mods)
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
def _present_details_dialog(self, details: Servers.Details) -> None:
dialog = ServerDetailsDialog(self.controller, details)
dialog.run() # type: ignore[no-untyped-call]
def _workshop_timeout(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.show_all()
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
def _server_timeout(self) -> None:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.set_details_buffer(kb.DZG_006)
dialog.show_all()
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
def get_debug_args(self) -> str:
addr = f"{self.record.ip}:{self.record.gameport}"
@@ -360,14 +351,8 @@ class ConnectionManager:
for title, mod, stamp, size in self.missing_mods:
if self.controller.is_cancel_pending():
return
try:
subscribe(key, int(mod))
time.sleep(API_RATE_LIMIT)
except Exception as e:
msg = format_exception(e)
func = StoredFunc(self._workshop_timeout, msg)
self.thread_man.set_cleanup_func(func, destroy_first=True)
return
subscribe(key, int(mod))
time.sleep(API_RATE_LIMIT)
self.thread_man.update_dialog(waiting_for_directories)
for title, mod, stamp, size in self.missing_mods:
+3 -7
View File
@@ -44,7 +44,7 @@ class ContextMenuManager:
# UNTHREADED
case ContextMenu.ADD_NOTE:
dialog = NoteDialog(self.controller)
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
case ContextMenu.COPY_SERVER_IP:
self.copy_server_ip()
case ContextMenu.COPY_SERVER_NAME:
@@ -75,14 +75,10 @@ class ContextMenuManager:
if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)):
match action:
case ContextMenu.OPEN_WORKSHOP:
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
case ContextMenu.OPEN_WORKSHOP:
self.open_mod_page()
if isinstance(self.treeview, LogTreeView):
match action:
+8 -9
View File
@@ -22,21 +22,20 @@ class FilterManager:
strings.filter_night: True,
strings.filter_full: show_full,
strings.filter_official: True,
strings.filter_modded: True,
strings.filter_nonascii: False,
strings.filter_lowpop: True,
strings.filter_unofficial: True,
strings.filter_unmodded: True,
strings.filter_duplicate: False,
strings.filter_nonascii: False,
strings.filter_modded: True,
}
# TODO: strings
self.tooltips = {
strings.filter_1pp: "First-person perspective",
strings.filter_day: "In-game time between 0600 and 1759",
strings.filter_day: "In-game time between 0700 and 1659",
strings.filter_empty: "Servers with no players",
strings.filter_3pp: "Third-person perspective",
strings.filter_night: "In-game time between 1800 and 0559",
strings.filter_night: "In-game time between 1700 and 0659",
strings.filter_full: "Servers with no open slots",
strings.filter_official: "Bohemia official servers",
strings.filter_nonascii: "Server names using non-standard, complex glyphs",
@@ -44,13 +43,13 @@ class FilterManager:
strings.filter_unofficial: "Third-party servers",
strings.filter_duplicate: "Duplicates of existing servers (usually spoofed)",
strings.filter_modded: "Servers with one or more mods",
strings.filter_unmodded: "Servers with no mods",
}
self.active_keyword = ""
self.active_map = (0, all_maps)
self.prior_map = all_maps
self.filters: list
self.enabled_filters = dict(self.default_filters)
def get_tooltips(self) -> dict[str, str]:
@@ -74,11 +73,11 @@ class FilterManager:
def set_active_keyword(self, word: str) -> None:
self.active_keyword = word
def get_default_filters(self) -> dict[str, bool]:
def get_default_filters(self) -> dict:
"""Deep copy of defaults"""
return dict(self.default_filters)
def get_filters(self) -> dict[str, bool]:
def get_filters(self) -> dict:
return self.enabled_filters
def set_filter(self, label: str, state: bool) -> None:
@@ -115,7 +114,7 @@ class FilterManager:
def get_unique_maps(self) -> list[str]:
return [row[0] for row in self.map_store if row[0] != "All maps"]
def get_all_filters(self) -> tuple[str, ...]:
def get_all_filters(self) -> tuple:
map_name = self.get_active_map_name()
enabled = self.get_filters()
kw = self.get_active_keyword()
+4 -32
View File
@@ -4,7 +4,7 @@ import time
from pathlib import Path
from typing import TYPE_CHECKING
from dzgui.api.steam import unsubscribe, validate_mod
from dzgui.api.steam import unsubscribe
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 get_selected_mods(self) -> list[tuple[str, Gtk.TreeIter]]:
def unsub_mods(self) -> None:
sel = self.treeview.get_selection()
model, pathlist = sel.get_selected_rows()
# NOTE: reverse when multiple selection
@@ -96,15 +96,6 @@ 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)
@@ -118,21 +109,6 @@ 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:
@@ -146,11 +122,7 @@ class ModManager:
def unsub_atomic_mod(self, mod: str) -> None:
config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.STEAM)
try:
unsubscribe(key, int(mod))
except Exception as e:
logger.critical(e)
return
unsubscribe(key, int(mod))
steam_path = Path(self.path)
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
@@ -221,7 +193,7 @@ class ModManager:
total_size += mod[3]
return total_mods, total_size
def _on_stale_mods_found(self, stale_mods: list[int]) -> None:
def _on_stale_mods_found(self, stale_mods: list) -> None:
"""Manipulates attached ListStore in the main event loop"""
model = self.treeview.get_model()
if model is None:
+1 -1
View File
@@ -52,7 +52,7 @@ class OfflineManager:
is_valid = is_mission(folder)
self.emitter.emit("custom_mission_loaded", str(folder), is_valid)
def find_custom_mods(self, callback: Callable[[], None]) -> None:
def find_custom_mods(self, callback: Callable) -> None:
folder = self.open_folderpicker(dialogs.custom_mod_dialog)
window = self.controller.get_window()
window.set_sensitive(False)
+3 -3
View File
@@ -22,8 +22,8 @@ logger = logging.getLogger(APP_NAME)
def call_on_thread(
dialog_str: str, show_dialog: bool = True, show_cancel: bool = False
) -> Callable[..., Any]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
) -> Callable:
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> None:
self = args[0]
@@ -42,7 +42,7 @@ def call_on_thread(
class StoredFunc:
def __init__(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
def __init__(self, func: Callable, *args: Any, **kwargs: Any) -> None:
sig = inspect.signature(func)
self.func = func
self.bindings = sig.bind(*args, **kwargs)
+2 -2
View File
@@ -60,9 +60,9 @@ class UpdateManager:
def _on_update_success(self) -> None:
msg = dialogs.update_success
dialog = QuitDialog(self.controller, msg)
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
def _on_update_failure(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.show_all()
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
+1 -1
View File
@@ -11,7 +11,7 @@ from gi.repository.Gtk import ListStore, TreeIter # noqa E402
from gi.repository import GObject # noqa E402
GTYPE_TO_PYTHON = {
GObject.type_from_name(GObject.type_name(ptype)): ptype # type: ignore[no-untyped-call]
GObject.type_from_name(GObject.type_name(ptype)): ptype
for ptype in (int, float, str, bool, object)
}
+14 -27
View File
@@ -1,6 +1,5 @@
import re
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING, Union
from dzgui.const.enum import FilterMode
@@ -13,24 +12,8 @@ if TYPE_CHECKING:
from dzgui.api.servers import Record
from dzgui.model.servers import NewPlayerCount
DAY_REG = r"([0][6-9]|[1][0-7])"
NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])"
@dataclass(slots=True, frozen=True)
class ServerRow:
name: str
mapname: str
view: str
time: str
players: int
max_players: int
queue: int
ip: str
qport: int
ping: str
provider: str
modded: bool
DAY_REG = r"([0][7-9]|[1][0-6])"
NIGHT_REG = r"([0][0-6]|[1][7-9]|[2][0-3])"
class ProxyModelManager:
@@ -47,7 +30,7 @@ class ProxyModelManager:
def __init__(self, filter_man: "FilterManager") -> None:
self.filter_cache: dict[
tuple[str, ...],
tuple[str],
list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]],
] = {}
@@ -68,6 +51,9 @@ class ProxyModelManager:
return False
return True
def append_row(self, row: list) -> None:
self.proxy_model.append(row)
def append_row_to_control(
self, row: tuple[str, str, str, str, int, int, int, str, int, int, str, bool]
) -> None:
@@ -81,6 +67,7 @@ class ProxyModelManager:
self.proxy_model[treeiter][4] = playercount.players
self.proxy_model[treeiter][6] = playercount.queue
# TODO: use dataclass for record rows
def append_row_to_history(
self,
history: tuple[str, str, str, str, int, int, int, str, int, int, str, bool],
@@ -256,11 +243,9 @@ class ProxyModelManager:
rows = [row for row in rows if (row[4] / row[5] * 100) > 30]
case strings.filter_modded:
rows = [row for row in rows if not row[11]]
case strings.filter_unmodded:
rows = [row for row in rows if row[11]]
return rows
def filter_toggle_on(self, filters: tuple[str, ...]) -> list:
def filter_toggle_on(self, filters: tuple[str]) -> list:
"""Effectively applies all filters"""
self.set_filtered(self.control_model)
self.set_filtered(self.filter_map(filters))
@@ -271,9 +256,13 @@ class ProxyModelManager:
self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered
def set_cache(self, filters: tuple[str, ...], filtered_rows: list[tuple]) -> None:
def set_cache(self, filters: tuple[str], filtered_rows: list[tuple]) -> None:
self.filter_cache[filters] = filtered_rows
# @deprecated("Currently unused")
# def convert_model_to_list(self, model: "FastInsertListStore") -> list:
# return [[el for el in row] for row in model]
def set_filtered(self, rows: list | None) -> None:
if rows is None:
self.filtered = []
@@ -309,6 +298,4 @@ class ProxyModelManager:
def push(self, data: list[Any]) -> None:
self.wipe_cache()
self.set_control(data)
cur_keyword = self.filter_man.get_active_keyword()
mode = FilterMode.TOGGLE_ON if len(cur_keyword) > 0 else FilterMode.INITIAL
self.filter(mode)
self.filter(FilterMode.INITIAL)
+11 -39
View File
@@ -21,7 +21,7 @@ from dzgui.views.dialogs.generic import ExceptionDialog
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402
from gi.repository import Gtk # noqa E402
if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record
@@ -92,7 +92,6 @@ class ServerModelManager:
job = Servers.query_api
params = Servers.params
servers = []
with ThreadPoolExecutor() as executor:
futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params]
for future in as_completed(futures):
@@ -201,43 +200,19 @@ class ServerModelManager:
@call_on_thread(dialog.querying)
def add_by_ip(self, addr: str) -> None:
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
res = Servers.query_by_ip(addr)
self._parse_single_record(res)
@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
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
res = Servers.query_by_record(record)
self._parse_single_record(res)
@call_on_thread(dialog.querying)
def remove_by_record(self, record: Servers.Record) -> None:
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
res = Servers.query_by_record(record)
self._parse_single_record(res, delete=True)
def update_history(
self,
@@ -304,6 +279,7 @@ 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:
@@ -414,8 +390,6 @@ class ServerModelManager:
def _cleanup_on_success(self) -> None:
proxy = self.proxy_man.get_proxy_model()
self.tv.set_model(proxy)
if self.tv.enum == ServerTab.BROWSER:
GLib.idle_add(self.tv.wipe_cache)
if self.preserve_on_fail is True:
self.proxy_man.wipe_cache()
@@ -432,14 +406,12 @@ class ServerModelManager:
if show_dialog:
dialog = ExceptionDialog(self.controller, dialogs.load_error_lan)
dialog.show_all()
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
def _cleanup_on_failure(self, show_dialog: bool = True) -> None:
# TODO: disable map, keyword, and filter widgets if model is None
# signal driven (servers_empty, servers_failed_to_load)
self.emitter.emit("servers_timed_out")
if self.preserve_on_fail is False:
self.tv.set_model(None)
filter_man = self.tv.get_filter_man()
@@ -451,14 +423,14 @@ class ServerModelManager:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.set_details_buffer(kb.DZG_006)
dialog.show_all()
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
def _push_data(self, data: list[Any]) -> None:
self._get_proxy_man().push(data)
self._sort_unique_maps(data)
self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success))
def _sort_unique_maps(self, data: list[Any]) -> None:
def _sort_unique_maps(self, data: list) -> None:
u_maps = set([row[1] for row in data])
self._set_new_maps(sorted(u_maps))
-7
View File
@@ -1,7 +0,0 @@
heading_local_time = "Local time"
heading_server_time = "In-game time"
heading_target_time = "Target time"
heading_remainder_time = "Remainder until target"
heading_adjusted_time = "Adjusted local time"
calculate = "Calculate"
disclaimer = "Times shown are approximations.\nServer time may differ based on time of year in game."
+15 -17
View File
@@ -1,22 +1,20 @@
from dzgui.const.constants import UDP_PORT
add_button = "Add"
add_con_heading = "Add/connect"
add_button="Add"
add_con_heading="Add/connect"
add_popover = "This address is already in your Saved Servers"
add_tooltip = "Add to Saved Servers"
connect_button = "Connect"
connect_tooltip = "Connect to this server"
connect_entry_placeholder = f"Enter IP:Query port (e.g., 192.168.1.1:{UDP_PORT})"
add_tooltip="Add to Saved Servers"
connect_button="Connect"
connect_tooltip="Connect to this server"
connect_entry_placeholder="Enter IP:Query port (e.g., 192.168.1.1:27016)"
fav_heading = "Favorite server"
favs_empty = "None set. Right click a server and select 'Set favorite' to set."
fav_heading="Favorite server"
favs_empty="None set. Right click a server and select 'Set favorite' to set."
lan_heading = "LAN query port"
lan_default_button = f"Default port ({UDP_PORT})"
lan_custom_button = "Custom port"
lan_scan_button = "Scan"
lan_heading="LAN query port"
lan_default_button="Default port (27016)"
lan_custom_button="Custom port"
lan_scan_button="Scan"
lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster"
lan_checkbox = "Stop scanning on first hit"
lan_entry_placeholder = "Enter the query port (1-65535)"
lan_entry_tooltip = "Specify the port to search for DayZ servers on the local network"
lan_scan_tooltip = "Scan for servers"
lan_entry_placeholder="Enter the query port (1-65535)"
lan_entry_tooltip="Specify the port to search for DayZ servers on the local network"
lan_scan_tooltip="Scan for servers"
-6
View File
@@ -9,7 +9,6 @@ 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"
@@ -24,8 +23,3 @@ custom_mod_dialog = "Set location to a custom mods folder"
debug_heading = "Debug"
debug_secondary = "Launch arguments"
toggle_calc = "Toggle calculator"
server_details = "Server details"
server_message = "Server message"
-1
View File
@@ -4,4 +4,3 @@ api_validation_error = (
api_popover = "API key validation failed."
dayz_not_found = "Could not find a valid DayZ installation."
workshop_endpoint_error = "Steam Workshop service unavailable, try again later."
-7
View File
@@ -17,10 +17,3 @@ fullscreen_eventbox = "This option is not available on Steam Deck."
save_button = "Save"
api_failed = "API validation failed"
flatpak_label = "Flatpak client"
flatpak_standard = "Standard"
flatpak_container = "Containerized (flatpak-spawn --host)"
flatpak_info = (
"Containerized mode is used on certain distributions. May have side effects."
)
+2 -5
View File
@@ -11,6 +11,8 @@ mods = "Mods"
total_mods = "Total mods: "
up_to_date = "All mods are up to date."
all_updated = "All mods updated."
"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
required_space = " Need to download {0} MiB of mod updates."
placeholder_no_mods = "This server has no mods."
@@ -34,7 +36,6 @@ steam_not_running = "'{0}' is set as the default Steam client, but it is either
use_desktop_mode = "Use Desktop Mode to download mods on Steam Deck."
version_mismatch = "Local client version '{0}' does not match remote version '{1}'."
version_missing = "Remote server is running the build '{0}', but it is not installed."
no_errors = "No errors."
# Warnings
@@ -47,7 +48,3 @@ running_app = (
"Either stop the game first, or update your global Steam settings or the game's local settings.\n"
"Otherwise, mods may be queued for download but never update."
)
downloads_paused = (
"It looks like DayZ may have a pending update on Steam that was paused.\n"
"To enqueue mods for download, unpause any pending DayZ update and allow it to complete."
)
+1 -2
View File
@@ -30,8 +30,7 @@ path_remove_prefix = "The following path will be removed:"
not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall."
standalone_uninstall = (
"It looks like you are running the standalone version of DZGUI.\n"
"To finalize the uninstall process, invoke './dzgui self remove',\n"
"then remove the 'dzgui' file."
"To finalize the uninstall process, you can remove the 'dzgui' file."
)
system_uninstall = (
"It looks like DZGUI was installed via your system package manager or directly from source code.\n"
+2 -16
View File
@@ -3,8 +3,6 @@ title_intro = "Welcome"
blurb_intro = """
This wizard will help you set up some common config options before launching the application.
"""
error_sandbox = "Setup cannot be run from inside Flatpak sandbox.\nPlease launch the application directly."
welcome = "Welcome to {0}!"
### SteamPathPage
heading_steam_path = "Steam path"
@@ -19,7 +17,7 @@ desc_flatpak_path = (
desc_ubuntu_path = "This is the default Steam path on <b>Ubuntu-based systems</b>."
desc_debian_path = "This is the default Steam path on <b>Debian-based systems</b>."
no_valid_paths = (
"No valid Steam path or Steam command found on system. Please install Steam to continue."
"No valid Steam paths found on system. Please install Steam to continue."
)
button_scan = "Scan for Steam"
@@ -72,8 +70,7 @@ checkbox_steam_shortcut = (
(
"This creates a standalone copy of DZGUI and adds it to Steam. "
"You <b>must restart Steam</b> for the shortcut to appear. This will not replace or update existing shortcuts. "
"If you update DZGUI when it is launched via Steam, updates will be applied to the Steam version. "
"Not available if you selected the Flatpak client as your Steam default."
"If you update DZGUI when it is launched via Steam, updates will be applied to the Steam version."
),
)
checkbox_start_menu = (
@@ -82,17 +79,6 @@ checkbox_start_menu = (
)
checkbox_desktop_shortcut = ("Add a desktop shortcut", "Depends on the option above.")
### Flatpak page
heading_flatpak = "Flatpak sandbox overrides"
blurb_flatpak = (
"You chose to add a shortcut directly to Flatpak Steam. "
"To launch DZGUI from Flatpak's sandbox, you must grant "
"permission to read from and write to the following locations."
)
tree_column = "Paths"
overrides_applied = "Overrides applied."
button_flatpak = "Grant permissions"
### Completion page
heading_completion = "Setup complete"
blurb_completion = "Configuration completed successfully. Click 'Close' to close this dialog and launch DZGUI."
-7
View File
@@ -1,12 +1,5 @@
import shlex
from dzgui.const.constants import STEAM_CMD
from dzgui.init.proc import is_sandboxed
def concat_bash_args(command: str) -> list[str]:
# NOTE: NSGs spawned from sandbox cannot access host application commands,
# so fallback to Steam internal URIs
if is_sandboxed():
return [STEAM_CMD]
return shlex.split(command)
+4 -5
View File
@@ -59,7 +59,7 @@ class CalcDist:
self,
addr: str,
enum: "ServerTab",
result_queue: "Queue[tuple[str, Haversine | None, ServerTab]]",
result_queue: "Queue",
controller: "Controller",
cache: dict[str, Haversine],
) -> None:
@@ -72,7 +72,7 @@ class CalcDist:
self.ip = self.addr
dist = self.compare(self.ip)
self.result_queue.put((self.addr, dist, self.enum))
self.result_queue.put([self.addr, dist, self.enum])
def compare(self, remote_ip: str) -> Haversine | None:
prefs = self.controller.get_prefs()
@@ -80,9 +80,8 @@ class CalcDist:
if local is None:
return None
try:
remote = get_coords(prefs.paths.db, remote_ip)
except Exception as e:
logger.critical(e)
remote = get_coords(prefs.paths.ips, remote_ip)
except Exception:
return None
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
-39
View File
@@ -1,39 +0,0 @@
import logging
import subprocess
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME, FLATPAK_APPID, GTK_APP_ID, TMP_PATH
logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from pathlib import Path
def apply_overrides(xdg: dict[str, "Path"]) -> None:
try:
# NOTE: D-Bus registration required for sandbox
args = [
"flatpak",
"--user",
"override",
f"--own-name={GTK_APP_ID}",
FLATPAK_APPID,
]
subprocess.run([*args], capture_output=True)
paths: list[str] = []
paths.extend([str(path) for path in xdg.values()])
paths.append(TMP_PATH)
for path in paths:
args = [
"flatpak",
"--user",
"override",
f"--filesystem={path}",
FLATPAK_APPID,
]
subprocess.run([*args], capture_output=True)
except Exception as e:
logger.critical(e)
+1 -2
View File
@@ -1,5 +1,4 @@
import re
from typing import Any
from dzgui.util.localize import number
from dzgui.strings.generic import selectable_tree
@@ -61,7 +60,7 @@ def format_server_mods(mods: int) -> str:
return f"Found {mods:n} {plural}. {workshop}"
def format_player_count(model: Gtk.TreeModel | None, control: list[Any]) -> str:
def format_player_count(model: Gtk.TreeModel | None, control: list) -> str:
players = 0
hits: int
status: str
+46 -15
View File
@@ -1,5 +1,4 @@
import a2s
from ipaddress import ip_address
import requests
import shutil
import subprocess
@@ -8,7 +7,6 @@ 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
@@ -31,25 +29,58 @@ class ReservedIPError(Exception):
pass
def ip2list(ip: str) -> list[int]:
def ip2list(ip: str) -> list:
return [int(digit) for digit in ip.split(".")]
def is_usable(ip: str) -> bool:
address = ip_address(ip)
if address.is_reserved or address.is_private:
return False
return True
# 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 get_coords(db_path: "Path", ip: str) -> Coords:
if not is_usable(ip):
def get_coords(ips: "Path", ip: str) -> Coords:
split = ip2list(ip)
if is_reserved(split):
raise ReservedIPError(f"{ip} is a reserved IP address.")
try:
lat, lon = read_from_table(db_path, ip)
return Coords(lat, lon)
except Exception:
raise GeolocationError("No matching records found")
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")
def get_local_ip() -> str:
-80
View File
@@ -1,80 +0,0 @@
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
+3 -7
View File
@@ -11,6 +11,7 @@ delimiter = "␞"
# GenericDialog
main_menu = "Return to main menu"
exit_app = "Exit"
server_details = "Server details"
modlist = "Modlist"
input_required = "User input required"
confirm = "Confirmation"
@@ -31,7 +32,6 @@ 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"
@@ -59,6 +59,7 @@ browser_cols = [
]
# DetailsDialog
server_message = "Server message"
workshop = "Enter/double click a row to open in Steam Workshop."
# LanDialog
@@ -157,7 +158,6 @@ filter_duplicate = "Duplicate"
filter_official = "Official"
filter_unofficial = "Unofficial"
filter_modded = "Modded"
filter_unmodded = "Unmodded"
# maps
all_maps = "All maps"
@@ -201,8 +201,6 @@ class ModPanelStrings:
clear_highlights: str
select_stale: str
select_stale_tooltip: str
validate: str
validate_tooltip: str
@dataclass(slots=True, frozen=True)
@@ -300,7 +298,7 @@ class Options:
init = Init(
is_steam_running="Is Steam running? DZGUI must be run on top of Steam.",
is_dayz_running="Is DayZ already running? DZGUI cannot launch DayZ if another process is using it.",
requires_steam="Your chosen client (Steam/Flatpak Steam) must be installed on the system.",
requires_steam="DZGUI requires that Steam or Flatpak Steam be installed on the system.",
)
mod_panel = ModPanelStrings(
@@ -322,8 +320,6 @@ 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",
)
+5 -10
View File
@@ -2,12 +2,7 @@ import logging
from pathlib import Path
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
APP_NAME,
DAYZ_COMMUNITY_ROOT,
)
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT
from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
@@ -25,7 +20,6 @@ def expunge_link(file: Path) -> None:
if str(file.stem)[:2] == "@C":
file.unlink()
def rebuild_symlinks(config: Path) -> None:
# TODO: pass direct path as argument
path = lookup(config, Preferences.DEFAULT)
@@ -69,9 +63,10 @@ def symlink_mission(steam_path: Path, target: str) -> str:
stem = path.name
parent = path.parent
source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT)
if source.exists():
source.unlink()
source.symlink_to(parent)
try:
source.symlink_to(parent)
except Exception:
return ""
return f"{DAYZ_COMMUNITY_ROOT}/{stem}"
+19 -20
View File
@@ -1,17 +1,19 @@
import logging
import signal
import warnings
from typing import Any, Literal, Self, TYPE_CHECKING
from typing import TYPE_CHECKING, Literal, Self
from dzgui.const.constants import (
APP_NAME,
APP_NAME_LOWER,
GTK_APP_ID,
STEAM_ICON,
)
from dzgui.const.enum import NotebookPage
from dzgui.controllers.emitter import Emitter
from dzgui.controllers.mc import Controller
from dzgui.util import css
from dzgui.util import css, strings
from dzgui.util.keys import is_ctrl_mask
from dzgui.views.components.connect_panel import ConnectPanel
from dzgui.views.components.crumbs import Breadcrumbs
@@ -45,9 +47,13 @@ if TYPE_CHECKING:
logger = logging.getLogger(APP_NAME)
# TODO: drop
# https://bugzilla.gnome.org/show_bug.cgi?id=708676
warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
class OuterWindow(Gtk.ApplicationWindow):
def __init__(self, app: "App") -> None:
def __init__(self, app: Gtk.Application) -> None:
super().__init__(
application=app, title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER
)
@@ -78,15 +84,6 @@ 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()
@@ -300,14 +297,6 @@ 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)
@@ -350,3 +339,13 @@ class App(ColorAwareApp, Gtk.Application): # type: ignore
MainController = Controller()
try:
theme = Gtk.IconTheme.get_default()
icons = theme.list_icons(None)
if STEAM_ICON not in icons:
logger.warn(strings.steam_icon_missing)
warnings.warn(strings.steam_icon_missing, stacklevel=2)
except Exception as e:
logger.warning(e)
warnings.warn(strings.gtk_theme_missing, stacklevel=2)
-1
View File
@@ -24,7 +24,6 @@ class VBox(GenericBox):
def __init__(self, spacing: int = 0) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing)
class ShortHBox(HBox):
def __init__(self, widget: Gtk.Widget) -> None:
super().__init__(spacing=5)
+37 -16
View File
@@ -1,8 +1,10 @@
from typing import Callable, Literal, Self, TYPE_CHECKING, Union
from dzgui.util.clip import copy_clipboard
from dzgui.util.format import pluralize
from dzgui.strings import buttons
from dzgui.util.strings import (
alert_button_tooltip,
atomic_buttons,
)
from dzgui.const.constants import (
@@ -12,6 +14,7 @@ from dzgui.const.constants import (
LIST_ADD,
REFRESH_ICON,
STEAM_ICON,
WARNING,
WEB_BROWSER,
)
from dzgui.strings import connect_panel
@@ -61,8 +64,7 @@ class IconButton(Gtk.Button):
self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end)
self.set_image(self.icon)
self.set_image_position(position)
self.set_focus_on_click(False) # type: ignore[no-untyped-call]
self.set_always_show_image(True)
self.set_focus_on_click(False)
def swap_icon(self, icon: str) -> None:
start = self.icon.get_margin_start()
@@ -98,14 +100,15 @@ class LargeIconTextButton(IconButton):
class ClipboardButton(IconTextButton):
def __init__(self, func: Callable[[], str]) -> None:
def __init__(self, controller: Union["Controller", None], func: Callable) -> None:
super().__init__(CLIPBOARD, atomic_buttons.copy)
self.controller = controller
self.connect("clicked", self._on_button_clicked, func)
# TODO: strings
self.set_tooltip_text("Copy to clipboard")
def _on_button_clicked(self, button: Self, func: Callable[[], str]) -> None:
def _on_button_clicked(self, button: Self, func: Callable) -> None:
def revert() -> Literal[False]:
self.revert_icon()
return False
@@ -116,9 +119,10 @@ class ClipboardButton(IconTextButton):
GLib.timeout_add(600, revert)
# TODO: determine when controller would be passed to this button or drop
class CopyIpButton(ClipboardButton):
def __init__(self, func: Callable[[], str]) -> None:
super().__init__(func)
def __init__(self, controller: Union["Controller", None], func: Callable) -> None:
super().__init__(controller, func)
# TODO: strings
self.set_tooltip_text("Copy IP to clipboard")
@@ -158,24 +162,18 @@ class RefreshButton(IconTextButton):
self.set_tooltip_text(atomic_buttons.refresh_tooltip)
self.connect("clicked", self._on_refresh_clicked)
emitter.connect("servers_loaded", self._on_load)
emitter.connect("servers_timed_out", self._on_timeout)
def _on_timeout(self, emitter: "Emitter") -> None:
self.is_clicked = True
self.start_decrement()
def _on_load(self, emitter: "Emitter", tab: "ServerTab") -> None:
self.start_decrement()
emitter.connect("servers_loaded", self.start_decrement)
def _on_refresh_clicked(self, button: Self) -> None:
"""Spawned in a thread"""
if self.controller.get_active_treeview().is_loaded() is False:
return
self.is_clicked = True
self.controller.refresh_tree()
# TODO: get server tab enum
# if LAN tab, reload existing entries in place
def start_decrement(self) -> None:
def start_decrement(self, emitter: "Emitter", tab: "ServerTab") -> None:
if self.is_clicked is False:
return
if self.loading:
@@ -247,6 +245,29 @@ class AddButton(IconTextButton):
self.set_tooltip_text(connect_panel.add_tooltip)
class LoggerAlertsButton(IconTextButton):
def __init__(self, warnings: int, errors: int) -> None:
warnings_text = ""
errors_text = ""
separator = ""
if warnings > 0:
warnings_plural = pluralize("warnings", warnings)
warnings_text = f"{warnings} {warnings_plural}"
if errors > 0:
errors_plural = pluralize("errors", errors)
errors_text = f" {errors} {errors_plural}"
if warnings + errors > 1:
separator = ","
concat = warnings_text + separator + errors_text
super().__init__(
icon=WARNING,
label=f"Loaded with{concat}",
)
self.set_halign(Gtk.Align.END)
self.set_hexpand(True)
self.set_tooltip_text(alert_button_tooltip)
class SpinnerButton(Gtk.Button):
def __init__(self, label: str) -> None:
super().__init__()
+1 -1
View File
@@ -187,7 +187,7 @@ class FavPanel(Gtk.Frame):
self.fav_button = SteamConnectButton()
self.fav_button.connect("clicked", self._on_connect_clicked)
self.copy_button = CopyIpButton(self.get_fav_ip)
self.copy_button = CopyIpButton(self.controller, self.get_fav_ip)
# TODO: strings
self.unset_button = CloseButton("Unset")
+5 -4
View File
@@ -38,7 +38,7 @@ class ValidatedEntry(Gtk.Entry):
def __init__(
self,
controller: "Controller",
func: Callable[[str], bool],
func: Callable,
placeholder_text: str = "",
tooltip_text: str = "",
) -> None:
@@ -46,6 +46,7 @@ class ValidatedEntry(Gtk.Entry):
hexpand=True, placeholder_text=placeholder_text, tooltip_text=tooltip_text
)
self.func: Callable
self.set_validation_func(func)
self.controller = controller
@@ -71,7 +72,7 @@ class ValidatedEntry(Gtk.Entry):
self.emit("string_validated", False)
remove_class(self, self.classname)
def set_validation_func(self, func: Callable[[str], bool]) -> None:
def set_validation_func(self, func: Callable) -> None:
self.func = func
def insert_icon(self) -> None:
@@ -140,7 +141,7 @@ class APIEntry(Gtk.Box):
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.func: Callable[[str], None] | None = None
self.func: Callable | None = None
# TODO: strings
self.entry = Gtk.Entry(
@@ -221,7 +222,7 @@ class APIEntry(Gtk.Box):
res = self.func(text)
return res
def set_validation_func(self, func: Callable[[str], None] | None) -> None:
def set_validation_func(self, func: Callable | None) -> None:
self.func = func
def disable_button(self) -> None:
+9 -9
View File
@@ -9,6 +9,8 @@ from dzgui.const.constants import (
NO_PADDING,
SEARCH_ICON,
)
from dzgui.const.enum import FilterMode
from dzgui.model.servers import ServerModelManager
from dzgui.util.format import format_exception
from dzgui.util.strings import all_maps
from dzgui.views.components.maps_combo import MapsCombo
@@ -30,7 +32,7 @@ class ButtonGrid(Gtk.Grid):
def __init__(
self,
controller: "Controller",
defaults: dict[str, bool],
defaults: dict[str, str],
tooltips: dict[str, str],
) -> None:
super().__init__(
@@ -42,10 +44,7 @@ class ButtonGrid(Gtk.Grid):
self.checks: list[Gtk.CheckButton] = []
flowbox = Gtk.FlowBox(
halign=Gtk.Align.CENTER,
min_children_per_line=3,
max_children_per_line=3,
selection_mode=Gtk.SelectionMode.NONE,
halign=Gtk.Align.CENTER, min_children_per_line=3, max_children_per_line=3, selection_mode=Gtk.SelectionMode.NONE
)
for check in defaults.keys():
@@ -96,7 +95,7 @@ class ButtonGrid(Gtk.Grid):
logger.info(f"User toggled button '{label}' to {state}")
self.emitter.emit("check_toggled", label, state)
def get_checkboxes(self) -> list[Gtk.CheckButton]:
def get_checkboxes(self) -> list:
return self.checks
@@ -149,6 +148,10 @@ class KeywordEntry(Gtk.Entry):
self.controller.set_active_keyword(keyword)
logger.info(f"User filtered by keyword '{keyword}'")
ServerModelManager(
self.controller, self.controller.get_active_treeview()
).refilter(FilterMode.TOGGLE_ON)
class FilterPanel(Gtk.Box):
def __init__(self, controller: "Controller") -> None:
@@ -308,7 +311,6 @@ class FilterPanel(Gtk.Box):
Gdk.KEY_0: 9,
Gdk.KEY_minus: 10,
Gdk.KEY_backslash: 11,
Gdk.KEY_apostrophe: 12,
}
if keyval not in mappings:
return False
@@ -317,8 +319,6 @@ 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()
-3
View File
@@ -9,6 +9,3 @@ class Icon(Gtk.Image):
super().__init__(
icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin
)
def set_icon_name(self, name: str) -> None:
super().set_from_icon_name(name, Gtk.IconSize.BUTTON)
+4 -34
View File
@@ -1,72 +1,42 @@
from typing import Self
from warnings import deprecated
from dzgui.const.constants import ERROR, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD
from dzgui.init.prereqs import has_steam_client, has_flatpak_client
from dzgui.strings import options
from dzgui.views.components.box import HBox
from dzgui.views.components.buttons import Icon
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa
@deprecated("Client choice is determined at setup time")
class ClientCombo(Gtk.ComboBox):
def __init__(self) -> None:
super().__init__()
has_steam = has_steam_client()
has_flatpak = has_flatpak_client()
self.client_store = Gtk.ListStore(str, str, bool)
self.clients = (
self.client_store = Gtk.ListStore(str, str)
clients = (
(
options.steam_combo,
STEAM_CMD,
has_steam,
),
(
options.flatpak_combo,
FLATPAK_RUN_CMD,
has_flatpak,
),
(
options.flatpak_container_combo,
FLATPAK_SANDBOX,
has_flatpak,
),
)
for client in self.clients:
for client in clients:
self.client_store.append(client)
self.set_model(self.client_store)
renderer_text = Gtk.CellRendererText()
self.pack_start(renderer_text, True)
self.add_attribute(renderer_text, "text", 0)
self.set_active(0)
self.set_cell_data_func(self.get_cells()[0], self.data_func)
def data_func(
self,
combo: Self,
cell: Gtk.CellRendererText,
model: Gtk.ListStore,
_iter: Gtk.TreeIter,
) -> None:
sens = model[_iter][2]
cell.set_property("sensitive", sens)
def get_client_human_readable(self, cmd: str) -> str:
for client in self.clients:
human, command, sens = client
if command == cmd:
return human
raise TypeError("Not a valid Steam client selection")
class ErrorPopover(Gtk.Popover):
def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None:
super().__init__(relative_to=relative_to, position=position)
-3
View File
@@ -50,7 +50,6 @@ class ModSelectionPanel(Gtk.Box):
ModButton.SELECT_ALL,
ModButton.UNSELECT_ALL,
ModButton.UNSUB_SELECTED,
ModButton.VALIDATE,
)
for button in buttons:
b = EnumeratedModButton(button)
@@ -115,5 +114,3 @@ class ModSelectionPanel(Gtk.Box):
self.swap_sensitive(False)
case ModButton.SELECT_STALE:
self.controller.select_colorized()
case ModButton.VALIDATE:
self.controller.validate_mods()
+4
View File
@@ -2,6 +2,7 @@ from typing import Self, Union, TYPE_CHECKING
from dzgui.const.enum import NotebookPage, ServerTab
from dzgui.util.strings import esc_to_return, question_to_return
from dzgui.views.components.buttons import LoggerAlertsButton
import gi
@@ -46,6 +47,9 @@ class Statusbar(Gtk.Grid):
self.emitter.connect("servers_loaded", self._on_servers_loaded)
self.emitter.connect("mods_updated", self._on_mods_updated)
def _on_alerts_clicked(self, button: LoggerAlertsButton) -> None:
self.controller.populate_log()
def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None:
self.set_by_context(NotebookPage.MODS, msg)
+4 -4
View File
@@ -102,10 +102,10 @@ class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
margin_top=30,
margin_bottom=30,
)
self.error_label = Gtk.Label(margin_start=15, margin_end=15)
self.error_label = Gtk.Label()
# TODO: abstract class
self.copy_button = ClipboardButton(self.error_label.get_text)
self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text())
self.button_hbox = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.CENTER, spacing=10
)
@@ -132,9 +132,9 @@ class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
preboot.signatures,
False,
),
(StoredFunc(get_ipdb, self.xdg.db), preboot.geo, False),
(StoredFunc(get_ipdb, self.xdg.ips), preboot.geo, False),
# TODO: handle IP DB failure and use coords fallback
(StoredFunc(get_local_coords, self.xdg.db), preboot.coords, True),
(StoredFunc(get_local_coords, self.xdg.ips), preboot.coords, True),
(StoredFunc(check_updates, self.version), preboot.updates, True),
]
self.results: list[Any] = []
-457
View File
@@ -1,457 +0,0 @@
import gi
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from typing import Literal
from dzgui.const.constants import DAY_ICON, NIGHT_ICON
from dzgui.strings import calc
from dzgui.views.components.icon import Icon
from dzgui.views.components.labels import BoldLabel
from dzgui.views.components.box import VBox, HBox
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, GObject # noqa E402
DAY_START_TIME = time(hour=6, minute=0, second=0)
DAY_END_TIME = time(hour=18, minute=0, second=0)
NIGHT_START_TIME = time(hour=18, minute=0, second=0)
NIGHT_END_TIME = time(hour=6, minute=0, second=0)
EXTENDED_TIME_FORMAT = "%H:%M:%S"
@dataclass(slots=True, frozen=True)
class Time:
time: str
day_accel: float
night_accel: float
class Emitter(GObject.GObject):
def __init__(self) -> None:
super().__init__()
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, object))
def local_time_incremented(self, time_now: datetime, elapsed: timedelta) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def server_time_incremented(self, server_time: datetime) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def calculate_button_clicked(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def target_time_changed(self, time: timedelta) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def target_time_reset(self, time: timedelta) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def remaining_time_changed(self, time: datetime) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def adjusted_local_time_changed(self, time: datetime) -> None:
pass
class LocalClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
self.start_time = datetime.now()
# NOTE: highest theoretical accel interval is 24 * 64
GLib.timeout_add(500, self.check_time)
def get_time(self) -> str:
return datetime.now().time().strftime(EXTENDED_TIME_FORMAT)
def get_elapsed_time(self) -> timedelta:
return datetime.now() - self.start_time
def check_time(self) -> Literal[True]:
time_now = datetime.now()
elapsed = datetime.now() - self.start_time
self.emitter.emit("local_time_incremented", time_now, elapsed)
return True
class ServerClock:
def __init__(
self, server_time: Time, emitter: Emitter, local_clock: LocalClock
) -> None:
self.local_clock = local_clock
self.emitter = emitter
hour, minute = self.split_time(server_time)
self.day_accel = server_time.day_accel
self.night_accel = server_time.night_accel
self.start_time = datetime.combine(datetime.today(), time(hour, minute))
self.cur_time = datetime.combine(datetime.today(), time(hour, minute))
self._last_elapsed = self.local_clock.get_elapsed_time()
self.emitter.connect("local_time_incremented", self._on_local_time_incremented)
@staticmethod
def split_time(server_time: Time) -> tuple[int, int]:
split = server_time.time.split(":")
return int(split[0]), int(split[1])
def is_day(self) -> bool:
if DAY_START_TIME <= self.cur_time.time() <= DAY_END_TIME:
return True
return False
def _on_local_time_incremented(
self,
emitter: Emitter,
time: datetime,
elapsed: timedelta,
) -> Literal[True]:
delta = elapsed - self._last_elapsed
self._last_elapsed = elapsed
factor = self.get_accel_factor(self.cur_time)
self.cur_time += delta * factor
self.emitter.emit("server_time_incremented", self.cur_time)
return True
def get_accel_factor(self, dt: datetime) -> float:
return (
self.day_accel
if DAY_START_TIME <= dt.time() < DAY_END_TIME
else (self.night_accel * self.day_accel)
)
def get_values(self) -> tuple[int, int]:
return self.cur_time.hour, self.cur_time.minute
def get_datetime(self) -> datetime:
return self.cur_time
def get_time(self) -> time:
return self.cur_time.time()
def calc_delta(self, end_time: datetime) -> timedelta:
total = 0.0
current_time = self.cur_time
if current_time > end_time:
end_time += timedelta(days=1)
while current_time < end_time:
factor = self.get_accel_factor(current_time)
day_start = datetime.combine(current_time.date(), DAY_START_TIME)
day_end = datetime.combine(current_time.date(), DAY_END_TIME)
if current_time.time() < DAY_START_TIME:
edge = day_start
elif current_time.time() < DAY_END_TIME:
edge = day_end
else:
edge = datetime.combine(
current_time.date() + timedelta(days=1), DAY_START_TIME
)
chunk_end = min(end_time, edge)
total += (chunk_end - current_time).total_seconds() / factor
current_time = chunk_end
return timedelta(seconds=total)
class RemainderClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
self.emitter.connect("target_time_changed", self._on_target_time_changed)
self.emitter.connect("target_time_reset", self._on_target_time_reset)
self.emitter.connect("local_time_incremented", self._on_local_time_incremented)
self.reset_time(True)
self.previous = timedelta(0)
def reset_time(self, zero_out: bool = False) -> None:
now = datetime.now()
second, ms = (0, 0) if zero_out else (now.second, now.microsecond)
self.time = datetime.combine(
now, time(hour=0, minute=0, second=second, microsecond=ms)
)
def _on_local_time_incremented(
self, emitter: Emitter, time: datetime, elapsed: timedelta
) -> None:
if self.time == datetime.min.time():
return
delta = elapsed - self.previous
# NOTE: clamp to midnight
self.time = max(
self.time - delta, self.time.replace(hour=0, minute=0, second=0)
)
self.previous = elapsed
self.emitter.emit("remaining_time_changed", self.time)
def _on_target_time_reset(self, emitter: Emitter, time: timedelta) -> None:
self.reset_time(True)
self.emitter.emit("remaining_time_changed", self.time)
# NOTE: restore adjusted time to current time
self.emitter.emit("adjusted_local_time_changed", datetime.now())
def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None:
self.reset_time(True)
self.time += time
self.emitter.emit("remaining_time_changed", self.time)
def get_time(self) -> str:
return self.time.strftime(EXTENDED_TIME_FORMAT)
class AdjustedClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
self.emitter.connect("target_time_changed", self._on_target_time_changed)
self.start_time = datetime.now()
def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None:
self.start_time = datetime.now()
adjusted = self.start_time + time
self.emitter.emit("adjusted_local_time_changed", adjusted)
def get_time(self) -> str:
return self.start_time.strftime(EXTENDED_TIME_FORMAT)
class VerticalSpinBox(Gtk.SpinButton):
def __init__(self, value: int, upper: int) -> None:
super().__init__(
adjustment=Gtk.Adjustment(
value=value,
lower=0,
upper=upper,
step_increment=1,
page_increment=1,
page_size=0,
),
orientation=Gtk.Orientation.VERTICAL,
)
class HourSpinBox(VerticalSpinBox):
def __init__(self, hour: int) -> None:
super().__init__(value=hour, upper=23)
class MinuteSpinBox(VerticalSpinBox):
def __init__(self, minute: int) -> None:
super().__init__(value=minute, upper=59)
self.get_adjustment().set_page_increment(10)
class TimePicker(Gtk.Box):
def __init__(self, hour: int, minute: int) -> None:
super().__init__(
orientation=Gtk.Orientation.HORIZONTAL,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
self.hour_spin = HourSpinBox(hour)
self.minute_spin = MinuteSpinBox(minute)
self.add(self.hour_spin)
self.add(self.minute_spin)
self.hour_spin.connect("output", self._on_output)
self.minute_spin.connect("output", self._on_output)
def _on_output(self, spin_button: VerticalSpinBox) -> Literal[True]:
val = spin_button.get_value()
spin_button.set_text(f"{int(val):02d}")
# NOTE: indicates that rendering completed
return True
def get_values(self) -> tuple[int, int]:
hour = self.get_hour()
minute = self.get_minute()
return hour, minute
def get_hour(self) -> int:
return int(self.hour_spin.get_adjustment().get_value())
def get_minute(self) -> int:
return int(self.minute_spin.get_adjustment().get_value())
def get_time(self) -> datetime:
"""
NOTE: interpolating seconds/microseconds from the current time
may prevent abrupt visual changes when setting a new TimePicker time
while a clock tick is updating the local remainder label.
However, this can introduce false negatives when running tests due to microsecond drift.
Ideally, tests should be decoupled from this logic.
"""
d = datetime.now()
return datetime.combine(
d,
time(
hour=self.get_hour(),
minute=self.get_minute(),
second=0,
microsecond=0,
),
)
def set_time(self, hour: int, minute: int) -> None:
self.hour_spin.get_adjustment().set_value(hour)
self.minute_spin.get_adjustment().set_value(minute)
class AdjustedTimeFrame(Gtk.Frame):
def __init__(self, emitter: Emitter) -> None:
super().__init__(margin_start=50, margin_end=50)
self.emitter = emitter
self.emitter.connect(
"adjusted_local_time_changed", self._on_adjusted_time_changed
)
self.emitter.connect("remaining_time_changed", self._on_remainder_changed)
self.remainder_clock = RemainderClock(emitter)
self.remainder_heading = BoldLabel(calc.heading_remainder_time)
self.remaining_time = Gtk.Label(label=self.remainder_clock.get_time())
self.adjusted_clock = AdjustedClock(emitter)
self.adjusted_heading = BoldLabel(calc.heading_adjusted_time)
self.adjusted_local_time = Gtk.Label(label=self.adjusted_clock.get_time())
flowbox = Gtk.FlowBox(
row_spacing=20,
column_spacing=50,
min_children_per_line=2,
max_children_per_line=2,
margin=15,
halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.NONE,
)
flowbox.add(self.remainder_heading)
flowbox.add(self.adjusted_heading)
flowbox.add(self.remaining_time)
flowbox.add(self.adjusted_local_time)
recalc = Gtk.Button(
label=calc.calculate, halign=Gtk.Align.CENTER, margin_bottom=20
)
recalc.connect("clicked", self._on_recalc_clicked)
vbox = VBox()
vbox.extend([flowbox, recalc])
self.add(vbox)
def _on_remainder_changed(self, emitter: Emitter, time: datetime) -> None:
strtime = str(time.strftime(EXTENDED_TIME_FORMAT))
self.remaining_time.set_text(strtime)
def _on_adjusted_time_changed(self, emitter: Emitter, time: datetime) -> None:
strtime = str(time.strftime(EXTENDED_TIME_FORMAT))
self.adjusted_local_time.set_text(strtime)
def _on_recalc_clicked(self, button: Gtk.Button) -> None:
self.emitter.emit("calculate_button_clicked")
class TimePickerFrame(Gtk.Frame):
def __init__(self, emitter: Emitter, stime: Time) -> None:
super().__init__(margin_start=50, margin_end=50)
self.emitter = emitter
self.emitter.connect("local_time_incremented", self._on_local_clock_increment)
self.emitter.connect("server_time_incremented", self._on_server_clock_increment)
self.emitter.connect(
"calculate_button_clicked", self._on_calculate_button_clicked
)
self.local_clock = LocalClock(self.emitter)
local_clock_time = self.local_clock.get_time()
self.server_time = ServerClock(stime, self.emitter, self.local_clock)
hour, minute = self.server_time.get_values()
self.picker = TimePicker(hour, minute)
self.local_timelabel = Gtk.Label(label=local_clock_time)
self.server_timelabel = Gtk.Label(label=stime.time, halign=Gtk.Align.CENTER)
h0 = BoldLabel(calc.heading_local_time)
h1 = BoldLabel(calc.heading_server_time)
h2 = BoldLabel(calc.heading_target_time)
self.icon_indicator = Icon(DAY_ICON)
self.update_server_icon()
server_time_box = HBox(10)
server_time_box.set_halign(Gtk.Align.CENTER)
server_time_box.extend([self.server_timelabel, self.icon_indicator])
flowbox = Gtk.FlowBox(
row_spacing=10,
column_spacing=50,
min_children_per_line=3,
max_children_per_line=3,
margin=15,
halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.NONE,
)
flowbox.add(h0)
flowbox.add(h1)
flowbox.add(h2)
flowbox.add(self.local_timelabel)
flowbox.add(server_time_box)
flowbox.add(self.picker)
self.add(flowbox)
def update_server_icon(self) -> None:
icon = DAY_ICON if self.server_time.is_day() else NIGHT_ICON
self.icon_indicator.set_icon_name(icon)
def _on_server_clock_increment(self, emitter: Emitter, time_now: datetime) -> None:
t = time_now.time().strftime("%H:%M")
self.server_timelabel.set_text(t)
self.update_server_icon()
def _on_local_clock_increment(
self, emitter: Emitter, time_now: datetime, elapsed: timedelta
) -> None:
t = time_now.time().strftime(EXTENDED_TIME_FORMAT)
self.local_timelabel.set_text(t)
def _on_calculate_button_clicked(self, button: Gtk.Button) -> None:
if (self.picker.get_values()) == (self.server_time.get_values()):
total_time = timedelta(seconds=0)
self.emitter.emit("target_time_reset", total_time)
else:
picker_time = self.picker.get_time()
total_time = self.server_time.calc_delta(picker_time)
self.emitter.emit("target_time_changed", total_time)
class ServerTimeCalculator(Gtk.ScrolledWindow):
def __init__(self, stime: Time) -> None:
super().__init__(propagate_natural_width=True)
self.emitter = Emitter()
outerbox = Gtk.Box(
halign=Gtk.Align.CENTER,
valign=Gtk.Align.START,
spacing=20,
orientation=Gtk.Orientation.VERTICAL,
)
time_picker_frame = TimePickerFrame(self.emitter, stime)
adjusted_time_frame = AdjustedTimeFrame(self.emitter)
disclaimer = Gtk.Label(
label=calc.disclaimer,
justify=Gtk.Justification.CENTER,
)
for el in time_picker_frame, adjusted_time_frame, disclaimer:
outerbox.add(el)
self.add(outerbox)
+1 -1
View File
@@ -46,7 +46,7 @@ class AbortDialog(ColorAwareApp, Gtk.MessageDialog): # type: ignore
self.connect("response", self._on_response)
self.run() # type: ignore[no-untyped-call]
self.run()
self.destroy()
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
+6 -6
View File
@@ -16,14 +16,14 @@ class FilePicker(Gtk.FileChooserDialog):
transient_for=parent,
resizable=True,
)
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL) # type: ignore[no-untyped-call]
self.add_buttons("_Save", Gtk.ResponseType.OK) # type: ignore[no-untyped-call]
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL)
self.add_buttons("_Save", Gtk.ResponseType.OK)
self.set_default_response(Gtk.ResponseType.OK)
self.set_current_name(picker.placeholder)
def pick_file(self) -> Path | None:
res = self.run() # type: ignore[no-untyped-call]
res = self.run()
if res in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
self.destroy()
return None
@@ -43,12 +43,12 @@ class FolderPicker(Gtk.FileChooserDialog):
parent=parent,
resizable=True,
)
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL) # type: ignore[no-untyped-call]
self.add_buttons("_OK", Gtk.ResponseType.OK) # type: ignore[no-untyped-call]
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL)
self.add_buttons("_OK", Gtk.ResponseType.OK)
self.set_default_response(Gtk.ResponseType.OK)
def pick_folder(self) -> Path | None:
res = self.run() # type: ignore[no-untyped-call]
res = self.run()
if res in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
self.destroy()
return None
+1 -9
View File
@@ -187,7 +187,7 @@ class TextBufferDialog(GenericDialog):
secondary=secondary,
)
self.text = text
copy_button = ClipboardButton(self.get_text)
copy_button = ClipboardButton(controller, self.get_text)
self.add_action_widget(copy_button, Gtk.ResponseType.NONE)
self.add_button("OK", Gtk.ResponseType.OK)
self.connect("response", self._on_response)
@@ -309,14 +309,6 @@ class ExceptionDialog(TextBufferDialog):
content.set_spacing(0)
content.add(scrollable)
self.connect("map", self._on_map)
def _on_map(self, dialog: Self) -> None:
button = self.get_widget_for_response(Gtk.ResponseType.OK)
if button is None:
return
button.grab_focus()
def _on_page_changed(
self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int
) -> None:
+25 -46
View File
@@ -1,21 +1,20 @@
import gi
import html
from typing import Self, TYPE_CHECKING
from dzgui.const.constants import CALCULATOR
from dzgui.const.constants import EXPAND, FILL
from dzgui.const.enum import ContextMenuGroup
from dzgui.model.model_factory import ModelFactory
from dzgui.util import css
from dzgui.util import strings
from dzgui.util.format import format_hyperlinks, format_server_mods
from dzgui.strings import server_mods, dialogs
from dzgui.views.components.buttons import IconTextButton
from dzgui.views.components.box import VBox
from dzgui.views.dialogs.calc import Time, ServerTimeCalculator
from dzgui.strings import server_mods
from dzgui.views.dialogs.generic import GenericDialog
from dzgui.views.trees.tree_base import TreeView
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, Pango # noqa
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
if TYPE_CHECKING:
from dzgui.api.servers import Details
@@ -47,19 +46,12 @@ class ServerDialog(GenericDialog):
self.connect("response", self._on_response)
self.view.connect("key-press-event", self._on_keypress)
self.scrollable_tree = Gtk.ScrolledWindow(vexpand=True)
self.scrollable_tree = Gtk.ScrolledWindow()
self.scrollable_tree.add(self.view)
self.scrollable_tree.set_size_request(700, 500)
self.stack = Gtk.Stack()
self.content_box = VBox(10)
self.content_box.set_valign(Gtk.Align.FILL)
self.content_box.add(self.scrollable_tree)
self.stack.add_named(self.content_box, "METADATA")
self.scrollable_tree.set_size_request(700, 400)
self.content = self.get_content_area()
self.content.pack_start(self.stack, expand=True, fill=True, padding=0)
self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0)
def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool:
# NOTE: ESC normally unfocuses treeview instead of destroying dialog
@@ -74,7 +66,9 @@ class ServerDialog(GenericDialog):
class ServerDetailsDialog(ServerDialog):
def __init__(self, controller: "Controller", details: "Details"):
super().__init__(controller, dialogs.server_details, details.name, menu=None)
# TODO: server name should also be packed in details struct
name = controller.get_server_name()
super().__init__(controller, strings.server_details, name, menu=None)
self.store = Gtk.ListStore(str, str, Pango.Weight)
self.view.connect("row-activated", self._on_row_activated)
@@ -91,9 +85,14 @@ class ServerDetailsDialog(ServerDialog):
column.set_sort_column_id(i)
column.set_expand(True)
scrollable_message = Gtk.ScrolledWindow(vexpand=True)
desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START)
css.add_class(desc, "server-subheading")
# NOTE: constrain tree area to logical height of metadata
# leaving room for server message
self.scrollable_tree.set_size_request(700, 150)
# TODO: make "Server message" text boldface
scrollable_message = Gtk.ScrolledWindow()
desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START)
css.add_class(desc, "details-heading")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER)
self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True)
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
@@ -101,7 +100,11 @@ class ServerDetailsDialog(ServerDialog):
for el in desc, sep, self.description:
box.add(el)
scrollable_message.add(box)
self.content_box.add(scrollable_message)
self.pack(scrollable_message)
if details.data is None:
return
for row in details.data:
self.store.append(row + [Pango.Weight.BOLD])
@@ -110,32 +113,8 @@ class ServerDetailsDialog(ServerDialog):
text = format_hyperlinks(text)
self.description.set_markup(html.escape(text))
calc_button = IconTextButton(CALCULATOR, dialogs.toggle_calc)
calc_button.connect("clicked", self._on_calc_pressed)
calc_button.set_halign(Gtk.Align.END)
calc_button.set_valign(Gtk.Align.START)
self.content.pack_start(calc_button, False, False, 0)
self.content.reorder_child(calc_button, 2)
self.scrollable_tree.set_size_request(700, 250)
self.calc = ServerTimeCalculator(
Time(details.gametime, details.day_accel, details.night_accel)
)
self.stack.add_named(self.calc, "CALCULATOR")
self.connect("map", self._on_map)
self.show_all()
def _on_map(self, dialog: Self) -> None:
if widget := self.get_widget_for_response(Gtk.ResponseType.OK):
widget.grab_focus()
def _on_calc_pressed(self, button: Gtk.Button) -> None:
if self.stack.get_visible_child_name() == "METADATA":
self.stack.set_visible_child_name("CALCULATOR")
else:
self.stack.set_visible_child_name("METADATA")
def _on_row_activated(
self,
treeview: Gtk.TreeView,
+4 -4
View File
@@ -22,7 +22,7 @@ from gi.repository import Gtk, GLib, Pango # noqa E402
class Assistant(Gtk.Assistant):
def __init__(self, is_deck: bool, paths: dict[str, Path]):
def __init__(self, is_deck: bool, paths: dict[str, str]):
super().__init__()
if is_deck:
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
@@ -63,7 +63,7 @@ class Assistant(Gtk.Assistant):
class Window(Gtk.Window):
def __init__(self, is_deck: bool, paths: dict[str, Path]) -> None:
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None:
super().__init__(title=APP_NAME, icon_name=APP_NAME)
self.assistant = Assistant(is_deck, paths)
@@ -121,7 +121,7 @@ class CheckboxWithPath(CheckboxWithLabel):
class UninstallPage(ScrolledWizardPage):
def __init__(self, paths: dict[str, Path], pyapp: str | None):
def __init__(self, paths: dict[str, str], pyapp: str | None):
super().__init__(heading=uninstall.pg1_heading, description=uninstall.pg1_blurb)
self.pyapp = pyapp
@@ -219,7 +219,7 @@ class UninstallPage(ScrolledWizardPage):
class UninstallWizard(ColorAwareApp, Gtk.Application): # type: ignore
def __init__(self, is_deck: bool, paths: dict[str, Path]) -> None:
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None:
super().__init__()
config = Path(paths["XDG_CONFIG_HOME"])
if not config.is_dir() or not config.joinpath("config.json").is_file():
+28 -99
View File
@@ -12,25 +12,21 @@ from dzgui.const.constants import (
APP_NAME,
APP_NAME_LOWER,
HERO_PATH,
FLATPAK_ROOT_PATH,
LEGACY_CONFIG_PATH,
TMP_PATH,
)
from dzgui.const.boilerplate import config_boilerplate
from dzgui.const.endpoints import STEAM_API_SETUP
from dzgui.config import freedesktop
from dzgui.init.proc import is_sandboxed, path_to_steam_cmd
from dzgui.init.migrate import migrate_legacy_conf
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import wizard
from dzgui.util._json import write_json
from dzgui.util.flatpak import apply_overrides
from dzgui.util.open_links import open_link_by_url
from dzgui.util.css import add_class, load_css
from dzgui.views.components.buttons import WebButton
from dzgui.views.components.entry import APIEntry
from dzgui.views.components.misc import ClientCombo
from dzgui.views.mixins.colorscheme import ColorAwareApp
from dzgui.views.trees.tree_simple import SimpleTree
import gi
@@ -48,8 +44,7 @@ class PageNum(Enum):
STEAM_API = 4
USER_PREFS = 5
SHORTCUTS = 6
FLATPAK = 7
FINAL = 8
FINAL = 7
class OptionalPageMixin:
@@ -148,7 +143,7 @@ class NotificationFrame(Gtk.Frame):
margin_start=50,
margin_end=50,
)
wrapped = textwrap.fill(label, width=60)
wrapped = textwrap.fill(label, width=80)
self.label = Gtk.Label(
label=wrapped,
justify=Gtk.Justification.CENTER,
@@ -162,18 +157,13 @@ class NotificationFrame(Gtk.Frame):
add_class(self, "error-frame")
def set_text(self, text: str) -> None:
wrapped = textwrap.fill(text, width=60)
wrapped = textwrap.fill(text, width=80)
self.label.set_markup(wrapped)
class APIValidationPage(EnumeratedWizardPage):
def __init__(
self,
enum: PageNum,
heading: str,
description: str,
link: str,
func: Callable[[str], None],
self, enum: PageNum, heading: str, description: str, link: str, func: Callable
) -> None:
super().__init__(
enum=enum,
@@ -252,23 +242,12 @@ class IntroductionPage(EnumeratedWizardPage):
def __init__(self) -> None:
super().__init__(
enum=PageNum.INTRO,
heading=wizard.welcome.format(APP_NAME),
heading=f"Welcome to {APP_NAME}!",
description=wizard.blurb_intro,
)
self.page_type = Gtk.AssistantPageType.INTRO
self.set_title(wizard.title_intro)
self.error_box = NotificationFrame(wizard.error_sandbox, error=True)
self.add_start(self.error_box)
self.connect("map", self._on_map)
def _on_map(self, page: "EnumeratedWizardPage") -> None:
self.error_box.hide()
if is_sandboxed():
self.error_box.show()
EMITTER.emit("step_pending")
class Heading(Gtk.Label):
def __init__(self, label: str):
@@ -405,17 +384,26 @@ class PreferencesPage(EnumeratedWizardPage):
self.dist_box.add(self.radio_km)
self.dist_box.add(self.radio_miles)
client_label = Gtk.Label(label=wizard.label_client, halign=Gtk.Align.START)
self.client_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.client_combo = ClientCombo()
self.client_box.add(client_label)
self.client_box.add(self.client_combo)
outer_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, spacing=10
)
for el in self.name_box, self.dist_box:
for el in self.name_box, self.client_box, self.dist_box:
outer_box.add(el)
self.add_start(outer_box)
def get_prefs(self) -> tuple[str, bool]:
def get_prefs(self) -> tuple[str, bool, str]:
name = self.name_entry.get_text().strip()
use_miles = self.radio_miles.get_active()
return name, use_miles
model = self.client_combo.get_model()
ind = self.client_combo.get_active()
client = model[ind][1]
return name, use_miles, client
def _on_entry_changed(self, entry: Gtk.Entry) -> None:
text = entry.get_text()
@@ -425,44 +413,6 @@ class PreferencesPage(EnumeratedWizardPage):
EMITTER.emit("step_complete")
class FlatpakOverridePage(EnumeratedWizardPage):
def __init__(self, xdg_paths: dict[str, Path]) -> None:
super().__init__(
enum=PageNum.FLATPAK,
heading=wizard.heading_flatpak,
description=wizard.blurb_flatpak,
)
self.xdg_paths = xdg_paths
self.page_type = Gtk.AssistantPageType.INTRO
store = Gtk.ListStore(str)
for path in xdg_paths.values():
store.append((str(path),))
store.append((TMP_PATH,))
tree = SimpleTree(store, wizard.tree_column)
tree.set_halign(Gtk.Align.CENTER)
self.authorize_button = Gtk.Button(
label=wizard.button_flatpak, halign=Gtk.Align.CENTER
)
self.authorize_button.connect("clicked", self._on_authorize)
self.notice = NotificationFrame(wizard.overrides_applied)
self.connect("map", self._on_map)
for el in tree, self.authorize_button, self.notice:
self.add_start(el)
def _on_map(self, page: "EnumeratedWizardPage") -> None:
self.notice.hide()
def _on_authorize(self, button: Gtk.Button) -> None:
apply_overrides(self.xdg_paths)
self.notice.show()
EMITTER.emit("step_complete")
class CompletionPage(EnumeratedWizardPage):
def __init__(self) -> None:
super().__init__(
@@ -476,7 +426,7 @@ class CompletionPage(EnumeratedWizardPage):
class Assistant(Gtk.Assistant):
def __init__(self, is_deck: bool, XDG: "Xdg", xdg_paths: dict[str, Path]):
def __init__(self, is_deck: bool, XDG: "Xdg"):
super().__init__()
if is_deck:
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
@@ -495,7 +445,6 @@ class Assistant(Gtk.Assistant):
self.page_api = SteamValidationPage()
self.page_prefs = PreferencesPage()
self.page_shortcuts = ShortcutCreationPage(XDG.shortcut)
self.page_flatpak = FlatpakOverridePage(xdg_paths)
self.page_completion = CompletionPage()
self.set_forward_page_func(self._advance_page)
@@ -515,7 +464,6 @@ class Assistant(Gtk.Assistant):
self.page_api,
self.page_prefs,
self.page_shortcuts,
self.page_flatpak,
self.page_completion,
):
# NOTE: skip config migration page if no legacy config file
@@ -536,10 +484,7 @@ class Assistant(Gtk.Assistant):
def write_config(self, config: dict[str, Any]) -> None:
write_json(config, self.config_path)
def get_completion_page(self) -> int:
return self.pages[self.page_completion]
def get_shortcut_page(self) -> int:
def get_final_page(self) -> int:
return self.pages[self.page_shortcuts]
def _advance_page(self, index: int) -> int:
@@ -552,39 +497,26 @@ class Assistant(Gtk.Assistant):
sp = page.migrated_conf["default_steam_path"]
self.page_shortcuts.set_steam_path(sp)
self.config_values = self.page_migration.migrated_conf
return self.get_shortcut_page()
return self.get_final_page()
case SteamPathPage():
path = page.get_path_from_radio()
self.config_values["default_steam_path"] = path
self.config_values["client"] = path_to_steam_cmd(path)
self.config_values["default_steam_path"] = page.get_path_from_radio()
case SteamValidationPage():
self.config_values["steam_api"] = page.get_api_key()
case PreferencesPage():
# NOTE: collects config values before advancing to last page
name, use_miles = self.page_prefs.get_prefs()
name, use_miles, client = self.page_prefs.get_prefs()
self.config_values["name"] = name
self.config_values["use_miles"] = use_miles
self.config_values["client"] = client
self.page_shortcuts.set_steam_path(
self.config_values["default_steam_path"]
)
case ShortcutCreationPage():
page.create_shortcuts()
if not self.needs_flatpak_override():
return self.get_completion_page()
case FlatpakOverridePage():
pass
case _:
raise AttributeError("Trying to advance a non-canonical page")
return index + 1
def needs_flatpak_override(self) -> bool:
if (
self.page_shortcuts.is_steam_checkbox_active()
and FLATPAK_ROOT_PATH in self.config_values["default_steam_path"]
):
return True
return False
def is_setup_complete(self) -> bool:
return self.setup_complete
@@ -710,9 +642,6 @@ class ShortcutCreationPage(OptionalPageMixin, EnumeratedWizardPage): # type: ig
def set_steam_path(self, path: Path) -> None:
self.steam_path = path
def is_steam_checkbox_active(self) -> bool:
return self.steam_checkbox.get_active()
def create_shortcuts(self) -> None:
# NOTE: best-effort, permissive even on failure (page is already marked as complete)
if self.steam_checkbox.get_active():
@@ -781,10 +710,10 @@ class SteamPathPage(EnumeratedWizardPage):
class SetupWizard(ColorAwareApp, Gtk.Application): # type: ignore
def __init__(self, is_deck: bool, XDG: "Xdg", xdg_paths: dict[str, Path]) -> None:
def __init__(self, is_deck: bool, XDG: "Xdg") -> None:
super().__init__()
GLib.set_prgname(APP_NAME)
self.win = Window(is_deck, XDG, xdg_paths)
self.win = Window(is_deck, XDG)
Gtk.main()
def is_setup_complete(self) -> int:
@@ -792,9 +721,9 @@ class SetupWizard(ColorAwareApp, Gtk.Application): # type: ignore
class Window(Gtk.Window):
def __init__(self, is_deck: bool, XDG: "Xdg", xdg_paths: dict[str, Path]) -> None:
def __init__(self, is_deck: bool, XDG: "Xdg") -> None:
super().__init__(title=APP_NAME, icon_name=APP_NAME)
self.assistant = Assistant(is_deck, XDG, xdg_paths)
self.assistant = Assistant(is_deck, XDG)
class Emitter(GObject.GObject):
+6 -2
View File
@@ -15,7 +15,11 @@ logger = logging.getLogger(APP_NAME)
class ContextMixin(TreeView):
def present_menu(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
def present_menu(
self,
widget: Gtk.Widget,
event: Gdk.EventButton | Gdk.EventKey,
) -> bool:
if self.menu is None:
return False
@@ -24,7 +28,7 @@ class ContextMixin(TreeView):
match event.type:
case Gdk.EventType.BUTTON_PRESS:
if event.button != Gdk.BUTTON_SECONDARY: # type: ignore[comparison-overlap]
if event.button != 3:
return False
self._process_button_event(event) # type: ignore
case Gdk.EventType.KEY_PRESS:
+3 -4
View File
@@ -13,7 +13,6 @@ if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
# TODO: refactor into autopopulated ListStores
class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
"""
Notebook page holding a prearranged grid
@@ -36,7 +35,7 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
self.connect("key-press-event", self._on_keypress)
def build_keys(self, items: list[dict[str, str]]) -> Gtk.Grid:
def build_keys(self, items: list) -> Gtk.Grid:
grid = Gtk.Grid(row_spacing=10, column_homogeneous=True)
grid.set_halign(Gtk.Align.START)
row, col = 1, 0
@@ -73,7 +72,7 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
row += 1
return grid
def build_sidebar(self, categories: list[str]) -> Gtk.Grid:
def build_sidebar(self, categories: list) -> Gtk.Grid:
row, col = 0, 0
w, h = 1, 1
sidebar = Gtk.Grid(row_homogeneous=True, orientation=Gtk.Orientation.VERTICAL)
@@ -84,7 +83,7 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
sidebar.attach(label, col, row, w, h)
return sidebar
def build_grid(self, items: list[dict[str, str]]) -> Gtk.Grid:
def build_grid(self, items: list) -> Gtk.Grid:
grid = Gtk.Grid(
row_spacing=20,
halign=Gtk.Align.CENTER,
+40 -53
View File
@@ -1,8 +1,7 @@
from pathlib import Path
from typing import Any, Callable, TYPE_CHECKING
from typing import Callable, TYPE_CHECKING
from dzgui.api import pefile as PeFile
from dzgui.api.steam import get_last_updated
from dzgui.config import query
from dzgui.const.constants import (
@@ -10,9 +9,6 @@ from dzgui.const.constants import (
APPID_DAYZ_EXP,
APPNAME_DAYZ,
APPNAME_DAYZ_EXP_HUMAN,
FLATPAK_RUN_CMD,
FLATPAK_SANDBOX,
FLATPAK_STEAM_PATH,
NO_EXPAND,
NO_FILL,
NO_PADDING,
@@ -29,7 +25,7 @@ from dzgui.views.components.eventbox import InfoEventBox
from dzgui.views.components.labels import LeftLabel
from dzgui.views.components.buttons import SpinnerButton, WebButton
from dzgui.views.components.frame import HeadingFrame
from dzgui.views.components.misc import ErrorPopover
from dzgui.views.components.misc import ClientCombo, ErrorPopover
from dzgui.views.dialogs.generic import ExceptionDialog
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
@@ -55,7 +51,7 @@ class SubmitField(Gtk.Box):
) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.callback: Callable[[], None]
self.callback: Callable
self.controller = controller
self.context = context
@@ -80,7 +76,7 @@ class SubmitField(Gtk.Box):
self.button = Gtk.Button(label=options.save_button)
self.button.connect("clicked", self._on_save_clicked)
self.entry.connect("changed", self._on_text_typed)
self.entry.connect("insert-text", self._on_text_typed)
self.entry.connect("activate", self._on_field_activated)
self.entry.get_property("buffer").connect("deleted-text", self._on_text_deleted)
@@ -117,9 +113,13 @@ class SubmitField(Gtk.Box):
def _on_text_typed(
self,
entry: Gtk.Entry,
text: str,
length: int,
pos: int,
) -> None:
text = entry.get_text()
buffer = entry.get_property("buffer")
text = buffer.get_text() + text
state = self._is_valid_text(text)
self.button.set_sensitive(state)
@@ -143,7 +143,7 @@ class SubmitField(Gtk.Box):
return
self.save_option()
def set_callback(self, callback: Callable[[], None]) -> None:
def set_callback(self, callback: Callable) -> None:
self.callback = callback
def save_option(self) -> None:
@@ -210,7 +210,7 @@ class NameSubmitField(SubmitField):
Preferences.NAME,
private=False,
)
self.entry.set_width_chars(30)
self.entry.set_width_chars(30) # type: ignore
self.set_callback(self.save_player_name)
def save_player_name(self) -> None:
@@ -250,26 +250,6 @@ class ToggleField(Gtk.Box):
el.set_sensitive(state)
class FlatpakToggleField(ToggleField):
def __init__(
self,
controller: "Controller",
) -> None:
super().__init__(
controller,
first_option=options.flatpak_standard,
second_option=options.flatpak_container,
context=Preferences.CLIENT,
)
self.add(InfoEventBox(options.flatpak_info, self.controller))
self.controller = controller
self.radio1.connect("toggled", self._on_radio_toggled, Preferences.CLIENT)
def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None:
cmd = FLATPAK_RUN_CMD if self.radio1.get_active() else FLATPAK_SANDBOX
self.controller.update_config(context, cmd)
class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
def __init__(self, controller: "Controller"):
super().__init__(
@@ -298,7 +278,6 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
self.player_box.set_halign(Gtk.Align.START)
self.player_box.get_children()[0].set_width_chars(30) # type: ignore
self.flatpak_toggle = FlatpakToggleField(self.controller)
self.fullscreen_toggle = ToggleField(
self.controller,
strings.options.last_used,
@@ -306,6 +285,11 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
Preferences.WINDOW,
)
self.client_combo = ClientCombo()
self.client_combo.connect("changed", self._on_client_changed)
client_hbox = ShortHBox(self.client_combo)
self.distance_toggle = ToggleField(
self.controller, strings.options.km, strings.options.mi, Preferences.DIST
)
@@ -329,22 +313,15 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
self.start_tab_combo.connect("changed", self._on_start_tab_changed)
pref_rows = [
[LeftLabel(strings.options.client), client_hbox],
[LeftLabel(strings.options.window_size), self.fullscreen_toggle],
[LeftLabel(strings.options.distance), self.distance_toggle],
[LeftLabel(options.start_tab), start_tab_hbox],
[LeftLabel(strings.options.name), self.player_box],
]
default = self.controller.query_config(Preferences.DEFAULT)
if FLATPAK_STEAM_PATH in default:
pref_rows.insert(
0,
[LeftLabel(options.flatpak_label), self.flatpak_toggle],
)
self.dayz_version_label = Gtk.Label(label=strings.null, halign=Gtk.Align.START)
self.dayz_exp_version_label = Gtk.Label(
label=strings.null, halign=Gtk.Align.START
)
self.dayz_version_label = Gtk.Label(label=strings.null)
self.dayz_exp_version_label = Gtk.Label(label=strings.null)
version_rows = [
[LeftLabel(APPNAME_DAYZ), self.dayz_version_label],
@@ -394,11 +371,19 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
self.connect("key-press-event", self._on_keypress)
def get_client_name(self) -> str:
model = self.client_combo.get_model()
ind = self.client_combo.get_active()
return str(model[ind][0])
def block_text_entry(self) -> None:
self.steam_box.block_text_entry()
# self.steam_entry.set_position(-1)
# self.steam_entry.set_can_focus(False)
def unblock_text_entry(self) -> None:
self.steam_box.unblock_text_entry()
# self.steam_entry.set_can_focus(True)
def _on_developers_clicked(self, button: Gtk.Button) -> None:
self.controller.show_developers_page()
@@ -406,8 +391,7 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
def _on_link_button_clicked(self, button: Gtk.Button, url: str) -> None:
open_links.open_link_by_url(url)
# TODO: use FlowBox
def _make_grid(self, rows: list[list[Any]]) -> Gtk.Grid:
def _make_grid(self, rows: list) -> Gtk.Grid:
grid = Gtk.Grid(
orientation=Gtk.Orientation.VERTICAL,
column_spacing=10,
@@ -434,13 +418,20 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
index = enum.value
self.controller.update_config(Preferences.START_TAB, index)
def _on_client_changed(self, combo: Gtk.ComboBoxText) -> None:
_iter = combo.get_active_iter()
if _iter is None:
raise ValueError(f"No active iterator set on {combo}")
real_cmd = combo.get_model()[_iter][1]
self.controller.update_config(Preferences.CLIENT, real_cmd)
def populate_settings(self) -> None:
prefs = self.controller.get_prefs()
# NOTE: re-check in case file was removed by user between runs
if prefs.paths.config.is_file() is False:
dialog = ExceptionDialog(self.controller, strings.config_not_found)
dialog.show_all()
dialog.run() # type: ignore[no-untyped-call]
dialog.run()
raise OSError(f"Config file '{prefs.paths.config}' not found")
config = query.get_config(prefs.paths.config)
@@ -471,22 +462,18 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ)
if dayz_version is None:
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)
if dayz_exp_version is None:
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_exp_version_label.set_text(dayz_exp_version)
# TODO: bicolumn list store with no cell renderer on index 1, use raw command names
active_combo = query.get_client_index(config["client"])
self.client_combo.set_active(active_combo)
start_tab = self.controller.query_config(Preferences.START_TAB)
self.start_tab_combo.set_active(start_tab)
+3 -4
View File
@@ -176,7 +176,8 @@ class PreConnectionAssistant(Gtk.Box):
self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.error_tree = MaskedTree(ERROR)
self.error_placeholder = Placeholder(preconnect.no_errors)
# TODO: strings
self.error_placeholder = Placeholder("No errors.")
self.error_box.add(self.error_tree)
self.error_box.add(self.error_placeholder)
self.error_frame = HeadingFrame.new_with_widget_and_label(
@@ -231,7 +232,7 @@ class PreConnectionAssistant(Gtk.Box):
args = self.controller.get_debug_args()
d = DebugDialog(self.controller, args)
d.show_all()
d.run() # type: ignore[no-untyped-call]
d.run()
def _on_ok_clicked(self, button: Gtk.Button) -> None:
self.controller.update_and_connect()
@@ -279,8 +280,6 @@ class PreConnectionAssistant(Gtk.Box):
warnings.append(preconnect.protected_server)
if prereqs.dayz_running is True:
warnings.append(preconnect.dayz_running)
if len(prereqs.mods) > 0 and prereqs.download_paused:
warnings.append(preconnect.downloads_paused)
allows_dl, running_app = prereqs.allows_downloads
if len(prereqs.mods) > 0 and allows_dl is False:
+32 -11
View File
@@ -79,6 +79,11 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
index = path.get_indices()[0]
return index
def get_selected_records(self) -> list:
sel = self.get_selection()
model, rows = sel.get_selected_rows()
return [model[row] for row in rows]
def _on_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> bool:
from dzgui.views.trees.tree_servers import ServerTreeView
@@ -103,9 +108,14 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
def _on_key_release(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None:
"""
Adds debounce to key press/release events to prevent spamming
when rapidly navigating through records
Suppresses spamming on keydown
TODO: explain this better
"""
# 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:
@@ -140,11 +150,9 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
self.set_cursor(path)
def get_value_at_index(self, index: int) -> Any:
model, pathlist = self.get_model_and_pathlist()
(model, pathlist) = self.get_model_and_pathlist()
if len(pathlist) < 1:
return ""
if model is None:
return ""
path = pathlist[0]
tree_iter = model.get_iter(path)
value = model.get_value(tree_iter, index)
@@ -158,16 +166,20 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
sel = self.get_selection()
self._on_tree_selection_changed(sel)
def get_model_and_pathlist(
self,
) -> tuple[Gtk.TreeModel | None, list[Gtk.TreePath]]:
def get_model_and_pathlist(self) -> tuple:
select = self.get_selection()
sels = select.get_selected_rows()
model: Gtk.TreeModel | None
pathlist: list[Gtk.TreePath]
model, pathlist = sels
(model, pathlist) = sels
return (model, pathlist)
# @deprecated("Currently unused")
# def get_mpath(self) -> Optional[Gtk.TreePath]:
# (model, pathlist) = self.get_model_and_pathlist()
# if len(pathlist) < 1:
# return None
# path = pathlist[0]
# return path
def _on_row_activated(
self,
treeview: Gtk.TreeView,
@@ -182,6 +194,15 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
return True
return False
# @deprecated("unused")
# def get_selected_row(self) -> Optional[Gtk.TreeModelRow]:
# ind = self.get_selected_row_index()
# model = self.get_model()
# if model is None:
# return None
# row = model[ind]
# return row
def get_col_value_by_path_index(self, path: Gtk.TreePath, index: int) -> Any:
model = self.get_model()
if model is None:
+14 -21
View File
@@ -32,7 +32,6 @@ if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
from dzgui.model.model_factory import FastInsertListStore
from dzgui.util.dist import Haversine
QUEUE_CHECK_DELAY = 200
@@ -65,9 +64,9 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
self.queue_id: int
self.handler_id: int
self.queue: Queue[tuple[str, "Haversine" | None, ServerTab]] = Queue()
self.queue: Queue = Queue()
self.seen_cache: list[Record] = []
self.seen_cache: list[str] = []
prefs = self.controller.get_prefs()
columns = prefs.paths.columns
@@ -253,6 +252,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
case Gdk.KEY_c:
self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self)
case Gdk.KEY_r:
# TODO: unimplemented, needs threading
self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self)
else:
match event.keyval:
@@ -341,24 +341,16 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
control = self.proxy_man.get_control()
return model, control
@staticmethod
def ping_server(
self,
model: "FastInsertListStore",
_iter: Gtk.TreeIter,
record: Record,
ip: str,
qport: int,
ping_column: int,
) -> None:
_ping = ping(record.ip, record.qport)
GLib.idle_add(self._redraw, model, _iter, _ping)
def _redraw(
self, model: "FastInsertListStore", _iter: Gtk.TreeIter, _ping: int
) -> None:
ping_column = 9
model.set(_iter, ping_column, _ping)
self.queue_draw()
def wipe_cache(self) -> None:
self.seen_cache = []
_ping = ping(ip, qport)
GLib.idle_add(lambda: model.set(_iter, ping_column, _ping))
def _get_ping(
self,
@@ -371,12 +363,13 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
addr_column = 7
qport_column = 8
ping_column = 9
addr = model.get_value(_iter, addr_column).split(":")
ip = addr[0]
gameport = int(addr[1])
gameport = addr[1]
qport = model.get_value(_iter, qport_column)
record = Record(ip, gameport, qport)
record = f"{addr}:{gameport}:{qport}"
if record in self.seen_cache:
return
@@ -385,7 +378,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
thread = threading.Thread(
daemon=True,
target=self.ping_server,
args=(model, _iter, record),
args=(model, _iter, ip, qport, ping_column),
)
thread.start()
@@ -407,7 +400,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
path = model.get_path(it)
row_index = path.get_indices()[0]
try:
start, end = self.get_visible_range() # type: ignore[no-untyped-call]
start, end = self.get_visible_range()
except Exception:
return
if row_index >= start[0] <= end[0]:
-35
View File
@@ -1,35 +0,0 @@
from typing import TYPE_CHECKING, Union
if TYPE_CHECKING:
from dzgui.model.model_factory import FastInsertListStore
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa
class SimpleTree(Gtk.ScrolledWindow):
"""Simple scrollable 1-column tree"""
def __init__(
self, model: Union[Gtk.ListStore, "FastInsertListStore"], title: str
) -> None:
super().__init__(
overlay_scrolling=False,
propagate_natural_width=True,
propagate_natural_height=True,
)
self.tree = Gtk.TreeView(model=model, enable_search=False)
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(title, renderer, text=0)
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
column.set_resizable(False)
column.set_sort_column_id(0)
self.tree.append_column(column)
self.add(self.tree)
def get_tree(self) -> Gtk.TreeView:
return self.tree
+3 -5
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [
{name = "aclist"}
]
version = "7.0.0b30"
version = "7.0.0b23"
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
readme = "README.md"
@@ -47,7 +47,6 @@ docs = [
]
test = [
"black==25.1.0",
"hypothesis==6.167.1",
"pre-commit==4.4.0",
"pytest==8.4.2",
"pytest-cov==7.0.0",
@@ -104,17 +103,16 @@ python-a2s = { path = "./dzgui/lib/a2s", editable = true }
dayzquery = { path = "./dzgui/lib/dayzquery", editable = true }
[tool.uv]
config-settings-package = { pygobject-stubs = { config = "Gtk3,Gdk3" } }
config-settings-package = { pygobject-stubs = { config = "Gtk3,Gdk3,Soup2" } }
[tool.pytest.ini_options]
markers = [
"apitest: checks remote endpoints",
"config: config file keys/values",
"flatpak: depends on local flatpak installation",
"realconfig: depends on actual generated local config file",
"mods: tests mod metadata/link creation",
"pefile: validate PE files",
"post_install: requires a completed installation",
"realconfig: depends on actual generated local config file",
"redact: log redaction mechanisms",
"slow: long-running tests",
"webtest: depends on remote endpoint",
+2 -2
View File
@@ -1,4 +1,4 @@
from shared import get_git_root
from pathlib import Path
BOILERPLATE = """
This file includes the combined licenses to the following components:
@@ -40,7 +40,7 @@ class CombinedLicense:
def concat_license() -> str:
combined = CombinedLicense()
root = get_git_root()
root = Path(__file__).resolve().parents[1]
licenses = root.joinpath("docs/licenses")
main_license = root.joinpath(LICENSE_FILE)
+4 -5
View File
@@ -1,11 +1,10 @@
import shutil
import subprocess
from pathlib import Path
from shared import get_git_root
root = get_git_root()
clog = root.joinpath("CHANGELOG.md")
target = root.joinpath("dzgui/data/CHANGELOG.md")
par = Path(__file__).parent.parent
clog = par / "CHANGELOG.md"
target = par / "dzgui/data/CHANGELOG.md"
shutil.copy(clog, target)
+2 -2
View File
@@ -1,9 +1,9 @@
import tomllib
from shared import get_git_root
from pathlib import Path
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)
+2 -3
View File
@@ -3,15 +3,14 @@ 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 = get_git_root()
root = Path(__file__).resolve().parents[1]
build_dir = root.joinpath("build")
cpython_dir = build_dir.joinpath(CPYTHON_BUILD)
+1 -2
View File
@@ -9,7 +9,6 @@ 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:
@@ -18,7 +17,7 @@ def update_uname(info: tarfile.TarInfo) -> tarfile.TarInfo:
return info
root = get_git_root()
root = Path(__file__).resolve().parents[1]
dist_dir = root.joinpath("dist")
build_dir = root.joinpath("build")
-14
View File
@@ -1,14 +0,0 @@
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
@@ -1,11 +0,0 @@
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 -1
View File
@@ -8,6 +8,6 @@
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"timestamp" "0"
"Timestamp" "0"
}
}
+2 -2
View File
@@ -8,7 +8,7 @@
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"timestamp" "1787904411"
"Timestamp" "1787904411"
}
"1"
{
@@ -18,6 +18,6 @@
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"timestamp" "1787904647"
"Timestamp" "1787904647"
}
}
+2 -2
View File
@@ -8,7 +8,7 @@
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"timestamp" "1797904411"
"Timestamp" "1797904411"
}
"1"
{
@@ -18,6 +18,6 @@
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"timestamp" "1787904647"
"Timestamp" "1787904647"
}
}
+1 -1
View File
@@ -9,6 +9,6 @@
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "1"
"timestamp" "0"
"Timestamp" "0"
}
}
+2 -2
View File
@@ -9,7 +9,7 @@
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "0"
"timestamp" "0"
"Timestamp" "0"
}
"1"
{
@@ -20,6 +20,6 @@
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "1"
"timestamp" "10"
"Timestamp" "10"
}
}
-53
View File
@@ -1,53 +0,0 @@
"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
@@ -1,53 +0,0 @@
"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"
}
}
+1 -9
View File
@@ -3,7 +3,7 @@ import pytest
from _pytest.monkeypatch import MonkeyPatch
from pathlib import Path
from dzgui.api.steam import get_app_allows_downloads, get_last_updated
from dzgui.api.steam import get_app_allows_downloads
from tests.fixtures import fixture_path
pytestmark = pytest.mark.apitest
@@ -43,14 +43,6 @@ def patch_api() -> None:
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:
assert get_app_allows_downloads(steam_path, 111) is True
-19
View File
@@ -1,19 +0,0 @@
import pytest
from dzgui.const.constants import FLATPAK_RUN_CMD, STEAM_CMD
from dzgui.controllers.mc import Controller
@pytest.mark.parametrize(
"value, expected", [(FLATPAK_RUN_CMD, "Flatpak"), (STEAM_CMD, "Steam")]
)
def test_human_name(monkeypatch, value, expected) -> None:
def mock_query_config(*args) -> str:
return value
monkeypatch.setattr(
"dzgui.controllers.mc.Controller.query_config", mock_query_config
)
controller = Controller()
client = controller.get_client_name()
assert client == expected
+4 -3
View File
@@ -10,7 +10,7 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gio, GLib # noqa
class DummyWidget(ColorAwareApp, Gtk.Button):
class TestWidget(ColorAwareApp, Gtk.Button):
def __init__(self) -> None:
super().__init__()
@@ -22,10 +22,11 @@ class DummyWidget(ColorAwareApp, Gtk.Button):
@pytest.fixture
def widget() -> DummyWidget:
return DummyWidget()
def widget() -> TestWidget:
return TestWidget()
@pytest.mark.integration
@pytest.mark.parametrize("key, expect", [("default", False), ("prefer-dark", True)])
def test_light_mode(widget, key: str, expect: bool) -> None:
local_pref = subprocess.run(
+1
View File
@@ -36,6 +36,7 @@ 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)
-45
View File
@@ -1,45 +0,0 @@
import configparser
import pytest
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
PATHS_KEY = "Context"
PATHS_SUBKEY = "filesystems"
BUS_KEY = "Session Bus Policy"
pytestmark = pytest.mark.flatpak
@pytest.fixture
def xdg_paths() -> dict[str, str]:
return get_xdg_paths()
@pytest.fixture
def override(xdg_paths) -> None:
apply_overrides(xdg_paths)
@pytest.fixture
def config(xdg_paths) -> configparser.ConfigParser:
conf_path = xdg_paths["XDG_DATA_HOME"].parent.joinpath(
f"flatpak/overrides/{FLATPAK_APPID}"
)
config = configparser.ConfigParser()
config.read(conf_path)
return config
def test_session_bus(override, config) -> None:
bus = config[BUS_KEY][GTK_APP_ID]
assert bus == "own"
def test_paths(override, xdg_paths, config) -> None:
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)
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more