Merge pull request #319 from aclist/feat/test-writeable
Some checks failed
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled

feat: disallow updates if binary path is not writeable
This commit is contained in:
aclist 2026-05-29 01:09:25 +09:00 committed by GitHub
commit dac02d75f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 184 additions and 50 deletions

View File

@ -36,6 +36,7 @@
- Preconnect warnings/failsafes like filesize
- Save filters per server context between sessions
- Preboot progress dialog
- Choose to jump into splash screen instead of server
## Changed
@ -52,6 +53,7 @@
- Embed Workshop link in Options menu
- Disable overlay scrollbars on server tables
- Reduce size of geolocation DB on disk (~100MB)
- Enable LAN page Empty/Full filters on first run of app
## Dropped
- Debug mode
@ -67,7 +69,6 @@
## Unreleased
- Load offline mods
- Choose to jump into splash screen instead of server
- Local documentation
- Raw debug command in context menu

View File

@ -456,6 +456,5 @@ def get_pretty_version(steam_path: Path, appid: int) -> str | None:
vers = get_dayz_version(pe_file_path)
dayz_version = dayz_version_to_str(vers)
return dayz_version
except Exception as e:
print(e)
except Exception:
return None

View File

@ -61,6 +61,8 @@ class Details:
@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
"""
@ -509,6 +511,10 @@ def fqip_to_record(addr: str) -> Optional[Record]:
return Record(r[0], int(r[1]), int(r[2]))
def record_to_fqip(record: Record) -> str:
return f"{record.ip}:{record.gameport}:{record.qport}"
def response_to_fqip(res: dict) -> str:
ip = res["addr"].split(":")[0]
gameport = res["gameport"]

View File

@ -146,6 +146,23 @@ def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> i
return proc.returncode
def load_to_menu(client: str, addr: 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)
params = [
"-applaunch",
str(appid),
"-nolauncher",
"-nosplash",
"-skipintro",
f"-name={name}",
f"-mod={concat}",
]
proc = subprocess.run([*client_args, *params])
return proc.returncode
def find_user_id(path: Path) -> str | None:
resolved_path = path / "config" / "loginusers.vdf"
try:

View File

@ -409,9 +409,12 @@ class Controller(GObject.GObject):
saved_tree = self.get_servers().get_saved()
ServerModelManager(self, saved_tree).remove_by_record(record)
def add_to_history(self, record: dict[str, Any]) -> None:
def add_to_history(self, row: dict[str, Any], record: "Record") -> None:
tv = self.get_servers().get_recent()
ServerModelManager(self, tv).add_to_history(record)
ServerModelManager(self, tv).add_to_history((row, record))
# def append_to_history(self, record: "Record") -> None:
# self.config_man.append_to_history_file(record)
def remove_from_history(self, record: "Record") -> None:
# NOTE: remove action is only possible from history tree context menu,
@ -508,6 +511,9 @@ class Controller(GObject.GObject):
ind = self.config_man.get_start_tab()
self.get_servers().notebook.set_current_page(ind)
def update_and_load_to_menu(self, raise_window: bool) -> None:
self.connection_man.update_and_connect(raise_window, menu_only=True)
def update_and_connect(self, raise_window: bool) -> None:
self.connection_man.update_and_connect(raise_window)

View File

@ -36,6 +36,7 @@
- Preconnect warnings/failsafes like filesize
- Save filters per server context between sessions
- Preboot progress dialog
- Choose to jump into splash screen instead of server
## Changed
@ -51,6 +52,8 @@
- Suppress log messages from imported modules
- Embed Workshop link in Options menu
- Disable overlay scrollbars on server tables
- Reduce size of geolocation DB on disk (~100MB)
- Enable LAN page Empty/Full filters on first run of app
## Dropped
- Debug mode
@ -66,7 +69,6 @@
## Unreleased
- Load offline mods
- Choose to jump into splash screen instead of server
- Local documentation
- Raw debug command in context menu

View File

@ -1,6 +1,7 @@
import logging
import traceback
from collections import deque
from typing import Any, TYPE_CHECKING
from dzgui.const.constants import (
@ -52,11 +53,25 @@ class ConfigManager:
ips.append(record)
self.update_config(Preferences.IP_LIST, ips)
def update_history_file(self, records: list[Any]) -> None:
def update_history_file(self, fqip: str) -> None:
with open(self.prefs.paths.history, "r") as f:
ips = [line.rstrip() for line in f.readlines()]
seen = set()
ips.append(fqip)
# NOTE: Preserve linear order of records while deduplicating
unique = deque(maxlen=10) # type: ignore
for i in range(len(ips) - 1, -1, -1):
if ips[i] not in seen:
seen.add(ips[i])
unique.appendleft(ips[i])
# NOTE: appendleft will push 11th item off right edge
if len(unique) == 10:
break
with open(self.prefs.paths.history, "w") as f:
for record in records:
addr = f"{record[7]}:{record[8]}"
f.write(f"{addr}\n")
for record in unique:
f.write(f"{record}\n")
def remove_saved_server(self, record: str) -> None:
ips = self.lookup(Preferences.IP_LIST)

View File

@ -15,6 +15,7 @@ from dzgui.api.steam import (
enqueue_mod,
get_remote_signatures,
get_needs_update,
load_to_menu,
)
from dzgui.api.mods import (
@ -74,6 +75,7 @@ class Prerequisites:
steam_proc: SteamProcess
mods: list[list[str]]
game_mode: bool
is_last_server: bool
class ConnectionManager:
@ -172,6 +174,8 @@ class ConnectionManager:
game_mode = prefs.is_game_mode
is_last = self.is_last_server()
prereqs = Prerequisites(
name=info.server_name,
appid=info.game_id,
@ -186,11 +190,23 @@ class ConnectionManager:
steam_proc=steam_proc,
mods=remote_mods,
game_mode=game_mode,
is_last_server=is_last,
)
func = StoredFunc(self.controller.open_connection_assistant, prereqs)
self.thread_man.set_cleanup_func(func, destroy_first=True)
def is_last_server(self) -> bool:
prefs = self.controller.get_prefs()
history = prefs.paths.history
try:
lines = history.read_text().splitlines()
last = lines[-1]
current = Servers.record_to_fqip(self.record)
return last == current
except Exception:
return False
@call_on_thread(dialog.querying)
def query_details(self, record: Servers.Record) -> None:
details = Servers.get_details(record)
@ -245,11 +261,14 @@ class ConnectionManager:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.run()
def _connect_steam(self) -> None:
def _connect_steam(self, menu_only: bool) -> None:
addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME)
client = self.controller.query_config(Preferences.CLIENT)
rc = connect(client, addr, self.appid, playername, self.remote_mod_ids)
if menu_only:
rc = load_to_menu(client, addr, self.appid, playername, self.remote_mod_ids)
else:
rc = connect(client, addr, self.appid, playername, self.remote_mod_ids)
if rc != 0:
# TODO: log/pop the error
func = StoredFunc(self.controller.update_status)
@ -271,10 +290,10 @@ class ConnectionManager:
self.thread_man.set_cleanup_func(func)
def _add_to_history_and_return(self) -> None:
self.controller.add_to_history(self.history)
self.controller.add_to_history(self.history, self.record)
self.controller.open_page(NotebookPage.SERVERS)
def _update_mods(self, raise_window: bool) -> None:
def _update_mods(self, raise_window: bool, menu_only: bool = False) -> None:
# NOTE: fast enqueue all mods in auto mode
prefs = self.controller.get_prefs()
@ -312,11 +331,11 @@ class ConnectionManager:
update_signatures(self.missing_mods, prefs.paths.version)
# TODO: just push steam path directly
rebuild_symlinks(prefs.paths.config)
self._connect_steam()
self._connect_steam(menu_only)
@call_on_thread(waiting_for_mods, show_cancel=True)
def update_and_connect(self, raise_window: bool) -> None:
def update_and_connect(self, raise_window: bool, menu_only: bool = False) -> None:
if len(self.missing_mods) > 0:
self._update_mods(raise_window)
self._update_mods(raise_window, menu_only)
else:
self._connect_steam()
self._connect_steam(menu_only)

View File

@ -9,7 +9,7 @@ if TYPE_CHECKING:
class FilterManager:
def __init__(self) -> None:
def __init__(self, show_empty: bool = False, show_full: bool = False) -> None:
self.map_store = ModelFactory().make_map_store()
@ -17,10 +17,10 @@ class FilterManager:
self.default_filters = {
strings.filter_1pp: True,
strings.filter_day: True,
strings.filter_empty: False,
strings.filter_empty: show_empty,
strings.filter_3pp: True,
strings.filter_night: True,
strings.filter_full: False,
strings.filter_full: show_full,
strings.filter_lowpop: True,
strings.filter_nonascii: False,
strings.filter_duplicate: False,

View File

@ -85,8 +85,9 @@ class ProxyModelManager:
if found is False:
self.control_model.append(history)
if len(self.control_model) == 11:
del self.control_model[0]
# if len(self.control_model) == 11:
# del self.control_model[0]
self.filter(FilterMode.INITIAL, skip_cache=True)

View File

@ -14,6 +14,7 @@ from dzgui.const.constants import (
)
from dzgui.const.enum import FilterMode, Preferences, ServerTab
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import dialogs
from dzgui.util.strings import api_warn_msg, dialog
from dzgui.views.dialogs.generic import ExceptionDialog
@ -23,7 +24,7 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo
from dzgui.api.servers import A2SInfo, Record
from dzgui.controllers.mc import Controller
from dzgui.model.proxy_model import ProxyModelManager
from dzgui.views.trees.tree_servers import ServerTreeView
@ -130,7 +131,7 @@ class ServerModelManager:
servers = []
ports = range(1, 256)
failure_func = StoredFunc(self._cleanup_on_failure)
failure_func = StoredFunc(self._cleanup_on_lan_failure)
event = threading.Event()
with ThreadPoolExecutor() as executor:
@ -225,15 +226,12 @@ class ServerModelManager:
) = None,
) -> None:
proxy_man = self._get_proxy_man()
config_man = self.controller.get_config_man()
if rows is not None:
records = rows
else:
control_model = proxy_man.get_control()
records = control_model
config_man.update_history_file(records)
config_man.update_history_file(records)
self._sort_unique_maps(records)
proxy = proxy_man.get_proxy_model()
self.tv.set_model(proxy)
@ -249,15 +247,20 @@ class ServerModelManager:
self.emitter.emit("load_maps", store)
# self.emitter.emit("servers_loaded_init")
def add_to_history(self, record: dict[str, Any]) -> None:
# TODO: dataclass for record rows; check for other dict annotations
def add_to_history(self, data: tuple[dict[str, Any], "Record"]) -> None:
row, record = data
proxy_man = self._get_proxy_man()
rows = Servers.parse_json([record])
rows = Servers.parse_json([row])
try:
proxy_man.append_row_to_history(rows[0])
self.update_history()
except Exception:
self.update_history(rows)
return
self.update_history()
fqip = Servers.record_to_fqip(record)
config_man = self.controller.get_config_man()
config_man.update_history_file(fqip)
def remove_from_history(self, record: Servers.Record) -> None:
proxy_man = self._get_proxy_man()
@ -403,6 +406,16 @@ class ServerModelManager:
if self.first_iteration:
self._update_maps()
def _cleanup_on_lan_failure(self, show_dialog: bool = True) -> None:
if self.preserve_on_fail is False:
self.tv.set_model(None)
filter_man = self.tv.get_filter_man()
filter_man.set_unique_maps([])
if show_dialog:
dialog = ExceptionDialog(self.controller, dialogs.load_error_lan)
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)

View File

@ -4,3 +4,5 @@ waiting_for_mods = "Waiting for Steam to update mods"
fetching_update = "Fetching update"
failed_to_update = "Failed to update DZGUI executable"
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."

View File

@ -1,5 +1,9 @@
update_mods = "Update mods and connect"
connect = "Connect"
connect_last = "Load to menu screen"
connect_last_tooltip = (
"Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting."
)
back = "Back"
cancel = "Cancel"
warnings = "Warnings"

15
dzgui/util/file.py Normal file
View File

@ -0,0 +1,15 @@
from datetime import datetime
from pathlib import Path
def is_writeable(path: "Path") -> bool:
now = int(datetime.now().timestamp())
suffix = ".dzgtmp"
file = str(now) + suffix
filepath = Path(str(path) + "_" + file)
try:
filepath.touch()
filepath.unlink()
return True
except OSError:
return False

View File

@ -611,6 +611,7 @@ atomic_buttons = AtomicButton(
keys_tooltip="Toggles the keybindings dialog",
)
gtk_theme_missing = "No GTK theme provided"
steam_icon_missing = "Steam icon not found in IconTheme"
missing_changelog = "Error: failed to read changelog"

View File

@ -323,8 +323,12 @@ class App(Gtk.Application):
MainController = Controller()
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)
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)

View File

@ -1,6 +1,7 @@
import logging
import os
from pathlib import Path
from typing import Literal, TYPE_CHECKING
from dzgui.const.constants import (
@ -13,6 +14,7 @@ from dzgui.const.constants import (
from dzgui.const.enum import ServerTab
from dzgui.managers.update import UpdateManager
from dzgui.util.clip import copy_clipboard
from dzgui.util.file import is_writeable
from dzgui.views.components.buttonbox import ButtonBox
from dzgui.views.components.filter_panel import FilterPanel
from dzgui.views.components.mod_panel import ModSelectionPanel
@ -87,11 +89,12 @@ class RightPanel(Gtk.Box):
)
exe_path = os.getenv("PYAPP")
if exe_path and update is not None:
self.update_button.set_halign(Gtk.Align.END)
self.gutter_box.add(self.update_button)
self.update_button.connect(
"clicked", self._on_update_button_clicked, exe_path, update
)
if is_writeable(Path(exe_path)) is True:
self.update_button.set_halign(Gtk.Align.END)
self.gutter_box.add(self.update_button)
self.update_button.connect(
"clicked", self._on_update_button_clicked, exe_path, update
)
self.gutter_box.add(eb)
self.pack_start(self.gutter_box, NO_EXPAND, FILL, NO_PADDING)
@ -108,7 +111,7 @@ class RightPanel(Gtk.Box):
return
self.filters_vbox.set_sensitive(False)
# TODO: unless it is lan page
#self.refresh_button.set_sensitive(False)
# self.refresh_button.set_sensitive(False)
def _on_lan_page_init(self, emitter: "Emitter") -> None:
self.filters_vbox.set_sensitive(False)

View File

@ -106,6 +106,9 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
label=preconnect.back, halign=Gtk.Align.END, hexpand=True
)
self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END)
self.connect_last = Gtk.Button(
label=preconnect.connect_last, halign=Gtk.Align.END, tooltip_text=preconnect.connect_last_tooltip
)
# TODO: abstract
self.raise_window = Gtk.CheckButton(
@ -126,13 +129,14 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
spacing=5,
)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
for button in self.back, self.ok:
for button in self.back, self.ok, self.connect_last:
box.add(button)
for el in self.raise_window, box:
self.button_box.add(el)
self.back.connect("clicked", self._on_back_clicked)
self.ok.connect("clicked", self._on_ok_clicked)
self.connect_last.connect("clicked", self._on_connect_last_clicked)
self.title = Gtk.Label(label="")
add_class(self.title, "preconnect-heading")
@ -213,6 +217,9 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
if event.keyval == Gdk.KEY_u:
self.ok.emit("clicked")
def _on_connect_last_clicked(self, button: Gtk.Button) -> None:
self.controller.update_and_load_to_menu(self.raise_window.get_active())
def _on_ok_clicked(self, button: Gtk.Button) -> None:
self.controller.update_and_connect(self.raise_window.get_active())
@ -308,6 +315,11 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
prefix = preconnect.total_mods
self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}")
if prereqs.is_last_server:
self.connect_last.show()
else:
self.connect_last.hide()
self._process_warnings(prereqs)
if self.tree.is_visible():
self.tree.grab_focus()

View File

@ -47,7 +47,13 @@ class ServerNotebook(Gtk.ScrolledWindow):
self.recent = ServerTreeView(
controller, ServerTab.RECENT, ContextMenuGroup.RECENT
)
self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN)
self.lan = ServerTreeView(
controller,
ServerTab.LAN,
ContextMenuGroup.SCAN_LAN,
show_empty=True,
show_full=True,
)
tabs = [
(self.browser, server_labels.browser),

View File

@ -38,7 +38,12 @@ QUEUE_CHECK_DELAY = 200
class ServerTreeView(ContextMixin, TreeView): # type: ignore
def __init__(
self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup
self,
controller: "Controller",
enum: ServerTab,
menu: ContextMenuGroup,
show_empty: bool = False,
show_full: bool = False,
) -> None:
super().__init__(controller, menu=menu)
@ -48,7 +53,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
self.loaded = False
self.filter_man = FilterManager()
self.filter_man = FilterManager(show_empty, show_full)
self.proxy_man = ProxyModelManager(self.filter_man)
model = self.proxy_man.get_proxy_model()
self.set_model(model)

View File

@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [
{name = "aclist"}
]
version = "7.0.0b7"
version = "7.0.0b8"
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
readme = "README.md"

View File

@ -53,7 +53,8 @@ def untar(file: str, glob: str, renamed: str) -> None:
def install_deps() -> None:
pip = build_dir.joinpath(f"{CPYTHON_BUILD}/bin/pip")
subprocess.run([pip, "install", root])
proc = subprocess.run([pip, "install", root])
return proc.returncode
def repackage() -> None:
@ -66,7 +67,9 @@ def repackage() -> None:
def rebuild_cpython() -> None:
if cpython_dir.is_dir() is False:
get_cpython()
install_deps()
code = install_deps()
if code != 0:
return
repackage()
return get_packaged_version()