feat: preconnect dialog warnings (WIP)

This commit is contained in:
aclist 2026-05-07 02:47:42 +09:00
parent 7523bffbd8
commit e2022fdee7
9 changed files with 256 additions and 107 deletions

View File

@ -88,6 +88,17 @@ class A2SInfo:
qport = self.record.qport
return source_info_to_dict(ip, qport, self.info)
def is_modded(self) -> bool:
if self.info is None:
raise ValueError("Cannot call this method on Nonetype")
try:
kw = self.info.keywords.split(",")
state = True if "mod" in kw else False
return state
except Exception as e:
logger.warning(e)
raise e
def get_netmask() -> str:
hostname = os.uname()[1]

View File

@ -84,6 +84,9 @@ def get_needs_update(
def get_remote_signatures(mods: list[str]) -> list[tuple[str, int]]:
"""
Attempts to continue connecting even if signatures are empty
"""
payload: dict[str, str] = {}
payload["itemcount"] = len(mods)
for i, mod in enumerate(mods):

View File

@ -36,9 +36,9 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402
logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from dayzquery import DayzMod
from dzgui.api.servers import A2SInfo, Record
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
from dzgui.views.base import Notebook, Grid, OuterWindow
@ -88,6 +88,7 @@ class Controller(GObject.GObject):
self.pending_jobs = 1
self.exit_event = threading.Event()
self.connection_man: ConnectionManager
def get_emitter(self) -> Emitter:
return self.emitter
@ -104,6 +105,7 @@ class Controller(GObject.GObject):
def set_prefs(self, prefs: UserPrefs) -> None:
self.config_man = ConfigManager(prefs, self)
self.notes_man = NoteManager(self, prefs.paths.notes)
self.prefs = prefs
def query_config(self, key: Preferences) -> Any:
@ -393,12 +395,15 @@ class Controller(GObject.GObject):
if addr.isdigit():
config_man = self.get_config_man()
key = config_man.lookup(Preferences.BM)
ConnectionManager(self).connect_by_id(int(addr), key)
self.connection_man = ConnectionManager(self)
self.connection_man.connect_by_id(int(addr), key)
else:
ConnectionManager(self).connect_by_ip(addr)
self.connection_man = ConnectionManager(self)
self.connection_man.connect_by_ip(addr)
def connect_by_record(self, record: "Record") -> None:
ConnectionManager(self).connect_by_record(record)
self.connection_man = ConnectionManager(self)
self.connection_man.connect_by_record(record)
def get_details(self, record: "Record") -> None:
ConnectionManager(self).query_details(record)
@ -458,10 +463,13 @@ class Controller(GObject.GObject):
def set_exit_event(self) -> None:
self.exit_event.set()
def open_connection_assistant(self, res: "A2SInfo", mods: list["DayzMod"]) -> None:
def open_connection_assistant(self, prereqs: "Prerequisites") -> None:
self.open_page(NotebookPage.CONNECTION)
self.mediator.preconnect.populate(res, mods)
self.mediator.preconnect.populate(prereqs)
def set_start_tab(self) -> None:
ind = self.config_man.get_start_tab()
self.get_servers().notebook.set_current_page(ind)
def update_and_connect(self) -> None:
self.connection_man.update_and_connect()

View File

@ -6,14 +6,19 @@ from dzgui.views.dialogs.early_alert import EarlyAlertDialog
from dzgui.util.strings import init
def is_dayz_running() -> None:
# TODO: simplify
def is_dayz_running(dialog: bool = False) -> None:
if dialog is False:
return is_running(DAYZ_BINARY)
if is_running(DAYZ_BINARY) is True:
EarlyAlertDialog(init.is_dayz_running)
sys.exit(1)
def is_steam_running() -> None:
def is_steam_running(dialog: bool = False) -> None:
# TODO: check proc name of flatpak steam
if dialog is False:
return is_running(STEAM_CMD)
if is_running(STEAM_CMD) is False:
EarlyAlertDialog(init.is_steam_running)
sys.exit(1)

View File

@ -130,8 +130,8 @@ def main() -> None:
EarlyAlertDialog(init.requires_steam)
is_dayz_installed(XDG.config)
is_dayz_running()
is_steam_running()
is_dayz_running(dialog=True)
is_steam_running(dialog=True)
# NOTE: clear versions file of unlinked mods
rebuild_symlinks(XDG.config)

View File

@ -1,6 +1,8 @@
import logging
import shutil
from dataclasses import dataclass
from packaging.version import Version
from pathlib import Path
from typing import TYPE_CHECKING
@ -9,9 +11,17 @@ import dzgui.api.servers as Servers
from dzgui.api.steam import get_remote_signatures, get_needs_update
from dzgui.api.mods import get_local_mod_ids
from dzgui.const.constants import APP_NAME
from dzgui.const.constants import (
APP_NAME,
APPID_DAYZ,
APPID_DAYZ_EXP,
APPNAME_DAYZ,
APPNAME_DAYZ_EXP,
)
from dzgui.const.enum import Preferences
from dzgui.init.proc import is_dayz_running, is_steam_running
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.util.format import format_mib
from dzgui.util.strings import dialog, server_timeout, checkmark
from dzgui.views.dialogs.generic import ExceptionDialog
from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog
@ -22,18 +32,40 @@ 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
logger = logging.getLogger(APP_NAME)
@dataclass(slots=True, frozen=True)
class Prerequisites:
name: str
appid: int
local_version: Version
remote_version: Version
build: str
binary_missing: bool
required_space: float
available_space: float
passworded: bool
dayz_running: bool
steam_running: bool
mods: list[str]
class ConnectionManager:
def __init__(self, controller: "Controller") -> None:
self.controller = controller
self.thread_man = ThreadingManager(controller)
self.appid: int
self.record: Record
self.remote_mod_ids: list[str] = []
self.missing_mods: list[str] = []
@call_on_thread(dialog.querying)
def connect_by_id(self, _id: int, key: str) -> None:
res = Servers.query_by_id(_id, key)
@ -56,52 +88,66 @@ class ConnectionManager:
return
record = res.get_record()
try:
remote_mods = self._query_modlist(record)
remote_mod_ids = [mod[1] for mod in remote_mods]
except Exception as e:
print(e)
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
return
info = res.get_info()
# NOTE: store metadata for later connection
self.appid = info.game_id
self.record = record
builds = {APPID_DAYZ: APPNAME_DAYZ, APPID_DAYZ_EXP: APPNAME_DAYZ_EXP}
build = builds[self.appid]
binary_missing = False
required_mib = 0.0
free_mib = 0.0
steam_path = Path(self.controller.query_config(Preferences.DEFAULT))
local_version = PeFile.get_pretty_version(steam_path, info.game_id)
if local_version is None:
local_version = "0.0.0"
binary_missing = True
hashes = get_remote_signatures(remote_mod_ids)
version_file = self.controller.get_prefs().paths.version
needs_update = get_needs_update(version_file, hashes)
remote_mods: list[str, str, str] = []
if res.is_modded():
try:
remote_mods = self._query_modlist(record)
self.remote_mod_ids = [mod[1] for mod in remote_mods]
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
return
# TODO: store mods that need update in class object for referencing later
# TODO: store remote destination to connect to
hashes = get_remote_signatures(self.remote_mod_ids)
version_file = self.controller.get_prefs().paths.version
self.missing_mods = get_needs_update(version_file, hashes)
# missing mods should be the totality of all mods with no signature
# missing = get_missing_mods(local_mod_ids, remote_mod_ids)
# print(missing)
# TODO: when downloading mods, create symlinks if missing
if local_version is not None:
pefile_path = PeFile.get_pefile_path(steam_path, info.game_id)
total, used, free = shutil.disk_usage(pefile_path)
if len(self.missing_mods) > 0:
required_size = sum(int(row[2]) for row in self.missing_mods)
required_mib = format_mib(required_size)
free_mib = format_mib(required_size)
# TODO: get missing mod sizes, warn if not enough space
info = res.get_info()
try:
dayz_path = PeFile.get_pefile_path(steam_path, info.game_id)
# TODO: handle missing path; do not calculate size if appid is missing
total, used, free = shutil.disk_usage(dayz_path)
if len(needs_update) > 0:
# TODO: generic mib function
required_size = sum(int(row[2]) for row in needs_update)
required_mib = round(required_size / (1024**2), 3)
free_mib = round(free / (1024**2), 3)
print(required_mib)
print(free_mib)
except Exception:
# TODO: if this fails, need to show missing build warning, not failure func
# build up list of warnings/errors
# logger.warning(e)
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
dayz_running = is_dayz_running()
steam_running = is_steam_running()
# TODO: is dayz downloading
# TODO: number separator func
# TODO: pack a final PreReq struct with pre-processed values
prereqs = Prerequisites(
name=info.server_name,
appid=info.game_id,
local_version=Version(local_version),
remote_version=Version(info.version),
build=build,
binary_missing=binary_missing,
required_space=required_mib,
available_space=free_mib,
passworded=info.password_protected,
dayz_running=dayz_running,
steam_running=steam_running,
mods=remote_mods,
)
# TODO: connection assistant only receives user-facing warnings and list of mods
func = StoredFunc(self.controller.open_connection_assistant, res, remote_mods)
func = StoredFunc(self.controller.open_connection_assistant, prereqs)
self.thread_man.set_cleanup_func(func, destroy_first=True)
@call_on_thread(dialog.querying)
@ -157,10 +203,24 @@ class ConnectionManager:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.run()
def connect(self) -> None:
print(self.record.ip)
print(self.record.gameport)
print(self.appid)
# TODO: convert mod ids to symlink hashes
# steam api, concat mods
# TODO: custom threading with glib idle callback
def update_mods(self) -> None:
print(self.missing_mods)
# TODO: when downloading mods, create symlinks if missing
# TODO: pack a final PreReq struct with pre-processed values
# self.needs_update
# then connect
pass
def connect(self) -> None:
# steam api, concat mods
pass
def update_and_connect(self) -> None:
if len(self.missing_mods) > 0:
self.update_mods()
else:
self.connect()

View File

@ -1,4 +1,5 @@
update_mods = "Update mods and connect"
connect = "Connect"
back = "Back"
cancel = "Cancel"
warnings = "Warnings"

View File

@ -51,6 +51,10 @@ def format_mods(size: int, mods: int) -> str:
return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}"
def format_mib(bits: int) -> float:
return round(bits / (1024**2), 3)
def format_server_mods(mods: int) -> str:
plural = pluralize("mods", mods)
return f"Found {mods:n} {plural}. {workshop}"

