From ee73c2cfa35348dbe98ddccc6ba6b3e7208cb585 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Wed, 10 Sep 2025 16:45:49 +0900 Subject: [PATCH] feat: parse pefile --- helpers/funcs | 123 ++++------- helpers/pefile.py | 499 +++++++++++++++++++++++++++++++++++++++++++++ helpers/servers.py | 107 +++++++++- helpers/ui.py | 347 ++++++++++++++++++++++++++----- 4 files changed, 935 insertions(+), 141 deletions(-) create mode 100644 helpers/pefile.py diff --git a/helpers/funcs b/helpers/funcs index 8c20d94..2a41ece 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -4,6 +4,7 @@ version="6.0.0-beta.5" #CONSTANTS aid=221100 +exp=1024020 game="dayz" app_name="dzgui" app_name_upper="DZGUI" @@ -46,6 +47,7 @@ _cache_my_servers="$cache_dir/$prefix.my_servers" _cache_history="$cache_dir/$prefix.history" _cache_launch="$cache_dir/$prefix.launch_mods" _cache_address="$cache_dir/$prefix.launch_address" +_cache_binary="$cache_dir/$prefix.binary" _cache_coords="$cache_path/$prefix.coords" _cache_cooldown="$cache_path/$prefix.cooldown" _cache_lan="$cache_path/$prefix.lan" @@ -97,12 +99,9 @@ declare -A funcs=( ["Change Steam API key"]="update_config_val" ["Change Battlemetrics API key"]="update_config_val" ["Change favorite server"]="add_record" -["Quick-connect to favorite server"]="quick_connect" ["Add server by IP"]="add_record" ["Add server by ID"]="add_record" -["Connect by IP"]="validate_and_connect" -["Connect by ID"]="validate_and_connect" -["Connect from table"]="connect_from_table" +["try_connect"]="try_connect" ["find_id"]="find_id" ["toggle"]="toggle" ["Open link"]="open_link" @@ -126,11 +125,26 @@ declare -A funcs=( ["Remove from history"]="remove_from_history" ["Force update local mods"]="force_update" ["Resolve IP"]="resolve_ip" -["Handshake"]="final_handshake" +["Handshake"]="handshake" +["Handshake_EXP"]="handshake_exp" ["get_player_count"]="get_player_count" ["update_symlinks"]="update_symlinks" ) +clone_symlinks(){ + local path="$(< $_cache_binary)" + path=$(dirname "$path") + for dir in $(find $game_dir -type l); do + local link=$(basename $dir) + ln -sf "${dir}" "${path}/${link}" + done +} +handshake(){ + final_handshake "$aid" +} +handshake_exp(){ + final_handshake "$exp" +} query_favorites(){ if [[ -z "${ip_list[@]}" ]]; then return 1 @@ -191,44 +205,6 @@ get_player_count(){ printf "%s\n%s" "$players" "$queue" } -validate_and_connect(){ - local context="$1" - local addr="$2" - - local record - case "$context" in - "Connect by ID") - if [[ -z "$api_key" ]]; then - printf "No Battlemetrics API key set" - return 4 - fi - record=$(map_id_to_ip "$addr") - if [[ $? -eq 1 ]]; then - logger WARN "Not a valid record: '$addr'" - printf "Not a valid ID" - return 2 - fi - logger INFO "Battlemetrics ID resolved to IP $record" - ;; - "Connect by IP") - if [[ $(validate_ip "$addr") -eq 1 ]]; then - printf "Not a valid IP format. Supply IP:Queryport" - return 2 - fi - local ip=$(<<< $addr awk -F: '{print $1}') - local qport=$(<<< $addr awk -F: '{print $2}') - local res - res=$(a2s $ip $qport info) - if [[ ! $? -eq 0 ]]; then - printf "Timed out when querying the server. Is this a valid server?" - return 2 - fi - local gameport="$(<<< $res jq -r '.[].gameport')" - record="${ip}:${gameport}:${qport}" - logger INFO "Record resolved to $record" - esac - try_connect "$record" -} map_id_to_ip(){ local id="$1" local res=$(curl -s "$bm_api" -H "Authorization: Bearer "$api_key"" \ @@ -294,21 +270,6 @@ add_record(){ ;; esac } -connect_by_id(){ - if [[ $(validate_ip "$addr") -eq 1 ]]; then - printf "Not a valid IP format. Supply IP:Queryport" - return 2 - fi - local ip=$(<<< $addr awk -F: '{print $1}') - local qport=$(<<< $addr awk -F: '{print $2}') - local res - res=$(a2s $ip $qport info) - if [[ ! $? -eq 0 ]]; then - printf "Timed out when querying the server. Is this a valid server?" - return 2 - fi - #res contains modlist -} start_cooldown(){ logger WARN "API response empty. Started 60s cooldown at $(date +%s)" date +%s > $_cache_cooldown @@ -444,6 +405,7 @@ query_config(){ "fav_label" "preferred_client" "fullscreen" + "default_steam_path" ) if [[ -n $key ]]; then if [[ -n ${!key} ]]; then @@ -924,19 +886,6 @@ open_link(){ xdg-open "$url" fi } - -quick_connect(){ - if [[ -z $fav_server ]]; then - printf "No favorite server currently set" - return 1 - fi - try_connect "$fav_server" -} -connect_from_table(){ - shift - local record="$1" - try_connect "$record" -} generate_log(){ source $config_file cat <<-DOC > $system_log @@ -1126,10 +1075,17 @@ try_fallback(){ esac } try_connect(){ + shift local record="$1" + local appid="$2" + local binary="$3" + local ip=$(<<< $record awk -F: '{print $1}') local gameport=$(<<< $record awk -F: '{print $2}') local qport=$(<<< $record awk -F: '{print $3}') + + echo "$binary" > $_cache_binary + local remote_mods remote_mods=$(a2s $ip $qport rules) if [[ $? -eq 1 ]]; then @@ -1154,11 +1110,11 @@ try_connect(){ return 1 fi case $auto_install in - "") manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods";; - 1|2) manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "auto" ;; + "") manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "$appid";; + 1|2) manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "auto" "$appid" ;; esac else - launch "$ip" "$gameport" "$sanitized_mods" + launch "$ip" "$gameport" "$sanitized_mods" "$appid" fi } check_architecture(){ @@ -1176,7 +1132,7 @@ force_update(){ fi rm "$versions_file" local update=$(check_timestamps) - manual_mod_install "null" "null" "$update" "null" "force" + manual_mod_install "null" "null" "$update" "null" "force" "null" echo "Finished requesting mod updates." return 0 } @@ -1293,6 +1249,8 @@ launch(){ local ip="$1" local gameport="$2" local mods="$3" + local appid="$4" + local concat if [[ -n $mods ]]; then concat=$(concat_mods "$mods") @@ -1301,18 +1259,22 @@ launch(){ fi update_symlinks + [[ $appid -eq $exp ]] && clone_symlinks + if [[ $debug -eq 1 ]]; then - local launch_options="$steam_cmd -applaunch $aid -connect=$ip:$gameport -nolauncher -nosplash -name=$name -skipintro -mod=$concat" + local launch_options="$steam_cmd -applaunch $appid -connect=$ip:$gameport -nolauncher -nosplash -name=$name -skipintro -mod=$concat" printf "Debug mode: these options would have been used to launch the game: $launch_options" return 0 fi echo "$concat" > "$_cache_launch" echo "$ip:$gameport" > "$_cache_address" logger INFO "Saved launch params: '$concat'" - printf "Launch conditions satisfied. DayZ will now launch after you confirm this dialog." - return 100 + printf "Launch conditions satisfied. DayZ will launch after you confirm this dialog." + [[ $appid == "$aid" ]] && return 100 + [[ $appid == "$exp" ]] && return 101 } final_handshake(){ + local appid="$1" local saved_mods=$(< "$_cache_launch") local saved_address=$(< "$_cache_address") local res=$(is_dayz_running) @@ -1329,7 +1291,7 @@ final_handshake(){ params+=("-skipintro") params+=("-name=$name") params+=("-mod=$saved_mods") - $steam_cmd -applaunch $aid "${params[@]}" & + $steam_cmd -applaunch $appid "${params[@]}" & until [[ $(is_dayz_running) -eq 1 ]]; do sleep 0.1s done @@ -1343,6 +1305,7 @@ manual_mod_install(){ local diff="$3" local sanitized_mods="$4" local mode="$5" + local appid="$6" local ex="$state_path/dzg.watcher" readarray -t stage_mods <<< "$diff" @@ -1400,7 +1363,7 @@ manual_mod_install(){ rm "$versions_file" check_timestamps fi - launch "$ip" "$gameport" "$sanitized_mods" + launch "$ip" "$gameport" "$sanitized_mods" "$appid" else printf "User aborted download process, or some mods may have failed to download. Try connecting again to resync." exit 1 diff --git a/helpers/pefile.py b/helpers/pefile.py new file mode 100644 index 0000000..0b026d0 --- /dev/null +++ b/helpers/pefile.py @@ -0,0 +1,499 @@ +import json +import struct +import typing # noqa + +from dataclasses import dataclass +from enum import Enum +from packaging.version import Version +from pathlib import Path +from shlex import shlex +from typing import BinaryIO, Union + +# https://learn.microsoft.com/en-us/windows/win32/debug/pe-format +endian = "<" +IMAGE_DIRECTORY_ENTRY = 2 +VERSION_RESOURCE = 16 +RESOURCE_NODE = ".rsrc" +PE32_x86 = "0x10b" +PE32_x64 = "0x20b" +VS_VERSION_INFO_MAGIC = "0xfeef04bd0000" +VS_VERSION_INFO_ID = "VS_VERSION_INFO" + + +class VersionMatch(Enum): + LOCAL_OLDER = 1 + LOCAL_NEWER = 2 + SAME_VERSION = 3 + FAIL = 4 + + +class u8: + fmt = "B" + + +class u16: + fmt = "H" + + +class u32: + fmt = "L" + + +class u64: + fmt = "Q" + + +class i8: + fmt = "b" + + +class i16: + fmt = "h" + + +class i32: + fmt = "l" + + +class i64: + fmt = "q" + + +class PackedData: + @classmethod + def unpack(cls, data: BinaryIO): + r = [] + for key, value in cls.__annotations__.items(): + if value == str: + f = data.read(8).rstrip(b"\x00\x00").decode() + else: + fmt = endian + (value.fmt) + size = struct.calcsize(fmt) + f = struct.unpack(fmt, data.read(size))[0] + r.append(f) + return cls(*r) + + +@dataclass(slots=True, frozen=True) +class COFF_FILE_HDR(PackedData): + machine_type: u16 + number_of_sections: u16 + timestamp: u32 + pointer_to_symbol_table: u32 + number_of_symbols: u32 + size_of_optional_header: u16 + characteristics: u16 + + +@dataclass(slots=True, frozen=True) +class OPTIONAL_HDR_X86(PackedData): + magic: u16 + major_linker_ver: u8 + minor_linker_ver: u8 + size_of_code: u32 + size_of_initialized_data: u32 + size_of_uninitialized_data: u32 + address_of_entry_point: u32 + base_of_code: u32 + base_of_data: u32 + + +@dataclass(slots=True, frozen=True) +class OPTIONAL_HDR_X64(PackedData): + magic: u16 + major_linker_ver: u8 + minor_linker_ver: u8 + size_of_code: u32 + size_of_initialized_data: u32 + size_of_uninitialized_data: u32 + address_of_entry_point: u32 + base_of_code: u32 + + +@dataclass(slots=True, frozen=True) +class OPTIONAL_HDR_WIN_X86(PackedData): + image_base: u32 + section_alignment: u32 + file_alignment: u32 + major_operating_system_version: u16 + minor_operating_system_version: u16 + major_image_version: u16 + minor_image_version: u16 + major_subsystem_version: u16 + minor_subsystem_version: u16 + win32_version_value: u32 + size_of_image: u32 + size_of_headers: u32 + checksum: u32 + subsystem: u16 + dll_characteristics: u16 + size_of_stack_reserve: u32 + size_of_stack_commit: u32 + size_of_heap_reserve: u32 + size_of_heap_commit: u32 + loader_flags: u32 + number_of_rva_and_sizes: u32 + + +@dataclass(slots=True, frozen=True) +class OPTIONAL_HDR_WIN_X64(PackedData): + image_base: u64 + section_alignment: u32 + file_alignment: u32 + major_operating_system_version: u16 + minor_operating_system_version: u16 + major_image_version: u16 + minor_image_version: u16 + major_subsystem_version: u16 + minor_subsystem_version: u16 + win32_version_value: u32 + size_of_image: u32 + size_of_headers: u32 + checksum: u32 + subsystem: u16 + dll_characteristics: u16 + size_of_stack_reserve: u64 + size_of_stack_commit: u64 + size_of_heap_reserve: u64 + size_of_heap_commit: u64 + loader_flags: u32 + number_of_rva_and_sizes: u32 + + +@dataclass(slots=True, frozen=True) +class DATA_DIR(PackedData): + virtual_address: u32 + size: u32 + + +@dataclass(slots=True, frozen=True) +class SECTION_HDR(PackedData): + name: str + virtual_size: u32 + virtual_address: u32 + size_of_raw_data: u32 + pointer_to_raw_data: u32 + pointer_to_relocations: u32 + pointer_to_line_numbers: u32 + number_of_relocations: u16 + number_of_line_numbers: u16 + characteristics: u32 + + +@dataclass(slots=True, frozen=True) +class RESOURCE_DIRECTORY_TABLE(PackedData): + characteristics: u32 + timestamp: u32 + major_version: u16 + minor_version: u16 + number_of_name_entries: u16 + number_of_id_entries: u16 + + +@dataclass(slots=True, frozen=True) +class RESOURCE_DIRECTORY_ENTRY(PackedData): + """ + This field is either a string identifying a data leaf + (if the high bit is set) or an ID to another nested directory + (if the high bit is clear). The outermost level is always a + directory. If it is a name, the lower 31 bits are the offset from the + beginning of the resource section's raw data to the name + (the name consists of 16 bits length and trailing wide characters, + in Unicode, not 0-terminated). + """ + name_or_id: u32 + data_or_subdir: u32 + + +@dataclass(slots=True, frozen=True) +class RESOURCE_DATA_ENTRY(PackedData): + data_rva: u32 + size: u32 + codepage: u32 + reserved: u32 + + +@dataclass(slots=True, frozen=True) +class VS_VERSION_INFO_HDR(PackedData): + size: u16 + value_length: u16 + value_type: u16 + + +@dataclass(slots=True, frozen=True) +class DayZVersion: + major: int + minor: int + patch: int + + +@dataclass(slots=True, frozen=True) +class FileVersion: + major: int + minor: int + build: int + revision: int + + +@dataclass(slots=True, frozen=True) +class Result: + local: Union[str, None] + remote: str + build: str + path: Union[Path, None] + match: VersionMatch + error: Union[Exception, None] + + +class PeFileError(Exception): + """Expected contents missing from headers or resource nodes""" + pass + + +class AppNotInstalledError(Exception): + """App not present in user's libraryfolders""" + pass + + +class AppMovedError(Exception): + """VDF points to a nonexistent location on disk""" + pass + + +class VDFLoadError(Exception): + """Malformed VDF or JSON conversion""" + pass + + +def parse_version_number(data: BinaryIO): + # https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo + minor = struct.unpack("> 16 & 0xffff + major = struct.unpack("> 0 & 0xffff + build = struct.unpack("> 0 & 0xffff + revision = struct.unpack("> 16 & 0xffff + return FileVersion(major, minor, build, revision) + + +def seek_to_hex(address: str, data: BinaryIO) -> None: + data.seek(int(address, base=16)) + + +def seek_to_pe_stub(data: BinaryIO) -> None: + MAGIC = "0x3c" + seek_to_hex(MAGIC, data) + e_lfanew = hex(struct.unpack(" DayZVersion: + version = get_version(file) + patch = str(version.build) + str(version.revision) + dz_vers = DayZVersion(version.major, version.minor, int(patch)) + return dz_vers + + +def get_dayz_version_str(file: Path) -> str: + v = get_dayz_version(file) + concat = ".".join(str(el) for el in [v.major, v.minor, v.patch]) + return concat + + +def get_version(file): + with open(file, "rb") as f: + seek_to_pe_stub(f) + COFF = COFF_FILE_HDR.unpack(f) + + pos = f.tell() + blob = f.read() + magic = hex(struct.unpack(" VERSION_RESOURCE: + raise PeFileError("no version info node found") + seek_to_hex(hex(offset + entry.data_or_subdir), f) + + data = RESOURCE_DATA_ENTRY.unpack(f) + # https://stackoverflow.com/questions/2170843/va-virtual-address-rva-relative-virtual-address + offset = data.data_rva - hdr.virtual_address + hdr.pointer_to_raw_data + seek_to_hex(hex(offset), f) + + hdr = VS_VERSION_INFO_HDR.unpack(f) + # https://learn.microsoft.com/en-us/windows/win32/menurc/vs-versioninfo + byte_len = len(VS_VERSION_INFO_ID.encode("utf-16le")) + label = f.read(byte_len).decode("utf-16le") + if label != VS_VERSION_INFO_ID: + raise PeFileError(f"header identifier != '{VS_VERSION_INFO_ID}'") + f.read(32 - byte_len) + + identifier = hex(struct.unpack(" bool: + return Version(local) < Version(remote) + + +def is_newer_version(local: str, remote: str) -> bool: + return Version(local) > Version(remote) + + +def get_pefile_path(path: str, appid: int) -> Path: + binary = "DayZ_x64.exe" + identifier = {221100: "DayZ", 1024020: "DayZ Exp"} + name = identifier[appid] + + pe_path = None + path = path + "/steamapps/libraryfolders.vdf" + + with open(path, "r") as f: + try: + j = json.loads(vdf_to_json(f)) + except Exception: + raise VDFLoadError("Failed to parse libraryfolders") + + for obj in j["libraryfolders"]: + if str(appid) in j["libraryfolders"][obj]["apps"]: + pe_path = j["libraryfolders"][obj]["path"] + pe_path += f"/steamapps/common/{name}/{binary}" + break + + if pe_path is None: + raise AppNotInstalledError( + f"Failed to find a libraryfolder for the appid '{appid}'" + ) + + pe_path = Path(pe_path) + if pe_path.exists() is False: + raise AppMovedError( + f"Path '{pe_path}' specified in libraryfolders does not exist" + ) + + return pe_path + + +def compare_versions(remote: str, appid: int, path: str): + if appid == 221100: + build = "DayZ" + else: + build = "DayZ Experimental" + + local = None + pe_filepath = None + error = None + + try: + pe_filepath = get_pefile_path(path, appid) + except Exception as e: + return Result( + local, remote, build, pe_filepath, VersionMatch.FAIL, e + ) + + try: + local = get_dayz_version_str(pe_filepath) + except PeFileError: + return Result( + local, remote, build, pe_filepath, VersionMatch.FAIL, error + ) + + if is_older_version(local, remote): + res = VersionMatch.LOCAL_OLDER + elif is_newer_version(local, remote): + res = VersionMatch.LOCAL_NEWER + else: + res = VersionMatch.SAME_VERSION + + return Result(local, remote, build, pe_filepath, res, error) + + +def vdf_to_json(stream): + def _istr(indent, string): + return (indent * ' ') + string + + jbuf = '{\n' + lex = shlex(stream) + indent = 1 + + while True: + tok = lex.get_token() + if not tok: + return jbuf + '}\n' + if tok == '}': + indent -= 1 + jbuf += _istr(indent, '}') + ntok = lex.get_token() + lex.push_token(ntok) + if ntok and ntok != '}': + jbuf += ',' + jbuf += '\n' + else: + ntok = lex.get_token() + if ntok == '{': + jbuf += _istr(indent, tok + ': {\n') + indent += 1 + else: + jbuf += _istr(indent, tok + ': ' + ntok) + ntok = lex.get_token() + lex.push_token(ntok) + if ntok != '}': + jbuf += ',' + jbuf += '\n' diff --git a/helpers/servers.py b/helpers/servers.py index 5636710..565ad0a 100644 --- a/helpers/servers.py +++ b/helpers/servers.py @@ -29,6 +29,18 @@ params = [ ] +class BmAPIError(Exception): + pass + + +class BmIdError(Exception): + pass + + +class InvalidIpError(Exception): + pass + + def get_netmask() -> str: hostname = os.uname()[1] i = socket.gethostbyname(hostname) @@ -196,7 +208,7 @@ class Res: json: Union[str, None] -@dataclass +@dataclass(slots=True, frozen=True) class Ping: addr: str iteration: int @@ -210,17 +222,33 @@ class Details: success: bool -def is_passworded(ip: str, qport: int) -> bool: +@dataclass +class Prereqs: + password: bool + gameport: int + appid: Union[int, None] + version: Union[str, None] + + +@dataclass(slots=True) +class Record: + ip: str + gameport: int + qport: int + + +def get_prereqs(ip: str, qport: int) -> Prereqs: try: info = a2s.info((ip, qport)) except TimeoutError: - return False + return Prereqs(False, 0, None, None) - try: - password = info.password_protected - except AttributeError: - return False - return password + gameport = getattr(info, "port", 0) + is_password = getattr(info, "password_protected", False) + appid = getattr(info, "game_id", None) + version = getattr(info, "version", None) + + return Prereqs(is_password, gameport, appid, version) def details(ip: str, qport: int) -> Details: @@ -343,12 +371,12 @@ def ping(iteration: int, row: list) -> Ping: return Ping(addr, iteration, ping) -def query_api(key: str, param: str) -> Res: +def query_api(key: str, appid: int, param: str) -> Res: LIMIT = 10000 url = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?" payload: dict[str, Union[int, str]] = { - "filter": r"\appid\221100" + param, + "filter": r"\appid" + fr"\{appid}" + param, "limit": LIMIT, "key": key, } @@ -375,3 +403,62 @@ def query_api(key: str, param: str) -> Res: data = None finally: return Res(status, parsed, data) + + +def query_bm_api(api_key: str, bm_id: str) -> Record: + if bm_id.isnumeric() is False: + raise BmIdError("ID must be numeric only") + + payload: dict[str, Union[int, str]] = { + "sort": "-players", + "filter[game]": "dayz", + "filter[ids][whitelist]": bm_id, + } + + url = "https://api.battlemetrics.com/servers?" + par = parse.urlencode(payload) + url = f"{url}{par}" + + hdr = {"Authorization": "Bearer " + api_key} + r = request.Request(url, headers=hdr) + + try: + with request.urlopen(r) as response: + try: + j = json.load(response) + except json.decoder.JSONDecodeError: + raise BmAPIError("Malformed response from Battlemetrics") + + if len(j["data"]) < 1: + raise BmAPIError("Not a valid Battlemetrics ID") + j = j["data"][0]["attributes"] + return Record(j["ip"], j["port"], j["portQuery"]) + except HTTPError: + raise BmAPIError("Failed to query Battlemetrics") + + +def validate_ip(addr: str): + fields = addr.split(":") + if len(fields) != 2: + raise InvalidIpError("Address must be formatted as IP:Queryport") + + ip = fields[0] + port = fields[1] + + try: + int(port) + except ValueError: + raise InvalidIpError(f"'{port}' is not a valid port") + + if int(port) > 65535 or int(port) < 0: + raise InvalidIpError(f"'{port}' is not a valid port") + + try: + socket.inet_aton(ip) + except OSError: + raise InvalidIpError(f"'{ip}' is not a valid IP") + + ip = addr.split(":")[0] + qport = int(addr.split(":")[1]) + record = Record(ip, 0, qport) + return record diff --git a/helpers/ui.py b/helpers/ui.py index 02022a6..0c3f33b 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -13,21 +13,24 @@ import threading import typing # noqa import warnings +from dataclasses import dataclass from enum import Enum +from collections.abc import Callable from concurrent.futures import wait from concurrent.futures import ThreadPoolExecutor - -from collections.abc import Callable from typing import Literal, Self, Any import servers as Servers # noqa E402 +import pefile as PeFile # noqa E402 + +from pefile import VDFLoadError, AppNotInstalledError, AppMovedError, PeFileError +from pefile import VersionMatch locale.setlocale(locale.LC_ALL, "") import gi # noqa E402 - gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa: E402 +from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 # https://bugzilla.gnome.org/show_bug.cgi?id=708676 warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) @@ -37,6 +40,9 @@ app_name_lower = app_name.lower() app_name_abbr = "dzg" delimiter = "␞" +APPID_DAYZ = 221100 +APPID_DAYZ_EXP = 1024020 + cache: dict[str, int] = {} config_vals: list[str] = [] @@ -87,6 +93,13 @@ If this issue persists, your API key may be defunct. """ +@dataclass +class Record: + ip: str + gameport: int + qport: int + + class Preferences(Enum): STEAM = 1 BM = 2 @@ -349,6 +362,12 @@ class RowType(EnumWithAttrs): "wait_msg": "Waiting for DayZ", "type": Command.ONESHOT, } + HANDSHAKE_EXP = { + "label": "Handshake_EXP", + "tooltip": None, + "wait_msg": "Waiting for DayZ", + "type": Command.ONESHOT, + } DELETE_SELECTED = { "label": "Delete selected mods", "tooltip": None, @@ -666,6 +685,7 @@ def save_res_and_quit(*args) -> None: def suppress_signal( owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool ) -> None: + func = getattr(owner, func_name) if state: logger.debug(f"Blocking {func_name} for {widget}") @@ -705,6 +725,7 @@ def format_metadata(row_sel: str) -> str: "fav_label": config_vals[4], "preferred_client": config_vals[5], "fullscreen": config_vals[6], + "default_steam_path": config_vals[7] } if row is None: return "" @@ -899,6 +920,11 @@ def process_shell_return_code( if final_conf == 1 or final_conf is None: return process_tree_option(RowType.HANDSHAKE) + case 101: # final handshake, exp + final_conf = spawn_dialog(msg, Popup.CONFIRM) + if final_conf == 1 or final_conf is None: + return + process_tree_option(RowType.HANDSHAKE_EXP) case 255: # dzgui version update msg = "Update complete. Please close DZGUI and restart." spawn_dialog(msg, Popup.QUIT) @@ -973,6 +999,22 @@ def process_tree_option(choice: RowType) -> None: App.grid.notebook.set_page_by_enum(NotebookPage.CHANGELOG) return + if command == RowType.QUICK_CONNECT: + record = query_config("fav_server")[0] + if record == "": + spawn_dialog("No favorite server currently set", Popup.NOTIFY) + return + + record = str_to_record(record) + thread_new_with_dialog( + App.treeview.prepare_connection, + parse_shell_output, + "Querying server", + command, + [record] + ) + return + match command.dict["type"]: case Command.HELP: call_bash_func("Open link", cmd_string) @@ -989,6 +1031,61 @@ def process_tree_option(choice: RowType) -> None: return +def parse_shell_output(proc: subprocess.CompletedProcess, row: RowType): + out = proc.stdout.splitlines() + try: + msg = out[-1] + except IndexError: + msg = "" + process_shell_return_code(msg, proc.returncode, row) + + +def thread_new_with_dialog( + func: Callable, + callback: Callable | None, + msg: str, + row: RowType | None, + args: list +) -> None: + + """ + Pop a GenericDialog transient to App.treeview and + call a function on a thread, with optional callback. + Chiefly used for connection-related subprocesses. + + After completion, the dialog is destroyed in the main event loop + and additional exception handling occurs. + + This is intended as a bridge between legacy shell methods and the UI. + A more abstracted version of call_on_thread() for when extra threaded + processing occurs before calls to shell subprocesses. + """ + + def background(*args): + def cleanup(): + App.treeview.dialog_hide() + if exception is not None: + spawn_dialog(str(exception), Popup.NOTIFY) + process_user_input(row) + return + if callback is not None and proc is not None: + callback(proc, row) + + exception = None + proc = None + try: + proc = func(*args) + except Exception as e: + exception = e + GLib.idle_add(cleanup) + return + GLib.idle_add(cleanup) + + App.treeview.dialog_show(msg) + thread = threading.Thread(target=background, args=(args)) + thread.start() + + def process_toggle(command: RowType) -> None: cmd_string = command.dict["label"] match command: @@ -1013,11 +1110,60 @@ def process_toggle(command: RowType) -> None: proc = call_out("toggle", cmd_string) +def str_to_record(record: str) -> Record | None: + r = record.split(":") + if len(r) != 3: + return None + return Record(r[0], int(r[1]), int(r[2])) + + +def record_to_str(record: Record) -> str: + return f"{record.ip}:{record.gameport}:{record.qport}" + + +def connect_by_ip(enum: RowType, response: str) -> None: + def _prep(response: str) -> None: + record = Servers.validate_ip(response) + proc = App.treeview.prepare_connection(record) + return proc + + thread_new_with_dialog( + _prep, + parse_shell_output, + "Querying IP", + enum, + [response] + ) + return + + +def connect_by_id(enum: RowType, response: str, key: str) -> None: + def _prep(key: str, response: str) -> None: + record = Servers.query_bm_api(key, response) + proc = App.treeview.prepare_connection(record) + return proc + + thread_new_with_dialog( + _prep, + parse_shell_output, + "Querying API", + enum, + [key, response] + ) + return + + def process_user_input(enum: RowType) -> None: prompt = enum.dict["prompt"] link_label = enum.dict["link_label"] cmd_string = enum.dict["label"] + if enum == RowType.CONN_BY_ID: + key = query_config("api_key")[0] + if len(key) == 0: + spawn_dialog("No Battlemetrics API key is set; see Options", Popup.NOTIFY) + return + user_entry = EntryDialog(prompt, Popup.ENTRY, link_label) response = user_entry.get_input() @@ -1026,6 +1172,14 @@ def process_user_input(enum: RowType) -> None: return logger.info(f"User entered: '{response}'") + if enum == RowType.CONN_BY_IP: + connect_by_ip(enum, response) + return + + if enum == RowType.CONN_BY_ID: + connect_by_id(enum, response, key) + return + show_wait_dialog = True wait_msg = "Working" call_on_thread( @@ -1789,7 +1943,7 @@ class TreeView(Gtk.TreeView): it = self.get_current_iter() name = model.get_value(it, 0) record = self.get_record_dict() - DetailsDialog(name, record["ip"], record["qport"]) + DetailsDialog(name, record.ip, record.qport) def show_mods(self) -> None: record = self.get_record_string() @@ -1976,7 +2130,7 @@ class TreeView(Gtk.TreeView): if not record: grid.statusbar.update_server_meta() return - ip = record["ip"] + ip = record.ip if ip in cache: km = cache[ip] grid.statusbar.append_distance(km) @@ -2135,8 +2289,8 @@ class TreeView(Gtk.TreeView): addr = model[path][7] qport = model[path][8] ip = addr.split(":")[0] - qport = str(qport) - return {"ip": ip, "qport": qport} + gameport = int(addr.split(":")[1]) + return Record(ip, gameport, qport) def update_players(self, players: int) -> None: model = self.get_model() @@ -2183,9 +2337,7 @@ class TreeView(Gtk.TreeView): record = self.get_record_dict() if not record: return - ip = record["ip"] - qport = record["qport"] - data = call_out("get_player_count", ip, qport) + data = call_out("get_player_count", record.ip, str(record.qport)) if data.returncode == 1: wait_dialog.destroy() return @@ -2195,10 +2347,10 @@ class TreeView(Gtk.TreeView): key = query_config("steam_api")[0] job = Servers.query_api params = Servers.params + serv = [] with ThreadPoolExecutor() as executor: - futures = [executor.submit(job, key, param) for param in params] + futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] wait(futures) - serv = [] for future in futures: res = future.result() if res.status != 200 or not res.parsed: @@ -2208,7 +2360,13 @@ class TreeView(Gtk.TreeView): return j = res.json serv += j["response"]["servers"] - parsed = Servers.parse_json(serv) + + res = Servers.query_api(key, APPID_DAYZ_EXP, "") + if res.status == 200 and res.parsed is True: + j = res.json + serv += j["response"]["servers"] + + parsed = Servers.parse_json(serv) return parsed def _dump_lan(self, port: int) -> list | None: @@ -2649,27 +2807,15 @@ class TreeView(Gtk.TreeView): ) thread.start() - def _background_connection( - self, dialog: "GenericDialog", record: str - ) -> None: - def load(): - dialog.destroy() - out = proc.stdout.splitlines() - msg = out[-1] - process_shell_return_code(msg, proc.returncode, record) + def dialog_hide(self) -> None: + if hasattr(self, "wait_dialog"): + self.wait_dialog.destroy() - proc = call_out("Connect from table", record) - GLib.idle_add(load) - - def _attempt_connection(self) -> None: - record = self.get_record_string() - msg = "Querying server and aligning mods" - wait_dialog = GenericDialog(msg, Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread( - target=self._background_connection, args=(wait_dialog, record) - ) - thread.start() + def dialog_show(self, msg: str) -> None: + if hasattr(self, "wait_dialog"): + self.wait_dialog.destroy() + self.wait_dialog = GenericDialog(msg, Popup.WAIT) + self.wait_dialog.show_all() def is_row_to_server_context(self, view: RowType) -> bool: """Row activation that jumps into a server table""" @@ -2709,6 +2855,86 @@ class TreeView(Gtk.TreeView): def get_view(self): return self.view + def prepare_connection(self, record: Record) -> subprocess.CompletedProcess | None: + """ + Always called on a thread with a dialog on the transient parent window + """ + prereqs = Servers.get_prereqs(record.ip, record.qport) + if prereqs.appid is None: + msg = "Timed out when querying server, check IP or try again later" + spawn_dialog(msg, Popup.NOTIFY) + return None + + if prereqs.version is not None: + path = query_config("default_steam_path")[0] + result = PeFile.compare_versions(prereqs.version, prereqs.appid, path) + + if result.error is not None: + logger.warning(result.error) + + if result.match == VersionMatch.FAIL: + if isinstance(result.error, VDFLoadError) or isinstance(result.error, PeFileError): + # permissive; file exists, but could not determine version + pass + if isinstance(result.error, AppNotInstalledError): + if prereqs.appid == 1024020: + msg = ( + "This server is running DayZ Experimental, a beta build. " + "You can install DayZ Experimental by searching for it in " + "your Steam library." + ) + spawn_dialog(msg, Popup.NOTIFY) + return None + if isinstance(result.error, AppMovedError): + msg = ( + f"Steam is reporting that {result.build} is installed at a non-existent location. " + f"If you recently installed {result.build} or moved it to a different drive, " + "restart Steam to allow these changes to synchronize, then try again." + ) + spawn_dialog(msg, Popup.NOTIFY) + return None + + if result.match == VersionMatch.LOCAL_OLDER: + msg = ( + f"This server is running a newer build ({result.remote}) of {result.build} than " + f"your local version ({result.local}). You may be unable to connect. Proceed anyway?" + ) + res = spawn_dialog(msg, Popup.CONFIRM) + if res is True: + return None + + if result.match == VersionMatch.LOCAL_NEWER: + msg = ( + f"This server is running an out-of-date build ({result.remote}) of {result.build}. " + "You may be unable to connect. Proceed anyway?" + ) + res = spawn_dialog(msg, Popup.CONFIRM) + if res is True: + return None + + if prereqs.password is True: + msg = ( + "This server is password-protected and you will be " + "prompted when connecting. Do you want to proceed?" + ) + res = spawn_dialog(msg, Popup.CONFIRM) + if res is True: + return None + + """ + When using RowType.CONN_BY_IP, the gameport needs to be interpolated + """ + + record.gameport = prereqs.gameport + addr = record_to_str(record) + proc = call_out( + "try_connect", + addr, + str(prereqs.appid), + str(result.path) + ) + return proc + @signal_emission @update_window_labels def _on_row_activated( @@ -2777,15 +3003,13 @@ class TreeView(Gtk.TreeView): record = self.get_record_dict() if record is None: return - if Servers.is_passworded(record["ip"], int(record["qport"])): - msg = ( - "This server is password-protected and you will be " - "prompted when connecting. Do you want to proceed?" - ) - res = spawn_dialog(msg, Popup.CONFIRM) - if res is True: - return - self._attempt_connection() + thread_new_with_dialog( + self.prepare_connection, + parse_shell_output, + "Querying server", + None, + [record] + ) case _: # any other non-server option from the main menu process_tree_option(output) @@ -3042,7 +3266,7 @@ class LanDialog(Gtk.MessageDialog): class DetailsDialog(GenericDialog): - def __init__(self, server_name: str, ip: str, qport: str): + def __init__(self, server_name: str, ip: str, qport: int): super().__init__(server_name, Popup.DETAILS) dialog_box = self.get_content_area() @@ -3050,7 +3274,7 @@ class DetailsDialog(GenericDialog): self.set_size_request(800, 700) self.ip = ip.split(":")[0] - self.qport = int(qport) + self.qport = qport self.store = Gtk.ListStore(str, str, Pango.Weight) self.view = Gtk.TreeView( @@ -3203,12 +3427,10 @@ class ModDialog(GenericDialog): self.run() self.destroy() - addr = App.treeview.get_record_dict() - if not addr: + record = App.treeview.get_record_dict() + if not record: return - ip = addr["ip"] - qport = addr["qport"] - data = call_out("show_server_modlist", ip, qport) + data = call_out("show_server_modlist", record.ip, str(record.qport)) mod_count = self._parse_modlist_rows(data) self.view.set_model(modlist_store) GLib.idle_add(_load) @@ -3487,6 +3709,9 @@ class Options(Gtk.Box): [LeftLabel("Force update local mods"), self.force_button, eb2], ] + self.dayz_version_label = Gtk.Label(label="-") + self.dayz_exp_version_label = Gtk.Label(label="-") + self.branch_combo = Gtk.ComboBoxText() self.branch_combo.append_text("Stable") self.branch_combo.append_text("Testing") @@ -3499,7 +3724,11 @@ class Options(Gtk.Box): ) eb = InfoEventBox(msg) - version_rows = [[LeftLabel("Branch"), self.branch_combo, eb]] + version_rows = [ + [LeftLabel("DayZ"), self.dayz_version_label], + [LeftLabel("DayZ Experimental"), self.dayz_exp_version_label], + [LeftLabel("DZGUI branch"), self.branch_combo, eb] + ] api_grid = self._make_grid(api_rows) prefs_grid = self._make_grid(pref_rows) @@ -3743,6 +3972,7 @@ class Options(Gtk.Box): name = config_vals[3] client = config_vals[5] fullscreen = config_vals[6] + default_steam_path = config_vals[7] try: steam = query_config("steam_api")[0] @@ -3785,6 +4015,21 @@ class Options(Gtk.Box): if field[0] == "": field[1].get_children()[1].set_sensitive(False) + try: + pe_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ) + dayz_version = PeFile.get_dayz_version_str(pe_file_path) + except Exception: + dayz_version = "-" + + try: + exp_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ_EXP) + dayz_exp_version = PeFile.get_dayz_version_str(exp_file_path) + except Exception: + dayz_exp_version = "-" + + self.dayz_version_label.set_text(dayz_version) + self.dayz_exp_version_label.set_text(dayz_exp_version) + if branch == "testing": self.branch_combo.set_active(1) else: