diff --git a/CHANGELOG.md b/CHANGELOG.md index f150c3d..0a443f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [6.0.2-beta.1] 2026-01-18 +## Fixed +- Do not spawn dialogs from inside of thread when fetching prereqs +## Changed +- Optimize time complexity of startup checks +- Optimize coordinate calculation by using local records first +- Rename some internal functions +- Wrap entire startup process in dialog + ## [6.0.1-beta.1] 2026-01-16 ## Fixed - Explicitly use Python 3.13 when calling subprocesses diff --git a/dzgui.sh b/dzgui.sh index e084137..00b480f 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -2,8 +2,7 @@ set -o pipefail src_path="$(readlink -e "$0")" - -version=6.0.1.beta-1 +version=6.0.2-beta.1 #CONSTANTS aid=221100 @@ -143,7 +142,7 @@ test_gobject(){ python3.13 -c "import gi" if [[ ! $? -eq 0 ]]; then logger CRITICAL "Missing PyGObject" - quit_with_pdialog "Requires PyGObject (python-gobject)" + quit_with_help_dialog "Requires PyGObject (python-gobject)" exit 1 fi logger INFO "Found PyGObject in Python env" @@ -248,7 +247,7 @@ open_url(){ xdg-open "$url" fi } -quit_with_pdialog(){ +quit_with_help_dialog(){ local sel msg="$1" help_button="Open help page" @@ -261,7 +260,7 @@ quit_with_pdialog(){ check_pyver(){ if [[ ! $(python3.13 --version) ]]; then local msg="Requires Python 3.13" - quit_with_pdialog "$msg" + quit_with_help_dialog "$msg" fi } watcher_deps(){ @@ -336,6 +335,7 @@ check_unmerged(){ fi } check_version(){ + echo "# Checking version" [[ -n $reference_branch ]] && return local version_url=$(format_version_url) local upstream=$(curl -Ls "$version_url" | awk -F= '/^version=/ {print $2}') @@ -415,6 +415,7 @@ test_display_mode(){ fi } check_architecture(){ + echo "# Checking system architecture" local cpu=$(< /proc/cpuinfo awk -F": " '/AMD Custom APU [0-9]{4}$/ {print $2; exit}') read -a APU_MODEL <<< "$cpu" if [[ ${APU_MODEL[3]} != "0932" ]] && [[ ${APU_MODEL[3]} != "0405" ]]; then @@ -431,6 +432,7 @@ check_architecture(){ logger INFO "Setting architecture to 'Steam Deck'" } check_map_count(){ + echo "# Checking map count" [[ $is_steam_deck -gt 0 ]] && return 0 local map_count_file="/proc/sys/vm/max_map_count" local min_count=1048576 @@ -480,6 +482,7 @@ tdialog(){ $steamsafe_zenity --info --text="$1" "${zenity_flags[@]}" } steam_deps(){ + echo "# Checking Steam" local flatpak local steam [[ $(command -v flatpak) ]] && flatpak=$(flatpak list | grep valvesoftware.Steam) @@ -490,16 +493,8 @@ steam_deps(){ exit 1 fi } -migrate_files(){ - if [[ ! -f $config_path/dztuirc.oldapi ]]; then - cp $config_file $config_path/dztuirc.oldapi - logger INFO "Migrated old API file" - fi - [[ ! -f $hist_file ]] && return - rm $hist_file - logger INFO "Wiped old history file" -} stale_symlinks(){ + echo "# Cleaning stale symlinks" local game_dir="$steam_path/steamapps/common/DayZ" readarray -t links < <(find "$game_dir" -xtype l) for link in "${links[@]}"; do @@ -513,7 +508,7 @@ check_availability() { return 1 fi local url=$1 - local timeout_sec="3" + local timeout_sec="1" if [[ $2 ]]; then timeout_sec=$2 fi @@ -524,6 +519,7 @@ check_availability() { } local_latlon(){ + echo "# Checking coordinates" if [[ -z $(command -v dig) ]]; then local url_ipecho="https://ipecho.net/plain" if ! check_availability "ipecho.net"; then @@ -535,6 +531,9 @@ local_latlon(){ # TODO : implement checking remote local local_ip=$(dig -4 +short myip.opendns.com @resolver1.opendns.com) fi + if calc_local_coords "$local_ip"; then + return 0 + fi local url_ip_api="http://ip-api.com/json/$local_ip" if ! check_availability "ip-api.com"; then logger WARN "Failed to get local coordinates, ip-api.com service may be down." @@ -549,6 +548,7 @@ local_latlon(){ } lock(){ + echo "# Setting up lock file" [[ ! -f $lock_file ]] && touch $lock_file local pid=$(cat $lock_file) ps -p $pid -o pid= >/dev/null 2>&1 @@ -626,10 +626,10 @@ fetch_helpers_by_sum(){ [[ -f "$config_file" ]] && source "$config_file" declare -A sums sums=( - ["funcs"]="2a71d60974f5a869ff170366fd80676f" + ["funcs"]="69e83db00cbc7674e241565c88aed83d" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["servers.py"]="ed442c3aecf33f777d59dcf53650d263" - ["ui.py"]="3d67e5e8e85a23dde1fd0e85a9be62a9" + ["ui.py"]="d09b9d8bed3854efd51377289786c5ac" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0" ) @@ -691,12 +691,12 @@ fetch_km_helper(){ } get_response_code(){ local url="$1" - curl -Ls -I -o /dev/null -w "%{http_code}" "$url" + curl --connect-timeout 3 -Ls -I -o /dev/null -w "%{http_code}" "$url" } fetch_ip_db(){ parse_dl_url(){ - curl -Ls "$url" \ + curl --connect-timeout 3 -Ls "$url" \ | grep "csv.gz" \ | awk -F"['']" '{print $2}' } @@ -712,6 +712,9 @@ fetch_ip_db(){ fetch(){ logger INFO "Triggering fetch routine" local url="$1" + local ip_file="${state_path}/ips.csv" + local base_file="${state_path}/${this_month}.csv.gz" + local extracted_file="${state_path}/${this_month}.csv" curl -Ls "$url" > "$base_file" if [[ $? -ne 0 ]]; then @@ -731,7 +734,7 @@ fetch_ip_db(){ logger WARN "Abnormal exit while parsing IPs" return fi - rm "${state_path}/${month}.csv" + rm "${state_path}/${this_month}.csv" readarray -t records < <(cat $ip_file | awk -F, 'NR==1 {print $1} END {print $1}') if [[ ${records[0]} != "0.0.0.0" ]] && [[ ${records[1]} != "224.0.0.0" ]]; then @@ -746,55 +749,57 @@ fetch_ip_db(){ rm "$ip_file" fi - echo "$month" > "$month_file" - logger INFO "Wrote '$month' to stub '$month_file'" + echo "$this_month" > "$month_file" + logger INFO "Wrote '$this_month' to stub '$month_file'" logger INFO "Updated '$ip_file'" } + check_remote(){ + local url="$1" + local res=$(get_response_code "$url") + if [[ $res -ne 200 ]]; then + logger WARN "Failed to retrieve remote resource: '$url' ($res)" + return + fi + logger INFO "Resolved remote URL: '$url'" + + # test dl url + local dl_url="$(parse_dl_url)" + local res=$(get_response_code "$dl_url") + if [[ $res -ne 200 ]]; then + logger WARN "Remote resource unavailable: '$dl_url' ($res)" + return + fi + logger INFO "Resolved download URL: '$dl_url'" + fetch "$dl_url" + } + local url="https://db-ip.com/db/download/ip-to-city-lite" - local month_file="${state_path}/.month" - local ip_file="${state_path}/ips.csv" - - # test main url - local res=$(get_response_code "$url") - if [[ $res -ne 200 ]]; then - logger WARN "Failed to retrieve remote resource: '$url' ($res)" - return - fi - logger INFO "Resolved remote URL: '$url'" - - # test dl url - local dl_url="$(parse_dl_url)" - local res=$(get_response_code "$dl_url") - if [[ $res -ne 200 ]]; then - logger WARN "Remote resource unavailable: '$dl_url' ($res)" - return - fi - logger INFO "Resolved download URL: '$dl_url'" - - local month=$(parse_dl_url_date "$dl_url") - local base_file="${state_path}/${month}.csv.gz" - local extracted_file="${state_path}/${month}.csv" + month_file="${state_path}/.month" + this_month=$(date +%Y-%m) # no stub file if [[ ! -f $month_file ]]; then logger WARN "No stub file '$month_file' present" - fetch "$dl_url" - return - fi - - # local needs update - local last_month=$(< "$month_file") - if [[ $last_month != "$month" ]]; then - logger WARN "Local stub '$last_month' does not match remote stub '$month'" - fetch "$dl_url" + check_remote "$url" return fi # if stub is same date, abort - logger INFO "Local stub '$last_month' is identical to remote, skipping" + local last_month=$(< "$month_file") + if [[ $last_month == "$this_month" ]]; then + logger INFO "Local stub '$last_month' is identical to remote, skipping" + return + fi + + # local needs update + logger WARN "Local stub '$last_month' does not match '$this_month'" + check_remote "$url" + return + } fetch_helpers(){ + echo "# Checking helper files" fetch_a2s fetch_dzq fetch_km_helper @@ -982,6 +987,7 @@ create_config(){ done } varcheck(){ + echo "# Checking config file" local msg="Config file '$config_file' missing. Start first-time setup now?" local msg2="The Steam paths set in your config file appear to be invalid (DayZ was moved or uninstalled). Restart first-time setup now?" if [[ ! -f $config_file ]]; then @@ -1008,12 +1014,14 @@ varcheck(){ fi } is_dzg_downloading(){ + echo "# Checking DayZ" if [[ -d $steam_path ]] && [[ -d $steam_path/downloading/$aid ]]; then logger WARN "DayZ may be scheduling updates" return 0 fi } is_steam_running(){ + echo "# Checking Steam" local res=$(ps aux | grep "steamwebhelper" | grep -v grep) if [[ -z $res ]]; then logger WARN "Steam may not be running" @@ -1026,6 +1034,7 @@ get_response_code(){ curl -Ls -I -o /dev/null -w "%{http_code}" "$url" } test_connection(){ + echo "# Testing connection" declare -A hr local res1 local res2 @@ -1059,6 +1068,7 @@ legacy_cols(){ mv $cols_file.new $cols_file } stale_mod_signatures(){ + echo "# Checking stale mods" [[ ! -f "$versions_file" ]] && return local workshop_dir="$steam_path/steamapps/workshop/content/$aid" if [[ -d $workshop_dir ]]; then @@ -1072,18 +1082,48 @@ stale_mod_signatures(){ } create_new_links(){ + echo "# Setting up symlinks" "$func_helper" "update_symlinks" } +calc_local_coords(){ + local ip="$1" + [[ ! -f "$geo_helper" ]] && return 1 + + IFS="." read -ra split <<< "$ip" + [[ ${#split[@]} -ne 4 ]] && return 1 + + prefix="^${split[0]}.${split[1]}." + readarray -t res < <(grep "$prefix" "$geo_helper") + + for address in "${res[@]}"; do + IFS="," read -ra fields <<< "$address" + upper="${fields[1]}" + IFS="." read -ra upper_digits <<< "$upper" + if [[ ${split[2]} -gt ${upper_digits[2]} ]]; then + continue + fi + if [[ ${split[2]} -eq ${upper_digits[2]} ]]; then + if [[ ${split[3]} -gt ${upper_digits[3]} ]]; then + continue + fi + if [[ ${split[3]} -eq ${upper_digits[3]} ]]; then + echo "${fields[-2]}" >> "$coords_file" + echo "${fields[-1]}" >> "$coords_file" + return 0 + fi + fi + if [[ ${split[2]} -lt ${upper_digits[2]} ]]; then + echo "${fields[-2]}" >> "$coords_file" + echo "${fields[-1]}" >> "$coords_file" + return 0 + fi + done + return 1 +} initial_setup(){ - setup_dirs - setup_state_files - depcheck - check_pyver - test_gobject - watcher_deps check_architecture test_connection - fetch_helpers > >(pdialog "Checking helper files") + #fetch_helpers varcheck source "$config_file" lock @@ -1092,14 +1132,12 @@ initial_setup(){ check_version check_map_count steam_deps - migrate_files stale_symlinks stale_mod_signatures create_new_links local_latlon is_steam_running is_dzg_downloading - print_config_vals } uninstall(){ _full(){ @@ -1201,8 +1239,15 @@ main(){ set_im_module - printf "Initializing setup...\n" - initial_setup + printf "Checking dependencies...\n" + setup_dirs + setup_state_files + depcheck + check_pyver + test_gobject + watcher_deps + + initial_setup > >(pdialog "Initializing setup") printf "All OK. Kicking off UI...\n" python3.13 "$ui_helper" "--init-ui" "$version" "$is_steam_deck" diff --git a/helpers/funcs b/helpers/funcs index 431d5ec..94f1697 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -o pipefail -version="6.0.1-beta.1" +version="6.0.2-beta.1" #CONSTANTS aid=221100 diff --git a/helpers/ui.py b/helpers/ui.py index 81aaf3e..bff57a0 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -19,7 +19,7 @@ from collections.abc import Callable from concurrent.futures import wait from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from typing import Literal, Self, Any +from typing import Literal, Self, Any, TYPE_CHECKING import servers as Servers # noqa E402 import pefile as PeFile # noqa E402 @@ -32,10 +32,12 @@ from pefile import ( ) from pefile import VersionMatch +if TYPE_CHECKING: + from servers import Prereqs + 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 @@ -1027,13 +1029,7 @@ def process_tree_option(choice: RowType) -> None: return record = str_to_record(record) - thread_new_with_dialog( - App.treeview.prepare_connection, - parse_shell_output, - "Querying server", - command, - [record], - ) + prepare_connection(command, record) return match command.dict["type"]: @@ -1061,48 +1057,54 @@ def parse_shell_output(proc: subprocess.CompletedProcess, row: RowType): 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, +def prepare_connection(rowtype: RowType, record: Record) -> None: + def background(rowtype: RowType, record: Record) -> None: + def cleanup() -> None: + App.treeview.wait_dialog.destroy() + # NOTE: When using RowType.CONN_BY_IP, the gameport needs to be interpolated + record.gameport = prereqs.gameport + addr = record_to_str(record) + + if proceed is False: + spawn_dialog(msg, Popup.NOTIFY) + return + if msg != "": + res = spawn_dialog(msg, Popup.CONFIRM) + if res is False: + try_connect(addr, str(prereqs.appid), str(pefile_path), rowtype) + else: + try_connect(addr, str(prereqs.appid), str(pefile_path), rowtype) + + proceed, msg, pefile_path, prereqs = App.treeview.get_prereqs(record) + GLib.idle_add(cleanup) + + msg = "Checking prerequisites" + App.treeview.dialog_show(msg) + thread = threading.Thread(target=background, args=(rowtype, record)) + thread.start() + +def try_connect( + addr: str, + appid: str, + path: str, + row: RowType ) -> 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 background(addr, appid, path): 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) + parse_shell_output(proc, row) - exception = None - proc = None try: - proc = func(*args) + proc = call_out("try_connect", addr, appid, path) except Exception as e: - exception = e + logger.critical(e) GLib.idle_add(cleanup) return GLib.idle_add(cleanup) + msg = "Querying server" App.treeview.dialog_show(msg) - thread = threading.Thread(target=background, args=(args)) + thread = threading.Thread(target=background, args=(addr, appid, path)) thread.start() @@ -1142,27 +1144,31 @@ def record_to_str(record: Record) -> str: def connect_by_ip(enum: RowType, response: str) -> None: - def _prep(response: str) -> None: + try: 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 - + except Exception as e: + spawn_dialog(str(e), Popup.NOTIFY) + return + prepare_connection(enum, record) 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 + def cleanup() -> None: + App.treeview.dialog_hide() + prepare_connection(enum, record) - thread_new_with_dialog( - _prep, parse_shell_output, "Querying API", enum, [key, response] - ) - return + try: + record = Servers.query_bm_api(key, response) + except Exception as e: + spawn_dialog(str(e), Popup.NOTIFY) + App.treeview.dialog_hide() + return + GLib.idle_add(cleanup) + + msg = "Validating ID" + App.treeview.dialog_show(msg) + thread = threading.Thread(target=_prep, args=(key, response)) + thread.start() def process_user_input(enum: RowType) -> None: @@ -2493,6 +2499,8 @@ class TreeView(Gtk.TreeView): def _dump_servers(self, ips: list) -> list | None: if len(ips) == 0: return [] + # NOTE: block malformed records + ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != "" ] with ThreadPoolExecutor() as executor: futures = [ executor.submit( @@ -2943,9 +2951,9 @@ class TreeView(Gtk.TreeView): def get_view(self): return self.view - def prepare_connection( + def get_prereqs( self, record: Record - ) -> subprocess.CompletedProcess | None: + ) -> tuple[bool, str, str|None, "Prereqs"]: """ Always called on a thread with a dialog on the transient parent window """ @@ -2953,8 +2961,7 @@ class TreeView(Gtk.TreeView): 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" - spawn_dialog(msg, Popup.NOTIFY) - return None + return (False, msg, None, prereqs) build = "DayZ" if prereqs.appid == APPID_DAYZ else "DayZ Experimental" steam_path = query_config("default_steam_path")[0] @@ -2964,8 +2971,7 @@ class TreeView(Gtk.TreeView): "Config file has no value set for 'default_steam_path'" ) msg = "Local Steam installation is not set, possibly malformed config file." - spawn_dialog(msg, Popup.NOTIFY) - return None + return (False, msg, None, prereqs) try: pefile_path = PeFile.get_pefile_path(steam_path, prereqs.appid) @@ -2979,8 +2985,7 @@ class TreeView(Gtk.TreeView): f"If you recently installed {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 + return (False, msg, None, prereqs) except AppMovedError: logger.critical( f"Library folder synch error for '{prereqs.appid}'" @@ -2990,13 +2995,11 @@ class TreeView(Gtk.TreeView): f"If you recently installed {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 + return (False, msg, None, prereqs) 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 + return (False, msg, None, prereqs) try: local_vers = PeFile.get_dayz_version(pefile_path) @@ -3008,7 +3011,7 @@ class TreeView(Gtk.TreeView): local_vers = None try: - remote_vers = PeFile.dayz_version_from_str(prereqs.version) + remote_vers = PeFile.dayz_version_from_str(prereqs) except Exception: remote_vers = None @@ -3021,18 +3024,15 @@ class TreeView(Gtk.TreeView): f"This server is running a newer build ({prereqs.version}) of {build} than " f"your local version. You may be unable to connect. Proceed anyway?" ) - res = spawn_dialog(msg, Popup.CONFIRM) - if res is True: - return None + return (True, msg, pefile_path, prereqs) case VersionMatch.LOCAL_NEWER: msg = ( f"This server is running an out-of-date build ({prereqs.version}) of {build}. " "You may be unable to connect. Proceed anyway?" ) - res = spawn_dialog(msg, Popup.CONFIRM) - if res is True: - return None + return (True, msg, pefile_path, prereqs) case VersionMatch.SAME_VERSION: + return (True, "", pefile_path, prereqs) pass if prereqs.password is True: @@ -3040,19 +3040,10 @@ class TreeView(Gtk.TreeView): "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 + return (True, msg, pefile_path, prereqs) + + return (True, "", pefile_path, prereqs) - """ - 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(pefile_path) - ) - return proc @signal_emission @update_window_labels @@ -3122,13 +3113,7 @@ class TreeView(Gtk.TreeView): record = self.get_record() if record is None: return - thread_new_with_dialog( - self.prepare_connection, - parse_shell_output, - "Querying server", - None, - [record], - ) + prepare_connection(None, record) case _: # any other non-server option from the main menu process_tree_option(output)