Compare commits

..
Author SHA1 Message Date
aclist 8a94cd090f Merge ac286f4a72 into 5014402e39 2026-09-03 19:00:44 +00:00
27 changed files with 177 additions and 394 deletions

No files matched your search

-1
View File
@@ -52,7 +52,6 @@
- Filter button tooltips - Filter button tooltips
- Expand/collapse changelog versions - Expand/collapse changelog versions
- Support local light/dark mode changes on system theme - Support local light/dark mode changes on system theme
- Server time calculator
### Changed ### Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
-23
View File
@@ -6,7 +6,6 @@ import subprocess
import vdf # type: ignore import vdf # type: ignore
from pathlib import Path from pathlib import Path
from requests.exceptions import HTTPError
from typing import Any, Union from typing import Any, Union
from warnings import deprecated from warnings import deprecated
@@ -33,7 +32,6 @@ from dzgui.const.endpoints import (
UNSUB_ENDPOINT, UNSUB_ENDPOINT,
) )
from dzgui.strings import wizard from dzgui.strings import wizard
from dzgui.strings.errors import api_validation_error, workshop_endpoint_error
from dzgui.util.bash import concat_bash_args from dzgui.util.bash import concat_bash_args
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
@@ -264,16 +262,6 @@ def find_user_id_32(path: Path) -> int:
return int(uid) & 0xFFFFFFFF return int(uid) & 0xFFFFFFFF
class SteamAPIKeyError(Exception):
def __init__(self, msg=None):
super().__init__(msg or api_validation_error)
class SteamWorkshopEndpointError(Exception):
def __init__(self, msg=None):
super().__init__(msg or workshop_endpoint_error)
def update_workshop(key: str, mod: int, endpoint: str) -> None: def update_workshop(key: str, mod: int, endpoint: str) -> None:
payload: dict[str, Union[int, str]] = { payload: dict[str, Union[int, str]] = {
"publishedfileid": mod, "publishedfileid": mod,
@@ -285,19 +273,8 @@ def update_workshop(key: str, mod: int, endpoint: str) -> None:
try: try:
res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT) res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT)
res.raise_for_status() 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: except Exception as e:
logger.critical(e) logger.critical(e)
raise e
def subscribe(key: str, mod: int) -> None: def subscribe(key: str, mod: int) -> None:
+1 -3
View File
@@ -36,7 +36,7 @@ HEX_GREEN = "#32CD32"
HEX_RED = "#FF0000" HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C" HEX_ORANGE = "#FFAC1C"
# TODO: normalize icon names # FIXME: normalize icon names
CALCULATOR = "accessories-calculator-symbolic" CALCULATOR = "accessories-calculator-symbolic"
CARET_DOWN = "go-down-symbolic" CARET_DOWN = "go-down-symbolic"
CARET_UP = "go-up-symbolic" CARET_UP = "go-up-symbolic"
@@ -55,8 +55,6 @@ VIEW_CONCEAL = "view-conceal-symbolic"
VIEW_REVEAL = "view-reveal-symbolic" VIEW_REVEAL = "view-reveal-symbolic"
WARNING = "dialog-warning-symbolic" WARNING = "dialog-warning-symbolic"
WEB_BROWSER = "web-browser-symbolic" WEB_BROWSER = "web-browser-symbolic"
DAY_ICON = "weather-clear-symbolic"
NIGHT_ICON = "weather-clear-night-symbolic"
SEPARATOR = "SEPARATOR" SEPARATOR = "SEPARATOR"
+2 -2
View File
@@ -541,8 +541,8 @@ class Controller(GObject.GObject):
def update_status(self) -> None: def update_status(self) -> None:
self.mediator.preconnect.mark_finished() self.mediator.preconnect.mark_finished()
def get_steam_client_name(self, cmd: str) -> str: def get_steam_client_name(self) -> str:
return self.mediator.options.get_client_name(cmd) return self.mediator.options.get_client_name()
def present_window(self) -> None: def present_window(self) -> None:
self.mediator.window.present_with_time(Gdk.CURRENT_TIME) self.mediator.window.present_with_time(Gdk.CURRENT_TIME)
-1
View File
@@ -52,7 +52,6 @@
- Filter button tooltips - Filter button tooltips
- Expand/collapse changelog versions - Expand/collapse changelog versions
- Support local light/dark mode changes on system theme - Support local light/dark mode changes on system theme
- Server time calculator
### Changed ### Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
+2 -9
View File
@@ -4,24 +4,17 @@ from pathlib import Path
from dzgui.api.steam import get_app_path from dzgui.api.steam import get_app_path
from dzgui.config.query import lookup 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.const.enum import Preferences
from dzgui.strings.preconnect import resync
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
class VDFSyncError(Exception):
pass
def is_dayz_installed(config: Path) -> None: def is_dayz_installed(config: Path) -> None:
try: try:
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
get_app_path(Path(path), APPID_DAYZ) get_app_path(Path(path), APPID_DAYZ)
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
msg = resync.format(APPNAME_DAYZ) raise e
raise VDFSyncError(msg)
+4 -9
View File
@@ -16,15 +16,12 @@ logger = logging.getLogger(APP_NAME)
def is_steam_running(cmd: str) -> bool: def is_steam_running(cmd: str) -> bool:
is_flatpak_running = is_flatpak_steam_running() if cmd == STEAM_CMD:
if cmd in (FLATPAK_RUN_CMD, FLATPAK_SANDBOX):
return is_flatpak_running
elif cmd == STEAM_CMD:
if is_flatpak_running:
return False
if has_cmd(STEAM_CMD) is False: if has_cmd(STEAM_CMD) is False:
return False return False
return is_running(STEAM_CMD) return is_running(STEAM_CMD)
elif cmd == FLATPAK_RUN_CMD or FLATPAK_SANDBOX:
return is_flatpak_steam_running()
else: else:
raise TypeError("Not a valid Steam client selection") raise TypeError("Not a valid Steam client selection")
@@ -34,9 +31,7 @@ def is_steam_running(cmd: str) -> bool:
def is_flatpak_steam_running() -> bool: def is_flatpak_steam_running() -> bool:
if has_cmd(FLATPAK_CMD) is False: if has_cmd(FLATPAK_CMD) is False:
return False return False
proc = subprocess.check_output( proc = subprocess.check_output([FLATPAK_CMD, "ps"], text=True)
[FLATPAK_CMD, "ps", "--columns=application"], text=True
)
lines = proc.splitlines() lines = proc.splitlines()
if FLATPAK_APPID in lines: if FLATPAK_APPID in lines:
return True return True
+4 -15
View File
@@ -48,7 +48,7 @@ from dzgui.strings.dialogs import (
) )
from dzgui.strings import kb from dzgui.strings import kb
from dzgui.strings.server_mods import checkmark, resync 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.strings import dialog, server_timeout
from dzgui.util.symlink import rebuild_symlinks from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.generic import ExceptionDialog
@@ -184,8 +184,8 @@ class ConnectionManager:
else: else:
allows_downloads = (True, "") allows_downloads = (True, "")
client_name = self.controller.get_steam_client_name()
client = self.controller.query_config(Preferences.CLIENT) client = self.controller.query_config(Preferences.CLIENT)
client_name = self.controller.get_steam_client_name(client)
running = is_steam_running(client) running = is_steam_running(client)
steam_proc = SteamProcess(client_name, running) steam_proc = SteamProcess(client_name, running)
self.client = client self.client = client
@@ -300,11 +300,6 @@ class ConnectionManager:
dialog = ServerDetailsDialog(self.controller, details) dialog = ServerDetailsDialog(self.controller, details)
dialog.run() dialog.run()
def _workshop_timeout(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.show_all()
dialog.run()
def _server_timeout(self) -> None: def _server_timeout(self) -> None:
dialog = ExceptionDialog(self.controller, server_timeout) dialog = ExceptionDialog(self.controller, server_timeout)
dialog.set_details_buffer(kb.DZG_006) dialog.set_details_buffer(kb.DZG_006)
@@ -356,14 +351,8 @@ class ConnectionManager:
for title, mod, stamp, size in self.missing_mods: for title, mod, stamp, size in self.missing_mods:
if self.controller.is_cancel_pending(): if self.controller.is_cancel_pending():
return return
try: subscribe(key, int(mod))
subscribe(key, int(mod)) time.sleep(API_RATE_LIMIT)
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
self.thread_man.update_dialog(waiting_for_directories) self.thread_man.update_dialog(waiting_for_directories)
for title, mod, stamp, size in self.missing_mods: for title, mod, stamp, size in self.missing_mods:
+1 -5
View File
@@ -122,11 +122,7 @@ class ModManager:
def unsub_atomic_mod(self, mod: str) -> None: def unsub_atomic_mod(self, mod: str) -> None:
config_man = self.controller.get_config_man() config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.STEAM) key = config_man.lookup(Preferences.STEAM)
try: unsubscribe(key, int(mod))
unsubscribe(key, int(mod))
except Exception as e:
logger.critical(e)
return
steam_path = Path(self.path) steam_path = Path(self.path)
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
+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_popover = "This address is already in your Saved Servers"
add_tooltip = "Add to Saved Servers" add_tooltip="Add to Saved Servers"
connect_button = "Connect" connect_button="Connect"
connect_tooltip = "Connect to this server" connect_tooltip="Connect to this server"
connect_entry_placeholder = f"Enter IP:Query port (e.g., 192.168.1.1:{UDP_PORT})" connect_entry_placeholder="Enter IP:Query port (e.g., 192.168.1.1:27016)"
fav_heading = "Favorite server" fav_heading="Favorite server"
favs_empty = "None set. Right click a server and select 'Set favorite' to set." favs_empty="None set. Right click a server and select 'Set favorite' to set."
lan_heading = "LAN query port" lan_heading="LAN query port"
lan_default_button = f"Default port ({UDP_PORT})" lan_default_button="Default port (27016)"
lan_custom_button = "Custom port" lan_custom_button="Custom port"
lan_scan_button = "Scan" lan_scan_button="Scan"
lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" 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_checkbox = "Stop scanning on first hit"
lan_entry_placeholder = "Enter the query port (1-65535)" 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_entry_tooltip="Specify the port to search for DayZ servers on the local network"
lan_scan_tooltip = "Scan for servers" lan_scan_tooltip="Scan for servers"
-1
View File
@@ -4,4 +4,3 @@ api_validation_error = (
api_popover = "API key validation failed." api_popover = "API key validation failed."
dayz_not_found = "Could not find a valid DayZ installation." dayz_not_found = "Could not find a valid DayZ installation."
workshop_endpoint_error = "Steam Workshop service unavailable, try again later."
+2 -1
View File
@@ -11,6 +11,8 @@ mods = "Mods"
total_mods = "Total mods: " total_mods = "Total mods: "
up_to_date = "All mods are up to date." up_to_date = "All mods are up to date."
all_updated = "All mods updated." 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." required_space = " Need to download {0} MiB of mod updates."
placeholder_no_mods = "This server has no mods." 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." 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_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." version_missing = "Remote server is running the build '{0}', but it is not installed."
no_errors = "No errors."
# Warnings # Warnings
+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." not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall."
standalone_uninstall = ( standalone_uninstall = (
"It looks like you are running the standalone version of DZGUI.\n" "It looks like you are running the standalone version of DZGUI.\n"
"To finalize the uninstall process, invoke './dzgui self remove',\n" "To finalize the uninstall process, you can remove the 'dzgui' file."
"then remove the 'dzgui' file."
) )
system_uninstall = ( system_uninstall = (
"It looks like DZGUI was installed via your system package manager or directly from source code.\n" "It looks like DZGUI was installed via your system package manager or directly from source code.\n"
+5 -10
View File
@@ -2,12 +2,7 @@ import logging
from pathlib import Path from pathlib import Path
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
from dzgui.const.constants import ( from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT
APPID_DAYZ,
APPID_DAYZ_EXP,
APP_NAME,
DAYZ_COMMUNITY_ROOT,
)
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.config.query import lookup from dzgui.config.query import lookup
@@ -25,7 +20,6 @@ def expunge_link(file: Path) -> None:
if str(file.stem)[:2] == "@C": if str(file.stem)[:2] == "@C":
file.unlink() file.unlink()
def rebuild_symlinks(config: Path) -> None: def rebuild_symlinks(config: Path) -> None:
# TODO: pass direct path as argument # TODO: pass direct path as argument
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
@@ -69,9 +63,10 @@ def symlink_mission(steam_path: Path, target: str) -> str:
stem = path.name stem = path.name
parent = path.parent parent = path.parent
source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT) source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT)
if source.exists(): try:
source.unlink() source.symlink_to(parent)
source.symlink_to(parent) except Exception:
return ""
return f"{DAYZ_COMMUNITY_ROOT}/{stem}" return f"{DAYZ_COMMUNITY_ROOT}/{stem}"
+8 -1
View File
@@ -1,5 +1,6 @@
import logging import logging
import signal import signal
import warnings
from typing import TYPE_CHECKING, Literal, Self from typing import TYPE_CHECKING, Literal, Self
@@ -46,6 +47,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(APP_NAME) 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): class OuterWindow(Gtk.ApplicationWindow):
def __init__(self, app: Gtk.Application) -> None: def __init__(self, app: Gtk.Application) -> None:
@@ -339,6 +344,8 @@ try:
theme = Gtk.IconTheme.get_default() theme = Gtk.IconTheme.get_default()
icons = theme.list_icons(None) icons = theme.list_icons(None)
if STEAM_ICON not in icons: if STEAM_ICON not in icons:
logger.warning(strings.steam_icon_missing) logger.warn(strings.steam_icon_missing)
warnings.warn(strings.steam_icon_missing, stacklevel=2)
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
warnings.warn(strings.gtk_theme_missing, stacklevel=2)
-3
View File
@@ -9,6 +9,3 @@ class Icon(Gtk.Image):
super().__init__( super().__init__(
icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin 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)
+2 -9
View File
@@ -15,7 +15,7 @@ class ClientCombo(Gtk.ComboBox):
super().__init__() super().__init__()
self.client_store = Gtk.ListStore(str, str) self.client_store = Gtk.ListStore(str, str)
self.clients = ( clients = (
( (
options.steam_combo, options.steam_combo,
STEAM_CMD, STEAM_CMD,
@@ -29,7 +29,7 @@ class ClientCombo(Gtk.ComboBox):
FLATPAK_SANDBOX, FLATPAK_SANDBOX,
), ),
) )
for client in self.clients: for client in clients:
self.client_store.append(client) self.client_store.append(client)
self.set_model(self.client_store) self.set_model(self.client_store)
renderer_text = Gtk.CellRendererText() renderer_text = Gtk.CellRendererText()
@@ -37,13 +37,6 @@ class ClientCombo(Gtk.ComboBox):
self.add_attribute(renderer_text, "text", 0) self.add_attribute(renderer_text, "text", 0)
self.set_active(0) self.set_active(0)
def get_client_human_readable(self, cmd: str) -> str:
for client in self.clients:
human, command = client
if command == cmd:
return human
raise TypeError("Not a valid Steam client selection")
class ErrorPopover(Gtk.Popover): class ErrorPopover(Gtk.Popover):
def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None: def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None:
super().__init__(relative_to=relative_to, position=position) super().__init__(relative_to=relative_to, position=position)
+1 -1
View File
@@ -102,7 +102,7 @@ class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
margin_top=30, margin_top=30,
margin_bottom=30, margin_bottom=30,
) )
self.error_label = Gtk.Label(margin_start=15, margin_end=15) self.error_label = Gtk.Label()
# TODO: abstract class # TODO: abstract class
self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text()) self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text())
+115 -95
View File
@@ -3,19 +3,17 @@ from dataclasses import dataclass
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from typing import Literal 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.labels import BoldLabel
from dzgui.views.components.box import VBox, HBox from dzgui.strings import calc
from dzgui.views.components.box import VBox
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, GObject # type: ignore # noqa E402 from gi.repository import Gtk, GLib, GObject # type: ignore # noqa E402
DAY_START_TIME = time(hour=6, minute=0, second=0) DAY_START_TIME = time(hour=6, minute=0, second=0)
DAY_END_TIME = time(hour=18, minute=0, second=0) DAY_END_TIME = time(hour=17, minute=59, second=59)
NIGHT_START_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) NIGHT_END_TIME = time(hour=5, minute=59, second=59)
EXTENDED_TIME_FORMAT = "%H:%M:%S" EXTENDED_TIME_FORMAT = "%H:%M:%S"
@@ -46,10 +44,6 @@ class Emitter(GObject.GObject):
def target_time_changed(self, time: timedelta) -> None: def target_time_changed(self, time: timedelta) -> None:
pass 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,)) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def remaining_time_changed(self, time: datetime) -> None: def remaining_time_changed(self, time: datetime) -> None:
pass pass
@@ -63,7 +57,6 @@ class LocalClock:
def __init__(self, emitter: Emitter) -> None: def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter self.emitter = emitter
self.start_time = datetime.now() self.start_time = datetime.now()
# NOTE: highest theoretical accel interval is 24 * 64
GLib.timeout_add(500, self.check_time) GLib.timeout_add(500, self.check_time)
def get_time(self) -> str: def get_time(self) -> str:
@@ -94,80 +87,131 @@ class ServerClock:
self.start_time = datetime.combine(datetime.today(), time(hour, minute)) self.start_time = datetime.combine(datetime.today(), time(hour, minute))
self.cur_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() # NOTE: at highest possible accel interval (64 * 64), real time is 0.876 seconds
self.emitter.connect("local_time_incremented", self._on_local_time_incremented) if self.is_night():
GLib.timeout_add(500, self.increment_night)
else:
GLib.timeout_add(500, self.increment_day)
@staticmethod @staticmethod
def split_time(server_time: Time) -> tuple[int, int]: def split_time(server_time: Time) -> tuple[int, int]:
split = server_time.time.split(":") split = server_time.time.split(":")
return int(split[0]), int(split[1]) return int(split[0]), int(split[1])
def is_day(self) -> bool: def _increment_with_offset(self, offset: float) -> None:
if DAY_START_TIME <= self.cur_time.time() <= DAY_END_TIME: elapsed = self.local_clock.get_elapsed_time()
return True virtual_elapsed = elapsed * offset
return False self.cur_time = self.start_time + virtual_elapsed
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) self.emitter.emit("server_time_incremented", self.cur_time)
def increment_day(self) -> bool:
if self.is_night():
GLib.timeout_add(500, self.increment_night)
return False
offset = self.day_accel
self._increment_with_offset(offset)
return True return True
def get_accel_factor(self, dt: datetime) -> float: def increment_night(self) -> bool:
if not self.is_night():
GLib.timeout_add(500, self.increment_day)
return False
offset = self.night_accel * self.day_accel
self._increment_with_offset(offset)
return True
def is_night(self) -> bool:
return ( return (
self.day_accel self.cur_time.time() >= NIGHT_START_TIME
if DAY_START_TIME <= dt.time() < DAY_END_TIME or self.cur_time.time() <= NIGHT_END_TIME
else (self.night_accel * self.day_accel)
) )
def get_values(self) -> tuple[int, int]: def get_values(self) -> tuple[int, int]:
return self.cur_time.hour, self.cur_time.minute return self.cur_time.hour, self.cur_time.minute
def get_datetime(self) -> datetime:
return self.cur_time
def get_time(self) -> time: def get_time(self) -> time:
return self.cur_time.time() return self.cur_time.time()
def calc_delta(self, end_time: datetime) -> timedelta: def get_cycle_remainder(self, accel: bool = True) -> float:
total = 0.0 # NOTE: calc from cycle start time to current point
current_time = self.cur_time if self.is_night():
cutoff = DAY_START_TIME
offset = self.day_accel * self.night_accel
else:
cutoff = NIGHT_START_TIME
offset = self.day_accel
start = datetime.combine(self.cur_time, cutoff)
if current_time > end_time: if accel is False:
end_time += timedelta(days=1) offset = 1
return (start - self.cur_time).total_seconds() / offset
while current_time < end_time: def get_full_cycle(self, end_time: time, start_time: time) -> float:
factor = self.get_accel_factor(current_time) end = datetime.combine(self.cur_time, end_time)
day_start = datetime.combine(current_time.date(), DAY_START_TIME) start = datetime.combine(self.cur_time, start_time)
day_end = datetime.combine(current_time.date(), DAY_END_TIME) if start > end:
end += timedelta(days=1)
return (end - start).total_seconds()
if current_time.time() < DAY_START_TIME: def get_day_cycle(self) -> float:
edge = day_start return self.get_full_cycle(DAY_END_TIME, DAY_START_TIME)
elif current_time.time() < DAY_END_TIME:
edge = day_end def get_night_cycle(self) -> float:
return self.get_full_cycle(NIGHT_END_TIME, NIGHT_START_TIME)
def get_delta(self, picker_time: time, start_time: time) -> float:
start = datetime.combine(self.cur_time, start_time)
picker_date = datetime.combine(self.cur_time, picker_time)
if start > picker_date:
picker_date += timedelta(days=1)
delta = (picker_date - start).total_seconds()
return delta
def get_next_cycle(self) -> float:
if self.is_night():
return self.get_day_cycle() / self.day_accel
else:
return self.get_night_cycle() / (self.day_accel * self.night_accel)
def get_remaining_delta(self, picker_time: time, is_night: bool) -> float:
if is_night:
start_time = NIGHT_START_TIME
offset = self.day_accel * self.night_accel
else:
start_time = DAY_START_TIME
offset = self.day_accel
return self.get_delta(picker_time, start_time) / offset
def calc_delta(self, picker_time: time, is_night: bool) -> timedelta:
# NOTE: time spans two full cycles
if self.get_time() > picker_time:
to_end = self.get_cycle_remainder()
next_cycle = self.get_next_cycle()
delta = self.get_remaining_delta(picker_time, is_night)
total = to_end + next_cycle + delta
else:
picker_date = datetime.combine(self.cur_time, picker_time)
# NOTE: strip sub-minute values
new = self.cur_time.replace(second=0, microsecond=0)
delta = (picker_date - new).total_seconds()
remainder_till_cycle_end = self.get_cycle_remainder(accel=False)
if delta <= remainder_till_cycle_end:
if self.is_night():
total = delta / (self.day_accel * self.night_accel)
else:
total = delta / self.day_accel
else: else:
edge = datetime.combine( total = self.get_remaining_delta(picker_time, is_night)
current_time.date() + timedelta(days=1), DAY_START_TIME
)
chunk_end = min(end_time, edge) total = round(total)
total += (chunk_end - current_time).total_seconds() / factor td = timedelta(seconds=total)
current_time = chunk_end return td
return timedelta(seconds=total)
class RemainderClock: class RemainderClock:
def __init__(self, emitter: Emitter) -> None: def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter self.emitter = emitter
self.emitter.connect("target_time_changed", self._on_target_time_changed) 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.emitter.connect("local_time_incremented", self._on_local_time_incremented)
self.reset_time(True) self.reset_time(True)
@@ -194,14 +238,8 @@ class RemainderClock:
self.previous = elapsed self.previous = elapsed
self.emitter.emit("remaining_time_changed", self.time) 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: def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None:
self.reset_time(True) self.reset_time()
self.time += time self.time += time
self.emitter.emit("remaining_time_changed", self.time) self.emitter.emit("remaining_time_changed", self.time)
@@ -283,29 +321,23 @@ class TimePicker(Gtk.Box):
def get_minute(self) -> int: def get_minute(self) -> int:
return int(self.minute_spin.get_adjustment().get_value()) return int(self.minute_spin.get_adjustment().get_value())
def get_time(self) -> datetime: def get_time(self) -> time:
"""
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() d = datetime.now()
return datetime.combine( return time(
d, hour=self.get_hour(),
time( minute=self.get_minute(),
hour=self.get_hour(), second=d.second,
minute=self.get_minute(), microsecond=d.microsecond,
second=0,
microsecond=0,
),
) )
def set_time(self, hour: int, minute: int) -> None: def set_time(self, hour: int, minute: int) -> None:
self.hour_spin.get_adjustment().set_value(hour) self.hour_spin.get_adjustment().set_value(hour)
self.minute_spin.get_adjustment().set_value(minute) self.minute_spin.get_adjustment().set_value(minute)
def is_night(self) -> bool:
t = self.get_time()
return t >= NIGHT_START_TIME or t <= NIGHT_END_TIME
class AdjustedTimeFrame(Gtk.Frame): class AdjustedTimeFrame(Gtk.Frame):
def __init__(self, emitter: Emitter) -> None: def __init__(self, emitter: Emitter) -> None:
@@ -385,13 +417,6 @@ class TimePickerFrame(Gtk.Frame):
h1 = BoldLabel(calc.heading_server_time) h1 = BoldLabel(calc.heading_server_time)
h2 = BoldLabel(calc.heading_target_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( flowbox = Gtk.FlowBox(
row_spacing=10, row_spacing=10,
column_spacing=50, column_spacing=50,
@@ -405,19 +430,14 @@ class TimePickerFrame(Gtk.Frame):
flowbox.add(h1) flowbox.add(h1)
flowbox.add(h2) flowbox.add(h2)
flowbox.add(self.local_timelabel) flowbox.add(self.local_timelabel)
flowbox.add(server_time_box) flowbox.add(self.server_timelabel)
flowbox.add(self.picker) flowbox.add(self.picker)
self.add(flowbox) 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: def _on_server_clock_increment(self, emitter: Emitter, time_now: datetime) -> None:
t = time_now.time().strftime("%H:%M") t = time_now.time().strftime("%H:%M")
self.server_timelabel.set_text(t) self.server_timelabel.set_text(t)
self.update_server_icon()
def _on_local_clock_increment( def _on_local_clock_increment(
self, emitter: Emitter, time_now: datetime, elapsed: timedelta self, emitter: Emitter, time_now: datetime, elapsed: timedelta
@@ -428,11 +448,11 @@ class TimePickerFrame(Gtk.Frame):
def _on_calculate_button_clicked(self, button: Gtk.Button) -> None: def _on_calculate_button_clicked(self, button: Gtk.Button) -> None:
if (self.picker.get_values()) == (self.server_time.get_values()): if (self.picker.get_values()) == (self.server_time.get_values()):
total_time = timedelta(seconds=0) total_time = timedelta(seconds=0)
self.emitter.emit("target_time_reset", total_time)
else: else:
picker_time = self.picker.get_time() picker_time = self.picker.get_time()
total_time = self.server_time.calc_delta(picker_time) is_night = self.picker.is_night()
self.emitter.emit("target_time_changed", total_time) total_time = self.server_time.calc_delta(picker_time, is_night)
self.emitter.emit("target_time_changed", total_time)
class ServerTimeCalculator(Gtk.ScrolledWindow): class ServerTimeCalculator(Gtk.ScrolledWindow):
+4 -2
View File
@@ -371,8 +371,10 @@ class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
self.connect("key-press-event", self._on_keypress) self.connect("key-press-event", self._on_keypress)
def get_client_name(self, cmd: str) -> str: def get_client_name(self) -> str:
return self.client_combo.get_client_human_readable(cmd) model = self.client_combo.get_model()
ind = self.client_combo.get_active()
return str(model[ind][0])
def block_text_entry(self) -> None: def block_text_entry(self) -> None:
self.steam_box.block_text_entry() self.steam_box.block_text_entry()
+2 -1
View File
@@ -176,7 +176,8 @@ class PreConnectionAssistant(Gtk.Box):
self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.error_tree = MaskedTree(ERROR) 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_tree)
self.error_box.add(self.error_placeholder) self.error_box.add(self.error_placeholder)
self.error_frame = HeadingFrame.new_with_widget_and_label( self.error_frame = HeadingFrame.new_with_widget_and_label(
+1
View File
@@ -252,6 +252,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
case Gdk.KEY_c: case Gdk.KEY_c:
self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self) self.controller.menu_action(ContextMenu.COPY_SERVER_IP, self)
case Gdk.KEY_r: case Gdk.KEY_r:
# TODO: unimplemented, needs threading
self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self) self.controller.menu_action(ContextMenu.REFRESH_PLAYERS, self)
else: else:
match event.keyval: match event.keyval:
+1 -2
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [ authors = [
{name = "aclist"} {name = "aclist"}
] ]
version = "7.0.0b26" version = "7.0.0b23"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
license-files = ["LICENSE"] license-files = ["LICENSE"]
readme = "README.md" readme = "README.md"
@@ -47,7 +47,6 @@ docs = [
] ]
test = [ test = [
"black==25.1.0", "black==25.1.0",
"hypothesis==6.167.1",
"pre-commit==4.4.0", "pre-commit==4.4.0",
"pytest==8.4.2", "pytest==8.4.2",
"pytest-cov==7.0.0", "pytest-cov==7.0.0",
+3 -8
View File
@@ -22,14 +22,9 @@ class MockController:
def test_html_escaping() -> None: def test_html_escaping() -> None:
name = "Test server" text = "<Server description> Game & mods"
desc = "<Server description> Game & mods" details = Details([["0", "1"]], text, True)
gametime = "05:30"
day_accel = 5.0
night_accel = 1.0
details = Details([["0", "1"]], name, desc, gametime, day_accel, night_accel)
controller = MockController() controller = MockController()
dialog = ServerDetailsDialog(controller, details) dialog = ServerDetailsDialog(controller, details)
dialog.destroy()
assert dialog.description.get_text() == desc assert dialog.description.get_text() == text
-61
View File
@@ -1,61 +0,0 @@
import unittest
from unittest.mock import patch
from dzgui.const.constants import (
FLATPAK_APPID,
FLATPAK_RUN_CMD,
FLATPAK_SANDBOX,
STEAM_CMD,
)
from dzgui.init import proc
class TestSteamDetection(unittest.TestCase):
def test_flatpak_detection_with_multiple_running_apps(self):
def flatpak_output(args, *, text):
# Default `flatpak ps` includes instance, PID, app and runtime columns.
if args == ["flatpak", "ps", "--columns=application"]:
return f"com.spotify.Client\n{FLATPAK_APPID}\n{FLATPAK_APPID}\n"
return f"3904921611\t1257689\t{FLATPAK_APPID}\torg.freedesktop.Platform\n"
with (
patch.object(proc, "has_cmd", return_value=True),
patch.object(proc.subprocess, "check_output", side_effect=flatpak_output),
):
for client in (FLATPAK_RUN_CMD, FLATPAK_SANDBOX):
with self.subTest(client=client):
self.assertTrue(proc.is_steam_running(client))
def test_flatpak_not_running(self):
with patch.object(proc, "has_cmd", return_value=True):
for output in ("", "com.spotify.Client\n", f"{FLATPAK_APPID}Helper\n"):
with (
self.subTest(output=output),
patch.object(proc.subprocess, "check_output", return_value=output),
):
self.assertFalse(proc.is_flatpak_steam_running())
def test_flatpak_not_installed(self):
with (
patch.object(proc, "has_cmd", return_value=False),
patch.object(proc.subprocess, "check_output") as check_output,
):
self.assertFalse(proc.is_flatpak_steam_running())
check_output.assert_not_called()
def test_native_steam(self):
with patch.object(proc, "has_cmd", return_value=True):
for running in (True, False):
with (
self.subTest(running=running),
patch.object(proc, "is_running", return_value=running),
):
self.assertEqual(proc.is_steam_running(STEAM_CMD), running)
def test_native_steam_while_flatpak_is_running(self):
with patch.object(proc, "is_flatpak_steam_running", return_value=True):
self.assertEqual(proc.is_steam_running(STEAM_CMD), False)
def test_invalid_client(self):
with self.assertRaises(TypeError):
proc.is_steam_running("invalid-client")
-112
View File
@@ -1,112 +0,0 @@
import pytest
from hypothesis import given, strategies as st
from datetime import datetime, timedelta
from dzgui.views.dialogs.calc import (
LocalClock,
ServerClock,
Time,
TimePicker,
DAY_START_TIME,
DAY_END_TIME,
)
class DummyEmitter:
def __init__(self) -> None:
pass
def connect(self, _, __) -> None:
pass
@pytest.fixture(params=[("05:00", 10.0), ("15:30", 5.0)])
def accel_server_clock(request) -> None:
clock_time, expect = request.param
cur_time = Time(clock_time, 5.0, 2.0)
emitter = DummyEmitter()
local_clock = LocalClock(emitter)
server_clock = ServerClock(cur_time, emitter, local_clock)
return expect, server_clock
def test_accel_factor(accel_server_clock) -> None:
"""Static test checking times at known boundaries"""
expect, clock = accel_server_clock
assert clock.get_accel_factor(clock.cur_time) == expect
def reference_calc(start, end, day_factor, night_factor):
"""
Reference implementation to test validity of UI-side calculator
Aggregates total seconds and applies accel factors after
"""
day_start = datetime.combine(start.date(), DAY_START_TIME)
day_end = datetime.combine(start.date(), DAY_END_TIME)
night_factor = day_factor * night_factor
if end < start:
end += timedelta(days=1)
day_max = max(start, day_start)
day_min = min(end, day_end)
next_day_start = datetime.combine(end.date(), DAY_START_TIME)
next_day_end = datetime.combine(end.date(), DAY_END_TIME)
range1 = (day_end - day_max).total_seconds()
range2 = (min(end, next_day_end) - next_day_start).total_seconds()
if end.date() > start.date():
day_seconds = max(0, range1) + max(0, range2)
else:
day_seconds = max(0, (day_min - day_max).total_seconds())
total_seconds = (end - start).total_seconds()
night_seconds = (total_seconds - day_seconds) / night_factor
day_seconds = day_seconds / day_factor
total = night_seconds + day_seconds
return timedelta(seconds=total)
day_accel = st.floats(
min_value=1.0, max_value=24.0, allow_nan=False, allow_infinity=False
)
night_accel = st.floats(
min_value=1.0, max_value=64.0, allow_nan=False, allow_infinity=False
)
@st.composite
def time_ranges(draw):
start_time = draw(st.times())
deltas = st.timedeltas(min_value=timedelta(0), max_value=timedelta(days=1))
duration = draw(deltas)
start = datetime.combine(datetime.now(), start_time)
end = start + duration
return start, end
@given(span=time_ranges(), day_factor=day_accel, night_factor=night_accel)
def test_time_aggregation(span, day_factor, night_factor) -> None:
start, end = span
strtime = start.strftime("%H:%M")
t = Time(strtime, day_factor, night_factor)
emitter = DummyEmitter()
local_clock = LocalClock(emitter)
server_clock = ServerClock(t, emitter, local_clock)
h = end.time().hour
m = end.time().minute
picker = TimePicker(h, m)
# NOTE: truncates microseconds to mimic widget HH:MM selection behavior
start = server_clock.get_datetime()
end = picker.get_time()
actual = server_clock.calc_delta(end)
expect = reference_calc(start, end, day_factor, night_factor)
assert actual == expect
+3
View File
@@ -1,6 +1,9 @@
import re import re
from dzgui.model.proxy_model import DAY_REG, NIGHT_REG from dzgui.model.proxy_model import DAY_REG, NIGHT_REG
import pytest
pytestmark = pytest.mark.FOO
def iterate(h: str, r: str) -> None: def iterate(h: str, r: str) -> None:
for m in range(60): for m in range(60):