View File

@ -1,16 +1,12 @@
from dataclasses import dataclass
from typing import Self, TYPE_CHECKING
from typing import Self, Sequence, TYPE_CHECKING
from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
APPNAME_DAYZ,
APPNAME_DAYZ_EXP,
ERROR,
WARNING,
)
from dayzquery import DayzMod
from dzgui.util.css import add_class
from dzgui.util.localize import number
from dzgui.strings import preconnect
from dzgui.views.components.frame import HeadingFrame
from dzgui.views.trees.tree_server_mods import ServerModTreeView
@ -22,7 +18,7 @@ from gi.repository import Gdk, Gtk # type: ignore # noqa E402
if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo
from dzgui.managers.connection import Prerequisites
from dzgui.controllers.mc import Controller
@ -41,6 +37,17 @@ class Errors:
no_dayz: bool
class Placeholder(Gtk.Label):
def __init__(self, text: str) -> None:
super().__init__(
label=text,
halign=Gtk.Align.START,
valign=Gtk.Align.START,
margin_start=10,
margin_bottom=5,
)
class MaskedTree(Gtk.TreeView):
def __init__(self, icon: str) -> None:
super().__init__(headers_visible=False, can_focus=False)
@ -64,7 +71,12 @@ class MaskedTree(Gtk.TreeView):
self.append_column(text_column)
add_class(self, "masked-tree")
def append(self, items: list[str]) -> None:
def append(self, item: Sequence[str]) -> None:
if len(item) > 1:
raise ValueError("This method only accepts one item")
self.store.append([self.icon, item])
def extend(self, items: list[str]) -> None:
self.store.clear()
for item in items:
self.store.append([self.icon, item])
@ -87,11 +99,11 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
self.controller.register_widget("preconnect", self)
# TODO: dynamic button text if no mods needed
self.back = Gtk.Button(label=preconnect.back, halign=Gtk.Align.START)
self.cancel = Gtk.Button(
label=preconnect.cancel, halign=Gtk.Align.END, sensitive=False, hexpand=True
)
# TODO: dynamic button text if no mods needed
self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END)
self.button_box = Gtk.Box(
@ -111,7 +123,6 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
self.tree = ServerModTreeView(self.controller)
self.mod_count = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=5)
# TODO: live count of remaining downloads
# "Steam is downloading: {mod_name}"
# mention whether manual or auto mod is active
@ -124,16 +135,30 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
self.tree_box.add(self.scrolled)
self.tree_box.add(self.mod_count)
# TODO: strings
self.mods_placeholder = Placeholder("This server has no mods.")
self.tree_box.add(self.mods_placeholder)
self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods)
# TODO: abstract into components
self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.warning_tree = MaskedTree(WARNING)
self.warning_frame = HeadingFrame(self.warning_tree, preconnect.warnings)
# TODO: strings
self.warning_placeholder = Placeholder("No warnings.")
self.warning_box.add(self.warning_tree)
self.warning_box.add(self.warning_placeholder)
self.warning_frame = HeadingFrame(self.warning_box, preconnect.warnings)
self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.error_tree = MaskedTree(ERROR)
self.error_frame = HeadingFrame(self.error_tree, preconnect.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(self.error_box, preconnect.errors)
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self.box.add(self.title)
self.box.add(self.tree_frame)
self.box.add(self.warning_frame)
@ -146,8 +171,15 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
self.connect("map", self._on_map)
def _on_map(self, widget: Self) -> None:
self.tree_frame.set_visible(True)
self.mod_count.set_visible(True)
widgets = (
self.tree_frame,
self.mod_count,
self.error_placeholder,
self.warning_placeholder,
)
for widget in widgets:
widget.set_visible(True)
self.ok.set_sensitive(True)
def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None:
if event.keyval == Gdk.KEY_Escape:
@ -155,59 +187,84 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
def _on_ok_clicked(self, button: Gtk.Button) -> None:
# TODO: update mod store in place with spinner/toast
# no dialog
# TODO: cancel mod downloads
# TODO: add to history file and list store
# TODO: concat mods
self.controller.update_and_connect()
pass
def _on_back_clicked(self, button: Gtk.Button) -> None:
page = self.controller.get_prior_page()
self.controller.open_page(page)
def populate(self, res: "A2SInfo", mods: list["DayzMod"]) -> None:
def _process_warnings(self, prereqs: "Prerequisites") -> None:
warnings: list[str] = []
errors: list[str] = []
if prereqs.binary_missing:
errors.append(
f"Remote server is running the build '{prereqs.build}', but it is not installed"
)
elif prereqs.local_version != prereqs.remote_version:
print("versions do not match")
errors.append(
f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'"
)
if prereqs.required_space > prereqs.available_space:
required_pretty = number(prereqs.required_space)
available_pretty = number(prereqs.available_space)
errors.append(
f"Need to update {required_pretty} MiB of mods, but installation path only has {available_pretty} MiB"
)
if prereqs.passworded:
warnings.append(
"Protected: you will be prompted for a password when connecting to this server"
)
if prereqs.dayz_running is True:
warnings.append(
"It looks like DayZ is already running in the background. Exit DayZ before connecting"
)
if prereqs.steam_running is False:
warnings.append(
"It looks like Steam is not running. Launch Steam before connecting"
)
self.add_warnings(warnings)
self.add_errors(errors)
if len(warnings) > 0:
self.warning_placeholder.set_visible(False)
if len(errors) > 0:
self.error_placeholder.set_visible(False)
self.ok.set_sensitive(False)
def populate(self, prereqs: "Prerequisites") -> None:
mods = prereqs.mods
self.tree.populate(mods)
total = len(mods)
total_mods = len(mods)
self._set_warnings()
info = res.get_info()
name = info.server_name
name = prereqs.name
self.title.set_text(name)
if total < 1:
self.tree_frame.set_visible(False)
if total_mods < 1:
self.scrolled.set_visible(False)
self.mod_count.set_visible(False)
return
self.mods_placeholder.set_visible(True)
self.ok.set_label(preconnect.connect)
else:
self.tree.set_visible(True)
self.scrolled.set_visible(True)
self.mod_count.set_visible(True)
self.mods_placeholder.set_visible(False)
# TODO: print no. of mods that need updating
prefix = preconnect.total_mods
self.mod_count.set_text(f"{prefix}{str(total)}")
self.mod_count.set_text(f"{prefix}{str(total_mods)}")
"""
blocking warning types:
- build mismatch
- version mismatch
- not enough drive space
passing warning types:
- dayz is running
- steam is not running
- server has password
"""
# TODO: if errors > 1, disable buttons
self._process_warnings(prereqs)
def _set_warnings(self) -> None:
self.warning_tree.append(["Password protected", "Some other error", "Error 3"])
self.error_tree.append(["Password protected", "Some other error", "Error 3"])
pass
def add_errors(self, errors: list[str]) -> None:
self.error_tree.extend(errors)
def download_mods(self) -> None:
pass
def connect_server(self) -> None:
# TODO: add to history file and list store
# TODO: concat mods
"""
spawn dialog in thread
watch for subprocess
return to prior page when finished
"""
pass
def add_warnings(self, warnings: list[str]) -> None:
self.warning_tree.extend(warnings)