Compare commits

..

No commits in common. "81c2d19a0963692c8b13b9e2ee9119f759d63c99" and "b92e337016d00dd8313dd3ac333581f378d06ce9" have entirely different histories.

4 changed files with 150 additions and 185 deletions

View File

@ -587,10 +587,10 @@ fetch_helpers_by_sum(){
sums=( sums=(
["funcs"]="f1db0e8b1068defdf834e9c9510bf315" ["funcs"]="f1db0e8b1068defdf834e9c9510bf315"
["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067"
["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf" ["servers.py"]="3610debc3f2931d2aa7c002ae912db88"
["ui.py"]="3258d5c85ef22517277c213ee2fc9b1d" ["ui.py"]="567b39cefb08f66f63f77cae5fa0c94f"
["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397"
["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0" ["pefile.py"]="cc23ff2725fedb1c64908f77477360b6"
) )
local author="aclist" local author="aclist"
local repo="dztui" local repo="dztui"

View File

@ -4,6 +4,7 @@ import typing # noqa
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from packaging.version import Version
from pathlib import Path from pathlib import Path
from shlex import shlex from shlex import shlex
from typing import BinaryIO, Union from typing import BinaryIO, Union
@ -23,6 +24,7 @@ class VersionMatch(Enum):
LOCAL_OLDER = 1 LOCAL_OLDER = 1
LOCAL_NEWER = 2 LOCAL_NEWER = 2
SAME_VERSION = 3 SAME_VERSION = 3
FAIL = 4
class u8: class u8:
@ -199,7 +201,6 @@ class RESOURCE_DIRECTORY_ENTRY(PackedData):
(the name consists of 16 bits length and trailing wide characters, (the name consists of 16 bits length and trailing wide characters,
in Unicode, not 0-terminated). in Unicode, not 0-terminated).
""" """
name_or_id: u32 name_or_id: u32
data_or_subdir: u32 data_or_subdir: u32
@ -246,34 +247,30 @@ class Result:
class PeFileError(Exception): class PeFileError(Exception):
"""Expected contents missing from headers or resource nodes""" """Expected contents missing from headers or resource nodes"""
pass pass
class AppNotInstalledError(Exception): class AppNotInstalledError(Exception):
"""App not present in user's libraryfolders""" """App not present in user's libraryfolders"""
pass pass
class AppMovedError(Exception): class AppMovedError(Exception):
"""VDF points to a nonexistent location on disk""" """VDF points to a nonexistent location on disk"""
pass pass
class VDFLoadError(Exception): class VDFLoadError(Exception):
"""Malformed VDF or JSON conversion""" """Malformed VDF or JSON conversion"""
pass pass
def parse_version_number(data: BinaryIO): def parse_version_number(data: BinaryIO):
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo # https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xffff
major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xffff
build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xffff
revision = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF revision = struct.unpack("<L", data.read(4))[0] >> 16 & 0xffff
return FileVersion(major, minor, build, revision) return FileVersion(major, minor, build, revision)
@ -291,24 +288,17 @@ def seek_to_pe_stub(data: BinaryIO) -> None:
raise PeFileError("missing PE header data") raise PeFileError("missing PE header data")
def get_dayz_version(file: Path) -> DayZVersion | Exception: def get_dayz_version(file: Path) -> DayZVersion:
try:
version = get_version(file) version = get_version(file)
except Exception as e:
return e
patch = str(version.build) + str(version.revision) patch = str(version.build) + str(version.revision)
dz_vers = DayZVersion(version.major, version.minor, int(patch)) dz_vers = DayZVersion(version.major, version.minor, int(patch))
return dz_vers return dz_vers
def dayz_version_to_str(v: DayZVersion) -> str: def get_dayz_version_str(file: Path) -> str:
return ".".join(str(el) for el in [v.major, v.minor, v.patch]) v = get_dayz_version(file)
concat = ".".join(str(el) for el in [v.major, v.minor, v.patch])
return concat
def dayz_version_from_str(v: str) -> DayZVersion:
vers = v.split(".")
assert len(vers) == 3
return DayZVersion(*[int(el) for el in vers])
def get_version(file): def get_version(file):
@ -362,8 +352,8 @@ def get_version(file):
seek_to_hex(hex(offset + shift), f) seek_to_hex(hex(offset + shift), f)
table = RESOURCE_DIRECTORY_TABLE.unpack(f) table = RESOURCE_DIRECTORY_TABLE.unpack(f)
total = ( total = (
table.number_of_name_entries table.number_of_name_entries +
+ table.number_of_id_entries table.number_of_id_entries
) )
for entry in range(total): for entry in range(total):
entry = RESOURCE_DIRECTORY_ENTRY.unpack(f) entry = RESOURCE_DIRECTORY_ENTRY.unpack(f)
@ -399,6 +389,14 @@ def get_version(file):
return version return version
def is_older_version(local: str, remote: str) -> 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: def get_pefile_path(path: str, appid: int) -> Path:
binary = "DayZ_x64.exe" binary = "DayZ_x64.exe"
identifier = {221100: "DayZ", 1024020: "DayZ Exp"} identifier = {221100: "DayZ", 1024020: "DayZ Exp"}
@ -433,60 +431,69 @@ def get_pefile_path(path: str, appid: int) -> Path:
return pe_path return pe_path
def compare_versions(local: DayZVersion, remote: DayZVersion): def compare_versions(remote: str, appid: int, path: str):
""" if appid == 221100:
packaging.version module is not available OOTB on some distributions build = "DayZ"
""" else:
if dayz_version_to_str(local) == dayz_version_to_str(remote): build = "DayZ Experimental"
return VersionMatch.SAME_VERSION
if local.major < remote.major: local = None
return VersionMatch.LOCAL_OLDER pe_filepath = None
if local.major > remote.major: error = None
return VersionMatch.LOCAL_NEWER
if local.major == remote.major: try:
if local.minor < remote.minor: pe_filepath = get_pefile_path(path, appid)
return VersionMatch.LOCAL_OLDER except Exception as e:
if local.minor > remote.minor: return Result(
return VersionMatch.LOCAL_NEWER local, remote, build, pe_filepath, VersionMatch.FAIL, e
if local.minor == remote.minor: )
if local.patch < remote.patch:
return VersionMatch.LOCAL_OLDER try:
if local.patch > remote.patch: local = get_dayz_version_str(pe_filepath)
return VersionMatch.LOCAL_NEWER except PeFileError:
if local.patch == remote.patch: return Result(
return VersionMatch.SAME_VERSION 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 vdf_to_json(stream):
def _istr(indent, string): def _istr(indent, string):
return (indent * " ") + string return (indent * ' ') + string
jbuf = "{\n" jbuf = '{\n'
lex = shlex(stream) lex = shlex(stream)
indent = 1 indent = 1
while True: while True:
tok = lex.get_token() tok = lex.get_token()
if not tok: if not tok:
return jbuf + "}\n" return jbuf + '}\n'
if tok == "}": if tok == '}':
indent -= 1 indent -= 1
jbuf += _istr(indent, "}") jbuf += _istr(indent, '}')
ntok = lex.get_token() ntok = lex.get_token()
lex.push_token(ntok) lex.push_token(ntok)
if ntok and ntok != "}": if ntok and ntok != '}':
jbuf += "," jbuf += ','
jbuf += "\n" jbuf += '\n'
else: else:
ntok = lex.get_token() ntok = lex.get_token()
if ntok == "{": if ntok == '{':
jbuf += _istr(indent, tok + ": {\n") jbuf += _istr(indent, tok + ': {\n')
indent += 1 indent += 1
else: else:
jbuf += _istr(indent, tok + ": " + ntok) jbuf += _istr(indent, tok + ': ' + ntok)
ntok = lex.get_token() ntok = lex.get_token()
lex.push_token(ntok) lex.push_token(ntok)
if ntok != "}": if ntok != '}':
jbuf += "," jbuf += ','
jbuf += "\n" jbuf += '\n'

View File

@ -201,7 +201,7 @@ def query_direct(ip: str, qport: int, TIMEOUT=3.0) -> dict | None:
return None return None
@dataclass(slots=True, frozen=True) @dataclass
class Res: class Res:
status: int status: int
parsed: bool parsed: bool
@ -215,14 +215,14 @@ class Ping:
ping: int ping: int
@dataclass(slots=True, frozen=True) @dataclass
class Details: class Details:
data: Union[list, None] data: Union[list, None]
description: str description: str
success: bool success: bool
@dataclass(slots=True, frozen=True) @dataclass
class Prereqs: class Prereqs:
password: bool password: bool
gameport: int gameport: int
@ -232,9 +232,6 @@ class Prereqs:
@dataclass(slots=True) @dataclass(slots=True)
class Record: class Record:
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str ip: str
gameport: int gameport: int
qport: int qport: int

View File

@ -23,18 +23,12 @@ from typing import Literal, Self, Any
import servers as Servers # noqa E402 import servers as Servers # noqa E402
import pefile as PeFile # noqa E402 import pefile as PeFile # noqa E402
from pefile import ( from pefile import VDFLoadError, AppNotInstalledError, AppMovedError, PeFileError
VDFLoadError, from pefile import VersionMatch
AppNotInstalledError,
AppMovedError,
PeFileError,
)
from pefile import VersionMatch, DayZVersion
locale.setlocale(locale.LC_ALL, "") locale.setlocale(locale.LC_ALL, "")
import gi # noqa E402 import gi # noqa E402
gi.require_version("Gtk", "3.0") 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
@ -99,7 +93,7 @@ If this issue persists, your API key may be defunct.
""" """
@dataclass(slots=True) @dataclass
class Record: class Record:
ip: str ip: str
gameport: int gameport: int
@ -731,7 +725,7 @@ def format_metadata(row_sel: str) -> str:
"fav_label": config_vals[4], "fav_label": config_vals[4],
"preferred_client": config_vals[5], "preferred_client": config_vals[5],
"fullscreen": config_vals[6], "fullscreen": config_vals[6],
"default_steam_path": config_vals[7], "default_steam_path": config_vals[7]
} }
if row is None: if row is None:
return "" return ""
@ -942,7 +936,6 @@ def call_on_thread(
""" """
Exclusively used for threaded subprocesses Exclusively used for threaded subprocesses
""" """
def _background(subproc: str, args: str, dialog): def _background(subproc: str, args: str, dialog):
def _load() -> None: def _load() -> None:
wait_dialog.destroy() wait_dialog.destroy()
@ -1018,7 +1011,7 @@ def process_tree_option(choice: RowType) -> None:
parse_shell_output, parse_shell_output,
"Querying server", "Querying server",
command, command,
[record], [record]
) )
return return
@ -1052,8 +1045,9 @@ def thread_new_with_dialog(
callback: Callable | None, callback: Callable | None,
msg: str, msg: str,
row: RowType | None, row: RowType | None,
args: list, args: list
) -> None: ) -> None:
""" """
Pop a GenericDialog transient to App.treeview and Pop a GenericDialog transient to App.treeview and
call a function on a thread, with optional callback. call a function on a thread, with optional callback.
@ -1134,7 +1128,11 @@ def connect_by_ip(enum: RowType, response: str) -> None:
return proc return proc
thread_new_with_dialog( thread_new_with_dialog(
_prep, parse_shell_output, "Querying IP", enum, [response] _prep,
parse_shell_output,
"Querying IP",
enum,
[response]
) )
return return
@ -1146,7 +1144,11 @@ def connect_by_id(enum: RowType, response: str, key: str) -> None:
return proc return proc
thread_new_with_dialog( thread_new_with_dialog(
_prep, parse_shell_output, "Querying API", enum, [key, response] _prep,
parse_shell_output,
"Querying API",
enum,
[key, response]
) )
return return
@ -1159,9 +1161,7 @@ def process_user_input(enum: RowType) -> None:
if enum == RowType.CONN_BY_ID: if enum == RowType.CONN_BY_ID:
key = query_config("api_key")[0] key = query_config("api_key")[0]
if len(key) == 0: if len(key) == 0:
spawn_dialog( spawn_dialog("No Battlemetrics API key is set; see Options", Popup.NOTIFY)
"No Battlemetrics API key is set; see Options", Popup.NOTIFY
)
return return
user_entry = EntryDialog(prompt, Popup.ENTRY, link_label) user_entry = EntryDialog(prompt, Popup.ENTRY, link_label)
@ -2349,10 +2349,7 @@ class TreeView(Gtk.TreeView):
params = Servers.params params = Servers.params
serv = [] serv = []
with ThreadPoolExecutor() as executor: with ThreadPoolExecutor() as executor:
futures = [ futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params]
executor.submit(job, key, APPID_DAYZ, param)
for param in params
]
wait(futures) wait(futures)
for future in futures: for future in futures:
res = future.result() res = future.result()
@ -2858,96 +2855,62 @@ class TreeView(Gtk.TreeView):
def get_view(self): def get_view(self):
return self.view return self.view
def prepare_connection( def prepare_connection(self, record: Record) -> subprocess.CompletedProcess | None:
self, record: Record
) -> subprocess.CompletedProcess | None:
""" """
Always called on a thread with a dialog on the transient parent window Always called on a thread with a dialog on the transient parent window
""" """
prereqs = Servers.get_prereqs(record.ip, record.qport) prereqs = Servers.get_prereqs(record.ip, record.qport)
if prereqs.appid is None: if prereqs.appid is None:
logger.warning(f"Query to '{record.ip}:{record.qport}' timed out")
msg = "Timed out when querying server, check IP or try again later" msg = "Timed out when querying server, check IP or try again later"
spawn_dialog(msg, Popup.NOTIFY) spawn_dialog(msg, Popup.NOTIFY)
return None return None
build = "DayZ" if prereqs.appid == APPID_DAYZ else "DayZ Experimental" if prereqs.version is not None:
steam_path = query_config("default_steam_path")[0] path = query_config("default_steam_path")[0]
result = PeFile.compare_versions(prereqs.version, prereqs.appid, path)
if len(steam_path) < 1: if result.error is not None:
logger.critical( logger.warning(result.error)
"Config file has no value set for 'default_steam_path'"
)
msg = f"Local Steam installation is not set, possibly malformed config file."
spawn_dialog(msg, Popup.NOTIFY)
return None
try: if result.match == VersionMatch.FAIL:
pefile_path = PeFile.get_pefile_path(steam_path, prereqs.appid) if isinstance(result.error, VDFLoadError) or isinstance(result.error, PeFileError):
except AppNotInstalledError: # permissive; file exists, but could not determine version
logger.critical( pass
f"'{prereqs.appid}' not found in user's libraryfolders" if isinstance(result.error, AppNotInstalledError):
) if prereqs.appid == 1024020:
msg = ( msg = (
f"This server is running {build}. " "This server is running DayZ Experimental, a beta build. "
f"You can install {build} by searching for it in " "You can install DayZ Experimental by searching for it in "
"your Steam library." "your Steam library."
) )
spawn_dialog(msg, Popup.NOTIFY) spawn_dialog(msg, Popup.NOTIFY)
return None return None
except AppMovedError: if isinstance(result.error, AppMovedError):
logger.critical(
f"Library folder synch error for '{prereqs.appid}'"
)
msg = ( msg = (
f"Steam is reporting that {build} is installed at a non-existent location. " f"Steam is reporting that {result.build} is installed at a non-existent location. "
f"If you recently installed {build} or moved it to a different drive, " 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." "restart Steam to allow these changes to synchronize, then try again."
) )
spawn_dialog(msg, Popup.NOTIFY) spawn_dialog(msg, Popup.NOTIFY)
return None return None
except (VDFLoadError, PeFileError, Exception) as e:
logger.critical(e)
msg = "Steam settings or DayZ installation may be corrupted. Try restarting Steam."
spawn_dialog(msg, Popup.NOTIFY)
return None
try: if result.match == VersionMatch.LOCAL_OLDER:
local_vers = PeFile.get_dayz_version(pefile_path)
except (PeFileError, Exception) as e:
"""
Currently permissive; file exists, but was unparseable.
"""
logger.warning(f"Failed to parse PE file: {e}")
local_vers = None
try:
remote_vers = PeFile.dayz_version_from_str(prereqs.version)
except Exception:
remote_vers = None
if local_vers is not None and remote_vers is not None:
match = PeFile.compare_versions(local_vers, remote_vers)
match match:
case VersionMatch.LOCAL_OLDER:
msg = ( msg = (
f"This server is running a newer build ({prereqs.version}) of {build} than " f"This server is running a newer build ({result.remote}) of {result.build} than "
f"your local version. You may be unable to connect. Proceed anyway?" f"your local version ({result.local}). You may be unable to connect. Proceed anyway?"
) )
res = spawn_dialog(msg, Popup.CONFIRM) res = spawn_dialog(msg, Popup.CONFIRM)
if res is True: if res is True:
return None return None
case VersionMatch.LOCAL_NEWER:
if result.match == VersionMatch.LOCAL_NEWER:
msg = ( msg = (
f"This server is running an out-of-date build ({prereqs.version}) of {build}. " f"This server is running an out-of-date build ({result.remote}) of {result.build}. "
"You may be unable to connect. Proceed anyway?" "You may be unable to connect. Proceed anyway?"
) )
res = spawn_dialog(msg, Popup.CONFIRM) res = spawn_dialog(msg, Popup.CONFIRM)
if res is True: if res is True:
return None return None
case VersionMatch.SAME_VERSION:
pass
if prereqs.password is True: if prereqs.password is True:
msg = ( msg = (
@ -2961,10 +2924,14 @@ class TreeView(Gtk.TreeView):
""" """
When using RowType.CONN_BY_IP, the gameport needs to be interpolated When using RowType.CONN_BY_IP, the gameport needs to be interpolated
""" """
record.gameport = prereqs.gameport record.gameport = prereqs.gameport
addr = record_to_str(record) addr = record_to_str(record)
proc = call_out( proc = call_out(
"try_connect", addr, str(prereqs.appid), str(pefile_path) "try_connect",
addr,
str(prereqs.appid),
str(result.path)
) )
return proc return proc
@ -3041,7 +3008,7 @@ class TreeView(Gtk.TreeView):
parse_shell_output, parse_shell_output,
"Querying server", "Querying server",
None, None,
[record], [record]
) )
case _: # any other non-server option from the main menu case _: # any other non-server option from the main menu
process_tree_option(output) process_tree_option(output)
@ -3395,7 +3362,7 @@ class DetailsDialog(GenericDialog):
reg = r"\s(www\.*?)" reg = r"\s(www\.*?)"
text = re.sub(reg, " http://" + r"\1", text) text = re.sub(reg, " http://" + r"\1", text)
reg2 = r"(http.*?)([ ,\r\n]|$)" reg2 = r"(http.*?)([ ,\r\n]|$)"
text = re.sub(reg2, comp(r"\1") + r"\2", text) text = re.sub(reg2, comp(r"\1")+r"\2", text)
self.description.set_markup(text) self.description.set_markup(text)
self.success = response.success self.success = response.success
@ -3760,7 +3727,7 @@ class Options(Gtk.Box):
version_rows = [ version_rows = [
[LeftLabel("DayZ"), self.dayz_version_label], [LeftLabel("DayZ"), self.dayz_version_label],
[LeftLabel("DayZ Experimental"), self.dayz_exp_version_label], [LeftLabel("DayZ Experimental"), self.dayz_exp_version_label],
[LeftLabel("DZGUI branch"), self.branch_combo, eb], [LeftLabel("DZGUI branch"), self.branch_combo, eb]
] ]
api_grid = self._make_grid(api_rows) api_grid = self._make_grid(api_rows)
@ -4049,20 +4016,14 @@ class Options(Gtk.Box):
field[1].get_children()[1].set_sensitive(False) field[1].get_children()[1].set_sensitive(False)
try: try:
pe_file_path = PeFile.get_pefile_path( pe_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ)
default_steam_path, APPID_DAYZ dayz_version = PeFile.get_dayz_version_str(pe_file_path)
)
vers = PeFile.get_dayz_version(pe_file_path)
dayz_version = PeFile.dayz_version_to_str(vers)
except Exception: except Exception:
dayz_version = "-" dayz_version = "-"
try: try:
exp_file_path = PeFile.get_pefile_path( exp_file_path = PeFile.get_pefile_path(default_steam_path, APPID_DAYZ_EXP)
default_steam_path, APPID_DAYZ_EXP dayz_exp_version = PeFile.get_dayz_version_str(exp_file_path)
)
vers = PeFile.get_dayz_version(exp_file_path)
dayz_exp_version = PeFile.dayz_version_to_str(vers)
except Exception: except Exception:
dayz_exp_version = "-" dayz_exp_version = "-"