diff --git a/CHANGELOG.md b/CHANGELOG.md index eb7d338..86a1e0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,87 @@ # Changelog +## [6.0.0] 2026-01-14 +## Added +- Support DayZ Experimental +- Ping display on server tables +- Save descriptive text notes on a per-server basis +- Speed up load time and navigation of server tables +- More robust threading and cache system when filtering servers +- Show additional DayZ client information in Options menu +- Warn user of DayZ client version mismatches +- Support clickable hyperlinks in dialogs +- Rich server metadata details dialog (right-click on server table) +- Filter by modded servers +- Breadcrumbs showing current menu context +- Dedicated changelog page +- Dedicated keybindings page +- Dedicated settings page ("Options") +- Support sandboxed version of Flatpak +- Vim-style navigation keybindings +- Added "Return to main menu" button to dialog windows when failing to load server table +- Dynamic context menus for modded servers +- Additional keybindings for new filter toggles +- If a server is locked, prompt user when connecting +- Internal flag to allow distro-packaged releases to disable in-app updates +- Pre-boot checks to test whether script was invoked directly +- Commandline usage help text (GaryBlackbourne) +- More descriptive help dialogs when initial dependencies are missing + +## Fixed +- Script failing to start when remote endpoints are unavailable (GaryBlackbourne) +- Key stickiness when quickly navigating through entries in tables +- Servers returning malformed A2S_INFO blocking server browser from loading +- Normalized buttons in dialogs and restored proper padding +- GTK errors being emitted to stdout when inserting debug table +- Entry dialog sensitivity when validating API keys +- Additional validation on entry dialogs to prevent submitting empty text +- Suppress errors during pre-boot checks when mods are not installed +- Centered filter checkboxes within panel +- Do not pop unhighlight/select stale buttons if no stale mods exist +- Improved keybinding interaction with side panels +- Suppress typeahead search in mod dialogs +- Do not trigger global API cooldown if no LAN/favorite servers are found +- Fix table column expansion in server mod dialogs +- Missing parameters when closing application via window decorations +- Statusbar contents not updating on certain pages +- Prevent debug button from activating when in certain input fields +- Window size not updating correctly when unmaximizing window after changing "fullscreen at boot" setting +- Branch toggle signal being emitted when entering Options menu from other contexts +- Mods rarely not appearing in local mod list if download completed too quickly +- Statusbar not updating when clicking a row after spamming keyboard input +- Extraneous logs being generated when subscribing to mods +- Narrow width of columns in modlist dialogs occluding text +- Newline terminators in history file +- Floating point number calculation +- Window resizing too small if no prior resolution was set +- ESC key destroying wait dialogs while a thread is pending +- Tooltip signals being erroneously emitted on main menu +- UI not being constructed correctly if CHANGELOG.md was missing +- Config file erroneously getting updated when populating settings menu +- Leaky variable name in dialog titles +- Prevent extraneous signals from propagating when column width is adjusted +- Server filter panel not being hidden when entering other page contexts +- Path to remote changelog being constructed incorrectly +- Dialogs with newlines breaking output in log table +- Path discovery during first-time setup when parsing filepaths with whitespaces +- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it + +## Changed +- Require Python 3.13 +- Reduced global API cooldown from 60s to 30s +- Clarify dialog messages when DayZ path could not be found +- Auto-focus first item when opening context menus +- Opacity setting on side buttons when in a different context +- Refactored BM API key validation to account for new key format +- State file serialization methods +- Optimize time complexity of pre-boot checks (GaryBlackbourne) +- Rewrote distance calculation module (GaryBlackbourne) +- Changed preferred client setting from radio toggle to combobox + +## Dropped +- Ping readout in statusbar +- Extraneous information from right statusbar + ## [5.8.3] 2026-01-04 ## Fixed - Normalize checksum numbers and dates diff --git a/dzgui.sh b/dzgui.sh index 6523fc1..6f4772c 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -o pipefail -version=5.8.3 +src_path="$(readlink -e "$0")" +version=6.0.0 #CONSTANTS aid=221100 @@ -39,6 +40,7 @@ cols_file="$state_path/$prefix.cols.json" #CACHE FILES coords_file="$cache_path/$prefix.coords" +src_path_file="$cache_path/$prefix.src" #legacy paths hist_file="$config_path/history" @@ -63,17 +65,14 @@ testing_url="$url_prefix/testing" releases_url="https://github.com/$author/$repo/releases/download/browser" km_helper_url="$releases_url/latlon" - set_im_module(){ #TODO: drop pending SteamOS changes - pgrep -a gamescope | grep -q "generate-drm-mode" - if [[ $? -eq 0 ]]; then - GTK_IM_MODULE="" + if pgrep -a gamescope | grep -q "generate-drm-mode"; then + unset GTK_IM_MODULE logger INFO "Detected Steam Deck (Game Mode), unsetting GTK_IM_MODULE" - else - return fi } + redact(){ sed 's@\(/home/\)[^/]*@\1REDACTED@g' } @@ -87,13 +86,21 @@ logger(){ printf "%s␞%s␞%s::%s()::%s␞%s\n" "$date" "$tag" "$self" "$caller" "$line" "$string" \ | redact >> "$debug_log" } + setup_dirs(){ - for dir in "$state_path" "$cache_path" "$share_path" "$helpers_path" "$freedesktop_path" "$config_path" "$log_path"; do - if [[ ! -d $dir ]]; then - mkdir -p "$dir" - fi + directories=() + directories+=("$state_path") + directories+=("$cache_path") + directories+=("$share_path") + directories+=("$helpers_path") + directories+=("$freedesktop_path") + directories+=("$config_path") + directories+=("$log_path") + for dir in "${directories[@]}"; do + mkdir -p "$dir" done } + setup_state_files(){ if [[ -f "$debug_log" ]]; then rm "$debug_log" && touch $debug_log @@ -104,13 +111,13 @@ setup_state_files(){ logger INFO "Migrating legacy version file" fi # wipe cache files - local path="$cache_path" - if find "$path" -mindepth 1 -maxdepth 1 | read; then - for file in $path/*; do + if [[ $(ls -A "$cache_path") ]]; then + for file in "$cache_path"/*; do rm "$file" done logger INFO "Wiped cache files" fi + echo "$src_path" > "$src_path_file" } print_config_vals(){ local keys=( @@ -132,10 +139,10 @@ print_config_vals(){ } test_gobject(){ - python3 -c "import gi" + python3.13 -c "import gi" if [[ ! $? -eq 0 ]]; then logger CRITICAL "Missing PyGObject" - fdialog "Requires PyGObject (python-gobject)" + quit_with_pdialog "Requires PyGObject (python-gobject)" exit 1 fi logger INFO "Found PyGObject in Python env" @@ -214,17 +221,14 @@ default_steam_path="$default_steam_path" #Preferred Steam launch command (for Flatpak support) preferred_client="$preferred_client" - -#DZGUI source path -src_path="$src_path" END } depcheck(){ for dep in "${!deps[@]}"; do - command -v "$dep" 2>&1>/dev/null - if [[ $? -eq 1 ]]; then + if ! command -v "$dep" &> /dev/null; then local msg="Requires $dep >= ${deps[$dep]}" - raise_error_and_quit "$msg" + echo "$msg" + exit 1 fi done local jqmsg="jq must be compiled with support for oniguruma" @@ -233,14 +237,31 @@ depcheck(){ [[ $? -ne 0 ]] && raise_error_and_quit "$jqmsg" logger INFO "Initial dependencies satisfied" } -check_pyver(){ - local pyver=$(python3 --version | awk '{print $2}') - local minor=$(<<< $pyver awk -F. '{print $2}') - if [[ -z $pyver ]] || [[ ${pyver:0:1} -lt 3 ]] || [[ $minor -lt 10 ]]; then - local msg="Requires Python >=3.10" - raise_error_and_quit "$msg" +open_url(){ + url="$1" + if [[ -n "$BROWSER" ]]; then + logger INFO "Opening '$url' in '$BROWSER'" + "$BROWSER" "$url" + else + logger INFO "Opening '$url' with xdg-open" + xdg-open "$url" + fi +} +quit_with_pdialog(){ + local sel + msg="$1" + help_button="Open help page" + url="https://aclist.github.io/dzgui/installation.html" + logger CRITICAL "$msg" + sel=$(zenity --info --extra-button="$help_button" --text="$msg") + [[ $sel == "$help_button" ]] && open_url "$url" & + exit 1 +} +check_pyver(){ + if [[ ! $(python3.13 --version) ]]; then + local msg="Requires Python 3.13" + quit_with_pdialog "$msg" fi - logger INFO "Found Python version: $pyver" } watcher_deps(){ if [[ ! $(command -v wmctrl) ]] && [[ ! $(command -v xdotool) ]]; then @@ -314,6 +335,7 @@ check_unmerged(){ fi } check_version(){ + [[ -n $reference_branch ]] && return local version_url=$(format_version_url) local upstream=$(curl -Ls "$version_url" | awk -F= '/^version=/ {print $2}') local res=$(get_response_code "$version_url") @@ -377,10 +399,11 @@ prompt_dl(){ dl_changelog(){ local mdbranch local md + source "$config_file" [[ $branch == "stable" ]] && mdbranch="dzgui" [[ $branch == "testing" ]] && mdbranch="testing" - local md="$url_prefix/${mdbranch}/$file" - curl -Ls "$md" > "$state_path/CHANGELOG.md" + local changelog="$url_prefix/${mdbranch}/CHANGELOG.md" + curl -Ls "$changelog" > "$state_path/CHANGELOG.md" } test_display_mode(){ pgrep -a gamescope | grep -q "generate-drm-mode" @@ -464,18 +487,7 @@ steam_deps(){ local msg="Found neither Steam nor Flatpak Steam" raise_error_and_quit "$msg" exit 1 - elif [[ -n "$steam" ]] && [[ -n "$flatpak" ]]; then - [[ -n $preferred_client ]] && return 0 - if [[ -z $preferred_client ]]; then - preferred_client="steam" - fi - elif [[ -n "$steam" ]]; then - preferred_client="steam" - else - preferred_client="flatpak" fi - update_config - logger INFO "Preferred client set to '$preferred_client'" } migrate_files(){ if [[ ! -f $config_path/dztuirc.oldapi ]]; then @@ -494,27 +506,54 @@ stale_symlinks(){ unlink "$link" done } + +check_availability() { + if [[ -z $1 ]]; then + return 1 + fi + local url=$1 + local timeout_sec="3" + if [[ $2 ]]; then + timeout_sec=$2 + fi + if ! ping -w "$timeout_sec" "$url" > /dev/null 2>&1; then + logger WARN "Failed to reach $url, service may be down." + return 1 + fi +} + local_latlon(){ if [[ -z $(command -v dig) ]]; then - local local_ip=$(curl -Ls "https://ipecho.net/plain") + local url_ipecho="https://ipecho.net/plain" + if ! check_availability "ipecho.net"; then + logger WARN "Failed to get external ip address, ipecho.net service may be down." + return 1 + fi + local local_ip=$(curl -Ls "$url_ipecho") else + # TODO : implement checking remote local local_ip=$(dig -4 +short myip.opendns.com @resolver1.opendns.com) fi - local url="http://ip-api.com/json/$local_ip" - local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"') + 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." + return 1 + fi + local res=$(curl -Ls "$url_ip_api" | jq -r '"\(.lat)\n\(.lon)"') if [[ -z "$res" ]]; then logger WARN "Failed to get local coordinates" return 1 fi echo "$res" > "$coords_file" } + lock(){ [[ ! -f $lock_file ]] && touch $lock_file local pid=$(cat $lock_file) ps -p $pid -o pid= >/dev/null 2>&1 res=$? if [[ $res -eq 0 ]]; then - local msg="DZGUI already running ($pid)" + local msg="DZGUI is already running ($pid)" raise_error_and_quit "$msg" elif [[ $pid == $$ ]]; then : @@ -543,13 +582,13 @@ fetch_a2s(){ logger INFO "Updated A2S helper to sha '$sha'" } fetch_dzq(){ - local sum="9caed1445c45832f4af87736ba3f9637" + local sum="0a334e1e144e76e560419d155435c91e" local file="$helpers_path/a2s/dayzquery.py" if [[ -f $file ]] && [[ $(get_hash "$file") == $sum ]]; then logger INFO "DZQ is current" return 0 fi - local sha=3088bbfb147b77bc7b6a9425581b439889ff3f7f + local sha=a22a9f428cbe075d7dda62f78000296955eea92a local author="yepoleb" local repo="dayzquery" local url="https://raw.githubusercontent.com/$author/$repo/$sha/dayzquery.py" @@ -586,11 +625,12 @@ fetch_helpers_by_sum(){ [[ -f "$config_file" ]] && source "$config_file" declare -A sums sums=( - ["ui.py"]="f128a97e744e9e11036d707198feb8a8" + ["funcs"]="2ac0ccc6c697208a1b097508d55ad886" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" + ["servers.py"]="ed442c3aecf33f777d59dcf53650d263" + ["ui.py"]="3d67e5e8e85a23dde1fd0e85a9be62a9" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" - ["funcs"]="93402a7b9ebae2901debb5cc3bc011a6" - ["lan"]="c62e84ddd1457b71a85ad21da662b9af" + ["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0" ) local author="aclist" local repo="dztui" @@ -609,6 +649,10 @@ fetch_helpers_by_sum(){ realbranch="dzgui" fi + if [[ -n $reference_branch ]]; then + realbranch="$reference_branch" + fi + for i in "${!sums[@]}"; do file="$i" sum="${sums[$i]}" @@ -632,7 +676,6 @@ fetch_helpers_by_sum(){ logger INFO "Updated '$full_path' to sum '$sum'" fi [[ $file == "funcs" ]] && chmod +x "$full_path" - [[ $file == "lan" ]] && chmod +x "$full_path" done return 0 } @@ -649,10 +692,7 @@ get_response_code(){ local url="$1" curl -Ls -I -o /dev/null -w "%{http_code}" "$url" } -raise_error_and_quit(){ - echo "$1" - exit 1 -} + fetch_ip_db(){ parse_dl_url(){ curl -Ls "$url" \ @@ -880,6 +920,7 @@ create_config(){ unset default_steam_path unset steam_path + preferred_client="steam" while true; do local player_input="$($steamsafe_zenity \ --forms \ @@ -941,7 +982,7 @@ create_config(){ } varcheck(){ 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. Restart 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 qdialog "$msg" "Yes" "Exit" if [[ $? -eq 1 ]]; then @@ -964,10 +1005,6 @@ varcheck(){ create_config return 0 fi - if [[ $src_path != $(realpath "$0") ]]; then - src_path=$(realpath "$0") - update_config - fi } is_dzg_downloading(){ if [[ -d $steam_path ]] && [[ -d $steam_path/downloading/$aid ]]; then @@ -1021,6 +1058,7 @@ legacy_cols(){ mv $cols_file.new $cols_file } stale_mod_signatures(){ + [[ ! -f "$versions_file" ]] && return local workshop_dir="$steam_path/steamapps/workshop/content/$aid" if [[ -d $workshop_dir ]]; then readarray -t old_mod_ids < <(awk -F, '{print $1}' $versions_file) @@ -1104,13 +1142,61 @@ uninstall(){ rm "$self" echo "Uninstall routine complete" } + +usage(){ +cat <<- EOM +DZGUI - Free and Open Source DayZ launcher + +Usage: + + dzgui.sh [options] + +Description: + + When no option is provided, the script launches DZGUI. + +Options: + + -u, --uninstall: + Uninstalls the software + + -v, --version: + Prints the version + + -h, --help: + Prints this message +EOM +} + main(){ - local zenv=$(zenity --version 2>/dev/null) + # setup zenity environment + local zenv="" + zenv=$(zenity --version 2>/dev/null) [[ -z $zenv ]] && { echo "Requires zenity >= ${deps[$steamsafe_zenity]}"; exit 1; } - if [[ $1 == "--uninstall" ]] || [[ $1 == "-u" ]]; then - uninstall && - exit 0 - fi + + # parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + "--uninstall" | "-u") + uninstall + exit 0 + # shift + ;; + "--version" | "-v") + echo $version + exit 0 + # shift + ;; + "--help" | "-h") + usage + exit 0 + # shift + ;; + *) + echo "Unrecognized command!" + return 1 + esac + done set_im_module @@ -1118,8 +1204,13 @@ main(){ initial_setup printf "All OK. Kicking off UI...\n" - python3 "$ui_helper" "--init-ui" "$version" "$is_steam_deck" + python3.13 "$ui_helper" "--init-ui" "$version" "$is_steam_deck" + } -main "$@" -#TODO: tech debt: cruddy handling for steam forking -[[ $? -eq 1 ]] && pkill -f dzgui.sh + +if [[ $(basename "$0") == "dzgui.sh" ]]; then + main "$@" + + #TODO: tech debt: cruddy handling for steam forking + [[ $? -eq 1 ]] && pkill -f dzgui.sh +fi diff --git a/helpers/funcs b/helpers/funcs index 4e2895e..d298a59 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1,9 +1,10 @@ #!/usr/bin/env bash set -o pipefail -version="5.8.3" +version="6.0.0" #CONSTANTS aid=221100 +exp=1024020 game="dayz" app_name="dzgui" app_name_upper="DZGUI" @@ -46,9 +47,11 @@ _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" +_cache_src_path="$cache_path/$prefix.src" #XDG freedesktop_path="$HOME/.local/share/applications" @@ -82,38 +85,28 @@ forum_url="https://old.reddit.com/r/dzgui" sponsor_url="$gh_prefix/sponsors/$author" battlemetrics_server_url="https://www.battlemetrics.com/servers/dayz" steam_api_url="https://steamcommunity.com/dev/apikey" -#TODO: update link in docs battlemetrics_api_url="https://www.battlemetrics.com/developers" bm_api="https://api.battlemetrics.com/servers" -if [[ $preferred_client == "steam" ]]; then - steam_cmd="steam" -else - steam_cmd="flatpak run com.valvesoftware.Steam" -fi +steam_cmd="$preferred_client" declare -A funcs=( ["Highlight stale"]="find_stale_mods" -["My servers"]="dump_servers" ["Change player name"]="update_config_val" ["Change Steam API key"]="update_config_val" ["Change Battlemetrics API key"]="update_config_val" +["Change client"]="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" -["filter"]="dump_servers" -["dump_servers"]="dump_servers" -["get_unique_maps"]="get_unique_maps" ["get_dist"]="get_dist" ["test_cooldown"]="test_cooldown" ["query_config"]="query_config" +["query_favorites"]="query_favorites" ["start_cooldown"]="start_cooldown" ["List installed mods"]="list_mods" ["Delete selected mods"]="delete_local_mod" @@ -130,21 +123,31 @@ 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" -["lan_scan"]="lan_scan" ["update_symlinks"]="update_symlinks" ) -lan_scan(){ - local port="$1" - local res - res=$("$lan_helper" "$port") - if [[ -z $res ]]; then - printf "\n" - else - printf "%s\n" "$res" +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 fi + printf "%s\n" "${ip_list[@]}" } find_stale_mods(){ local res @@ -200,44 +203,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"" \ @@ -303,32 +268,10 @@ 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 } -initialize_remote_servers(){ - local file="$_cache_servers" - [[ -f $file ]] && rm "$file" - local res - res=$(get_remote_servers) - parse_server_json "$res" >> "$file" -} is_dlc(){ local dlc local ip="$1" @@ -424,6 +367,10 @@ local_latlon(){ get_dist(){ shift local given_ip="$1" + if [[ ! -f $_cache_coords ]]; then + printf "Unknown" + return + fi readarray -t coords < "$_cache_coords" readarray -t n < <(<<< "$given_ip" awk 'BEGIN{RS="."}{$1=$1}1') @@ -446,60 +393,11 @@ get_dist(){ else logger INFO "Resolved remote server geolocation to '$remote_lat, $remote_lon'" local dist=$($km_helper "$local_lat" "$local_lon" "$remote_lat" "$remote_lon") - LC_NUMERIC=C printf "%d" "$dist" + LC_NUMERIC=C printf "%.0f" "$dist" logger INFO "Distance: $dist km" fi } -get_remote_servers(){ - params=( - "\\nor\1\map\chernarusplus\\nor\1\map\sakhal\\nor\1\map\enoch\empty\1\\nor\1\map\namalsk" - "\map\namalsk\empty\1" - "\map\namalsk\noplayers\1" - "\map\chernarusplus\empty\1" - "\map\chernarusplus\noplayers\1" - "\map\\sakhal\empty\1" - "\map\\sakhal\noplayers\1" - "\map\\enoch\empty\1" - "\map\\enoch\noplayers\1" - ) - local limit=10000 - local url="https://api.steampowered.com/IGameServersService/GetServerList/v1/?" - _fetch(){ - local param="$1" - curl -LsG "$url" \ - -d filter="\appid\221100${param}" \ - -d limit=$limit \ - -d key=$steam_api \ - | jq -M -r '.response.servers' - - } - - for ((i=0; i <${#params[@]}; i++ )); do - _fetch "${params[$i]}" > $_cache_temp.${i} - done - - # ubuntu variants do not support jq 1.7 chained operators - # https://github.com/jqlang/jq/releases/tag/jq-1.7 - jq -n '[ [inputs]|add ][]' $_cache_temp.* && rm $_cache_temp.* -} -get_unique_maps(){ - shift - local context="$1" - local filter_file - case "$context" in - "My saved servers") - filter_file="$_cache_my_servers" - ;; - "Server browser") - filter_file="$_cache_servers" - ;; - "Recent servers") - filter_file="$_cache_history" - esac - logger INFO "Map filter context is: '$context', using cached file at '$filter_file'" - < "$filter_file" awk -F$separator '{print $2}' | sort -u -} query_config(){ [[ -n $2 ]] && local key=$2 keys=( @@ -510,6 +408,7 @@ query_config(){ "fav_label" "preferred_client" "fullscreen" + "default_steam_path" ) if [[ -n $key ]]; then if [[ -n ${!key} ]]; then @@ -524,154 +423,7 @@ query_config(){ echo "${!i}" done } -filter_servers(){ - local filtered="$(< "$1")" - shift - readarray -t filters < <(printf "%s\n" "$@") - for ((i=0; i< ${#filters[@]}; ++i)); do - if [[ ${filters[$i]} =~ Keyword ]]; then - keyword=$(<<< ${filters[$i]} awk -F␞ '{print $2}') - elif [[ ${filters[$i]} =~ Map ]]; then - map=$(<<< ${filters[$i]} awk -F= '{print $2}') - fi - done - - filter_ascii(){ - if [[ ${filters[*]} =~ Non ]]; then - echo -n "$filtered" - else - <<< "$filtered" sed 's/␞/@@DZGUI_PLACEHOLDER@@/g' | grep -v -P '[^[:ascii:]]' | sed 's/@@DZGUI_PLACEHOLDER@@/␞/g' - fi - } - filter_time(){ - if [[ ${filters[*]} =~ Day ]] && [[ ${filters[*]} =~ Night ]]; then - echo -n "$filtered" - elif [[ ${filters[*]} =~ Day ]]; then - <<< "$filtered" awk -F$separator '$4~/^([0][6-9]:|[1][0-6])/' - elif [[ ${filters[*]} =~ Night ]]; then - <<< "$filtered" awk -F$separator '$4~/^([1][7-9]:|[2][0-3]:|[0][0-5])/' - else - echo -n "" - fi - } - filter_perspective(){ - if [[ ${filters[*]} =~ 1PP ]] && [[ ${filters[*]} =~ 3PP ]]; then - echo -n "$filtered" - elif [[ ${filters[*]} =~ 1PP ]]; then - <<< "$filtered" awk '!/3PP/' - elif [[ ${filters[*]} =~ 3PP ]]; then - <<< "$filtered" awk '!/1PP/' - else - echo -n "" - fi - } - filter_lowpop(){ - if [[ ${filters[*]} =~ Low ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '{if (($5 > 0) && ($5/$6)*100 >=30){print $0}}' - fi - } - filter_full(){ - if [[ ${filters[*]} =~ Full ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '$5 != $6' - fi - } - filter_empty(){ - if [[ ${filters[*]} =~ Empty ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '$5 != "0"' - fi - } - filter_map(){ - if [[ $map == "All maps" ]]; then - echo "$filtered" - else - <<< "$filtered" awk -v var="$map" -F$separator '$2 == var' - fi - } - filter_keyword(){ - keyword=$(sanitize "$keyword") - <<< "$filtered" awk -F$separator -v keyword="$keyword" 'tolower($0) ~ tolower(keyword)' - } - filter_duplicates(){ - if [[ ${filters[*]} =~ Duplicate ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '!seen[$1]++' - fi - } - filter_official(){ - if [[ ${filters[*]} =~ Official ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '$10 == "Community"' - fi - } - filter_community(){ - if [[ ${filters[*]} =~ Unoffic ]]; then - echo -n "$filtered" - else - <<< "$filtered" awk -F$separator '$10 == "Official"' - fi - } - - filtered=$(filter_perspective) - filtered=$(filter_full) - filtered=$(filter_empty) - filtered=$(filter_time) - filtered=$(filter_map) - filtered=$(filter_lowpop) - filtered=$(filter_ascii) - filtered=$(filter_duplicates) - filtered=$(filter_keyword) - filtered=$(filter_official) - filtered=$(filter_community) - - if [[ -z "$filtered" ]]; then - logger WARN "Filter result is empty" - echo -n "" - return - fi - - logger INFO "Returning sorted server list back to UI" - printf "%s\n" "$filtered" | sort -k1 -} -sanitize(){ - echo "$1" | sed \ - -e 's/\//\\\//g' \ - -e 's/\$/\\$/g' \ - -e 's/\[/\\[/g' \ - -e 's/\]/\\]/g' \ - -e 's/\#/\\#/g' \ - -e 's/\./\\./g' \ - -e 's/\^/\\^/g' \ - -e 's/\=/\\=/g' \ - -e 's/|/\\|/g' \ - -e 's/\+/\\+/g' \ - -e 's/(/\\(/g' \ - -e 's/)/\\)/g' -} -parse_server_json(){ - local response="$1" - # some servers pad SOH in name - <<< "$response" sed 's/\\u0001//g' | jq -r ' - .[]|"\(.name)␞" + - "\(.map|if type == "string" then ascii_downcase else "null" end)␞" + - "\(if .gametype == null then "null" else (.gametype|split(",")|if any(. == "no3rd") then "1PP" else "3PP" end) end)␞" + - "\(if .gametype == null then "null" else (.gametype as $time|$time|test("[0-9]{2}:[0-9]{2}$") as $match|(if $match == true then ($time|scan("[0-9]{2}:[0-9]{2}$")) else "XXXX" end)) end)␞" + - "\(.players)␞" + - "\(.max_players)␞" + - "\(if .gametype == null then "0" elif .gametype|split("lqs")[1] == null then "0" else .gametype|split("lqs")[1]|split(",")[0] end)␞" + - "\(.addr|split(":")[0]):\(if .gameport == null then "XXXX" else .gameport end)␞" + - "\(.addr|split(":")[1])␞" + - "\(if .gametype == null then "null" else (.gametype|split(",")|if any(. == "external") then "Community" else "Official" end) end)" - ' | sort -k1 -} align_versions_file(){ shift local mod="$1" @@ -723,69 +475,13 @@ test_cooldown(){ local old_time=$(< $_cache_cooldown) local cur_time=$(date +%s) local delta=$(($cur_time - $old_time)) - if [[ $delta -lt 60 ]]; then - local remains=$((60 - $delta)) + if [[ $delta -lt 30 ]]; then + local remains=$((30 - $delta)) local suffix=$(pluralize "seconds" $remains) printf "Global API cooldown in effect. Please wait %s %s." "$remains" "$suffix" exit 1 fi } -dump_servers(){ - local context="$1" - local subcontext="$2" - local ip - local qport - local res - _iterate(){ - local file="$1" - shift - for server in "$@"; do - ip=$(<<< $server awk -F: '{print $1}') - qport=$(<<< $server awk -F: '{print $3}') - res=$(a2s "$ip" "$qport" info) - if [[ ! $? -eq 0 ]]; then - continue - fi - parse_server_json "$res" >> "$file" - done - } - case "$subcontext" in - *Server[[:space:]]browser*) - local file="$_cache_servers" - if [[ ! $subcontext =~ Name ]]; then - initialize_remote_servers - fi - ;; - *My[[:space:]]saved[[:space:]]servers*) - local file="$_cache_my_servers" - if [[ ! $subcontext =~ Name ]]; then - [[ -f $file ]] && rm $file - _iterate "$file" "${ip_list[@]}" - fi - ;; - *Recent[[:space:]]servers*) - local file="$_cache_history" - if [[ ! $subcontext =~ Name ]]; then - [[ -f $file ]] && rm $file - readarray -t iters < <(cat $history_file) - _iterate "$file" "${iters[@]}" - fi - ;; - *Scan[[:space:]]LAN[[:space:]]servers*) - local port=$(<<< "$subcontext" awk -F: '{print $2}') - local file="$_cache_lan" - if [[ ! $subcontext =~ Name ]]; then - [[ -f $file ]] && rm $file - local lan=$(lan_scan $port) - readarray -t iters <<< "$lan" - _iterate "$file" "${iters[@]}" - fi - ;; - esac - shift - logger INFO "Server context is '$subcontext', reading from file '$file'" - filter_servers "$file" "$@" -} redact(){ sed 's@\(/home/\)[^/]*@\1REDACTED@g' } @@ -893,9 +589,6 @@ default_steam_path="$default_steam_path" #Preferred Steam launch command (for Flatpak support) preferred_client="$preferred_client" - -#DZGUI source path -src_path="$src_path" END } format_version_url(){ @@ -949,6 +642,7 @@ download_new_version(){ return 1 fi local version_url="$(format_version_url)" + local src_path="$(< "$_cache_src_path")" mv "$src_path" "$src_path.old" curl -Ls "$version_url" > "$src_path" rc=$? @@ -1005,13 +699,6 @@ toggle(){ debug="" fi ;; - Toggle[[:space:]]Steam/Flatpak) - if [[ $preferred_client == "steam" ]]; then - preferred_client="flatpak" - else - preferred_client="steam" - fi - ;; Toggle[[:space:]]DZGUI[[:space:]]fullscreen[[:space:]]boot) if [[ $fullscreen == "true" ]]; then fullscreen="false" @@ -1095,10 +782,10 @@ test_steam_api(){ } test_bm_api(){ local key="$1" - if [[ ! $key =~ ^[0-9]+$ ]]; then + if [[ -z $key ]]; then + echo 1 return fi - [[ -z $key ]] && return 1 local code=$(curl -ILs "$bm_api" \ -H "Authorization: Bearer "$key"" -G \ -d "filter[game]=$game" \ @@ -1110,29 +797,35 @@ update_config_val(){ local context="$1" local value="$2" case $1 in + "Change client") + key="preferred_client" + ;; "Change player name") key="name" + if [[ -z "${value// }" ]]; then + printf "Invalid name" + return 2 + fi ;; "Change Steam API key") key="steam_api" if [[ ${#value} -lt 32 ]] || [[ $(test_steam_api "$value") -eq 1 ]]; then printf "Invalid API key" - return 2 + return 78 fi ;; "Change Battlemetrics API key") key="api_key" if [[ $(test_bm_api "$value") -eq 1 ]]; then printf "Invalid API key" - return 2 + return 79 fi ;; esac declare -n nr=$key nr="$value" update_config - echo "Updated the key '$key' to '$value'" - return 90 + return 80 } show_log(){ < "$debug_log" sed 's/Keyword␞/Keyword/' @@ -1190,24 +883,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" -} -pretty_print(){ - while read -r line; do - printf "\t%s\n" "$line" - done < "$@" -} generate_log(){ source $config_file cat <<-DOC > $system_log @@ -1348,6 +1023,9 @@ update_history(){ echo "$record" >> "$history_file" } update_symlinks(){ + if [[ ! -d "$workshop_dir" ]]; then + return + fi legacy_symlinks symlinks } @@ -1394,10 +1072,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}') + + [[ $appid -eq $exp ]] && echo "$binary" > $_cache_binary + local remote_mods remote_mods=$(a2s $ip $qport rules) if [[ $? -eq 1 ]]; then @@ -1422,11 +1107,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(){ @@ -1437,68 +1122,6 @@ check_architecture(){ echo 0 fi } -focus_beta_client(){ - _wid(){ - wmctrl -ilx |\ - awk 'tolower($3) == "steamwebhelper.steam"' |\ - awk '$5 ~ /^Steam|Steam Games List/' |\ - awk '{print $1}' - } - $steam_cmd steam://open/library 2>/dev/null 1>&2 && - $steam_cmd steam://open/console 2>/dev/null 1>&2 && - sleep 1s - until [[ -n $(_wid) ]]; do - sleep 0.1s - done - wmctrl -ia $(_wid) - sleep 0.1s - - local wid=$(xdotool getactivewindow) - local geo=$(xdotool getwindowgeometry $wid) - local pos=$(<<< "$geo" awk 'NR==2 {print $2}' | sed 's/,/ /') - local dim=$(<<< "$geo" awk 'NR==3 {print $2}' | sed 's/x/ /') - local pos1=$(<<< "$pos" awk '{print $1}') - local pos2=$(<<< "$pos" awk '{print $2}') - local dim1=$(<<< "$dim" awk '{print $1}') - local dim2=$(<<< "$dim" awk '{print $2}') - local dim1=$(((dim1/2)+pos1)) - local dim2=$(((dim2/2)+pos2)) - - xdotool mousemove $dim1 $dim2 - xdotool click 1 - $steam_cmd steam://open/library 2>/dev/null 1>&2 && - $steam_cmd steam://open/console 2>/dev/null 1>&2 -} -auto_mod_install(){ - # currently unused, merged with manual method - local ip="$1" - local gameport="$2" - local diff="$3" - local sanitized_mods="$4" - - console_dl "$diff" && - $steam_cmd steam://open/downloads - - local total=$(<<< "$diff" wc -l) - until [[ -z $(compare "$diff") ]]; do - local missing=$(compare "$diff" | wc -l) - echo "# Downloaded $(($total-missing)) of $total mods. ESC cancels" - done | $steamsafe_zenity --pulsate --progress --title="DZG Watcher" --auto-close --no-cancel --width=500 2>/dev/null - if [[ ! $? -eq 0 ]]; then - echo "User aborted connect process. Steam may have mods pending for download." - exit 1 - fi - - local diff=$(compare "$sanitized_mods") - - if [[ -z $diff ]]; then - #wipe old version file and replace with latest stamps - rm "$versions_file" - check_timestamps - logger INFO "Local modlist matches remote, initiating launch request" - launch "$ip" "$gameport" "$sanitized_mods" - fi -} force_update(){ if [[ ! $auto_install -eq 1 ]]; then printf "Only available when mod auto-install is ON" @@ -1506,21 +1129,10 @@ 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 } -console_dl(){ - readarray -t modids <<< "$@" - focus_beta_client - sleep 1.5s - for i in "${modids[@]}"; do - xdotool type --delay 0 "workshop_download_item $aid $i" - sleep 0.5s - xdotool key Return - sleep 0.5s - done -} get_local_stamps(){ readarray -t modlist < <(printf "%s\n" "$@") local max="${#modlist[@]}" @@ -1559,8 +1171,6 @@ check_timestamps(){ local aligned=$(<<< "$local_stamps" jq -r '.response.publishedfiledetails[]|"\(.publishedfileid),\(.time_updated)"') readarray -t remote_ids < <(<<< "$aligned" awk -F, '{print $1}') readarray -t remote_times < <(<<< "$aligned" awk -F, '{print $2}') - readarray -t old_ids < <(< $versions_file awk -F, '{print $1}') - readarray -t old_times < <(< $versions_file awk -F, '{print $2}') if [[ ! -f $versions_file ]]; then logger INFO "No prior versions file found, creating" @@ -1570,6 +1180,9 @@ check_timestamps(){ return 0 fi + readarray -t old_ids < <(< $versions_file awk -F, '{print $1}') + readarray -t old_times < <(< $versions_file awk -F, '{print $2}') + declare -A remote_version declare -A local_version @@ -1612,6 +1225,9 @@ concat_mods(){ local encoded_id local link for i in "${concat_arr[@]}"; do + until [[ -f $workshop_dir/$i/meta.cpp ]]; do + sleep 0.1s + done id=$(awk -F"= " '/publishedid/ {print $2}' "$workshop_dir"/$i/meta.cpp | awk -F\; '{print $1}') encoded_id=$(encode $id) link="@$encoded_id;" @@ -1630,6 +1246,8 @@ launch(){ local ip="$1" local gameport="$2" local mods="$3" + local appid="$4" + local concat if [[ -n $mods ]]; then concat=$(concat_mods "$mods") @@ -1638,18 +1256,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) @@ -1666,7 +1288,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 @@ -1680,6 +1302,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" @@ -1688,7 +1311,6 @@ manual_mod_install(){ _watcher(){ for((i=0;i<${#stage_mods[@]};i++)); do [[ -f $ex ]] && return 1 - log ${stage_mods[$i]} if [[ $mode == "auto" ]] || [[ $mode == "force" ]]; then $steam_cmd "steam://url/CommunityFilePage/${stage_mods[$i]}+workshop_download_item $aid ${stage_mods[$i]}" @@ -1738,7 +1360,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 @@ -1762,7 +1384,7 @@ foreground(){ } main(){ local params="$(printf '"%s", ' "$@")" - logger INFO "Received request from UI constructor with params [${params::-2}]" + logger INFO "Received request from UI with params [${params::-2}]" func=${funcs["$1"]} [[ -z $func ]] && return 1 if [[ -z $2 ]]; then diff --git a/helpers/latlon.c b/helpers/latlon.c deleted file mode 100644 index 4c1788a..0000000 --- a/helpers/latlon.c +++ /dev/null @@ -1,36 +0,0 @@ -#include -#include -#include - -#define R 6371 -#define TO_RAD (3.1415926536 / 180) -double dist(double th1, double ph1, double th2, double ph2) -{ - double dx, dy, dz; - ph1 -= ph2; - ph1 *= TO_RAD, th1 *= TO_RAD, th2 *= TO_RAD; - - dz = sin(th1) - sin(th2); - dx = cos(ph1) * cos(th1) - cos(th2); - dy = sin(ph1) * cos(th1); - return asin(sqrt(dx * dx + dy * dy + dz * dz) / 2) * 2 * R; -} - -int main(int argc, const char * argv[]) -{ - if(argc < 5 || argc > 5){ - return 1; - } - float coords[4]; - for(int i=1;i<5;i++){ - if(atof(argv[i]) == 0){ - return 1; - } - coords[i] = atof(argv[i]); - } - - double d = dist(coords[1], coords[2], coords[3], coords[4]); - printf("%.1f\n", d); - - return 0; -} diff --git a/helpers/pefile.py b/helpers/pefile.py new file mode 100644 index 0000000..ab63490 --- /dev/null +++ b/helpers/pefile.py @@ -0,0 +1,492 @@ +import json +import struct +import typing # noqa + +from dataclasses import dataclass +from enum import Enum +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 + + +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 | Exception: + try: + version = get_version(file) + except Exception as e: + return e + patch = str(version.build) + str(version.revision) + dz_vers = DayZVersion(version.major, version.minor, int(patch)) + return dz_vers + + +def dayz_version_to_str(v: DayZVersion) -> str: + return ".".join(str(el) for el in [v.major, v.minor, v.patch]) + + +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): + 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(" 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(local: DayZVersion, remote: DayZVersion): + """ + packaging.version module is not available OOTB on some distributions + """ + if dayz_version_to_str(local) == dayz_version_to_str(remote): + return VersionMatch.SAME_VERSION + + if local.major < remote.major: + return VersionMatch.LOCAL_OLDER + if local.major > remote.major: + return VersionMatch.LOCAL_NEWER + if local.major == remote.major: + if local.minor < remote.minor: + return VersionMatch.LOCAL_OLDER + if local.minor > remote.minor: + return VersionMatch.LOCAL_NEWER + if local.minor == remote.minor: + if local.patch < remote.patch: + return VersionMatch.LOCAL_OLDER + if local.patch > remote.patch: + return VersionMatch.LOCAL_NEWER + if local.patch == remote.patch: + return VersionMatch.SAME_VERSION + + +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 new file mode 100644 index 0000000..00cd129 --- /dev/null +++ b/helpers/servers.py @@ -0,0 +1,472 @@ +import json +import math +import os +import re +import socket +import subprocess +import sys +import typing # noqa + +from dataclasses import dataclass +from urllib import request, parse +from urllib.error import HTTPError +from typing import Union + +sys.path.append("a2s") +import a2s # noqa +from a2s import dayzquery # noqa + +params = [ + r"\nor\1\map\chernarusplus\nor\1\map\sakhal\nor\1\map\enoch\empty\1\nor\1\map\namalsk", # noqa + r"\map\namalsk\empty\1", + r"\map\namalsk\noplayers\1", + r"\map\chernarusplus\empty\1", + r"\map\chernarusplus\noplayers\1", + r"\map\sakhal\empty\1", + r"\map\sakhal\noplayers\1", + r"\map\enoch\empty\1", + r"\map\enoch\noplayers\1", +] + + +class BmAPIError(Exception): + pass + + +class BmIdError(Exception): + pass + + +class InvalidIpError(Exception): + pass + + +def get_netmask() -> str: + hostname = os.uname()[1] + i = socket.gethostbyname(hostname) + netmask = i.rsplit(".", 1)[0] + return netmask + + +def test_ip(suffix: int, port: int) -> dict | None: + netmask = get_netmask() + hostname = f"{netmask}.{str(suffix)}" + ping = ["ping", "-c1", "-i", "0.1", "-w", "1"] + output = subprocess.run(ping + [hostname], capture_output=True) + if output.returncode == 0: + return query_direct(hostname, port) + return None + + +def sanitize(name: str) -> str: + name = re.sub(r"\r", r"", name) + name = re.sub(r"\n", r"", name) + name = re.sub(r"\x01", r"", name) + name = re.sub(r"\ufeff", r"", name) + name = re.sub(r"(^!\s*)", r"-", name) + name = re.sub(r"(^-\s*)", r"", name) + name = re.sub(r"(^-)", r"", name) + name = re.sub(r"(^\s*)", r"", name) + name = re.sub(r"\t", "", name) + return name + + +def parse_json(json: list) -> list: + """ + Server metadata is underspecified and server operators + tend to insert random garbage in the headers. In case + sanitization failed, discard malformed rows rather than + aborting outright. + """ + rows = [] + for row in json: + try: + name = sanitize(row["name"]) + if name == "": + continue + except KeyError: + continue + + malformed = False + for key in [ + "map", + "gametype", + "players", + "max_players", + "addr", + "gameport", + ]: + try: + row[key] + except KeyError: + malformed = True + break + + if malformed is True: + continue + + try: + r = row["gametype"].split(",") + except KeyError: + continue + + if "no3rd" in r: + view = "1PP" + else: + view = "3PP" + + if "external" in r: + provider = "Unoffic." + else: + provider = "Official" + + if "mod" in r: + modded = True + else: + modded = False + + try: + r = row["gametype"].split("lqs") + queue = r[1].split(",")[0] + except IndexError: + queue = 0 + + try: + test_time = re.search(r"[0-9]{2}:[0-9]{2}", row["gametype"]) + if test_time: + time = test_time.group(0) + else: + time = "Unknown" + except AttributeError: + time = "Unknown" + + try: + ip = row["addr"].split(":")[0] + ":" + str(row["gameport"]) + qport = row["addr"].split(":")[1] + except IndexError: + continue + + try: + ping = row["ping"] + except KeyError: + ping = 9999 + + mapname = row["map"].lower() + players = row["players"] + max_players = row["max_players"] + raw = [ + name, + mapname, + view, + time, + int(players), + int(max_players), + int(queue), + ip, + int(qport), + ping, + provider, + modded, + ] + rows.append(raw) + return rows + + +def query_direct(ip: str, qport: int, TIMEOUT: float=3.0) -> dict | None: + try: + info = a2s.info((ip, qport), TIMEOUT) + + name = info.server_name + mapname = info.map_name + address = ip + ":" + str(qport) + gameport = str(info.port) + players = info.player_count + max_players = info.max_players + keywords = info.keywords + + try: + ping = info.ping + ping = math.floor(info.ping * 1000) + except AttributeError: + ping = 9999 + + res = {} + res["name"] = name + res["map"] = mapname + res["gametype"] = keywords + res["players"] = players + res["max_players"] = max_players + res["addr"] = address + res["gameport"] = gameport + res["ping"] = ping + return res + except TimeoutError: + return None + except KeyError: + return None + + +@dataclass(slots=True, frozen=True) +class Res: + status: int + parsed: bool + json: Union[str, None] + + +@dataclass(slots=True, frozen=True) +class Ping: + addr: str + iteration: int + ping: int + + +@dataclass(slots=True, frozen=True) +class Details: + data: Union[list, None] + description: str + success: bool + + +@dataclass(slots=True, frozen=True) +class Prereqs: + password: bool + gameport: int + appid: Union[int, None] + version: Union[str, None] + + +@dataclass(slots=True) +class Record: + """ + The gameport field is manipulated by the RowType.CONN_BY_IP method + """ + ip: str + gameport: int + qport: int + + +def get_prereqs(ip: str, qport: int) -> Prereqs: + try: + info = a2s.info((ip, qport)) + except TimeoutError: + return Prereqs(False, 0, None, None) + + 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: + default_str = "None provided" + + try: + info = a2s.info((ip, qport)) + except TimeoutError: + return Details(None, default_str, False) + try: + rules = dayzquery.dayz_rules((ip, int(qport))) + except TimeoutError: + return Details(None, default_str, False) + + try: + keywords = info.keywords.split(",") + except AttributeError: + return Details(None, default_str, False) + + battleye = "Disabled" + if "battleye" in keywords: + battleye = "Enabled" + + day_accel = 0.0 + night_accel = 0.0 + for keyword in keywords: + if "etm" in keyword: + day_accel = float(keyword.lstrip("etm")) + day_accel = float(f"{day_accel:g}") + if "entm" in keywords: + night_accel = float(keyword.lstrip("entm")) + night_accel = float(f"{night_accel:g}") + + try: + password = info.password_protected + if password is False: + password = "Disabled" + else: + password = "Enabled" + except AttributeError: + password = "-" + + try: + vac = info.vac_enabled + if vac is False: + vac = "Disabled" + else: + vac = "Enabled" + except AttributeError: + vac = "-" + + try: + version = info.version + except AttributeError: + version = "-" + + try: + dlc = rules.dlc_flags + if dlc == 0: + dlc = "None" + if dlc == 2: + dlc = "Frostline" + except AttributeError: + dlc = "not specified" + + try: + platform = rules.platform + if platform == "win": + platform = "Windows" + if platform == "?": + platform = "Linux" + except AttributeError: + platform = "not specified" + + try: + description = rules.description.strip() + if description == "": + description = default_str + except AttributeError: + description = default_str + + rows = [ + ["Battleye", battleye], + ["Daytime acceleration", f"{day_accel}x"], + ["DLC", dlc], + ["Night-time acceleration", f"{night_accel}x"], + ["Password", password], + ["Platform", platform], + ["Valve Anti-Cheat", vac], + ["Version", version], + ] + + return Details(rows, description, True) + + +def ping(iteration: int, row: list) -> Ping: + addr = row[7] + qport = row[8] + + res = None + + if row[9] != 9999: + return Ping(addr, iteration, row[9]) + + try: + ip = addr.split(":")[0] + except IndexError: + ping = 9999 + + try: + res = query_direct(ip, qport, 0.5) + except Exception: + pass + + if res is None: + ping = 9999 + else: + ping = res["ping"] + + return Ping(addr, iteration, ping) + + +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" + fr"\{appid}" + param, + "limit": LIMIT, + "key": key, + } + par = parse.urlencode(payload) + url = f"{url}{par}" + + status = 200 + parsed = True + data = None + + try: + with request.urlopen(url) as response: + if response.status != 200: + status = response.status + try: + parsed = True + data = json.load(response) + except json.decoder.JSONDecodeError: + parsed = False + data = None + except HTTPError: + status = 403 + parsed = False + 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) -> Record: + 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/tools/.gitignore b/helpers/tools/.gitignore new file mode 100644 index 0000000..d8b458e --- /dev/null +++ b/helpers/tools/.gitignore @@ -0,0 +1 @@ +latlon diff --git a/helpers/tools/Makefile b/helpers/tools/Makefile new file mode 100644 index 0000000..38914fd --- /dev/null +++ b/helpers/tools/Makefile @@ -0,0 +1,24 @@ +bin := latlon + +CC = gcc + +CFLAGS += -Wall +CFLAGS += -Wextra +CFLAGS += -Wpedantic +CFLAGS += -std=c23 +CFLAGS += -O3 + +LDFLAGS += -lm + +PREFIX ?= $(HOME)/.local/share/dzgui + +all: $(bin) + +install: $(bin) + install -Dm 755 $(bin) $(PREFIX) + +$(bin): latlon.c + $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) + +clean: + rm -f $(bin) diff --git a/helpers/tools/latlon.c b/helpers/tools/latlon.c new file mode 100644 index 0000000..a734f31 --- /dev/null +++ b/helpers/tools/latlon.c @@ -0,0 +1,77 @@ +// This small program computes the distance between two points on the Earths +// surface. The algorithm used for computing is called haversine formula. +// Source for the algorithm can be found here: +// https://en.wikipedia.org/wiki/Haversine_formula + +#include +#include +#include + +// Earth's radius in meters. +#define RADIUS ((12756L / 2.0L) * 1000L) + +// PI natural constant +#define PI 3.1415926536L + +// coordinates struct for storing points +struct Coordinate { + double latitude; + double longitude; +}; + +/** + * @brief Calculates a radian value from an angle + */ +double radian_from(const double angle) { + return (angle * PI) / 180; +} + +/** + * @brief Calculate the haversine function from an angle in radian + */ +double haversine(const double angle) { return ((1.0L - cos(angle)) / 2); } + +/** + * @brief Calculate the distance between two coordinates + */ +double great_circle_distance(const struct Coordinate a, + const struct Coordinate b) { + + // calculate longitude and latitude differences + struct Coordinate diff_coord = { + .longitude = a.longitude - b.longitude, + .latitude = a.latitude - b.latitude, + }; + + // calculate haversine(theta) value, where theta is the angle between the + // two coordinates + double hav_theta = haversine(diff_coord.latitude) + + cos(a.latitude) * cos(b.latitude) * haversine(diff_coord.longitude); + + // calculate distance from radius, and haversine(theta) values + double distance = 2 * RADIUS * asin(sqrt(hav_theta)); + + return distance; +} + +int main(int argc, const char* argv[]) { + if (argc < 5 || argc > 5) { + return 1; + } + + const struct Coordinate a = { + .latitude = radian_from(atof(argv[1])), + .longitude = radian_from(atof(argv[2])), + }; + const struct Coordinate b = { + .latitude = radian_from(atof(argv[3])), + .longitude = radian_from(atof(argv[4])), + }; + + // Returns the distance in meters. + // To pretty-print the result in a consumer of this module, it is + // recommended to divide the result by 1,000 and round it. + printf("%.1f\n", great_circle_distance(a, b)); + + return 0; +} diff --git a/helpers/ui.py b/helpers/ui.py index e167277..81aaf3e 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -4,403 +4,118 @@ import locale import logging import multiprocessing import os +import re import signal import subprocess import sys import textwrap 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 pathlib import Path +from typing import Literal, Self, Any -locale.setlocale(locale.LC_ALL, '') +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 -import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango +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) app_name = "DZGUI" - -cache = {} -config_vals = [] -stored_keys = [] -toggled_checks = [] -server_filters = [] +app_name_lower = app_name.lower() +app_name_abbr = "dzg" delimiter = "␞" -selected_map = ["Map=All maps"] -keyword_filter = ["Keyword%s" %(delimiter)] -checks = list() +APPID_DAYZ = 221100 +APPID_DAYZ_EXP = 1024020 + +cache: dict[str, int] = {} +config_vals: list[str] = [] +notes_cache: dict[str, str] = {} + +_VERSION: str +IS_GAME_MODE: bool +IS_STEAM_DECK: bool + map_store = Gtk.ListStore(str) row_store = Gtk.ListStore(str) modlist_store = Gtk.ListStore(str, str, str) -#cf. mod_cols, last column holds hex color mod_store = Gtk.ListStore(str, str, str, float, str) -#cf. log_cols log_store = Gtk.ListStore(str, str, str, str) -#cf. browser_cols -server_store = Gtk.ListStore(str, str, str, str, int, int, int, str, int, str) -default_tooltip = "Select a row to see its detailed description" -server_tooltip = [None, None] - -user_path = os.path.expanduser('~') -cache_path = '%s/.cache/dzgui' %(user_path) -state_path = '%s/.local/state/dzgui' %(user_path) -helpers_path = '%s/.local/share/dzgui/helpers' %(user_path) -log_path = '%s/logs' %(state_path) -changelog_path = '%s/CHANGELOG.md' %(state_path) -geometry_path = '%s/dzg.cols.json' %(state_path) -res_path = '%s/dzg.res.json' %(state_path) -funcs = '%s/funcs' %(helpers_path) -mods_temp_file = '%s/dzg.mods_temp' %(cache_path) -stale_mods_temp_file = '%s/dzg.stale_mods_temp' %(cache_path) +user_path = os.path.expanduser("~") +cache_path = f"{user_path}/.cache/{app_name_lower}" +state_path = f"{user_path}/.local/state/{app_name_lower}" +helpers_path = f"{user_path}/.local/share/{app_name_lower}/helpers" +log_path = f"{state_path}/logs" +changelog_path = f"{state_path}/CHANGELOG.md" +geometry_path = f"{state_path}/{app_name_abbr}.cols.json" +res_path = f"{state_path}/{app_name_abbr}.res.json" +funcs = f"{helpers_path}/funcs" +mods_temp_file = f"{cache_path}/{app_name_abbr}.mods_temp" +stale_mods_temp_file = f"{cache_path}/{app_name_abbr}.stale_mods_temp" +servers_path = f"{cache_path}/{app_name_abbr}.servers" +config_path = f"{user_path}/.config/dztui" +config_file = f"{config_path}/dztuirc" +history_file = f"{state_path}/{app_name_abbr}.history" +notes_file = f"{config_path}/{app_name_abbr}.notes.json" logger = logging.getLogger(__name__) -log_file = '%s/DZGUI_DEBUG.log' %(log_path) -system_log = '%s/DZGUI_SYSTEM.log' %(log_path) +log_file = f"{log_path}/{app_name}_DEBUG.log" +system_log = f"{log_path}/{app_name}_SYSTEM.log" FORMAT = "%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s" -logging.basicConfig(filename=log_file, - format=FORMAT, -level=logging.DEBUG) +logging.basicConfig(filename=log_file, format=FORMAT, level=logging.DEBUG) -browser_cols = [ - "Name", - "Map", - "Perspective", - "Gametime", - "Players", - "Maximum", - "Queue", - "IP", - "Qport", -] -mod_cols = [ - "Mod", - "Symlink", - "Dir", - "Size (MiB)", - "Color" -] -log_cols = [ - "Timestamp", - "Flag", - "Traceback", - "Message" -] -filters = { - "1PP": True, - "Day": True, - "Empty": False, - "3PP": True, - "Night": True, - "Full": False, - "Low pop": True, - "Non-ASCII": False, - "Duplicate": False, - "Official": True, - "Unoffic.": True, -} +manual_sub_msg = """When switching from MANUAL to AUTO mod install mode, +DZGUI will manage mod installation and deletion for you. +To prevent conflicts with Steam Workshop subscriptions and old mods from being downloaded +when Steam updates, you should unsubscribe from any existing Workshop mods you manually subscribed to. +Open your Profile > Workshop Items and select 'Unsubscribe from all' +on the right-hand side, then click OK below to enable AUTO mod install mode. +""" + +api_warn_msg = """No servers returned. Possible causes: +no servers in favorites/history, local network issue, or API key on cooldown. +Return to the main menu, wait 30s, and try again. +If this issue persists, your API key may be defunct. +""" -class EnumWithAttrs(Enum): - - def __new__(cls, *args, **kwds): - value = len(cls.__members__) + 1 - obj = object.__new__(cls) - obj._value_ = value - return obj - def __init__(self, a): - self.dict = a +@dataclass(slots=True) +class Record: + ip: str + gameport: int + qport: int -class RowType(EnumWithAttrs): - @classmethod - def str2rowtype(cls, str): - for member in cls: - if str == member.dict["label"]: - return member - return RowType.DYNAMIC - - DYNAMIC = { - "label": None, - "tooltip": None, - } - RESOLVE_IP = { - "label": "Resolve IP", - "tooltip": None, - "wait_msg": "Resolving remote IP" - } - HIGHLIGHT = { - "label": "Highlight stale", - "tooltip": None, - "wait_msg": "Looking for stale mods" - } - HANDSHAKE = { - "label": "Handshake", - "tooltip": None, - "wait_msg": "Waiting for DayZ" - } - DELETE_SELECTED = { - "label": "Delete selected mods", - "tooltip": None, - "wait_msg": "Deleting mods" - } - SERVER_BROWSER = { - "label": "Server browser", - "tooltip": "Used to browse the global server list", - } - SAVED_SERVERS = { - "label": "My saved servers", - "tooltip": "Browse your saved servers. Unreachable/offline servers will be excluded", - } - QUICK_CONNECT = { - "label": "Quick-connect to favorite server", - "tooltip": "Connect to your favorite server", - "wait_msg": "Working", - "default": "unset", - "alt": None, - "val": "fav_label" - } - RECENT_SERVERS = { - "label": "Recent servers", - "tooltip": "Shows the last 10 servers you connected to (includes attempts)", - } - CONN_BY_IP = { - "label": "Connect by IP", - "tooltip": "Connect to a server by IP", - "prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)", - "link_label": None, - } - CONN_BY_ID = { - "label": "Connect by ID", - "tooltip": "Connect to a server by Battlemetrics ID", - "prompt": "Enter server ID", - "link_label": "Open Battlemetrics", - } - SCAN_LAN = { - "label": "Scan LAN servers", - "tooltip": "Search for servers on your local network" - } - ADD_BY_IP = { - "label": "Add server by IP", - "tooltip": "Add a server by IP", - "prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)", - "link_label": None, - } - ADD_BY_ID = { - "label": "Add server by ID", - "tooltip": "Add a server by Battlemetrics ID", - "prompt": "Enter server ID", - "link_label": "Open Battlemetrics", - } - CHNG_FAV = { - "label": "Change favorite server", - "tooltip": "Update your quick-connect server", - "prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)", - "link_label": None, - "alt": None, - "default": "unset", - "val": "fav_label" - } - LIST_MODS = { - "label": "List installed mods", - "tooltip": "Browse a list of locally-installed mods", - "quad_label": "Mods" - } - TGL_BRANCH = { - "label": "Toggle release branch", - "tooltip": "Switch between stable and testing branches", - "default": None, - "val": "branch" - } - TGL_INSTALL = { - "label": "Toggle mod install mode", - "tooltip": "Switch between manual and auto mod installation", - "default": "manual", - "link_label": "Open Steam Workshop", - "alt": "auto", - "val": "auto_install" - } - TGL_STEAM = { - "label": "Toggle Steam/Flatpak", - "tooltip": "Switch the preferred client to use for launching DayZ", - "alt": None, - "default": None, - "val": "preferred_client" - } - TGL_FULLSCREEN = { - "label": "Toggle DZGUI fullscreen boot", - "tooltip": "Whether to start DZGUI as a maximized window (desktop only)", - "alt": "true", - "default": "false", - "val": "fullscreen" - } - CHNG_PLAYER = { - "label": "Change player name", - "tooltip": "Update your in-game name (required by some servers)", - "prompt": "Enter new nickname", - "link_label": None, - "alt": None, - "default": None, - "val": "name" - } - CHNG_STEAM_API = { - "label": "Change Steam API key", - "tooltip": "Can be used if you revoked an old API key", - "prompt": "Enter new API key", - "link_label": "Open Steam API page", - } - CHNG_BM_API = { - "label": "Change Battlemetrics API key", - "tooltip": "Can be used if you revoked an old API key", - "link_label": "Open Battlemetrics API page", - "prompt": "Enter new API key", - } - FORCE_UPDATE = { - "label": "Force update local mods", - "tooltip": "Synchronize the signatures of all local mods with remote versions (experimental)", - "wait_msg": "Updating mods" - } - DUMP_LOG = { - "label": "Output system info to log file", - "tooltip": "Dump diagnostic data for troubleshooting", - "wait_msg": "Generating log" - } - CHANGELOG = { - "label": "View changelog", - "tooltip": "Opens the DZGUI changelog in a dialog window" - } - SHOW_LOG = { - "label": "Show debug log", - "tooltip": "Read the DZGUI log generated since startup", - "quad_label": "Debug log" - } - DOCS = { - "label": "Documentation/help files (GitHub) ⧉", - "tooltip": "Opens the DZGUI documentation in a browser" - } - DOCS_FALLBACK = { - "label": "Documentation/help files (Codeberg mirror) ⧉", - "tooltip": "Opens the DZGUI documentation in a browser" - } - BUGS = { - "label": "Report a bug (GitHub) ⧉", - "tooltip": "Opens the DZGUI issue tracker in a browser" - } - FORUM = { - "label": "DZGUI Subreddit ⧉", - "tooltip": "Opens the DZGUI discussion forum in a browser" - } - SPONSOR = { - "label": "Sponsor (GitHub) ⧉", - "tooltip": "Sponsor the developer of DZGUI" - } - - -class WindowContext(EnumWithAttrs): - @classmethod - def row2con(cls, row): - m = None - for member in cls: - if row in member.dict["rows"]: - m = member - elif row in member.dict["called_by"]: - m = member - else: - continue - return m - - - MAIN_MENU = { - "label": "", - "rows": [ - RowType.SERVER_BROWSER, - RowType.SAVED_SERVERS, - RowType.QUICK_CONNECT, - RowType.RECENT_SERVERS, - RowType.CONN_BY_IP, - RowType.CONN_BY_ID, - RowType.SCAN_LAN - ], - "called_by": [] - } - MANAGE = { - "label": "Manage", - "rows": [ - RowType.ADD_BY_IP, - RowType.ADD_BY_ID, - RowType.CHNG_FAV - ], - "called_by": [] - } - OPTIONS = { - "label": "Options", - "rows":[ - RowType.LIST_MODS, - RowType.TGL_BRANCH, - RowType.TGL_INSTALL, - RowType.TGL_STEAM, - RowType.TGL_FULLSCREEN, - RowType.CHNG_PLAYER, - RowType.CHNG_STEAM_API, - RowType.CHNG_BM_API, - RowType.FORCE_UPDATE, - RowType.DUMP_LOG - ], - "called_by": [] - } - HELP = { - "label": "Help", - "rows":[ - RowType.CHANGELOG, - RowType.SHOW_LOG, - RowType.DOCS, - RowType.DOCS_FALLBACK, - RowType.BUGS, - RowType.FORUM, - RowType.SPONSOR, - ], - "called_by": [] - } - # inner server contexts - TABLE_API = { - "label": "", - "rows": [], - "called_by": [ - RowType.SERVER_BROWSER - ], - } - TABLE_SERVER = { - "label": "", - "rows": [], - "called_by": [ - RowType.SAVED_SERVERS, - RowType.RECENT_SERVERS, - RowType.SCAN_LAN - ], - } - TABLE_MODS = { - "label": "", - "rows": [], - "called_by": [ - RowType.LIST_MODS, - ], - } - TABLE_LOG = { - "label": "", - "rows": [], - "called_by": [ - RowType.SHOW_LOG - ], - } - - -class WidgetType(Enum): - OUTER_WIN = 1 - TREEVIEW = 2 - GRID = 3 - RIGHT_PANEL = 4 - MOD_PANEL = 5 - FILTER_PANEL = 6 +class Preferences(Enum): + STEAM = 1 + BM = 2 + WINDOW = 3 + CLIENT = 4 + NAME = 5 + INSTALL = 6 class Port(Enum): @@ -413,1434 +128,609 @@ class Popup(Enum): NOTIFY = 2 CONFIRM = 3 ENTRY = 4 + RETURN = 5 + MODLIST = 6 + DETAILS = 7 + QUIT = 8 -class ButtonType(EnumWithAttrs): - MAIN_MENU = {"label": "Main menu", - "opens": WindowContext.MAIN_MENU, - "tooltip": "Search for and connect to servers" - } - MANAGE = {"label": "Manage", - "opens": WindowContext.MANAGE, - "tooltip": "Manage/add to saved servers" - } - OPTIONS = {"label": "Options", - "opens": WindowContext.OPTIONS, - "tooltip": "Change settings, list local mods and\nother advanced options" - } - HELP = {"label": "Help", - "opens": WindowContext.HELP, - "tooltip": "Links to documentation" - } - EXIT = {"label": "Exit", - "opens": None, - "tooltip": "Quits the application" - } +class NotebookPage(Enum): + # enums correspond to the page in linear order + MAIN = 0 + CHANGELOG = 1 + KEYS = 2 + OPTIONS = 3 -class EnumeratedButton(Gtk.Button): - @GObject.Property - def button_type(self): - return self._button_type - - @button_type.setter - def button_type(self, value): - self._button_type = value +class Command(Enum): + INTERACTIVE = 1 + ONESHOT = 2 + HELP = 3 + TOGGLE = 4 -def relative_widget(child): - # returns collection of outer widgets relative to source widget - # chiefly used for transient modals and accessing non-adjacent widget methods - # positions are always relative to grid sub-children - # containers and nested buttons should never need to call this function directly - - grid = child.get_parent().get_parent() - treeview = grid.scrollable_treelist.treeview - outer = grid.get_parent() - - widgets = { - 'grid': grid, - 'treeview': treeview, - 'outer': outer - } - - supported = [ - "ModSelectionPanel", # Grid < RightPanel < ModSelectionPanel - "ButtonBox", # Grid < RightPanel < ButtonBox - "TreeView" # Grid < ScrollableTree < TreeView - ] - - if child.__class__.__name__ not in supported: - raise Exception("Unsupported child widget") - - return widgets +class VAdjustment(Enum): + UP = 1 + DOWN = 2 + TOP = 3 + BOTTOM = 4 -def pluralize(plural, count): - suffix = plural[-2:] - if suffix == "es": - base = plural[:-2] - return f"%s{'es'[:2*count^2]}" %(base) - else: - base = plural[:-1] - return f"%s{'s'[:count^1]}" %(base) +class CursorPosition(Enum): + UP = 1 + DOWN = 2 + TOP = 3 + BOTTOM = 4 -def format_ping(ping): - ms = " | Ping: %s" %(ping) - return ms +class FilterMode(Enum): + KEYWORD = 1 + MAP = 2 + INITIAL = 3 + TOGGLE_OFF = 4 + TOGGLE_ON = 5 -def format_distance(distance): - if distance == "Unknown": - distance = "| Distance: %s" %(distance) - else: - d = int(distance) - formatted = f'{d:n}' - distance = "| Distance: %s km" %(formatted) - return distance +class EnumWithAttrs(Enum): + def __new__(cls, *args, **kwargs) -> Self: + value = len(cls.__members__) + 1 + obj = object.__new__(cls) + obj._value_ = value + return obj + + def __init__(self, d: dict): + self.dict = d -def set_surrounding_margins(widget, margin): - widget.set_margin_top(margin) - widget.set_margin_start(margin) - widget.set_margin_end(margin) +class RowType(EnumWithAttrs): + @classmethod + def str2rowtype(cls, string: str) -> "RowType": + for member in cls: + if string == member.dict["label"]: + return member + return RowType.DYNAMIC + # specialized behavior + DYNAMIC = { + "label": None, + "tooltip": None, + } + RESOLVE_IP = { + "label": "Resolve IP", + "tooltip": None, + "wait_msg": "Resolving remote IP", + } + HIGHLIGHT = { + "label": "Highlight stale", + "tooltip": None, + "wait_msg": "Looking for stale mods", + } -def parse_modlist_rows(data): - lines = data.stdout.splitlines() - hits = len(lines) - reader = csv.reader(lines, delimiter=delimiter) - try: - rows = [[row[0], row[1], row[2]] for row in reader if row] - except IndexError: - return 1 - for row in rows: - modlist_store.append(row) - return hits + # pages + SERVER_BROWSER = { + "label": "Server browser", + "tooltip": "Used to browse the global server list", + "type": "server", + } + SAVED_SERVERS = { + "label": "My saved servers", + "tooltip": "Browse your saved servers. Unreachable servers will be excluded", + "type": "server", + } + RECENT_SERVERS = { + "label": "Recent servers", + "tooltip": "Shows the last 10 servers you connected to (includes attempts)", + "type": "server", + } + SCAN_LAN = { + "label": "Scan LAN servers", + "tooltip": "Search for servers on your local network", + "type": "server", + } + LIST_MODS = { + "label": "List installed mods", + "tooltip": "Browse a list of locally-installed mods", + "quad_label": "Mods", + "type": "mods", + } + CHANGELOG = { + "label": "View changelog", + "tooltip": "Opens the DZGUI changelog in a dialog window", + } + OPTIONS = {"label": "Options", "tooltip": None} + KEYBINDINGS = {"label": "Keybindings", "tooltip": None} + SHOW_LOG = { + "label": "Show debug log", + "tooltip": "Read the DZGUI log generated since startup", + "quad_label": "Debug log", + } + # interactive dialogs + CONN_BY_IP = { + "label": "Connect by IP", + "tooltip": "Connect to a server by IP", + "prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)", + "link_label": None, + "type": Command.INTERACTIVE, + } + CONN_BY_ID = { + "label": "Connect by ID", + "tooltip": "Connect to a server by Battlemetrics ID", + "prompt": "Enter server ID", + "link_label": "Open Battlemetrics", + "type": Command.INTERACTIVE, + } + ADD_BY_IP = { + "label": "Add server by IP", + "tooltip": "Add a server by IP", + "prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)", + "link_label": None, + "type": Command.INTERACTIVE, + } + ADD_BY_ID = { + "label": "Add server by ID", + "tooltip": "Add a server by Battlemetrics ID", + "prompt": "Enter server ID", + "link_label": "Open Battlemetrics", + "type": Command.INTERACTIVE, + } + CHNG_FAV = { + "label": "Change favorite server", + "tooltip": "Update your quick-connect server", + "prompt": "Enter IP in IP:Queryport format (e.g. 192.168.1.1:27016)", + "link_label": None, + "alt": None, + "default": "unset", + "val": "fav_label", + "type": Command.INTERACTIVE, + } + CHNG_PLAYER = { + "label": "Change player name", + "tooltip": "Update your in-game name (required by some servers)", + "prompt": "Enter new nickname", + "link_label": None, + "alt": None, + "default": None, + "val": "name", + "type": Command.INTERACTIVE, + } + CHNG_STEAM_API = { + "label": "Change Steam API key", + "tooltip": "Can be used if you revoked an old API key", + "prompt": "Enter new API key", + "link_label": "Open Steam API page", + "type": Command.INTERACTIVE, + } + CHNG_BM_API = { + "label": "Change Battlemetrics API key", + "tooltip": "Can be used if you revoked an old API key", + "link_label": "Open Battlemetrics API page", + "prompt": "Enter new API key", + "type": Command.INTERACTIVE, + } -def parse_log_rows(data): - lines = data.stdout.splitlines() - reader = csv.reader(lines, delimiter=delimiter) - try: - rows = [[row[0], row[1], row[2], row[3]] for row in reader if row] - except IndexError: - return 1 - for row in rows: - log_store.append(row) + # settings toggles + TGL_BRANCH = { + "label": "Toggle release branch", + "tooltip": "Switch between stable and testing branches", + "default": None, + "val": "branch", + "type": Command.TOGGLE, + } + TGL_INSTALL = { + "label": "Toggle mod install mode", + "tooltip": "Switch between manual and auto mod installation", + "default": "manual", + "link_label": "Open Steam Workshop", + "alt": "auto", + "val": "auto_install", + "type": Command.TOGGLE, + } + TGL_STEAM = { + "label": "Toggle Steam/Flatpak", + "tooltip": "Switch the preferred client to use for launching DayZ", + "alt": None, + "default": None, + "val": "preferred_client", + "type": Command.TOGGLE, + } + TGL_FULLSCREEN = { + "label": "Toggle DZGUI fullscreen boot", + "tooltip": "Whether to start DZGUI as a maximized window (desktop only)", + "alt": "true", + "default": "false", + "val": "fullscreen", + "type": Command.TOGGLE, + } + # oneshot commands + QUICK_CONNECT = { + "label": "Quick-connect to favorite server", + "tooltip": "Connect to your favorite server", + "wait_msg": "Working", + "default": "unset", + "alt": None, + "val": "fav_label", + "type": Command.ONESHOT, + } + FORCE_UPDATE = { + "label": "Force update local mods", + "tooltip": "Synchronize local mods with remote versions (experimental)", + "wait_msg": "Updating mods", + "type": Command.ONESHOT, + } + DUMP_LOG = { + "label": "Output system info to log file", + "tooltip": "Dump diagnostic data for troubleshooting", + "wait_msg": "Generating log", + "type": Command.ONESHOT, + } + HANDSHAKE = { + "label": "Handshake", + "tooltip": None, + "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, + "wait_msg": "Deleting mods", + "type": Command.ONESHOT, + } -def parse_mod_rows(data): - # GTK pads trailing zeroes on floats - # https://stackoverflow.com/questions/26827434/gtk-cellrenderertext-with-format - sum = 0 - lines = data.stdout.splitlines() - hits = len(lines) - reader = csv.reader(lines, delimiter=delimiter) - # Nonetype inherits default GTK color - try: - rows = [[row[0], row[1], row[2], locale.atof(row[3], func=float), None] for row in reader if row] - except IndexError: - return 1 - for row in rows: - mod_store.append(row) - size = float(row[3]) - sum += size - return [sum, hits] - - -def parse_server_rows(data): - lines = data.stdout.splitlines() - reader = csv.reader(lines, delimiter=delimiter) - try: - rows = [[row[0], row[1], row[2], row[3], int(row[4]), int(row[5]), int(row[6]), row[7], int(row[8]), row[9]] for row in reader if row] - except IndexError: - return 1 - for row in rows: - server_store.append(row) - - -def query_config(widget, key=""): - proc = call_out(widget, "query_config", key) - config = list(proc.stdout.splitlines()) - return (config) - - -def call_out(widget, command, *args): - if widget is not None: - widget_name = widget.get_name() - try: - widget_name = widget_name.split('+')[1] - match widget_name: - case "TreeView": - context = widget.get_first_col() - case "ScrollableTree": - context = widget.treeview.get_first_col() - case "OuterWindow": - context = widget.grid.scrollable_treelist.treeview.get_first_col() - case "Grid": - context = widget.scrollable_treelist.treeview.get_first_col() - except IndexError: - context = "Generic" - else: - context = "Generic" - - arg_ar = [] - for i in args: - arg_ar.append(i) - logger.info("Context '%s' calling subprocess '%s' with args '%s'" %(context, command, arg_ar)) - proc = subprocess.run(["/usr/bin/env", "bash", funcs, command] + arg_ar, capture_output=True, text=True) - return proc - - -def spawn_dialog(transient_parent, msg, mode): - dialog = GenericDialog(transient_parent, msg, mode) - response = dialog.run() - dialog.destroy() - match response: - case Gtk.ResponseType.OK: - logger.info("User confirmed dialog with message '%s'" %(msg)) - return 0 - case Gtk.ResponseType.CANCEL | Gtk.ResponseType.DELETE_EVENT: - logger.info("User aborted dialog with message '%s'" %(msg)) - return 1 - - -def process_shell_return_code(transient_parent, msg, code, original_input): - logger.info("Processing return code '%s' for the input '%s', returned message '%s'" %(code, original_input, msg)) - match code: - case 0: - # success with notice popup - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - case 1: - # error with notice popup - if msg == "": - msg = "Something went wrong" - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - case 2: - # warn and recurse (e.g. validation failed) - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - treeview = transient_parent.grid.scrollable_treelist.treeview - process_tree_option(original_input, treeview) - case 4: - # for BM only - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - treeview = transient_parent.grid.scrollable_treelist.treeview - process_tree_option([treeview.view, RowType.CHNG_BM_API], treeview) - case 5: - # for steam only - # deprecated, Steam is mandatory now - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - treeview = transient_parent.grid.scrollable_treelist.treeview - process_tree_option([treeview.view, RowType.CHNG_STEAM_API], treeview) - case 6: - # return silently - pass - case 90: - # used to update configs and metadata in-place - treeview = transient_parent.grid.scrollable_treelist.treeview - col = treeview.get_column_at_index(0) - config_vals.clear() - for i in query_config(None): - config_vals.append(i) - tooltip = format_metadata(col) - transient_parent.grid.update_statusbar(tooltip) - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - return - case 95: - # successful mod deletion - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - treeview = transient_parent.grid.scrollable_treelist.treeview - grid = treeview.get_parent().get_parent() - (model, pathlist) = treeview.get_selection().get_selected_rows() - for p in reversed(pathlist): - it = model.get_iter(p) - model.remove(it) - total_size = 0 - total_mods = len(model) - for row in model: - total_size += row[3] - size = locale.format_string('%.3f', total_size, grouping=True) - pretty = pluralize("mods", total_mods) - grid.update_statusbar(f"Found {total_mods:n} {pretty} taking up {size} MiB") - # untoggle selection for visibility of other stale rows - treeview.toggle_selection(False) - case 96: - # unsuccessful mod deletion - spawn_dialog(transient_parent, msg, Popup.NOTIFY) - # re-block this signal before redrawing table contents - treeview = transient_parent.grid.scrollable_treelist.treeview - toggle_signal(treeview, treeview, '_on_keypress', False) - treeview.update_quad_column(RowType.LIST_MODS) - case 99: - # highlight stale mods - panel = transient_parent.grid.sel_panel - panel.colorize_cells(True) - panel.toggle_select_stale_button(True) - case 100: - # final handoff before launch - final_conf = spawn_dialog(transient_parent, msg, Popup.CONFIRM) - treeview = transient_parent.grid.scrollable_treelist.treeview - if final_conf == 1 or final_conf is None: - return - process_tree_option([treeview.view, RowType.HANDSHAKE], treeview) - case 255: - spawn_dialog(transient_parent, "Update complete. Please close DZGUI and restart.", Popup.NOTIFY) - save_res_and_quit(transient_parent) - - -def process_tree_option(input, treeview): - context = input[0] - command = input[1] - cmd_string = command.dict["label"] - logger.info("Parsing tree option '%s' for the context '%s'" %(command, context)) - - widgets = relative_widget(treeview) - transient_parent = widgets["outer"] - grid = widgets["grid"] - - def call_on_thread(bool, subproc, msg, args): - def _background(subproc, args, dialog): - def _load(): - wait_dialog.destroy() - out = proc.stdout.splitlines() - try: - msg = out[-1] - except: - msg = '' - rc = proc.returncode - logger.info("Subprocess returned code %s with message '%s'" %(rc, msg)) - process_shell_return_code(transient_parent, msg, rc, input) - proc = call_out(transient_parent, subproc, args) - GLib.idle_add(_load) - if bool is True: - wait_dialog = GenericDialog(transient_parent, msg, Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread(target=_background, args=(subproc, args, wait_dialog)) - thread.start() - else: - # False is used to bypass wait dialogs - proc = call_out(transient_parent, subproc, args) - rc = proc.returncode - out = proc.stdout.splitlines() - msg = out[-1] - process_shell_return_code(transient_parent, msg, rc, input) - - if command == RowType.RESOLVE_IP: - record = "%s:%s" %(treeview.get_column_at_index(7), treeview.get_column_at_index(8)) - wait_msg = command.dict["wait_msg"] - call_on_thread(True, cmd_string, wait_msg, record) - return # help pages - if context == WindowContext.TABLE_MODS and command == RowType.HIGHLIGHT: - wait_msg = command.dict["wait_msg"] - call_on_thread(True, cmd_string, wait_msg, '') - return - if context == WindowContext.HELP: - match command: - case RowType.CHANGELOG: - diag = ChangelogDialog(transient_parent) - diag.run() - diag.destroy() - case _: - base_cmd = "Open link" - arg_string = cmd_string - subprocess.Popen(['/usr/bin/env', 'bash', funcs, base_cmd, arg_string]) - pass - return + DOCS = { + "label": "Documentation/help files (GitHub) ⧉", + "tooltip": "Opens the DZGUI documentation in a browser", + "type": Command.HELP, + } + DOCS_FALLBACK = { + "label": "Documentation/help files (Codeberg mirror) ⧉", + "tooltip": "Opens the DZGUI documentation in a browser", + "type": Command.HELP, + } + BUGS = { + "label": "Report a bug (GitHub) ⧉", + "tooltip": "Opens the DZGUI issue tracker in a browser", + "type": Command.HELP, + } + FORUM = { + "label": "DZGUI Subreddit ⧉", + "tooltip": "Opens the DZGUI discussion forum in a browser", + "type": Command.HELP, + } + SPONSOR = { + "label": "Sponsor (GitHub) ⧉", + "tooltip": "Sponsor the developer of DZGUI", + "type": Command.HELP, + } - # config metadata toggles - toggle_commands = [ - RowType.TGL_INSTALL, - RowType.TGL_BRANCH, - RowType.TGL_STEAM, - RowType.TGL_FULLSCREEN - ] - if command in toggle_commands: - match command: - case RowType.TGL_BRANCH: - wait_msg = "Updating DZGUI branch" - call_on_thread(False, "toggle", wait_msg, cmd_string) - case RowType.TGL_INSTALL: - if query_config(None, "auto_install")[0] == "1": - proc = call_out(transient_parent, "toggle", cmd_string) - grid.update_right_statusbar() - tooltip = format_metadata(command.dict["label"]) - transient_parent.grid.update_statusbar(tooltip) - return - # manual -> auto mode - proc = call_out(transient_parent, "find_id", "") - if proc.returncode == 1: - link=None - uid=None - else: - link=command.dict["link_label"] - uid=proc.stdout - manual_sub_msg = """\ - When switching from MANUAL to AUTO mod install mode, - DZGUI will manage mod installation and deletion for you. - To prevent conflicts with Steam Workshop subscriptions and old mods from being downloaded - when Steam updates, you should unsubscribe from any existing Workshop mods you manually subscribed to. - Open your Profile > Workshop Items and select 'Unsubscribe from all' - on the right-hand side, then click OK below to enable AUTO mod install mode.""" - LinkDialog(transient_parent, textwrap.dedent(manual_sub_msg), Popup.NOTIFY, link, command, uid) - case _: - proc = call_out(transient_parent, "toggle", cmd_string) - grid.update_right_statusbar() - tooltip = format_metadata(command.dict["label"]) - transient_parent.grid.update_statusbar(tooltip) - return +class ContextMenu(EnumWithAttrs): + """ + Calls methods defined in TreeView + """ - # entry dialogs - interactive_commands = [ + ADD_SERVER = {"label": "Add to my servers", "action": "add_server"} + REMOVE_SERVER = { + "label": "Remove from my servers", + "action": "remove_server", + } + COPY_NAME = {"label": "Copy name to clipboard", "action": "copy_name"} + COPY_CLIPBOARD = { + "label": "Copy IP to clipboard", + "action": "copy_clipboard", + } + ADD_NOTE = {"label": "Add note", "action": "add_note"} + SHOW_MODS = {"label": "Show server-side mods", "action": "show_mods"} + SHOW_DETAILS = {"label": "Server details", "action": "show_details"} + REFRESH_PLAYERS = { + "label": "Refresh player count", + "action": "refresh_player_count", + } + REMOVE_HISTORY = { + "label": "Remove from history", + "action": "remove_from_history", + } + OPEN_WORKSHOP = { + "label": "Open in Steam Workshop", + "action": "open_workshop", + } + DELETE_MOD = {"label": "Delete mod", "action": "delete_mod"} + + +class WindowContext(EnumWithAttrs): + @classmethod + def row2con(cls, row: RowType) -> "WindowContext": + m = WindowContext.MAIN_MENU + for member in cls: + if row in member.dict["rows"]: + m = member + elif row in member.dict["called_by"]: + m = member + else: + continue + return m + + # outer menu pages + MAIN_MENU = { + "label": "Main menu", + "rows": [ + RowType.SERVER_BROWSER, + RowType.SAVED_SERVERS, + RowType.QUICK_CONNECT, + RowType.RECENT_SERVERS, RowType.CONN_BY_IP, RowType.CONN_BY_ID, + RowType.SCAN_LAN, + ], + "called_by": [], + } + MANAGE = { + "label": "Manage", + "rows": [ RowType.ADD_BY_IP, RowType.ADD_BY_ID, RowType.CHNG_FAV, + RowType.LIST_MODS, + ], + "called_by": [], + } + OPTIONS = { + "label": "Options", + "rows": [ + RowType.TGL_BRANCH, + RowType.TGL_INSTALL, + RowType.TGL_STEAM, + RowType.TGL_FULLSCREEN, RowType.CHNG_PLAYER, RowType.CHNG_STEAM_API, - RowType.CHNG_BM_API - ] - - if command in interactive_commands: - prompt = command.dict["prompt"] - flag = True - link_label = command.dict["link_label"] - wait_msg = "Working" - - user_entry = EntryDialog(transient_parent, prompt, Popup.ENTRY, link_label) - res = user_entry.get_input() - - if res is None: - logger.info("User aborted entry dialog") - return - logger.info("User entered: '%s'" %(res)) - - if command == RowType.CHNG_PLAYER: flag = False - call_on_thread(flag, cmd_string, wait_msg, res) - return - - # standalone commands - misc_commands = [ - RowType.DELETE_SELECTED, - RowType.HANDSHAKE, - RowType.DUMP_LOG, + RowType.CHNG_BM_API, RowType.FORCE_UPDATE, - RowType.QUICK_CONNECT - ] - if command in misc_commands: - wait_msg = command.dict["wait_msg"] - call_on_thread(True, cmd_string, wait_msg, '') - return - -def reinit_checks(): - toggled_checks.clear() - for check in checks: - label = check.get_label() - if filters[label] is True: - check.set_active(True) - toggled_checks.append(label) - else: - check.set_active(False) - - -class OuterWindow(Gtk.Window): - @GObject.Property - def widget_type(self): - return self._widget_type - - @widget_type.setter - def widget_type(self, value): - self._widget_type = value - - def __init__(self, is_steam_deck, is_game_mode): - super().__init__(title=app_name) - - self.hb = AppHeaderBar() - # steam deck taskbar may occlude elements - if is_steam_deck is False: - self.set_titlebar(self.hb) - - self.set_property("widget_type", WidgetType.OUTER_WIN) - - self.connect("delete-event", self.halt_proc_and_quit) - self.set_border_width(10) - - #app > win > grid > scrollable > treeview [row/server/mod store] - #app > win > grid > vbox > buttonbox > filterpanel > combo [map store] - - self.grid = Grid(is_steam_deck) - self.add(self.grid) - if is_game_mode is True: - self.fullscreen() - elif query_config(None, "fullscreen")[0] == "true": - logger.info("User preference for 'fullscreen' is 'true'") - self.maximize() - else: - if os.path.isfile(res_path): - with open(res_path, "r") as infile: - try: - data = json.load(infile) - valid_json = True - except json.decoder.JSONDecodeError: - logger.critical("JSON decode error in '%s'" %(res_path)) - valid_json = False - else: - valid_json = False - if valid_json: - res = data["res"] - w = res["width"] - h = res["height"] - logger.info("Restoring window size to %s,%s" %(w,h)) - self.set_default_size(w, h) - - self.show_all() - # Hide FilterPanel on main menu - self.grid.right_panel.set_filter_visibility(False) - self.grid.sel_panel.set_visible(False) - self.grid.scrollable_treelist.treeview.grab_focus() - - def halt_proc_and_quit(self, window, event): - self.grid.terminate_treeview_process() - save_res_and_quit(window) - - -class ScrollableTree(Gtk.ScrolledWindow): - def __init__(self, is_steam_deck): - super().__init__() - - self.treeview = TreeView(is_steam_deck) - self.add(self.treeview) - -class RightPanel(Gtk.Box): - def __init__(self, is_steam_deck): - super().__init__(spacing=6) - self.set_orientation(Gtk.Orientation.VERTICAL) - - self.button_vbox = ButtonBox(is_steam_deck) - self.filters_vbox = FilterPanel() - toggle_signal(self.filters_vbox, self.filters_vbox.maps_combo, '_on_map_changed', False) - - self.pack_start(self.button_vbox, False, False, 0) - self.pack_start(self.filters_vbox, False, False, 0) - - self.debug_toggle = Gtk.ToggleButton(label="Debug mode") - self.debug_toggle.set_tooltip_text("Used to perform a dry run without\nactually connecting to a server") - - if query_config(None, "debug")[0] == '1': - self.debug_toggle.set_active(True) - self.debug_toggle.connect("toggled", self._on_button_toggled, "Toggle debug mode") - set_surrounding_margins(self.debug_toggle, 10) - - self.question_button = Gtk.Button(label="?") - self.question_button.set_tooltip_text("Opens the keybindings dialog") - self.question_button.set_margin_top(10) - self.question_button.set_margin_start(50) - self.question_button.set_margin_end(50) - self.question_button.connect("clicked", self._on_button_clicked) - - self.pack_start(self.debug_toggle, False, True, 0) - self.pack_start(self.question_button, False, True, 0) - - def _on_button_toggled(self, button, command): - grid = self.get_parent() - transient_parent = grid.get_parent() - call_out(transient_parent, "toggle", command) - grid.update_right_statusbar() - grid.scrollable_treelist.treeview.grab_focus() - - def _on_button_clicked(self, button): - grid = self.get_parent() - grid.scrollable_treelist.treeview.spawn_keys_dialog(button) - - def set_filter_visibility(self, bool): - self.filters_vbox.set_visible(bool) - - def focus_button_box(self): - self.button_vbox.focus_button(0) - - def set_active_combo(self): - self.filters_vbox.set_active_combo() - - -class ButtonBox(Gtk.Box): - def __init__(self, is_steam_deck): - super().__init__(spacing=6) - self.set_orientation(Gtk.Orientation.VERTICAL) - set_surrounding_margins(self, 10) - - self.buttons = list() - self.is_steam_deck = is_steam_deck - - for side_button in ButtonType: - button = EnumeratedButton(label=side_button.dict["label"]) - button.set_property("button_type", side_button) - button.set_tooltip_text(side_button.dict["tooltip"]) - - if is_steam_deck is True: - button.set_size_request(10, 10) - else: - button.set_size_request(50,50) - #TODO: explore a more intuitive way of highlighting the active context - button.set_opacity(0.6) - self.buttons.append(button) - button.connect("clicked", self._on_selection_button_clicked) - self.pack_start(button, False, False, 0) - - self.buttons[0].set_opacity(1.0) - - def _update_single_column(self, context): - logger.info("Returning from multi-column view to monocolumn view for the context '%s'" %(context)) - widgets = relative_widget(self) - - # only applicable when returning from mod list - grid = widgets["grid"] - grid_last_child = grid.right_panel.get_children()[-1] - if isinstance(grid_last_child, ModSelectionPanel): - grid.sel_panel.set_visible(False) - right_panel = self.get_parent() - right_panel.set_filter_visibility(False) - - treeview = widgets["treeview"] - treeview.set_selection_mode(Gtk.SelectionMode.SINGLE) - - # Block maps combo when returning to main menu - toggle_signal(right_panel.filters_vbox, right_panel.filters_vbox.maps_combo, '_on_map_changed', False) - right_panel.filters_vbox.keyword_entry.set_text("") - keyword_filter.clear() - keyword_filter.append("Keyword␞") - server_store.clear() - - for column in treeview.get_columns(): - treeview.remove_column(column) - # used as a convenience for Steam Deck if it has no titlebar - for i, column_title in enumerate([context.dict["label"]]): - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(column_title, renderer, text=i) - treeview.append_column(column) - - if self.is_steam_deck is False: - treeview.set_headers_visible(False) - - self._populate(context.dict["opens"]) - toggle_signal(treeview, treeview, '_on_keypress', False) - treeview.set_model(row_store) - treeview.grab_focus() - - def _populate(self, context): - widgets = relative_widget(self) - treeview = widgets["treeview"] - grid = widgets["grid"] - window = widgets["outer"] - - # set global window context - treeview.view = context - - row_store.clear() - array = context.dict["rows"] - - window.hb.set_subtitle(context.dict["label"]) - - for item in array: - label = item.dict["label"] - tooltip = item.dict["tooltip"] - t = (label, ) - row_store.append(t) - grid.update_statusbar(tooltip) - treeview.grab_focus() - - def _on_selection_button_clicked(self, button): - treeview = self.get_treeview() - toggle_signal(treeview, treeview.selected_row, '_on_tree_selection_changed', False) - context = button.get_property("button_type") - logger.info("User clicked '%s'" %(context)) - - if context == ButtonType.EXIT: - logger.info("Normal user exit") - widgets = relative_widget(self) - window = widgets["outer"] - save_res_and_quit(window) - return - cols = treeview.get_columns() - - if len(cols) > 1: - self._update_single_column(context) - - # Highlight the active widget - for inactive_button in self.buttons: - inactive_button.set_opacity(0.6) - button.set_opacity(1.0) - - for col in cols: - col.set_title(context.dict["label"]) - - # get destination WindowContext enum from button - self._populate(context.dict["opens"]) - - toggle_signal(treeview, treeview.selected_row, '_on_tree_selection_changed', True) - - def focus_button(self, index): - self.buttons[index].grab_focus() - - def get_treeview(self): - grid = self.get_parent().get_parent() - treeview = grid.scrollable_treelist.treeview - return treeview - - -class CalcDist(multiprocessing.Process): - def __init__(self, widget, addr, qport, result_queue, cache): - super().__init__() - - self.widget = widget - self.result_queue = result_queue - self.addr = addr - self.qport = str(qport) - self.ip = addr.split(':')[0] - - def run(self): - if self.addr in cache: - logger.info("Address '%s' already in cache" %(self.addr)) - self.result_queue.put([self.addr, cache[self.addr][0], cache[self.addr][1]]) - return - proc = call_out(self.widget, "get_dist", self.ip) - proc2 = call_out(self.widget, "test_ping", self.ip, self.qport) - km = proc.stdout - ping = proc2.stdout - self.result_queue.put([self.addr, km, ping]) - - -class TreeView(Gtk.TreeView): - __gsignals__ = {"on_distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ())} - @GObject.Property - def widget_type(self): - return self._widget_type - - @widget_type.setter - def widget_type(self, value): - self._widget_type = value - - def __init__(self, is_steam_deck): - super().__init__() - - self.set_property("widget_type", WidgetType.TREEVIEW) - self.view = WindowContext.MAIN_MENU - - self.queue = multiprocessing.Queue() - self.current_proc = None - - # Disables typeahead search - self.set_enable_search(False) - self.set_search_column(-1) - - # Populate model with initial context - for row in WindowContext.MAIN_MENU.dict["rows"]: - label = row.dict["label"] - t = (label,) - row_store.append(t) - self.set_model(row_store) - - for i, column_title in enumerate( - ["Main menu"] - ): - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(column_title, renderer, text=i) - self.append_column(column) - - if is_steam_deck is False: - self.set_headers_visible(False) - self.connect("row-activated", self._on_row_activated) - self.connect("key-press-event", self._on_keypress) - self.connect("key-press-event", self._on_keypress_main_menu) - toggle_signal(self, self, '_on_keypress', False) - - self.selected_row = self.get_selection() - self.selected_row.connect("changed", self._on_tree_selection_changed) - self.connect("button-release-event", self._on_button_release) - - def terminate_process(self): - if self.current_proc and self.current_proc.is_alive(): - self.current_proc.terminate() - - def _on_menu_click(self, menu_item): - #TODO: context menus use old stringwise parsing - # use enumerated contexts - parent = self.get_outer_window() - context = self.get_first_col() - value = self.get_column_at_index(0) - context_menu_label = menu_item.get_label() - logger.info("User clicked context menu '%s'" %(context_menu_label)) - - match context_menu_label: - case "Add to my servers" | "Remove from my servers": - record = "%s:%s" %(self.get_column_at_index(7), self.get_column_at_index(8)) - process_tree_option([self.view, RowType.RESOLVE_IP], self) - if context == "Name (My saved servers)": - iter = self.get_current_iter() - server_store.remove(iter) - case "Remove from history": - record = "%s:%s" %(self.get_column_at_index(7), self.get_column_at_index(8)) - call_out(parent, context_menu_label, record) - iter = self.get_current_iter() - server_store.remove(iter) - case "Copy IP to clipboard": - self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - addr = self.get_column_at_index(7) - qport = self.get_column_at_index(8) - ip = addr.split(':')[0] - record = "%s:%s" %(ip, qport) - self.clipboard.set_text(record, -1) - case "Refresh player count": - self.refresh_player_count() - case "Show server-side mods": - record = "%s:%s" %(self.get_column_at_index(7), self.get_column_at_index(8)) - dialog = ModDialog(parent, "Enter/double click a row to open in Steam Workshop. ESC exits this dialog", "Modlist", record) - modlist_store.clear() - case "Delete mod": - conf_msg = "Really delete the mod '%s'?" %(value) - success_msg = "Successfully deleted the mod '%s'." %(value) - fail_msg = "An error occurred during deletion. Aborting." - res = spawn_dialog(parent, conf_msg, Popup.CONFIRM) - if res != 0: - return - mods = [] - symlink = self.get_column_at_index(1) - dir = self.get_column_at_index(2) - concat = symlink + " " + dir + "\n" - mods.append(concat) - with open(mods_temp_file, "w") as outfile: - outfile.writelines(mods) - process_tree_option([self.view, RowType.DELETE_SELECTED], self) - case "Open in Steam Workshop": - record = self.get_column_at_index(2) - base_cmd = "open_workshop_page" - subprocess.Popen(['/usr/bin/env', 'bash', funcs, base_cmd, record]) - - def toggle_selection(self, bool): - l = len(mod_store) - match bool: - case True: - for i in range (0, l): - path = Gtk.TreePath(i) - self.get_selection().select_path(path) - case False: - for i in range (0, l): - path = Gtk.TreePath(i) - self.get_selection().unselect_path(path) - - def _on_button_release(self, widget, event): - if event.type is Gdk.EventType.BUTTON_RELEASE and event.button != 3: - return - try: - pathinfo = self.get_path_at_pos(event.x, event.y) - if pathinfo is None: - return - (path, col, cellx, celly) = pathinfo - self.set_cursor(path,col,0) - except AttributeError: - pass - - context = self.get_first_col() - self.menu = Gtk.Menu() - - mod_context_items = ["Open in Steam Workshop", "Delete mod"] - subcontext_items = { - "Server browser": - ["Add to my servers", "Copy IP to clipboard", "Show server-side mods", "Refresh player count"], - "My saved servers": - ["Remove from my servers", "Copy IP to clipboard", "Show server-side mods", "Refresh player count"], - "Recent servers": - ["Add to my servers", "Remove from history", "Copy IP to clipboard", "Show server-side mods", "Refresh player count"], - } - # submenu hierarchy https://stackoverflow.com/questions/52847909/how-to-add-a-sub-menu-to-a-gtk-menu - - if self.view == WindowContext.TABLE_LOG: - return - if self.view == WindowContext.TABLE_MODS: - items = mod_context_items - subcontext = "List installed mods" - elif "Name" in context: - subcontext = context.split('(')[1].split(')')[0] - items = subcontext_items[subcontext] - else: - return - - for item in items: - if subcontext == "Server browser" or "Recent servers": - if item == "Add to my servers": - record = "%s:%s" %(self.get_column_at_index(7), self.get_column_at_index(8)) - proc = call_out(widget, "is_in_favs", record) - if proc.returncode == 0: - item = "Remove from my servers" - item = Gtk.MenuItem(label=item) - item.connect("activate", self._on_menu_click) - self.menu.append(item) - - self.menu.show_all() - - if event.type is Gdk.EventType.KEY_PRESS and event.keyval is Gdk.KEY_l: - sel = self.get_selection() - sels = sel.get_selected_rows() - (model, pathlist) = sels - if len(pathlist) < 1: - return - self.menu.popup_at_widget(widget, Gdk.Gravity.CENTER, Gdk.Gravity.WEST) - else: - self.menu.popup_at_pointer(event) - - def refresh_player_count(self): - parent = self.get_outer_window() - - cooldown = call_out(self, "test_cooldown", "", "") - if cooldown.returncode == 1: - spawn_dialog(self.get_outer_window(), cooldown.stdout, Popup.NOTIFY) - return 1 - call_out(self, "start_cooldown", "", "") - - thread = threading.Thread(target=self._background_player_count, args=()) - thread.start() - - def get_outer_window(self): - win = self.get_parent().get_parent().get_parent() - return win - - def get_outer_grid(self): - grid = self.get_parent().get_parent() - return grid - - def get_current_iter(self): - iter = self.get_selection().get_selected()[1] - return iter - - def get_current_index(self): - index = treeview.get_selection().get_selected_rows()[1][0][0] - return index - - def _on_tree_selection_changed(self, selection): - # no statusbar queue on quad tables - - grid = self.get_outer_grid() - context = self.get_first_col() - row_sel = self.get_column_at_index(0) - logger.info("Tree selection for context '%s' changed to '%s'" %(context, row_sel)) - if self.view == WindowContext.TABLE_MODS or context == "Timestamp": - return - - if self.current_proc and self.current_proc.is_alive(): - self.current_proc.terminate() - - if self.view == WindowContext.TABLE_API or self.view == WindowContext.TABLE_SERVER: - addr = self.get_column_at_index(7) - qport = self.get_column_at_index(8) - if addr is None: - server_tooltip[0] = format_tooltip() - grid.update_statusbar(server_tooltip[0]) - return - if addr in cache: - server_tooltip[0] = format_tooltip() - dist = format_distance(cache[addr][0]) - ping = format_ping(cache[addr][1]) - - tooltip = server_tooltip[0] + dist + ping - grid.update_statusbar(tooltip) - return - self.emit("on_distcalc_started") - self.current_proc = CalcDist(self, addr, qport, self.queue, cache) - self.current_proc.start() - else: - tooltip = format_metadata(row_sel) - grid.update_statusbar(tooltip) - - def spawn_keys_dialog(self, widget): - diag = KeysDialog(self.get_outer_window(), '', "Keybindings") - diag.run() - diag.destroy() - self.grab_focus() - - def _on_keypress_main_menu(self, treeview, event): - window = self.get_outer_window() - grid = self.get_outer_grid() - match event.keyval: - case Gdk.KEY_d: - debug = grid.right_panel.debug_toggle - if debug.get_active(): - debug.set_active(False) - else: - debug.set_active(True) - case Gdk.KEY_Right: - grid.right_panel.focus_button_box() - case Gdk.KEY_question: - if event.state is Gdk.ModifierType.SHIFT_MASK: - self.spawn_keys_dialog(None) - case Gdk.KEY_f: - if event.state is Gdk.ModifierType.CONTROL_MASK: - return True - case _: - return False - - def _on_keypress(self, treeview, event): - keyname = Gdk.keyval_name(event.keyval) - grid = self.get_outer_grid() - cur_proc = grid.scrollable_treelist.treeview.current_proc - if event.state is Gdk.ModifierType.CONTROL_MASK: - match event.keyval: - case Gdk.KEY_l: - self._on_button_release(self, event) - case Gdk.KEY_r: - self.refresh_player_count() - case Gdk.KEY_f: - if self.get_first_col() == "Mod": - return - grid.right_panel.filters_vbox.grab_keyword_focus() - case Gdk.KEY_m: - if self.get_first_col() == "Mod": - return - grid.right_panel.filters_vbox.maps_entry.grab_focus() - case _: - return False - elif keyname.isnumeric() and int(keyname) > 0: - if self.get_first_col() == "Mod": - return - digit = (int(keyname) - 1) - grid.right_panel.filters_vbox.toggle_check(checks[digit]) - else: - return False - - def _focus_first_row(self): - path = Gtk.TreePath(0) - try: - it = mod_store.get_iter(path) - self.get_selection().select_path(path) - except ValueError: - pass - - def get_column_at_index(self, index): - select = self.get_selection() - sels = select.get_selected_rows() - (model, pathlist) = sels - if len(pathlist) < 1: - return - path = pathlist[0] - tree_iter = model.get_iter(path) - value = model.get_value(tree_iter, index) - return value - - def _background_player_count(self): - def _load(): - lines = data.stdout.splitlines() - #update players - server_store[path][4] = int(lines[0]) - #update queue - server_store[path][6] = int(lines[1]) - wait_dialog.destroy() - - parent = self.get_outer_window() - wait_dialog = GenericDialog(parent, "Refreshing player count", Popup.WAIT) - wait_dialog.show_all() - select = self.get_selection() - sels = select.get_selected_rows() - (model, pathlist) = sels - if len(pathlist) < 1: - return - path = pathlist[0] - tree_iter = model.get_iter(path) - addr = server_store[path][7] - qport = server_store[path][8] - ip = addr.split(':')[0] - qport = str(qport) - - data = call_out(self, "get_player_count", ip, qport) - if data.returncode == 1: - wait_dialog.destroy() - return - GLib.idle_add(_load) - - def _background(self, dialog, mode): - def loadTable(): - for map in maps: - map_store.append([map]) - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', True) - right_panel.set_filter_visibility(True) - dialog.destroy() - self.grab_focus() - for column in self.get_columns(): - column.connect("notify::width", self._on_col_width_changed) - if len(server_store) == 0: - call_out(self, "start_cooldown", "", "") - api_warn_msg = """\ - No servers returned. Possible network issue or API key on cooldown? - Return to the main menu, wait 60s, and try again. - If this issue persists, your API key may be defunct.""" - spawn_dialog(self.get_outer_window(), textwrap.dedent(api_warn_msg), Popup.NOTIFY) - - grid = self.get_outer_grid() - right_panel = grid.right_panel - - filters = toggled_checks + keyword_filter + selected_map - data = call_out(self, "dump_servers", mode, *filters) - - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', False) - parse_server_rows(data) - server_tooltip[0] = format_tooltip() - grid.update_statusbar(server_tooltip[0]) - - map_data = call_out(self, "get_unique_maps", mode) - maps = map_data.stdout.splitlines() - self.set_model(server_store) - GLib.idle_add(loadTable) - - def _background_quad(self, dialog, mode): - # currently only used by list mods method - def load(): - dialog.destroy() - # suppress button panel if store is empty - if isinstance(panel_last_child, ModSelectionPanel): - if total_mods == 0: - # do not forcibly remove previously added widgets when reloading in-place - grid.sel_panel.set_visible(False) - right_panel.set_filter_visibility(False) - else: - grid.sel_panel.set_visible(True) - grid.sel_panel.initialize() - - self.set_model(mod_store) - self.grab_focus() - size = locale.format_string('%.3f', total_size, grouping=True) - pretty = pluralize("mods", total_mods) - grid.update_statusbar(f"Found {total_mods:n} {pretty} taking up {size} MiB") - - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', True) - toggle_signal(self, self, '_on_keypress', True) - self._focus_first_row() - if total_mods == 0: - logger.info("Nothing to do, spawning notice dialog") - spawn_dialog(self.get_outer_window(), data.stdout, Popup.NOTIFY) - - widgets = relative_widget(self) - grid = widgets["grid"] - right_panel = grid.right_panel - data = call_out(self, mode.dict["label"], '') - panel_last_child = right_panel.get_children()[-1] - - # suppress errors if no mods available on system - if data.returncode == 1: - logger.info("Failed to find mods on local system") - total_mods = 0 - total_size = 0 - GLib.idle_add(load) - else: - # show button panel missing (prevents duplication when reloading in-place) - if not isinstance(panel_last_child, ModSelectionPanel): - grid.sel_panel.set_visible(True) - result = parse_mod_rows(data) - total_size = result[0] - total_mods = result[1] - logger.info("Found mods on local system") - logger.info("Total mod size: %s" %(total_size)) - logger.info("Total mod count: %s" %(total_mods)) - GLib.idle_add(load) - - def _on_col_width_changed(self, col, width): - - def write_json(title, size): - data = {"cols": { title: size } } - j = json.dumps(data, indent=2) - with open(geometry_path, "w") as outfile: - outfile.write(j) - logger.info("Wrote initial column widths to '%s'" %(geometry_path)) - - title = col.get_title() - size = col.get_width() - # steam deck column title workaround - if "Name" in title: - title = "Name" - - if os.path.isfile(geometry_path): - with open(geometry_path, "r") as infile: - try: - data = json.load(infile) - data["cols"][title] = size - with open(geometry_path, "w") as outfile: - outfile.write(json.dumps(data, indent=2)) - except json.decoder.JSONDecodeError: - logger.critical("JSON decode error in '%s'" %(geometry_path)) - write_json(title, size) - else: - write_json(title, size) - - def _update_multi_column(self, mode): - # Local server lists may have different filter toggles from remote list - # FIXME: tree selection updates twice here. attach signal later - self.set_headers_visible(True) - - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', False) - for column in self.get_columns(): - self.remove_column(column) - row_store.clear() - - if os.path.isfile(geometry_path): - with open(geometry_path, "r") as infile: - try: - data = json.load(infile) - valid_json = True - except json.decoder.JSONDecodeError: - logger.critical("JSON decode error in '%s'" %(geometry_path)) - valid_json = False - else: - valid_json = False - - for i, column_title in enumerate(browser_cols): - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(column_title, renderer, text=i) - column.set_resizable(True) - column.set_sort_column_id(i) - - if valid_json: - if "Name" in column_title: - column_title = "Name" - saved_size = data["cols"][column_title] - column.set_fixed_width(saved_size) - column.set_expand(True) - else: - if ("Name" in column_title): - column.set_fixed_width(800) - if (column_title == "Map"): - column.set_fixed_width(300) - - if i != 10: - self.append_column(column) - - self.update_first_col(mode.dict["label"]) - - widgets = relative_widget(self) - grid = widgets["grid"] - window = widgets["outer"] - window.hb.set_subtitle(mode.dict["label"]) - - transient_parent = window - - # Reset map selection - selected_map.clear() - selected_map.append("Map=All maps") - - self.set_selection_mode(Gtk.SelectionMode.SINGLE) - - for check in checks: - toggle_signal(self.get_outer_grid().right_panel.filters_vbox, check, '_on_check_toggle', True) - toggle_signal(self, self, '_on_keypress', True) - - string = mode.dict["label"] - if mode == RowType.SCAN_LAN: - lan_dialog = LanButtonDialog(self.get_outer_window()) - port = lan_dialog.get_selected_port() - if port is None: - grid = self.get_outer_grid() - right_panel = grid.right_panel - vbox = right_panel.button_vbox - vbox._update_single_column(ButtonType.MAIN_MENU) - return - string = string + ":" + port - - wait_dialog = GenericDialog(transient_parent, "Fetching server metadata", Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread(target=self._background, args=(wait_dialog, string)) - thread.start() - - def update_first_col(self, title): - for col in self.get_columns(): - old_title = col.get_title() - col.set_title("%s (%s)" %(old_title, title)) - break - - def get_first_col(self): - for col in self.get_columns(): - cur_col = col.get_title() - break - return cur_col - - def _format_float(self, column, cell, model, iter, data): - # https://docs.huihoo.com/pygtk/2.0-tutorial/sec-CellRenderers.html - val = model[iter][3] - formatted = locale.format_string('%.3f', val, grouping=True) - cell.set_property('text', formatted) + ], + "called_by": [], + } + HELP = { + "label": "Help", + "rows": [ + RowType.CHANGELOG, + RowType.SHOW_LOG, + RowType.DUMP_LOG, + RowType.DOCS, + RowType.DOCS_FALLBACK, + RowType.BUGS, + RowType.FORUM, + RowType.SPONSOR, + ], + "called_by": [], + } + + # inner server contexts + TABLE_API = { + "label": "", + "rows": [], + "called_by": [RowType.SERVER_BROWSER], + } + TABLE_SERVER = { + "label": "", + "rows": [], + "called_by": [ + RowType.SAVED_SERVERS, + RowType.RECENT_SERVERS, + RowType.SCAN_LAN, + ], + } + TABLE_MODS = { + "label": "", + "rows": [], + "called_by": [ + RowType.LIST_MODS, + ], + } + TABLE_LOG = { + "label": "", + "rows": [], + "called_by": [RowType.SHOW_LOG], + } + + +class ButtonType(EnumWithAttrs): + MAIN_MENU = { + "label": "Main menu", + "opens": WindowContext.MAIN_MENU, + "tooltip": "Search for and connect to servers", + } + MANAGE = { + "label": "Manage", + "opens": WindowContext.MANAGE, + "tooltip": "Manage/add to saved servers", + } + OPTIONS = { + "label": "Options", + "opens": WindowContext.OPTIONS, + "tooltip": "Change settings, list local mods and\nother advanced options", + } + HELP = { + "label": "Help", + "opens": WindowContext.HELP, + "tooltip": "Links to documentation", + } + EXIT = {"label": "Exit", "opens": None, "tooltip": "Quits the application"} + + +def is_navkey(key: int) -> bool: + nav_keys = [ + Gdk.KEY_Down, + Gdk.KEY_Up, + Gdk.KEY_Page_Down, + Gdk.KEY_Page_Up, + Gdk.KEY_j, + Gdk.KEY_k, + Gdk.KEY_g, + Gdk.KEY_G, + ] + if key in nav_keys: + return True + return False + + +def call_bash_func(command: str, arg: str) -> None: + """ + Instantaneous system calls that open something + in the background (xdg-open) or serialize a file quickly + Contrast with call_out(), which should be called on a thread + """ + subprocess.Popen(["/usr/bin/env", "bash", funcs, command, arg]) + + +def load_css() -> None: + css = """ + .frame { + border: 0px; + } + .toast-label { + padding: 10px; + } + .frame > border { + border-radius: 5px; + padding: 5px; + } + .page-heading { + font-size: 1.5rem; + font-weight: 800; + margin-bottom: 0.5rem; + } + .settings-subheading { + font-size: 1.2rem; + font-weight: 700; + } + .left-label { + font-size: 1.3rem; + } + .details-heading { + font-size: 1.2rem + } + """ + prov = Gtk.CssProvider() + prov.load_from_data(css.encode("ascii")) + screen = Gdk.Screen.get_default() + if screen: + Gtk.StyleContext.add_provider_for_screen( + screen, prov, Gtk.STYLE_PROVIDER_PRIORITY_USER + ) + + +def add_class(widget: Gtk.Widget, label: str) -> None: + """ + Sets the classname of a widget, used + to apply CSS styling later + """ + context = widget.get_style_context() + context.add_class(label) + + +def unblock_signals() -> None: + block_signals(False) + + +def block_signals(state: bool = True) -> None: + suppress_signal( + App.grid.right_panel.filters_vbox, + App.grid.right_panel.filters_vbox.maps_combo, + "_on_map_changed", + state, + ) + suppress_signal( + App.treeview, + App.treeview.selected_row, + "_on_tree_selection_changed", + state, + ) + suppress_signal(App.treeview, App.treeview, "_on_keypress", state) + for check in App.grid.right_panel.filters_vbox.checks: + suppress_signal( + App.grid.right_panel.filters_vbox, + check, + "_on_check_toggled", + state, + ) + + +def read_json(path: str) -> str: + try: + with open(path, "r") as infile: + try: + data = json.load(infile) + except json.decoder.JSONDecodeError as e: + raise e + except OSError as e: + raise e + return data + + +def write_json(data: str, path: str) -> None: + try: + j = json.dumps(data, indent=2) + except Exception as e: + raise e + + try: + with open(path, "w") as outfile: + outfile.write(j) + except OSError as e: + raise e + + +def save_res_and_quit(*args) -> None: + if App.window.props.is_maximized: + Gtk.main_quit() return + rect = App.window.get_size() - def set_selection_mode(self, mode): - sel = self.get_selection() - sel.set_mode(mode) + data = {"res": {"width": rect.width, "height": rect.height}} + try: + write_json(data, res_path) + except Exception as e: + logger.critical(e) - def update_quad_column(self, mode): - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', False) - for column in self.get_columns(): - self.remove_column(column) - - self.set_headers_visible(True) - mod_store.clear() - log_store.clear() - - if mode == RowType.LIST_MODS: - cols = mod_cols - self.set_model(mod_store) - else: - cols = log_cols - self.set_model(log_store) - - for i, column_title in enumerate(cols): - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(column_title, renderer, text=i, foreground=4) - if mode == RowType.LIST_MODS: - if i == 3: - column.set_cell_data_func(renderer, self._format_float, func_data=None) - column.set_sort_column_id(i) - # hidden color property column - if i != 4: - self.append_column(column) - - widgets = relative_widget(self) - grid = widgets["grid"] - window = widgets["outer"] - try: - window.hb.set_subtitle(mode.dict["quad_label"]) - except KeyError: - window.hb.set_subtitle(mode.dict["label"]) - - if mode == RowType.LIST_MODS: - self.set_selection_mode(Gtk.SelectionMode.MULTIPLE) - else: - # short circuit and jump to debug log - data = call_out(self, "show_log") - res = parse_log_rows(data) - toggle_signal(self, self, '_on_keypress', True) - if res == 1: - spawn_dialog(self.get_outer_window(), "Failed to load log file, possibly corrupted", Popup.NOTIFY) - return + Gtk.main_quit() - wait_dialog = GenericDialog(window, "Checking mods", Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread(target=self._background_quad, args=(wait_dialog, mode)) - thread.start() +def suppress_signal( + owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool +) -> None: - def _background_connection(self, dialog, record): - def load(): - dialog.destroy() - transient = self.get_outer_window() - out = proc.stdout.splitlines() - msg = out[-1] - process_shell_return_code(transient, msg, proc.returncode, record) - - proc = call_out(self, "Connect from table", record) - GLib.idle_add(load) + func = getattr(owner, func_name) + if state: + logger.debug(f"Blocking {func_name} for {widget}") + widget.handler_block_by_func(func) + else: + logger.debug(f"Unblocking {func_name} for {widget}") + widget.handler_unblock_by_func(func) + App.treeview.sel_blocked = state - def _attempt_connection(self): - transient_parent = self.get_outer_window() - addr = self.get_column_at_index(7) - qport = self.get_column_at_index(8) - record = "%s:%s" %(addr, str(qport)) - - wait_dialog = GenericDialog(transient_parent, "Querying server and aligning mods", Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread(target=self._background_connection, args=(wait_dialog, record)) - thread.start() - - def _on_row_activated(self, treeview, tree_iter, col): - context = self.get_first_col() - chosen_row = self.get_column_at_index(0) - - # recycled from ModDialog - if self.view == WindowContext.TABLE_MODS: - select = treeview.get_selection() - sels = select.get_selected_rows() - (model, pathlist) = sels - if len(pathlist) < 1: - return - path = pathlist[0] - tree_iter = model.get_iter(path) - mod_id = model.get_value(tree_iter, 2) - base_cmd = "open_workshop_page" - subprocess.Popen(['/usr/bin/env', 'bash', funcs, base_cmd, mod_id]) - return - - dynamic_contexts = [ - WindowContext.TABLE_LOG, - WindowContext.TABLE_SERVER, - WindowContext.TABLE_API - ] - - # if already in table, the row selection is arbitrary - if self.view in dynamic_contexts: - cr = RowType.DYNAMIC - else: - cr = RowType.str2rowtype(chosen_row) - wc = WindowContext.row2con(cr) - self.view = wc - - output = self.view, cr - logger.info("User selected '%s' for the context '%s'" %(chosen_row, context)) - - if self.view == WindowContext.TABLE_LOG and cr == RowType.DYNAMIC: - return - - outer = self.get_outer_window() - right_panel = outer.grid.right_panel - filters_vbox = right_panel.filters_vbox - - server_contexts = [ - RowType.SCAN_LAN, - RowType.SERVER_BROWSER, - RowType.RECENT_SERVERS, - RowType.SAVED_SERVERS - ] - - # server contexts share the same model type - if cr in server_contexts: - if cr == RowType.SERVER_BROWSER: - cooldown = call_out(self, "test_cooldown", "", "") - if cooldown.returncode == 1: - spawn_dialog(outer, cooldown.stdout, Popup.NOTIFY) - # reset context to main menu if navigation was blocked - self.view = WindowContext.MAIN_MENU - return 1 - for check in checks: - toggle_signal(filters_vbox, check, '_on_check_toggle', False) - reinit_checks() - else: - for check in checks: - toggle_signal(filters_vbox, check, '_on_check_toggle', False) - if check.get_label() not in toggled_checks: - toggled_checks.append(check.get_label()) - check.set_active(True) - self._update_multi_column(cr) - - map_store.clear() - map_store.append(["All maps"]) - right_panel.set_active_combo() - - toggle_signal(filters_vbox, filters_vbox.maps_combo, '_on_map_changed', True) - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', True) - self.grab_focus() - return - - if self.view == WindowContext.TABLE_MODS or self.view == WindowContext.TABLE_LOG: - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', False) - self.update_quad_column(cr) - toggle_signal(self, self.selected_row, '_on_tree_selection_changed', True) - elif self.view == WindowContext.TABLE_SERVER or self.view == WindowContext.TABLE_API: - self._attempt_connection() - else: - # implies any other non-server option selected from main menu - process_tree_option(output, self) +def pluralize(plural: str, count: int) -> str: + suffix = plural[-2:] + if suffix == "es": + base = plural[:-2] + return f"{base}{'es'[:2*count ^ 2]}" + else: + base = plural[:-1] + return f"{base}{'s'[:count ^ 1]}" -def format_metadata(row_sel): - # this function is recycled for the add by ip/id methods + - # the right-click context menu (add/remove servers) - # in the latter case, there is no metadata to update - # see grid.update_statusbar(), so the returned row is None +def format_metadata(row_sel: str) -> str: + """ + Currently only being used for legacy + favorite server tooltip, cf Statusbar.update_app_meta() + """ row = None for i in RowType: if i.dict["label"] == row_sel: @@ -1848,76 +738,2401 @@ def format_metadata(row_sel): prefix = i.dict["tooltip"] break vals = { - "branch": config_vals[0], - "debug": config_vals[1], - "auto_install": config_vals[2], - "name": config_vals[3], - "fav_label": config_vals[4], - "preferred_client": config_vals[5], - "fullscreen": config_vals[6] - } + "branch": config_vals[0], + "debug": config_vals[1], + "auto_install": config_vals[2], + "name": config_vals[3], + "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 None + return "" + try: alt = row.dict["alt"] default = row.dict["default"] val = row.dict["val"] except KeyError: return prefix + try: cur_val = vals[val] if cur_val == "": - return "%s | Current: '%s'" %(prefix, default) - # TODO: migrate to human readable config values + current = default elif cur_val == "1": - return "%s | Current: '%s'" %(prefix, alt) + current = alt else: - return "%s | Current: '%s'" %(prefix, cur_val) + current = cur_val + return f"{prefix} | Current: '{current}'" except KeyError: return prefix -def format_tooltip(): - hits = len(server_store) - players = 0 - for row in server_store: - players+= row[4] - hits_pretty = pluralize("matches", hits) - players_pretty = pluralize("players", players) - tooltip = f"Found {hits:n} {hits_pretty} with {players:n} {players_pretty}" - return tooltip +def signal_emission(func: Callable) -> Callable: + def wrapper(*args, **kwargs): + block_signals() + func(*args, **kwargs) + unblock_signals() + + return wrapper -def filter_servers(transient_parent, filters_vbox, treeview, context): - def filter(dialog): - def clear_and_destroy(): - parse_server_rows(data) - server_tooltip[0] = format_tooltip() - transient_parent.grid.update_statusbar(server_tooltip[0]) +def update_window_labels(func: Callable) -> Callable: + """ + Decorator that sets metadata on the + current page context and subcontext + """ - toggle_signal(treeview, treeview.selected_row, '_on_tree_selection_changed', True) - toggle_signal(filters_vbox, filters_vbox, '_on_button_release', True) - toggle_signal(filters_vbox, filters_vbox.maps_combo, '_on_map_changed', True) - dialog.destroy() - treeview.grab_focus() + def wrapper(*args, **kwargs): + if not App.ready: + return + func(*args, **kwargs) + page_context = App.treeview.page.dict["label"] + text = page_context + App.window.hb.set_subtitle(page_context) - server_filters = toggled_checks + keyword_filter + selected_map - data = call_out(transient_parent, "filter", context, *server_filters) - GLib.idle_add(clear_and_destroy) + if App.treeview.subpage == RowType.KEYBINDINGS: + text = App.treeview.subpage.dict["label"] + elif App.treeview.subpage: + text = page_context + " > " + App.treeview.subpage.dict["label"] - # block additional input on FilterPanel while filters are running - toggle_signal(treeview, treeview.selected_row, '_on_tree_selection_changed', False) - toggle_signal(filters_vbox, filters_vbox, '_on_button_release', False) - toggle_signal(filters_vbox, filters_vbox.maps_combo, '_on_map_changed', False) + App.grid.set_breadcrumbs(text) + App.grid.statusbar.refresh() - dialog = GenericDialog(transient_parent, "Filtering results", Popup.WAIT) - dialog.show_all() - server_store.clear() + logger.info(f"Window context changed to: {App.treeview.view}") + logger.info(f"Page context changed to: {App.treeview.page}") + logger.info(f"Subpage context changed to: {App.treeview.subpage}") - thread = threading.Thread(target=filter, args=(dialog,)) + return wrapper + + +def set_surrounding_margins(widget: Gtk.Widget, margin: int) -> None: + """ + Utility function that sets all margins + on a widget to a uniform integer value + """ + widget.set_margin_top(margin) + widget.set_margin_start(margin) + widget.set_margin_end(margin) + + +def query_history() -> list | None: + try: + with open(history_file, "r") as f: + rows = [row.rstrip("\n") for row in f] + except OSError: + rows = None + return rows + + +def query_favorites() -> None | list: + proc = call_out("query_favorites") + if proc.returncode == 1: + return None + rows = proc.stdout.splitlines() + return rows + + +def query_config(key: str = "") -> list: + proc = call_out("query_config", key) + config = list(proc.stdout.splitlines()) + return config + + +def call_out(command: str, *args: str) -> subprocess.CompletedProcess: + if hasattr(TreeView, "view"): + name = getattr(TreeView, "view") + else: + name = "Generic" + + arg_ar = [] + for i in args: + arg_ar.append(i) + logger.info( + f"Context '{name}' calling subprocess '{command}' with args '{arg_ar}'" + ) + proc = subprocess.run( + ["/usr/bin/env", "bash", funcs, command] + arg_ar, + capture_output=True, + text=True, + ) + return proc + + +def spawn_dialog(msg: str, mode: Popup) -> bool: + msg = textwrap.dedent(msg) + dialog = GenericDialog(msg, mode) + response = dialog.run() + dialog.destroy() + clean_msg = msg.replace("\n", " ") + + match response: + case Gtk.ResponseType.OK: + logger.info(f"User confirmed dialog with message '{clean_msg}'") + return False + case Gtk.ResponseType.CANCEL | Gtk.ResponseType.DELETE_EVENT: + logger.info(f"User aborted dialog with message '{clean_msg}'") + return True + return False + + +def process_shell_return_code( + msg: str, code: int, original_input: RowType +) -> None: + logger.info( + f"Processing return code '{code}' for the input " + f"'{original_input}', returned message '{msg}'" + ) + match code: + case 0: # success with notice popup + spawn_dialog(msg, Popup.NOTIFY) + case 1: # error with notice popup + if msg == "": + msg = "Something went wrong" + spawn_dialog(msg, Popup.NOTIFY) + case 2: # warn and recurse (e.g. validation failed) + spawn_dialog(msg, Popup.NOTIFY) + process_tree_option(original_input) + case 4: # for BM only + spawn_dialog(msg, Popup.NOTIFY) + process_tree_option(RowType.CHNG_BM_API) + case 5: # for steam only, deprecated + spawn_dialog(msg, Popup.NOTIFY) + process_tree_option(RowType.CHNG_STEAM_API) + case 6: # return silently + pass + case 78: # failed settings update (steam) + spawn_dialog("Invalid Steam API key, reverting", Popup.NOTIFY) + App.notebook.settings.revert(Preferences.STEAM) + case 79: # failed settings update (bm) + spawn_dialog( + "Invalid Battlemetrics API key, reverting", Popup.NOTIFY + ) + App.notebook.settings.revert(Preferences.BM) + case 80: # pop toast after successful settings change + config_vals.clear() + for i in query_config(): + config_vals.append(i) + App.window.toast.set_text_and_fade("Settings updated!") + App.notebook.settings.populate_settings() + case 90: # used to update configs and metadata in-place + config_vals.clear() + for i in query_config(): + config_vals.append(i) + App.grid.statusbar.refresh() + spawn_dialog(msg, Popup.NOTIFY) + return + case 95: # successful mod deletion + spawn_dialog(msg, Popup.NOTIFY) + App.treeview._update_mod_store() + case 96: # unsuccessful mod deletion + spawn_dialog(msg, Popup.NOTIFY) + # re-block this signal before redrawing table contents + suppress_signal(App.treeview, App.treeview, "_on_keypress", False) + App.treeview.update_quad_column(RowType.LIST_MODS) + case 99: # highlight stale mods + panel = App.grid.sel_panel + panel.colorize_cells(True) + case 100: # final handshake before launch + final_conf = spawn_dialog(msg, Popup.CONFIRM) + 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) + + +def call_on_thread( + state: bool, subproc: str, msg: str, args: str, choice: RowType = None +) -> None: + """ + Exclusively used for threaded subprocesses + """ + + def _background(subproc: str, args: str, dialog): + def _load() -> None: + wait_dialog.destroy() + out = proc.stdout.splitlines() + try: + msg = out[-1] + except IndexError: + msg = "" + rc = proc.returncode + logger.info(f"Subprocess returned code {rc} with message '{msg}'") + process_shell_return_code(msg, rc, choice) + + proc = call_out(subproc, args) + GLib.idle_add(_load) + + if state: + wait_dialog = GenericDialog(msg, Popup.WAIT) + wait_dialog.show_all() + thread = threading.Thread( + target=_background, args=(subproc, args, wait_dialog) + ) + thread.start() + else: + """ + False is used to bypass wait dialogs; + used by fast, one-shot processes + """ + proc = call_out(subproc, args) + rc = proc.returncode + out = proc.stdout.splitlines() + msg = out[-1] + process_shell_return_code(msg, rc, choice) + + +def process_tree_option(choice: RowType) -> None: + context = App.treeview.view + command = choice + cmd_string = command.dict["label"] + logger.info(f"Parsing tree option '{command}' for the context '{context}'") + + # server tables + if command == RowType.RESOLVE_IP: + record = App.treeview.get_record_string() + wait_msg = command.dict["wait_msg"] + show_wait_dialog = True + call_on_thread( + show_wait_dialog, cmd_string, wait_msg, record, choice=choice + ) + return + + # modlist highlight stale action + if context == WindowContext.TABLE_MODS and command == RowType.HIGHLIGHT: + wait_msg = command.dict["wait_msg"] + show_wait_dialog = True + call_on_thread( + show_wait_dialog, cmd_string, wait_msg, "", choice=choice + ) + return + + if command == RowType.CHANGELOG: + App.grid.notebook.open_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) + case Command.TOGGLE: + process_toggle(command) + case Command.INTERACTIVE: + process_user_input(command) + case Command.ONESHOT: + wait_msg = command.dict["wait_msg"] + show_wait_dialog = True + call_on_thread(show_wait_dialog, cmd_string, wait_msg, "") + case _: + return + 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: + case RowType.TGL_BRANCH: + wait_msg = "Updating DZGUI branch" + show_wait_dialog = False + call_on_thread(show_wait_dialog, "toggle", wait_msg, cmd_string) + case RowType.TGL_INSTALL: + if query_config("auto_install")[0] == "1": + proc = call_out("toggle", cmd_string) + return + # manual -> auto mode + proc = call_out("find_id", "") + if proc.returncode == 1: + link = None + user_id = "" + else: + link = command.dict["link_label"] + user_id = proc.stdout + LinkDialog(manual_sub_msg, link, command, user_id) + case _: + 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() + + if response is None: + logger.info("User aborted entry dialog") + 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( + show_wait_dialog, cmd_string, wait_msg, response, choice=enum + ) + return + + +class OuterWindow(Gtk.Window): + def __init__(self): + super().__init__(title=app_name) + App.ready = False + + self.hb = AppHeaderBar() + + # steam deck taskbar may occlude elements + if not IS_STEAM_DECK: + self.set_titlebar(self.hb) + + self.connect("delete-event", self._on_delete_event) + self.connect("key-press-event", self._on_keypress) + + self.set_border_width(10) + self._set_resolution() + + self.grid = Grid() + self.toast = Toast() + self.overlay = Gtk.Overlay() + self.overlay.add_overlay(self.grid) + self.overlay.add_overlay(self.toast) + self.add(self.overlay) + + self.show_all() + self.toast.set_visible(False) + + self.grid.right_panel.filters_vbox.set_visible(False) + self.grid.right_panel.enable_ping_button(False) + self.grid.sel_panel.set_visible(False) + + global notes_cache + try: + notes_cache = read_json(notes_file) + except Exception as e: + logger.warning(e) + + # convenience to avoid deep calls + App.window = self + App.grid = self.grid + App.notebook = self.grid.notebook + App.treeview = self.grid.scrollable_treelist.treeview + App.right_panel = self.grid.right_panel + + load_css() + App.ready = True + App.grid.notebook.set_page_by_enum(NotebookPage.MAIN) + App.treeview.grab_focus() + + def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + if event.keyval is not Gdk.KEY_d: + return + if App.right_panel.filters_vbox.keyword_entry.is_focus(): + return + if App.right_panel.filters_vbox.maps_entry.is_focus(): + return + App.right_panel.toggle_debug() + + def _set_resolution(self) -> None: + if IS_GAME_MODE is True: + self.fullscreen() + return + elif query_config("fullscreen")[0] == "true": + logger.info("User preference for 'fullscreen' is 'true'") + self.fullscreen() + + try: + data = read_json(res_path) + valid_json = True + except Exception as e: + valid_json = False + logger.critical(e) + + if valid_json: + res = data["res"] + w, h = res["width"], res["height"] + logger.info(f"Restoring window size to {w},{h}") + self.set_default_size(w, h) + else: + w = 1400 + h = 800 + logger.info(f"Using default window size {w},{h}") + self.set_default_size(w, h) + + def _on_delete_event( + self, window: "OuterWindow", event: Gdk.EventKey + ) -> None: + self.halt_proc_and_quit() + + def halt_proc_and_quit(self) -> None: + App.grid.terminate_treeview_process() + save_res_and_quit() + + +class Toast(Gtk.EventBox): + def __init__(self): + super().__init__() + + self.label = Gtk.Label() + self.box = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.CENTER, + valign=Gtk.Align.CENTER, + ) + self.box.add(self.label) + self.add(self.box) + self.box.set_size_request(200, 100) + + add_class(self.box, "toast-label") + + def set_text(self, text: str) -> None: + self.label.set_text(text) + + def set_text_and_fade(self, text: str) -> None: + self.set_text(text) + self.pop() + self._defer_fade() + + def fade_out(self) -> bool: + if self.get_opacity() == 0: + self.set_visible(False) + self.set_opacity(1) + return False + self.set_opacity(self.get_opacity() - 0.03) + return True + + def pop(self) -> None: + self.set_visible(True) + + def _defer_fade(self) -> Literal[False]: + GLib.timeout_add(30, self.fade_out) + return False + + +class ScrollableTree(Gtk.ScrolledWindow): + def __init__(self): + super().__init__() + + self.treeview = TreeView() + self.add(self.treeview) + + +class RightPanel(Gtk.Box): + def __init__(self): + super().__init__(spacing=6, orientation=Gtk.Orientation.VERTICAL) + + self.button_vbox = ButtonBox() + self.filters_vbox = FilterPanel() + + self.pack_start(self.button_vbox, False, False, 0) + self.pack_start(self.filters_vbox, False, False, 0) + + debug_tooltip = ( + "Used to perform a dry run without\n" + "actually connecting to a server" + ) + ping_tooltip = ( + "Refresh the ping for visible servers.\n" + "Available once per unique filter context" + ) + + self.ping = Gtk.Button( + label="Ping servers", + margin_top=10, + margin_start=80, + margin_end=80, + tooltip_text=ping_tooltip, + ) + self.ping.connect("clicked", self._on_ping_clicked) + + self.debug_toggle = Gtk.ToggleButton( + label="Debug mode", + margin_top=10, + margin_start=80, + margin_end=80, + tooltip_text=debug_tooltip, + ) + + if query_config("debug")[0] == "1": + self.debug_toggle.set_active(True) + self.debug_toggle.connect("toggled", self._on_debug_toggled) + + self.question = Gtk.Button( + label="?", + margin_top=10, + margin_start=80, + margin_end=80, + tooltip_text="Opens the keybindings dialog", + ) + self.question.connect("clicked", self._on_question_clicked) + + self.pack_start(self.ping, False, True, 0) + self.pack_start(self.debug_toggle, False, True, 0) + self.pack_start(self.question, False, True, 0) + + def enable_ping_button(self, state: bool) -> None: + self.ping.set_visible(state) + + def reinit_maps(self, rows: list) -> None: + map_store.clear() + map_store.append(["All maps"]) + self.selected = "All maps" + self.filters_vbox.set_unique_maps(rows) + + def toggle_debug(self) -> None: + if type(App.window.get_focus()) is Gtk.Entry: + return + state = self.debug_toggle.get_active() + self.debug_toggle.set_active(not state) + + def _on_debug_toggled(self, button: Gtk.Button) -> None: + grid = App.grid + call_out("toggle", "Toggle debug mode") + grid.statusbar.refresh() + App.grid.notebook.focus_current() + + def _on_ping_clicked(self, button: Gtk.Button) -> None: + block_signals() + + def _update_pings(): + rows = ModelManager.get_filtered() + with ThreadPoolExecutor(100) as executor: + futures = [ + executor.submit(Servers.ping, i, row) + for i, row in enumerate(rows) + ] + wait(futures) + for future in futures: + res = future.result() + path = Gtk.TreePath.new_from_indices([res.iteration]) + temp_model[path][9] = res.ping + ModelManager.ping_cache[res.addr] = res.ping + App.treeview.set_model(temp_model) + App.treeview.wait_dialog.destroy() + App.treeview.enable_ping_column(True) + App.treeview.grab_focus() + App.right_panel.ping.set_sensitive(False) + + unblock_signals() + + temp_model = App.treeview.get_model() + App.treeview.set_model(None) + App.treeview.wait_dialog = GenericDialog("Pinging servers", Popup.WAIT) + App.treeview.wait_dialog.show_all() + thread = threading.Thread(target=_update_pings, args=()) + thread.start() + + def _on_question_clicked(self, button: Gtk.Button) -> None: + App.grid.notebook.toggle_keybindings() + + def focus_button_box(self) -> None: + self.button_vbox.buttons[0].grab_focus() + + +class ButtonBox(Gtk.Box): + def __init__(self): + super().__init__( + spacing=6, + margin_top=0, + margin_start=10, + margin_end=10, + orientation=Gtk.Orientation.VERTICAL, + ) + + self.buttons = list() + self.connect("key-press-event", self._on_keypress) + + for side_button in ButtonType: + button = Gtk.Button(label=side_button.dict["label"]) + button.type = side_button + button.set_tooltip_text(side_button.dict["tooltip"]) + + if IS_STEAM_DECK: + button.set_size_request(10, 10) + else: + button.set_size_request(50, 50) + self.buttons.append(button) + button.connect("clicked", self._on_selection_button_clicked) + self.pack_start(button, False, False, 0) + + @signal_emission + def _on_selection_button_clicked(self, button: Gtk.Button) -> None: + context = button.type + logger.info(f"User clicked '{context}'") + + App.grid.right_panel.filters_vbox.set_visible(False) + + if context == ButtonType.EXIT: + logger.info("Normal user exit") + save_res_and_quit() + return + + if context == ButtonType.OPTIONS: + App.notebook.settings.populate_settings() + App.notebook.set_page_by_enum(NotebookPage.OPTIONS) + return + + cols = App.treeview.get_columns() + if len(cols) > 1: + # restores tree from multi column view to main menu + App.treeview.update_single_column(context) + return + + App.treeview._populate(context.dict["opens"]) + cols[0].set_title(context.dict["label"]) + + def _walk_buttons(self, increment: int) -> None: + for i, button in enumerate(self.buttons): + if button.is_focus(): + n = i + increment + if n == len(self.buttons): + return + if n == -1: + return + n = self.buttons[n] + n.grab_focus() + return + + def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + match event.keyval: + case Gdk.KEY_h: + App.notebook.focus_current() + case Gdk.KEY_j: + self._walk_buttons(1) + case Gdk.KEY_k: + self._walk_buttons(-1) + case Gdk.KEY_question: + App.grid.notebook.toggle_keybindings() + + +class CalcDist(multiprocessing.Process): + def __init__( + self, + widget: Gtk.Widget, + addr: str, + result_queue: multiprocessing.Queue, + cache: dict, + ): + super().__init__() + + self.widget = widget + self.result_queue = result_queue + self.addr = addr + self.ip = addr.split(":")[0] + + def run(self) -> None: + if self.addr in cache: + logger.info(f"Address '{self.addr}' already in cache") + self.result_queue.put([self.addr, cache[self.addr]]) + return + proc = call_out("get_dist", self.ip) + km = proc.stdout + self.result_queue.put([self.addr, km]) + + +class ModelManagerSingleton: + """ + Manages access to ListStore cache resources and + performs filtering on behalf of TreeView. + + Filtration to and from ListStore format is + delegated to this singleston. + + Not thread-safe. + """ + + def __init__(self): + # packed ListStores + self.filter_cache = {} + self.ping_cache = {} + # stringwise (list) representation of the model + self.control_model = None + self.filtered = None + self.success = True + + def __new__(cls): + if not hasattr(cls, "instance"): + cls.instance = super(ModelManagerSingleton, cls).__new__(cls) + return cls.instance + + def filter(self, mode: FilterMode, *args, **kwargs) -> None: + """ + Native Gtk.TreeView.refilter() method was not performant enough + when running in the main loop with 40k+ records + """ + filters = App.right_panel.filters_vbox.get_filters() + + if filters in self.filter_cache: + cache = self.filter_cache[filters] + self.set_store(cache[0]) + self.set_filtered(cache[1]) + GLib.idle_add(App.treeview._filter_cleanup) + return + + match mode: + case FilterMode.INITIAL: + rows = self.filter_initial(filters) + + case FilterMode.MAP: + panel = App.right_panel.filters_vbox + prior_map = panel.get_prior_map() + + if prior_map == "All maps": + rows = self.filter_map(filters) + else: + App.right_panel.ping.set_sensitive(True) + rows = self.filter_toggle_on(filters, *args) + + case FilterMode.KEYWORD: + App.right_panel.ping.set_sensitive(True) + rows = self.filter_toggle_on(filters, *args) + + case FilterMode.TOGGLE_OFF: + for f in filters[2:]: + self.set_filtered(self.filter_toggle_off(filters, f)) + rows = self.filtered + + case FilterMode.TOGGLE_ON: + App.right_panel.ping.set_sensitive(True) + rows = self.filter_toggle_on(filters, *args) + + if mode is not FilterMode.INITIAL: + for row in rows: + if row[7] in self.ping_cache: + row[9] = self.ping_cache[row[7]] + + if len(rows) > 0: + clone = ModelManager.new_model() + rows = self.sort_rows(rows) + for row in rows: + clone.append(row) + else: + clone = None + + self.set_cache(filters, clone, rows) + self.set_store(clone) + GLib.idle_add(App.treeview._filter_cleanup) + + def sort_rows(self, rows: list) -> list: + rows.sort(key=lambda x: re.sub(r"[^A-Za-z0-9]+", "", x[0].lower())) + return rows + + def filter_initial(self, filters: tuple) -> list: + """ + Simply culls the control model of any disabled filters + """ + self.set_filtered(self.control_model) + for f in filters[2:]: + self.set_filtered(self.filter_toggle_off(filters, f)) + return self.filtered + + def filter_map(self, filters: tuple) -> list: + """ + Multi-filtration for any context starts by narrowing by map + """ + rows = self.filtered + panel = App.right_panel.filters_vbox + sel_map = panel.get_selected_map() + + if sel_map == "All maps": + return rows + + rows = [row for row in rows if row[1] == sel_map] + return rows + + def filter_keyword(self, filters: tuple) -> list: + keyword = App.right_panel.filters_vbox.get_keyword_filter() + rows = self.filtered + + if keyword == "": + return rows + + filtered = [ + row + for row in rows + if keyword in row[0].lower() + or keyword in row[1].lower() + or keyword in row[7].lower() + ] + return filtered + + def filter_toggle_off(self, filters: tuple, filter_type: str) -> list: + """ + Sub-filtration of the current model + """ + pairs = {"3PP": "1PP", "Day": "Night", "Official": "Unoffic."} + for k, v in pairs.items(): + if k in filters and v in filters: + self.set_filtered(None) + return [] + + rows = self.filtered + match filter_type: + case "3PP": + rows = [row for row in rows if row[2] != "3PP"] + case "1PP": + rows = [row for row in rows if row[2] != "1PP"] + case "Official": + rows = [row for row in rows if row[10] != "Official"] + case "Unoffic.": + rows = [row for row in rows if row[10] != "Unoffic."] + case "Empty": + rows = [row for row in rows if row[4] != 0] + case "Full": + rows = [row for row in rows if row[4] != row[5]] + case "Duplicate": + seen = [] + final = [] + for row in rows: + if row[0] in seen: + continue + seen.append(row[0]) + final.append(row) + rows = final + case "Day": + reg = r"([0][0-9]|[1][0-6])" + rows = [row for row in rows if not re.match(reg, row[3])] + case "Night": + reg = r"([0][0-4]|[1][8]|[2][0-3])" + rows = [row for row in rows if not re.match(reg, row[3])] + case "Non-ASCII": + rows = [row for row in rows if row[0].isascii()] + case "Low pop": + rows = [row for row in rows if (row[4] / row[5] * 100) > 30] + case "Modded": + rows = [row for row in rows if not row[11]] + return rows + + def filter_toggle_on(self, filters: tuple, *args: str) -> list: + """Effectively applies all filters""" + self.set_filtered(self.control_model) + self.set_filtered(self.filter_map(filters)) + self.set_filtered(self.filter_keyword(filters)) + + for f in filters[2:]: + self.set_filtered(self.filter_toggle_off(filters, f)) + return self.filtered + + def set_cache( + self, filters: tuple, model: Gtk.ListStore | None, rows: list + ) -> None: + self.filter_cache[filters] = (model, rows) + + def new_model(self) -> Gtk.ListStore: + return Gtk.ListStore( + str, str, str, str, int, int, int, str, int, int, str, bool + ) + + def resync_model(self, addr: str, qport: int) -> None: + """Handle in-situ updates to model during + row deletion actions. Skipped for ephemeral + actions like player count/ping updates + """ + for row in self.control_model: + if row[7] == addr and row[8] == qport: + self.control_model.remove(row) + + self.wipe_cache() + filters = App.right_panel.filters_vbox.get_filters() + refiltered = self.filter_toggle_on(filters) + self.set_filtered(refiltered) + self.set_success(True) + GLib.idle_add(App.treeview._filter_cleanup) + + def convert_model_to_list(self, model: Gtk.ListStore) -> list: + return [[el for el in row] for row in model] + + def set_filtered(self, rows: list | None) -> None: + if rows is None: + rows = [] + self.filtered = rows + + def get_filtered(self) -> list: + return self.filtered + + def set_store(self, model: Gtk.ListStore | None) -> None: + self.store = model + + def get_store(self) -> Gtk.ListStore | None: + return self.store + + def set_control(self, rows: list) -> None: + self.control_model = rows + + def set_success(self, result: bool) -> None: + self.success = result + + def get_success(self) -> bool: + return self.success + + def wipe_cache(self, full=False) -> None: + self.success = True + self.filtered = None + self.filter_cache = {} + self.ping_cache = {} + if full: + self.control_model = None + + +class TreeView(Gtk.TreeView): + __gsignals__ = { + "on_distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ()) + } + + def __init__(self): + super().__init__() + + """ + Since some views like TABLE_SERVER recycle + the same model type, self.view corresponds + to an enumeration of the current model in the tree. + + By contrast, self.page and self.subpage + correspond to user-facing navigation contexts, e.g., + Main menu (page) > Server browser (subpage) + + This is used by: + - logs + - labels like breadcrumbs and the headerbar subtitle + - methods passing contextual "you are here" info to helpers + + When on a top-level menu (WindowContext.MAIN_MENU), + the subpage is None. + """ + + self.view = WindowContext.MAIN_MENU + self.page = WindowContext.MAIN_MENU + self.subpage = None + self.sel_blocked = False + + self.set_fixed_height_mode(True) + self.set_has_tooltip(True) + self.connect("query-tooltip", self._on_tooltip) + + self.queue = multiprocessing.Queue() + self.current_proc = None + + # disables typeahead search + self.set_enable_search(False) + self.set_search_column(-1) + + # populate model with initial context + for row in WindowContext.MAIN_MENU.dict["rows"]: + label = row.dict["label"] + t = (label,) + row_store.append(t) + self.set_model(row_store) + + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn("Main menu", renderer, text=0) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + self.append_column(column) + + self.set_headers_visible(False) + + self.selected_row = self.get_selection() + self.selected_row.connect("changed", self._on_tree_selection_changed) + self.connect("button-release-event", self._on_button_release) + self.connect("row-activated", self._on_row_activated) + + self.connect("key-press-event", self._on_keypress) + self.connect("key-release-event", self._on_key_release) + + def _on_tooltip( + self, + widget: Gtk.Widget, + x: int, + y: int, + keyboard_mode: bool, + tooltip: Gtk.Tooltip, + ) -> bool: + if self.is_server_context(self.view) is False: + return + if self.subpage is None: + return + + coords = widget.convert_widget_to_bin_window_coords(x, y) + path = self.get_path_at_pos(coords.bx, coords.by) + if path is None: + return False + + model = self.get_model() + tree_iter = model.get_iter(path[0]) + ip = model.get_value(tree_iter, 7) + qport = model.get_value(tree_iter, 8) + addr = ip + ":" + str(qport) + if addr not in notes_cache: + return False + tooltip.set_text(notes_cache[addr]) + self.set_tooltip_row(tooltip, path[0]) + return True + + def _update_mod_store(self) -> None: + (model, pathlist) = self.get_selection().get_selected_rows() + for p in reversed(pathlist): + it = model.get_iter(p) + model.remove(it) + total_size = 0 + total_mods = len(model) + for row in model: + total_size += row[3] + size = locale.format_string("%.3f", total_size, grouping=True) + pretty = pluralize("mods", total_mods) + App.grid.statusbar.set_text( + f"Found {total_mods:n} {pretty} taking up {size} MiB" + ) + # untoggle selection for visibility of other stale rows + self.toggle_selection(False) + + def get_subpage_label(self) -> None: + if self.subpage: + return self.subpage.dict["label"] + return None + + def get_subpage(self) -> RowType: + return self.subpage + + def terminate_process(self) -> None: + if self.current_proc and self.current_proc.is_alive(): + self.current_proc.terminate() + + def _delete_note( + self, button: Gtk.Button, user_entry: Gtk.Box, addr: str + ) -> None: + box = button.get_parent() + dialog = box.get_parent() + try: + write_json(notes_cache, notes_file) + del notes_cache[addr] + except Exception as e: + logger.critical(e) + dialog.destroy() + + def add_note(self) -> None: + user_entry = EntryDialog( + "Add a short note/reminder. Limit: 30 chars", Popup.ENTRY, "" + ) + entry = user_entry.get_entry() + entry.set_max_length(30) + + addr = self.get_record_string() + if addr in notes_cache: + entry.set_text(notes_cache[addr]) + button = Gtk.Button(label="Delete note") + button.connect("clicked", self._delete_note, user_entry, addr) + button.set_margin_start(50) + button.set_margin_end(50) + user_entry.dialogBox.pack_end(button, False, False, 0) + + response = user_entry.get_input() + if response is None: + return + notes_cache[addr] = response + try: + write_json(notes_cache, notes_file) + except Exception as e: + logger.critical(e) + + def copy_name(self) -> None: + self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + record = self.get_name() + self.clipboard.set_text(record, -1) + + def copy_clipboard(self) -> None: + self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + record = self.get_record_string() + self.clipboard.set_text(record, -1) + + def delete_mod(self) -> None: + modname = self.get_value_at_index(0) + conf_msg = f"Really delete the mod '{modname}'?" + res = spawn_dialog(conf_msg, Popup.CONFIRM) + if res: + return + mods = [] + symlink = self.get_value_at_index(1) + path = self.get_value_at_index(2) + concat = symlink + " " + path + "\n" + mods.append(concat) + with open(mods_temp_file, "w") as outfile: + outfile.writelines(mods) + process_tree_option(RowType.DELETE_SELECTED) + + def open_workshop(self) -> None: + record = self.get_value_at_index(2) + base_cmd = "open_workshop_page" + call_bash_func(base_cmd, record) + + def add_server(self) -> None: + process_tree_option(RowType.RESOLVE_IP) + + def remove_server(self) -> None: + """ + Both add and remove server functionally + call the same logic in helpers/funcs; + if the record exists, it is removed, and vice versa. + When removing, the model needs to be updated in situ + if the current context is RowType.SAVED_SERVERS + """ + self.add_server() + + if self.subpage != RowType.SAVED_SERVERS: + return + + self.resync_with_manager() + + def resync_with_manager(self): + model = self.get_model() + it = self.get_current_iter() + if it: + addr = model.get_value(it, 7) + qport = model.get_value(it, 8) + model.remove(it) + + block_signals() + thread = threading.Thread( + target=ModelManager.resync_model, args=(addr, qport) + ) + thread.start() + + def remove_from_history(self) -> None: + record = self.get_record_string() + call_out("Remove from history", record) + self.resync_with_manager() + + def show_details(self) -> None: + model = self.get_model() + it = self.get_current_iter() + name = model.get_value(it, 0) + record = self.get_record() + DetailsDialog(name, record.ip, record.qport) + + def show_mods(self) -> None: + record = self.get_record_string() + ModDialog(record) + modlist_store.clear() + + def _on_menu_click(self, menu_item: Gtk.MenuItem) -> None: + if hasattr(TreeView, menu_item.action): + func = getattr(TreeView, menu_item.action) + msg = ( + f"User clicked context menu '{menu_item.get_label()}', " + f"calls {func}" + ) + logger.info(msg) + func(self) + else: + msg = ( + f"Context menu function for '{menu_item.action}' " + f"does not exist" + ) + u_msg = ( + f"Something went wrong when accessing the method " + f"'{menu_item.action}'" + ) + logger.critical(msg) + spawn_dialog(u_msg, Popup.NOTIFY) + return + + def toggle_selection(self, state: bool) -> None: + for i, rows in enumerate(mod_store): # type: ignore + path = Gtk.TreePath.new_from_indices([i]) + if state: + self.get_selection().select_path(path) + else: + self.get_selection().unselect_path(path) + + def has_mods(self) -> bool: + select = self.get_selection() + sels = select.get_selected_rows() + (model, pathlist) = sels + path = pathlist[0] + tree_iter = model.get_iter(path) + mods = model.get_value(tree_iter, 11) + return mods + + def is_in_favs(self) -> bool: + record = self.get_record_string() + proc = call_out("is_in_favs", record) + if proc.returncode == 0: + return True + return False + + def is_selection_empty(self) -> bool: + sel = self.get_selection() + sels = sel.get_selected_rows() + (model, pathlist) = sels + if len(pathlist) < 1: + return True + return False + + def _on_button_release( + self, widget: Gtk.Widget, event: Gdk.EventButton + ) -> None: + if event.type is Gdk.EventType.BUTTON_RELEASE and event.button != 3: + return + + try: + pathinfo = self.get_path_at_pos(int(event.x), int(event.y)) + if pathinfo is None: + return + (path, col, cellx, celly) = pathinfo + if path is None: + return + self.set_cursor(path, col, False) + except AttributeError: + pass + + self.menu = Gtk.Menu() + mod_context_items = [ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD] + server_context_items = { + RowType.SERVER_BROWSER: [ + ContextMenu.ADD_SERVER, + ContextMenu.COPY_NAME, + ContextMenu.COPY_CLIPBOARD, + ContextMenu.ADD_NOTE, + ContextMenu.SHOW_MODS, + ContextMenu.SHOW_DETAILS, + ContextMenu.REFRESH_PLAYERS, + ], + RowType.SCAN_LAN: [ + ContextMenu.COPY_NAME, + ContextMenu.COPY_CLIPBOARD, + ContextMenu.ADD_NOTE, + ContextMenu.SHOW_MODS, + ContextMenu.SHOW_DETAILS, + ContextMenu.REFRESH_PLAYERS, + ], + RowType.SAVED_SERVERS: [ + ContextMenu.REMOVE_SERVER, + ContextMenu.COPY_NAME, + ContextMenu.COPY_CLIPBOARD, + ContextMenu.ADD_NOTE, + ContextMenu.SHOW_MODS, + ContextMenu.SHOW_DETAILS, + ContextMenu.REFRESH_PLAYERS, + ], + RowType.RECENT_SERVERS: [ + ContextMenu.ADD_SERVER, + ContextMenu.REMOVE_HISTORY, + ContextMenu.COPY_NAME, + ContextMenu.ADD_NOTE, + ContextMenu.COPY_CLIPBOARD, + ContextMenu.SHOW_MODS, + ContextMenu.SHOW_DETAILS, + ContextMenu.REFRESH_PLAYERS, + ], + } + + if self.view == WindowContext.TABLE_MODS: + items = mod_context_items + elif self.subpage in server_context_items: + items = server_context_items[self.subpage] + else: + return + + for row in items: + if row == ContextMenu.ADD_SERVER: + if self.is_in_favs(): + row = ContextMenu.REMOVE_SERVER + item = Gtk.MenuItem(label=row.dict["label"]) + item.type = row + item.action = row.dict["action"] + item.connect("activate", self._on_menu_click) + self.menu.append(item) + if row == ContextMenu.SHOW_MODS: + if not self.has_mods(): + item.set_sensitive(False) + if row == ContextMenu.ADD_NOTE: + if self.get_record_string() in notes_cache: + item.set_label("Edit note") + + self.menu.show_all() + + if event.type is Gdk.EventType.KEY_PRESS and event.keyval is Gdk.KEY_l: + if self.is_selection_empty(): + return + self.menu.popup_at_widget( + widget, Gdk.Gravity.CENTER, Gdk.Gravity.WEST + ) + else: + self.menu.popup_at_pointer(event) + self.menu.select_first(False) + + def refresh_player_count(self) -> None: + if not self.is_server_context(self.view): + return + cooldown = call_out("test_cooldown", "", "") + if cooldown.returncode == 1: + spawn_dialog(cooldown.stdout, Popup.NOTIFY) + return None + call_out("start_cooldown", "", "") + + thread = threading.Thread( + target=self._background_player_count, args=() + ) + thread.start() + + def get_current_iter(self) -> Gtk.TreeIter | None: + it = self.get_selection().get_selected()[1] + return it + + def _on_tree_selection_changed(self, selection: Gtk.TreeSelection) -> None: + # bail out on early init + if not hasattr(App, "grid"): + return + grid = App.grid + + context = App.treeview.get_subpage_label() + row_sel = self.get_value_at_index(0) + logger.info( + f"Tree selection for context '{context}' changed to '{row_sel}'" + ) + + if self.current_proc and self.current_proc.is_alive(): + self.current_proc.terminate() + + if ( + self.view == WindowContext.TABLE_API + or self.view == WindowContext.TABLE_SERVER + ): + record = self.get_record() + if not record: + grid.statusbar.update_server_meta() + return + ip = record.ip + if ip in cache: + km = cache[ip] + grid.statusbar.append_distance(km) + return + self.emit("on_distcalc_started") + self.current_proc = CalcDist(self, ip, self.queue, cache) + self.current_proc.start() + else: + grid.statusbar.refresh() + + def get_selected_row_index(self) -> int: + sel = self.get_selection() + rows = sel.get_selected_rows() + cur_row = rows[1][0][0] + return cur_row + + def _move_cursor(self, position: CursorPosition) -> bool | None: + cur_row = self.get_selected_row_index() + model = self.get_model() + if model: + end = len(model) - 1 + else: + return None + + if position == CursorPosition.DOWN: + if cur_row == end: + return True + dest = cur_row + 1 + if position == CursorPosition.UP: + if cur_row == 0: + return True + dest = cur_row - 1 + if position == CursorPosition.TOP: + if cur_row == 0: + return True + dest = 0 + if position == CursorPosition.BOTTOM: + if cur_row == end: + return True + dest = end + path = Gtk.TreePath.new_from_indices([dest]) + self.set_cursor(path) + return None + + def _on_keypress( + self, treeview: Gtk.TreeView, event: Gdk.EventKey + ) -> bool | None: + keyname = Gdk.keyval_name(event.keyval) + grid = App.grid + if event.state is Gdk.ModifierType.CONTROL_MASK: + match event.keyval: + case Gdk.KEY_l: + self._on_button_release(self, event) + case Gdk.KEY_r: + self.refresh_player_count() + case Gdk.KEY_f: + if not App.treeview.is_server_context(App.treeview.view): + return True + App.right_panel.filters_vbox.keyword_entry.grab_focus() + case Gdk.KEY_m: + if App.treeview.view == WindowContext.TABLE_MODS: + return True + App.right_panel.filters_vbox.maps_entry.grab_focus() + case _: + return False + else: + if is_navkey(event.keyval): + if self.sel_blocked is False: + suppress_signal( + App.treeview, + App.treeview.selected_row, + "_on_tree_selection_changed", + True, + ) + if keyname.isnumeric() and int(keyname) > 0: + digit = int(keyname) - 1 + grid.right_panel.filters_vbox.toggle_check(digit) + return False + if event.keyval == Gdk.KEY_G: + self._move_cursor(CursorPosition.BOTTOM) + match event.keyval: + case Gdk.KEY_g: + self._move_cursor(CursorPosition.TOP) + case Gdk.KEY_j: + self._move_cursor(CursorPosition.DOWN) + case Gdk.KEY_k: + self._move_cursor(CursorPosition.UP) + case Gdk.KEY_l | Gdk.KEY_Right: + if event.state is Gdk.ModifierType.CONTROL_MASK: + return + App.right_panel.focus_button_box() + case Gdk.KEY_0: + grid.right_panel.filters_vbox.toggle_check(9) + case Gdk.KEY_minus: + grid.right_panel.filters_vbox.toggle_check(10) + case Gdk.KEY_backslash: + grid.right_panel.filters_vbox.toggle_check(11) + case _: + return False + return None + + def _on_key_release( + self, treeview: Gtk.TreeView, event: Gdk.EventKey + ) -> None: + """ + Suppresses spamming on keydown + """ + if is_navkey(event.keyval): + if self.sel_blocked is True: + suppress_signal( + App.treeview, + App.treeview.selected_row, + "_on_tree_selection_changed", + False, + ) + selection = self.get_selection() + self._on_tree_selection_changed(selection) + + def focus_first_row(self) -> None: + path = Gtk.TreePath.new_from_indices([0]) + try: + self.get_selection().select_path(path) + except ValueError: + pass + + def get_value_at_index(self, index: int) -> str: + select = self.get_selection() + sels = select.get_selected_rows() + (model, pathlist) = sels + if len(pathlist) < 1: + return "" + path = pathlist[0] + tree_iter = model.get_iter(path) + value = model.get_value(tree_iter, index) + return value + + def get_name(self) -> str: + name = self.get_value_at_index(0) + return name + + def get_record_string(self) -> str: + addr = self.get_value_at_index(7) + qport = self.get_value_at_index(8) + return f"{addr}:{qport}" + + def get_record(self) -> dict | None: + select = self.get_selection() + sels = select.get_selected_rows() + (model, pathlist) = sels + if len(pathlist) < 1: + return None + path = pathlist[0] + model = self.get_model() + if not model: + return None + addr = model[path][7] + qport = model[path][8] + ip = addr.split(":")[0] + gameport = int(addr.split(":")[1]) + return Record(ip, gameport, qport) + + def update_players(self, players: int) -> None: + model = self.get_model() + path = self.get_mpath() + if not model: + return + if not path: + return + model[path][4] = players + + def update_queue(self, players: int) -> None: + model = self.get_model() + path = self.get_mpath() + model[path][6] = players + + def enable_ping_column(self, state: bool) -> None: + columns = self.get_columns() + for column in columns: + if column.get_title() == "Ping": + column.set_visible(state) + + def select_first_row(self): + sel = self.get_selection() + self._on_tree_selection_changed(sel) + + def get_mpath(self) -> Gtk.TreePath | None: + select = self.get_selection() + sels = select.get_selected_rows() + (model, pathlist) = sels + if len(pathlist) < 1: + return None + path = pathlist[0] + return path + + def _background_player_count(self) -> None: + def _load(): + lines = data.stdout.splitlines() + self.update_players(int(lines[0])) + self.update_queue(int(lines[1])) + wait_dialog.destroy() + + wait_dialog = GenericDialog("Refreshing player count", Popup.WAIT) + wait_dialog.show_all() + record = self.get_record() + if not record: + return + data = call_out("get_player_count", record.ip, str(record.qport)) + if data.returncode == 1: + wait_dialog.destroy() + return + GLib.idle_add(_load) + + def _dump_api(self): + key = query_config("steam_api")[0] + job = Servers.query_api + params = Servers.params + serv = [] + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit(job, key, APPID_DAYZ, param) + for param in params + ] + wait(futures) + for future in futures: + res = future.result() + if res.status != 200 or not res.parsed: + ModelManager.set_store(None) + ModelManager.set_success(False) + GLib.idle_add(self._filter_cleanup) + return + j = res.json + serv += j["response"]["servers"] + + 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: + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit(Servers.test_ip, i, port) + for i in range(1, 256) + ] + wait(futures) + servers = [] + for future in futures: + res = future.result() + if res is None: + continue + servers.append(res) + if len(servers) == 0: + ModelManager.set_store(None) + ModelManager.set_success(False) + GLib.idle_add(self._filter_cleanup) + return None + parsed = Servers.parse_json(servers) + return parsed + + def _dump_servers(self, ips: list) -> list | None: + if len(ips) == 0: + return [] + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit( + Servers.query_direct, + ip.split(":")[0], + int(ip.split(":")[2]), + ) + for ip in ips + ] + wait(futures) + serv = [] + for future in futures: + res = future.result() + if res is None: + continue + serv.append(res) + if len(serv) == 0: + ModelManager.set_store(None) + ModelManager.set_success(False) + GLib.idle_add(self._filter_cleanup) + return None + parsed = Servers.parse_json(serv) + return parsed + + def _query_servers(self, mode: RowType, port: int = 27016) -> None: + block_signals() + + match mode: + case RowType.SCAN_LAN: + parsed = self._dump_lan(port) + case RowType.SERVER_BROWSER: + App.treeview.enable_ping_column(False) + App.right_panel.enable_ping_button(True) + parsed = self._dump_api() + case RowType.SAVED_SERVERS: + App.treeview.enable_ping_column(True) + favs = query_favorites() + if not favs: + ModelManager.set_success(False) + ModelManager.set_store(None) + GLib.idle_add(self._filter_cleanup) + return + parsed = self._dump_servers(favs) + case RowType.RECENT_SERVERS: + App.treeview.enable_ping_column(True) + history = query_history() + if not history: + ModelManager.set_success(False) + ModelManager.set_store(None) + GLib.idle_add(self._filter_cleanup) + return + parsed = self._dump_servers(history) + + if parsed is None: + return + App.right_panel.reinit_maps(parsed) + + # intialize to empty + ModelManager.set_store(None) + ModelManager.set_control(parsed) + ModelManager.filter(FilterMode.INITIAL) + + def _filter_cleanup(self, empty: bool = False) -> None: + model = ModelManager.get_store() + self.set_model(model) + + if App.treeview.subpage == RowType.SERVER_BROWSER: + call_out("start_cooldown", "", "") + + """ + There may be scenarios where opposed filter results + deterministically yield 0 hits. The model needs to be + emptied without triggering a fetch error. This method is + reserved for cases where the query actually failed. + """ + if not ModelManager.get_success(): + if self.wait_dialog: + self.wait_dialog.destroy() + spawn_dialog(api_warn_msg, Popup.RETURN) + unblock_signals() + return + + if App.right_panel.filters_vbox.get_active_combo() < 0: + App.right_panel.filters_vbox.set_active_combo(0) + App.grid.right_panel.filters_vbox.set_visible(True) + for column in self.get_columns(): + column.connect("notify::fixed-width", self._on_col_width_changed) + + App.grid.statusbar.update_server_meta() + + if self.wait_dialog: + self.wait_dialog.destroy() + unblock_signals() + self.grab_focus() + App.treeview.select_first_row() + + def filter(self, mode: FilterMode, *args) -> None: + block_signals() + self.wait_dialog = GenericDialog("Filtering servers", Popup.WAIT) + self.wait_dialog.show_all() + + ModelManager.set_store(App.treeview.get_model()) + App.treeview.set_model(None) + + thread = threading.Thread( + target=ModelManager.filter, args=(mode, *args) + ) + thread.start() + + def _background_quad(self, dialog: "GenericDialog", mode: RowType) -> None: + # currently only used by list mods method + def load(): + dialog.destroy() + # suppress button panel if store is empty + if total_mods == 0: + grid.sel_panel.set_visible(False) + right_panel.filters_vbox.set_visible(False) + logger.info("Nothing to do, spawning notice dialog") + spawn_dialog(data.stdout, Popup.RETURN) + return + else: + grid.sel_panel.set_visible(True) + grid.sel_panel.initialize() + + self.set_model(mod_store) + self.grab_focus() + size = locale.format_string("%.3f", total_size, grouping=True) + pretty = pluralize("mods", total_mods) + grid.statusbar.set_text( + f"Found {total_mods:n} {pretty} taking up {size} MiB" + ) + self.focus_first_row() + + grid = App.grid + right_panel = grid.right_panel + data = call_out(mode.dict["label"], "") + + # suppress errors if no mods available on system + if data.returncode == 1: + logger.info("Failed to find mods on local system") + total_mods = 0 + total_size = 0 + else: + if App.treeview.view == WindowContext.TABLE_MODS: + grid.sel_panel.set_visible(True) + result = self._parse_mod_rows(data) + try: + total_size = result[0] + total_mods = result[1] + info = ( + f"Found mods on local system: " + f"{total_mods} total, occupies {total_size}" + ) + except IndexError: + total_size = 0 + total_mods = 0 + info = "Found mods on system, but was unable to parse results." + finally: + logger.info(info) + GLib.idle_add(load) + + def _parse_log_rows(self, data: subprocess.CompletedProcess) -> bool: + lines = data.stdout.splitlines() + reader = csv.reader(lines, delimiter=delimiter) + try: + rows = [[row[0], row[1], row[2], row[3]] for row in reader if row] + except IndexError: + return False + for row in rows: + log_store.append(row) + return True + + def _parse_mod_rows(self, data: subprocess.CompletedProcess) -> list: + # GTK pads trailing zeroes on floats + # https://stackoverflow.com/questions/26827434/gtk-cellrenderertext-with-format + total = float(0) + lines = data.stdout.splitlines() + hits = len(lines) + reader = csv.reader(lines, delimiter=delimiter) + + # Nonetype inherits default GTK color + try: + rows = [ + [row[0], row[1], row[2], locale.atof(row[3], func=float), None] + for row in reader + if row + ] + except IndexError: + return [] + for row in rows: + mod_store.append(row) + total += float(row[3]) + return [total, hits] + + def _on_col_width_changed( + self, col: Gtk.TreeViewColumn, width: GObject.ParamSpecInt + ) -> None: + title = col.get_title() + size = col.get_width() + + try: + data = read_json(geometry_path) + data["cols"][title] = size + except Exception as e: + logger.critical(e) + data = {"cols": {title: size}} + + try: + write_json(data, geometry_path) + except Exception as e: + logger.critical(e) + + def initialize_columns(self) -> None: + try: + data = read_json(geometry_path) + valid_json = True + except Exception as e: + logger.critical(e) + valid_json = False + + browser_cols = [ + "Name", + "Map", + "Perspective", + "Gametime", + "Players", + "Maximum", + "Queue", + "IP", + "Qport", + "Ping", + ] + for i, column_title in enumerate(browser_cols): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + column.set_resizable(True) + column.set_sort_column_id(i) + + if valid_json: + try: + saved_size = data["cols"][column_title] + except KeyError: + saved_size = 100 + column.set_fixed_width(saved_size) + column.set_expand(True) + else: + if column_title == "Name": + column.set_fixed_width(800) + if column_title == "Map": + column.set_fixed_width(300) + + self.append_column(column) + + @update_window_labels + def _update_multi_column(self, mode: RowType, port: int = 27016) -> None: + self.subpage = mode + + self.set_headers_visible(True) + for column in self.get_columns(): + self.remove_column(column) + row_store.clear() + self.initialize_columns() + self.set_selection_mode(Gtk.SelectionMode.SINGLE) + + self.wait_dialog = GenericDialog( + "Fetching server metadata", Popup.WAIT + ) + self.wait_dialog.show_all() + thread = threading.Thread( + target=self._query_servers, args=(mode, port) + ) + thread.start() + + def _format_float( + self, + column: Gtk.TreeViewColumn, + cell: Gtk.CellRendererText, + model: Gtk.TreeModel, + it: Gtk.TreeIter, + data: Any, + ) -> Any: + # https://docs.huihoo.com/pygtk/2.0-tutorial/sec-CellRenderers.html + val = model[it][3] + formatted = locale.format_string("%.3f", val, grouping=True) + cell.set_property("text", formatted) + return + + def set_selection_mode(self, mode: Gtk.SelectionMode) -> None: + sel = self.get_selection() + sel.set_mode(mode) + + def _set_quad_col_mode(self, mode: RowType) -> None: + mod_cols = ["Mod", "Symlink", "Dir", "Size (MiB)", "Color"] + log_cols = ["Timestamp", "Flag", "Traceback", "Message"] + match mode: + case RowType.LIST_MODS: + cols = mod_cols + model = mod_store + self.set_selection_mode(Gtk.SelectionMode.MULTIPLE) + case RowType.SHOW_LOG: + cols = log_cols + model = log_store + self.set_model(model) + + for i, column_title in enumerate(cols): + renderer = Gtk.CellRendererText() + if mode == RowType.LIST_MODS: + column = Gtk.TreeViewColumn( + column_title, renderer, text=i, foreground=4 + ) + if i == 3: + column.set_cell_data_func( + renderer, self._format_float, func_data=None + ) + if column_title == "Mod": + column.set_fixed_width(500) + else: + column.set_fixed_width(150) + else: + # WindowContext.TABLE_LOG uses undecorated columns + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + column.set_sort_column_id(i) + # hidden color property column + if i != 4: + self.append_column(column) + + @update_window_labels + def _populate(self, context: WindowContext) -> None: + self.view = context + self.page = context + self.subpage = None + + row_store.clear() + array = context.dict["rows"] + + for item in array: + label = item.dict["label"] + row = (label,) + row_store.append(row) + App.grid.statusbar.refresh() + App.grid.notebook.set_page_by_enum(NotebookPage.MAIN) + self.grab_focus() + + @signal_emission + def update_single_column(self, button_context: ButtonType) -> None: + msg = ( + f"Returning from multi-column view to monocolumn view " + f"for the context '{button_context}'" + ) + logger.info(msg) + model = self.get_model() + if model: + model.clear() + ModelManager.wipe_cache(full=True) + + App.right_panel.enable_ping_button(False) + App.right_panel.filters_vbox.reinit_panel() + self.set_selection_mode(Gtk.SelectionMode.SINGLE) + + for column in self.get_columns(): + self.remove_column(column) + for i, column_title in enumerate([button_context.dict["label"]]): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + self.append_column(column) + + self.set_headers_visible(False) + self.set_model(row_store) + self._populate(button_context.dict["opens"]) + self.grab_focus() + + def update_quad_column(self, mode: RowType) -> None: + for column in self.get_columns(): + self.remove_column(column) + + self.subpage = mode + self.set_headers_visible(True) + self._set_quad_col_mode(mode) + + mod_store.clear() + log_store.clear() + + if mode == RowType.SHOW_LOG: + data = call_out("show_log") + res = self._parse_log_rows(data) + App.treeview.focus_first_row() + if not res: + spawn_dialog( + "Failed to load log file, possibly corrupted", Popup.NOTIFY + ) + return + else: + wait_dialog = GenericDialog("Checking mods", Popup.WAIT) + wait_dialog.show_all() + thread = threading.Thread( + target=self._background_quad, args=(wait_dialog, mode) + ) + thread.start() + + def dialog_hide(self) -> None: + if hasattr(self, "wait_dialog"): + self.wait_dialog.destroy() + + 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""" + row_contexts = [ + RowType.SERVER_BROWSER, + RowType.RECENT_SERVERS, + RowType.SAVED_SERVERS, + ] + if view in row_contexts: + return True + return False + + def is_server_context(self, view: WindowContext) -> bool: + """Server tables""" + server_contexts = [ + WindowContext.TABLE_API, + WindowContext.TABLE_SERVER, + ] + if view in server_contexts: + return True + return False + + def is_dynamic_context(self, view: WindowContext) -> bool: + """Tables populated with arbitrary content""" + dynamic_contexts = [ + WindowContext.TABLE_LOG, + WindowContext.TABLE_SERVER, + WindowContext.TABLE_API, + ] + if view in dynamic_contexts: + return True + return False + + def set_view(self, view): + self.view = view + + 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: + 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 + + build = "DayZ" if prereqs.appid == APPID_DAYZ else "DayZ Experimental" + steam_path = query_config("default_steam_path")[0] + + if len(steam_path) < 1: + logger.critical( + "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 + + try: + pefile_path = PeFile.get_pefile_path(steam_path, prereqs.appid) + except AppNotInstalledError: + logger.critical( + f"'{prereqs.appid}' not found in user's libraryfolders" + ) + msg = ( + f"This server is running {build}. You can install " + f"{build} by searching for it in your Steam library. " + 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 + except AppMovedError: + logger.critical( + f"Library folder synch error for '{prereqs.appid}'" + ) + msg = ( + f"Steam is reporting that {build} is installed at a non-existent location. " + 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 + 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: + 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 = ( + 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 + 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 + case VersionMatch.SAME_VERSION: + pass + + 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(pefile_path) + ) + return proc + + @signal_emission + @update_window_labels + def _on_row_activated( + self, + treeview: Gtk.TreeView, + tree_iter: Gtk.TreePath, + col: Gtk.TreeViewColumn, + ) -> None: + + context = self.page + chosen_row = self.get_value_at_index(0) + + if self.view == WindowContext.TABLE_MODS: + self.open_workshop() + return + + if self.is_dynamic_context(self.view): + cr = RowType.DYNAMIC + else: + cr = RowType.str2rowtype(chosen_row) + wc = WindowContext.row2con(cr) + self.set_view(wc) + + output = cr + logger.info(f"User selected '{cr}' for the context '{context}'") + + if cr == RowType.SCAN_LAN: + lan_dialog = LanDialog() + lan_dialog.run() + lan_dialog.destroy() + port = lan_dialog.get_selected_port() + if port is None: + return + App.right_panel.filters_vbox.enable_all_filters() + self._update_multi_column(cr, port=port) + return + + if self.is_row_to_server_context(cr): + if cr == RowType.SERVER_BROWSER: + cooldown = call_out("test_cooldown", "", "") + if cooldown.returncode == 1: + spawn_dialog(cooldown.stdout, Popup.NOTIFY) + self.set_view(WindowContext.MAIN_MENU) + return + try: + key = query_config("steam_api")[0] + except IndexError: + spawn_dialog("No Steam API key is set.", Popup.NOTIFY) + self.set_view(WindowContext.MAIN_MENU) + return + if len(key) < 1: + spawn_dialog("No Steam API key is set.", Popup.NOTIFY) + self.set_view(WindowContext.MAIN_MENU) + return + App.grid.right_panel.filters_vbox.reinit_filters() + else: + # local server lists need not be filter-restricted + App.right_panel.filters_vbox.enable_all_filters() + self._update_multi_column(cr) + return + + match self.view: + case WindowContext.TABLE_MODS | WindowContext.TABLE_LOG: + self.update_quad_column(cr) + case WindowContext.TABLE_SERVER | WindowContext.TABLE_API: + record = self.get_record() + if record is None: + return + 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) + + class AppHeaderBar(Gtk.HeaderBar): def __init__(self): super().__init__() @@ -1927,18 +3142,13 @@ class AppHeaderBar(Gtk.HeaderBar): class GenericDialog(Gtk.MessageDialog): - def __init__(self, parent, text, mode): - - def _on_dialog_delete(self, response_id): - """Passively ignore user-input""" - return True - + def __init__(self, text: str, mode: Popup): match mode: case Popup.WAIT: dialog_type = Gtk.MessageType.INFO button_type = Gtk.ButtonsType.NONE header_text = "Please wait" - case Popup.NOTIFY: + case Popup.NOTIFY | Popup.RETURN | Popup.QUIT: dialog_type = Gtk.MessageType.INFO button_type = Gtk.ButtonsType.OK header_text = "Notice" @@ -1950,20 +3160,24 @@ class GenericDialog(Gtk.MessageDialog): dialog_type = Gtk.MessageType.QUESTION button_type = Gtk.ButtonsType.OK_CANCEL header_text = "User input required" - case _: + case Popup.MODLIST: dialog_type = Gtk.MessageType.INFO button_type = Gtk.ButtonsType.OK - header_text = mode + header_text = "Modlist" + case Popup.DETAILS: + dialog_type = Gtk.MessageType.INFO + button_type = Gtk.ButtonsType.OK + header_text = "Server details" + # steam deck prints <2> if dialog title is duplicated Gtk.MessageDialog.__init__( self, - transient_for=parent, - flags=0, + transient_for=App.window, message_type=dialog_type, text=header_text, secondary_text=textwrap.fill(text, 50), buttons=button_type, - title="DZGUI - Dialog", + title=f"{app_name} - Dialog", modal=True, ) @@ -1972,292 +3186,397 @@ class GenericDialog(Gtk.MessageDialog): spinner = Gtk.Spinner() dialogBox.pack_end(spinner, False, False, 0) spinner.start() - self.connect("delete-event", _on_dialog_delete) + self.connect("delete-event", self._on_dialog_delete) + + if mode == Popup.RETURN: + button_label = "Return to main menu" + ok = self.action_area.get_children()[0] + ok.set_label(button_label) + ok.connect("clicked", self._return_to_main_menu) + self.connect("delete-event", self._return_to_main_menu) + + if mode == Popup.QUIT: + button_label = "Exit" + ok = self.action_area.get_children()[0] + ok.set_label(button_label) + ok.connect("clicked", save_res_and_quit) + self.connect("delete-event", save_res_and_quit) self.set_default_response(Gtk.ResponseType.OK) self.set_size_request(500, 0) self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) - def update_label(self, text): + self.action_area.set_layout(Gtk.ButtonBoxStyle.CENTER) + self.action_area.set_margin_bottom(20) + self.outer = self.get_content_area() + self.outer.set_margin_start(30) + self.outer.set_margin_end(30) + + def _on_dialog_delete( + self, response_id: Gtk.ResponseType, event: Gdk.Event + ) -> Literal[True]: + """ + Prevent manual dialog destruction + """ + return True + + def _return_to_main_menu(self, widget: Gtk.Widget) -> None: + App.treeview.update_single_column(ButtonType.MAIN_MENU) + + def update_label(self, text: str) -> None: self.format_secondary_text(text) -class LanButtonDialog(Gtk.Window): - def __init__(self, parent): - super().__init__() +class LanDialog(Gtk.MessageDialog): + """ + Performs integer validation on the provided port + and blocks if out of range. Returns None if user cancels + """ - self.buttonBox = Gtk.Box() + def __init__(self): + super().__init__( + transient_for=App.window, + flags=0, + message_type=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.OK_CANCEL, + text="Scan LAN servers", + secondary_text="Select the query port", + title=f"{app_name} - Dialog", + modal=True, + ) + + self.set_size_request(500, 0) + self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) - header_label = "Scan LAN servers" buttons = [ - ( "Use default query port (27016)", Port.DEFAULT ), - ( "Enter custom query port", Port.CUSTOM ), - ] + ("Use default query port (27016)", Port.DEFAULT), + ("Enter custom query port", Port.CUSTOM), + ] - self.buttonBox.set_orientation(Gtk.Orientation.VERTICAL) - self.buttonBox.active_button = None + self.button_box = Gtk.Box() + self.button_box.set_orientation(Gtk.Orientation.VERTICAL) + self.button_box.active_button = None - for i in enumerate(buttons): - - string = i[1][0] - enum = i[1][1] - - button = Gtk.RadioButton(label=string) - button.port = enum + for k, v in buttons: + button = Gtk.RadioButton(label=k) + button.port = v button.connect("toggled", self._on_button_toggled) - - if i[0] == 0: - self.buttonBox.active_button = button + self.button_box.add(button) + if v == Port.DEFAULT: + self.button_box.active_button = button else: - button.join_group(self.buttonBox.active_button) - - self.buttonBox.add(button) + button.join_group(self.button_box.active_button) self.entry = Gtk.Entry() - self.buttonBox.add(self.entry) + self.button_box.add(self.entry) self.entry.set_no_show_all(True) - self.label = Gtk.Label() - self.label.set_text("Invalid port") - self.label.set_no_show_all(True) - self.buttonBox.add(self.label) + self.warn_label = Gtk.Label(label="Invalid port") + self.warn_label.set_no_show_all(True) + self.button_box.add(self.warn_label) - self.dialog = LanDialog(parent, header_label, self.buttonBox, self.entry, self.label) - self.dialog.run() - self.dialog.destroy() + content = self.get_content_area() + content.pack_start(self.button_box, False, False, 0) + content.set_margin_start(30) + content.set_margin_end(30) + content.show_all() - def get_selected_port(self): - return self.dialog.p + self.action_area.set_layout(Gtk.ButtonBoxStyle.CENTER) + self.action_area.set_margin_bottom(20) - def _on_button_toggled(self, button): + self.port = None + self.ok = self.action_area.get_children()[1] + + self.connect("response", self._on_dialog_response) + self.connect("key-press-event", self._on_keypress) + self.connect("delete-event", self.restore_context) + + self.entry.connect("insert-text", self._on_text_typed) + self.entry.get_property("buffer").connect( + "deleted-text", self._on_text_deleted + ) + + def _validate(self, text: str) -> None: + if self._is_invalid(text): + state = False + self.warn_label.set_visible(True) + else: + state = True + self.warn_label.set_visible(False) + + self.ok.set_sensitive(state) + if len(text) == 0: + self.warn_label.set_visible(False) + + def _on_text_deleted( + self, buffer: Gtk.EntryBuffer, position: int, chars: int + ) -> None: + text = buffer.get_text() + self._validate(text) + + def _on_text_typed( + self, entry: Gtk.Entry, text: str, length: int, pos: int + ) -> None: + self._validate(entry.get_text() + text) + + def restore_context(self, *args) -> None: + context = WindowContext.MAIN_MENU + App.treeview.set_view(context) + + def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + if event.keyval == Gdk.KEY_Return: + if self.ok.is_sensitive(): + self.response(Gtk.ResponseType.OK) + if self.button_box.get_children()[0].is_focus(): + self.response(Gtk.ResponseType.OK) + else: + self.restore_context() + + if event.keyval == Gdk.KEY_Up: + self.ok.set_sensitive(True) + self.entry.set_text("") + self.button_box.get_children()[0].grab_focus() + + def _on_dialog_response( + self, dialog: "LanDialog", response: Gtk.ResponseType + ) -> None: + cancel_events = [ + Gtk.ResponseType.CLOSE, + Gtk.ResponseType.CANCEL, + Gtk.ResponseType.DELETE_EVENT, + ] + + if response in cancel_events: + self.restore_context() + return + + string = self.entry.get_text() + port = self.button_box.active_button.port + + match port: + case Port.DEFAULT: + self.port = 27016 + case Port.CUSTOM: + if self._is_invalid(string): + self.stop_emission_by_name("response") + else: + self.port = int(string) + + def _is_invalid(self, string: str) -> bool: + if ( + not string.isdigit() + or int(string) == 0 + or int(string[0]) == 0 + or int(string) > 65535 + ): + return True + return False + + def get_selected_port(self) -> int: + return self.port + + def _on_button_toggled(self, button: Gtk.Button) -> None: if button.get_active(): - self.buttonBox.active_button = button - + self.button_box.active_button = button match button.port: case Port.DEFAULT: self.entry.set_visible(False) case Port.CUSTOM: self.entry.set_visible(True) self.entry.grab_focus() - - def get_active_button(): - return self.buttonBox.active_button + self.ok.set_sensitive(False) -class LanDialog(Gtk.MessageDialog): - # Custom dialog class that performs integer validation and blocks input if invalid port - # Returns None if user cancels the dialog - def __init__(self, parent, text, child, entry, label): - super().__init__(transient_for=parent, - flags=0, - message_type=Gtk.MessageType.INFO, - buttons=Gtk.ButtonsType.OK_CANCEL, - text=text, - secondary_text="Select the query port", - title="DZGUI - Dialog", - modal=True, - ) +class DetailsDialog(GenericDialog): + def __init__(self, server_name: str, ip: str, qport: int): + super().__init__(server_name, Popup.DETAILS) - self.outer = self.get_content_area() - self.outer.pack_start(child, False, False, 0) - self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) - self.set_size_request(500, 0) - self.outer.set_margin_start(30) - self.outer.set_margin_end(30) - self.outer.show_all() - - self.connect("response", self._on_dialog_response, child, entry) - self.connect("key-press-event", self._on_keypress, entry) - self.connect("key-release-event", self._on_key_release, entry, label) - - self.child = child - - self.p = None - - def _on_key_release(self, dialog, event, entry, label): - label.set_visible(False) - if entry.is_visible() == False or entry.get_text() == "": - return - if self._is_invalid(entry.get_text()): - label.set_visible(True) - else: - label.set_visible(False) - - def _on_keypress(self, a, event, entry): - if event.keyval == Gdk.KEY_Return: - self.response(Gtk.ResponseType.OK) - if event.keyval == Gdk.KEY_Up: - entry.set_text("") - self.child.get_children()[0].grab_focus() - - def _on_dialog_response(self, dialog, resp, child, entry): - match resp: - case Gtk.ResponseType.CANCEL: - return - case Gtk.ResponseType.DELETE_EVENT: - return - - string = entry.get_text() - port = child.active_button.port - - match port: - case Port.DEFAULT: - self.p = "27016" - case Port.CUSTOM: - if self._is_invalid(string): - self.stop_emission_by_name("response") - else: - self.p = string - - def _is_invalid(self, string): - if string.isdigit() == False \ - or int(string) == 0 \ - or int(string[0]) == 0 \ - or int(string) > 65535: - return True - return False - - -def ChangelogDialog(parent): - - text = '' - mode = "Changelog -- content can be scrolled" - dialog = GenericDialog(parent, text, mode) - dialogBox = dialog.get_content_area() - dialog.set_default_response(Gtk.ResponseType.OK) - dialog.set_size_request(1000, 600) - - with open(changelog_path, 'r') as f: - changelog = f.read() - - scrollable = Gtk.ScrolledWindow() - label = Gtk.Label() - label.set_markup(changelog) - scrollable.add(label) - dialogBox.pack_end(scrollable, True, True, 0) - set_surrounding_margins(dialogBox, 30) - - dialog.show_all() - return dialog - - -def KeysDialog(parent, text, mode): - - dialog = GenericDialog(parent, text, mode) - dialogBox = dialog.get_content_area() - dialog.set_default_response(Gtk.ResponseType.OK) - dialog.set_size_request(700, 0) - - keybindings = """ - Basic navigation - Ctrl-q: quit - Enter/Space/Double click: select row item - Up, Down: navigate through row items - ?: open this dialog - - Button navigation - Right: jump from main view to side buttons - Left: jump from side buttons to main view - Up, Down: navigate up and down through side buttons - Tab, Shift-Tab: navigate forward/back through menu elements - - Any server browsing context - Enter/Space/Double click: connect to server - Right-click on row/Ctrl-l: displays additional context menus - Ctrl-f: jump to keyword field - Ctrl-m: jump to maps field - Ctrl-d: toggle dry run (debug) mode - Ctrl-r: refresh player count for active row - 1-9: toggle filter ON/OFF - ESC: jump back to main view from keyword/maps - """ - - label = Gtk.Label() - label.set_markup(keybindings) - dialogBox.pack_end(label, False, False, 0) - dialog.show_all() - return dialog - - -class PingDialog(GenericDialog): - def __init__(self, parent, text, mode, record): - super().__init__(parent, text, mode) - dialogBox = self.get_content_area() + dialog_box = self.get_content_area() self.set_default_response(Gtk.ResponseType.OK) - self.set_size_request(500, 200) - wait_dialog = GenericDialog(parent, "Checking ping", Popup.WAIT) - wait_dialog.show_all() - thread = threading.Thread(target=self._background, args=(wait_dialog, parent, record)) + self.set_size_request(800, 700) + + self.ip = ip.split(":")[0] + self.qport = qport + self.store = Gtk.ListStore(str, str, Pango.Weight) + + self.view = Gtk.TreeView( + enable_search=False, + search_column=-1, + headers_visible=False, + fixed_height_mode=True, + ) + self.view.connect("row-activated", self._on_row_activated) + + for i, column_title in enumerate(["Item", "Details"]): + renderer = Gtk.CellRendererText(xalign=0) + if i == 0: + column = Gtk.TreeViewColumn( + column_title, renderer, text=i, weight=2 + ) + else: + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + if i != 2: + self.view.append_column(column) + column.set_sort_column_id(i) + column.set_expand(True) + + scrollable_tree = Gtk.ScrolledWindow() + scrollable_tree.add(self.view) + scrollable_tree.set_size_request(700, 200) + + scrollable_message = Gtk.ScrolledWindow() + desc = Gtk.Label(label="Server message", valign=Gtk.Align.START) + add_class(desc, "details-heading") + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER + ) + self.description = Gtk.Label( + justify=Gtk.Justification.CENTER, wrap=True + ) + sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + sep.set_margin_bottom(10) + for el in desc, sep, self.description: + box.add(el) + scrollable_message.add(box) + + dialog_box.pack_start(scrollable_tree, True, True, 0) + dialog_box.pack_start(scrollable_message, True, True, 0) + + self.wait_dialog = GenericDialog("Fetching details", Popup.WAIT) + self.wait_dialog.show_all() + thread = threading.Thread( + target=self._background, args=(self.wait_dialog, ip, qport) + ) thread.start() - def _background(self, dialog, parent, record): - def _load(): - dialog.destroy() - self.show_all() - ping = data.stdout - self.format_secondary_text("Ping to remote server: %s" %(ping)) - res = self.run() - self.destroy() + def _on_row_activated( + self, + treeview: Gtk.TreeView, + tree_iter: Gtk.TreeIter, + col: Gtk.TreeViewColumn, + ) -> None: + self.destroy() - addr = record.split(':') - ip = addr[0] - qport = addr[2] - data = call_out(parent, "test_ping", ip, qport) - GLib.idle_add(_load) + def _load(self) -> None: + if self.wait_dialog: + self.wait_dialog.destroy() + if self.success is False: + msg = """Error while contacting server, possibly timed out. + Please wait and try again. + """ + spawn_dialog(msg, Popup.NOTIFY) + return + self.show_all() + self.run() + self.destroy() + + def _background( + self, dialog: "GenericDialog", ip: str, qport: int + ) -> None: + response = Servers.details(self.ip, self.qport) + if response.success: + for row in response.data: + self.store.append(row + [Pango.Weight.BOLD]) + self.view.set_model(self.store) + + text = response.description + reg = r"\s(www\.*?)" + text = re.sub(reg, " http://" + r"\1", text) + reg2 = r"(http.*?)([ ,\r\n]|$)" + text = re.sub(reg2, comp(r"\1") + r"\2", text) + + self.description.set_markup(text) + self.success = response.success + GLib.idle_add(self._load) + + +def comp(string): + return f'{string}' class ModDialog(GenericDialog): - def __init__(self, parent, text, mode, record): - super().__init__(parent, text, mode) + def __init__(self, record: str): + msg = "Enter/double click a row to open in Steam Workshop." + super().__init__(textwrap.dedent(msg), Popup.MODLIST) dialogBox = self.get_content_area() self.set_default_response(Gtk.ResponseType.OK) self.set_size_request(800, 500) self.scrollable = Gtk.ScrolledWindow() - self.view = Gtk.TreeView() + self.view = Gtk.TreeView( + enable_search=False, search_column=-1, fixed_height_mode=True + ) self.scrollable.add(self.view) set_surrounding_margins(self.scrollable, 20) self.view.connect("row-activated", self._on_row_activated) - for i, column_title in enumerate( - ["Mod", "ID", "Installed"] - ): - renderer = Gtk.CellRendererText() + for i, column_title in enumerate(["Mod", "ID", "Installed"]): + renderer = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) self.view.append_column(column) column.set_sort_column_id(i) + match column_title: + case "Mod": + column.set_fixed_width(350) + case "ID": + column.set_fixed_width(200) + case _: + pass dialogBox.pack_end(self.scrollable, True, True, 0) - wait_dialog = GenericDialog(parent, "Fetching modlist", Popup.WAIT) + wait_dialog = GenericDialog("Fetching modlist", Popup.WAIT) wait_dialog.show_all() - thread = threading.Thread(target=self._background, args=(wait_dialog, parent, record)) + thread = threading.Thread( + target=self._background, args=(wait_dialog, record) + ) thread.start() - def _background(self, dialog, parent, record): + def _background(self, dialog: "GenericDialog", record: str) -> None: def _load(): dialog.destroy() if data.returncode == 1: - spawn_dialog(parent, "Server has no mods installed or is unsupported in this mode", Popup.NOTIFY) + msg = """Error while contacting server, possibly timed out. + Please wait and try again. + """ + spawn_dialog(msg, Popup.NOTIFY) return self.show_all() - self.set_markup("Modlist (%s mods)" %(mod_count)) - res = self.run() + self.set_markup(f"Modlist ({mod_count} mods)") + self.run() self.destroy() - addr = record.split(':') - ip = addr[0] - qport = addr[2] - data = call_out(parent, "show_server_modlist", ip, qport) - mod_count = parse_modlist_rows(data) + record = App.treeview.get_record() + if not record: + return + 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) - def popup(self): - pass + def _parse_modlist_rows( + self, data: subprocess.CompletedProcess + ) -> bool | int: + lines = data.stdout.splitlines() + hits = len(lines) + reader = csv.reader(lines, delimiter=delimiter) + try: + rows = [[row[0], row[1], row[2]] for row in reader if row] + except IndexError: + return 1 + for row in rows: + modlist_store.append(row) + return hits - def _on_row_activated(self, treeview, tree_iter, col): + def _on_row_activated( + self, + treeview: Gtk.TreeView, + tree_iter: Gtk.TreeIter, + col: Gtk.TreeViewColumn, + ) -> None: select = treeview.get_selection() sels = select.get_selected_rows() (model, pathlist) = sels @@ -2266,263 +3585,1134 @@ class ModDialog(GenericDialog): path = pathlist[0] tree_iter = model.get_iter(path) mod_id = model.get_value(tree_iter, 1) - subprocess.Popen(['/usr/bin/env', 'bash', funcs, "open_workshop_page", mod_id]) + call_bash_func("open_workshop_page", mod_id) class LinkDialog(GenericDialog): - def __init__(self, parent, text, mode, link, command, uid=None): - super().__init__(parent, text, mode) + def __init__( + self, text: str, link: str | None, command: RowType, uid: str = "" + ): + super().__init__(text, Popup.NOTIFY) - self.dialog = GenericDialog(parent, text, mode) - self.dialogBox = self.dialog.get_content_area() - self.dialog.set_default_response(Gtk.ResponseType.OK) - self.dialog.set_size_request(500, 0) + text = textwrap.dedent(text) + self.dialogBox = self.get_content_area() + self.set_default_response(Gtk.ResponseType.OK) + self.set_size_request(500, 0) - if link is not None: + if link: button = Gtk.Button(label=link) button.set_margin_start(60) button.set_margin_end(60) button.connect("clicked", self._on_button_clicked, uid) self.dialogBox.pack_end(button, False, False, 0) - self.dialog.show_all() - self.dialog.connect("response", self._on_dialog_response, parent, command) + self.show_all() + self.connect("response", self._on_dialog_response, command) - def _on_button_clicked(self, button, uid): - subprocess.Popen(['/usr/bin/env', 'bash', funcs, "open_user_workshop", uid]) + def _on_button_clicked(self, button: Gtk.Button, uid: str) -> None: + call_bash_func("open_user_workshop", uid) - def _on_dialog_response(self, dialog, resp, parent, command): + def _on_dialog_response( + self, dialog: "LinkDialog", resp: Gtk.ResponseType, command: RowType + ) -> None: match resp: case Gtk.ResponseType.DELETE_EVENT: return case Gtk.ResponseType.OK: - self.dialog.destroy() - proc = call_out(parent, "toggle", command.dict["label"]) - parent.grid.update_right_statusbar() - tooltip = format_metadata(command.dict["label"]) - parent.grid.update_statusbar(tooltip) + self.destroy() + call_out("toggle", command.dict["label"]) + App.grid.statusbar.refresh() class EntryDialog(GenericDialog): - def __init__(self, parent, text, mode, link): - super().__init__(parent, text, mode) + def __init__(self, text: str, mode: Popup, link: str): + super().__init__(text, mode) - """ Returns user input as a string or None """ - """ If user does not input text it returns None, NOT AN EMPTY STRING. """ + """ + Wraps Gtk.Entry in a dialog and provides basic response handling. + Returns user input as a string or None. + The Entry widget itself can be manipulated via the get_entry() method. + """ - self.dialog = GenericDialog(parent, text, mode) + self.dialog = GenericDialog(text, mode) self.dialogBox = self.dialog.get_content_area() self.dialog.set_default_response(Gtk.ResponseType.OK) self.dialog.set_size_request(500, 0) - self.userEntry = Gtk.Entry() - set_surrounding_margins(self.userEntry, 20) - self.userEntry.set_margin_top(0) - self.userEntry.set_size_request(250, 0) - self.userEntry.set_activates_default(True) - self.dialogBox.pack_start(self.userEntry, False, False, 0) + self.user_entry = Gtk.Entry() + set_surrounding_margins(self.user_entry, 20) + self.user_entry.set_margin_top(0) + self.user_entry.set_size_request(250, 0) + self.user_entry.set_activates_default(True) + self.dialogBox.pack_start(self.user_entry, False, False, 0) - if link is not None: + if link: button = Gtk.Button(label=link) button.set_margin_start(60) button.set_margin_end(60) button.connect("clicked", self._on_button_clicked) self.dialogBox.pack_end(button, False, False, 0) - def _on_button_clicked(self, button): - label = button.get_label() - subprocess.Popen(['/usr/bin/env', 'bash', funcs, "Open link", label]) + self.ok = self.dialog.action_area.get_children()[1] + self.ok.set_sensitive(False) + self.user_entry.connect("insert-text", self._on_text_typed) + self.user_entry.get_property("buffer").connect( + "deleted-text", self._on_text_deleted + ) - def get_input(self): + def _is_valid_text(self, text: str) -> bool: + if text.isspace(): + return False + if len(text) == 0: + return False + return True + + def _on_text_deleted( + self, buffer: Gtk.EntryBuffer, position: int, chars: int + ) -> None: + text = buffer.get_text() + state = self._is_valid_text(text) + self.ok.set_sensitive(state) + + def _on_text_typed( + self, entry: Gtk.Entry, text: str, length: int, pos: int + ) -> None: + state = self._is_valid_text(text) + self.ok.set_sensitive(state) + + def _on_button_clicked(self, button: Gtk.Button) -> None: + label = button.get_label() + call_bash_func("Open link", label) + + def get_entry(self) -> Gtk.Entry: + return self.user_entry + + def get_input(self) -> str | None: self.dialog.show_all() + response = self.dialog.run() - text = self.userEntry.get_text() + text = self.user_entry.get_text() self.dialog.destroy() - if (response == Gtk.ResponseType.OK) and (text != ''): + if (response == Gtk.ResponseType.OK) and (text != ""): return text else: return None -class Grid(Gtk.Grid): - def __init__(self, is_steam_deck): - super().__init__() - self.set_column_homogeneous(True) - #self.set_row_homogeneous(True) +class ScrollableNote(Gtk.Box): + def __init__(self, content_box: Gtk.Box, back_button=True): + super().__init__(orientation=Gtk.Orientation.VERTICAL) - self._version = "%s %s" %(app_name, sys.argv[2]) + self.scrollable = Gtk.ScrolledWindow() + self.scrollable.set_vexpand(True) - self.scrollable_treelist = ScrollableTree(is_steam_deck) - self.scrollable_treelist.set_hexpand(False) - self.scrollable_treelist.set_vexpand(True) + self.back_button = Gtk.Button( + label="Back", hexpand=True, halign=Gtk.Align.CENTER + ) - self.right_panel = RightPanel(is_steam_deck) - self.sel_panel = ModSelectionPanel() - self.right_panel.pack_start(self.sel_panel, False, False, 0) - self.show_all() + self.gutter = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, valign=Gtk.Align.END + ) + if back_button: + self.gutter.add(self.back_button) + self.back_button.connect("clicked", self._on_back_clicked) - self.bar = Gtk.Statusbar() - self.scrollable_treelist.treeview.connect("on_distcalc_started", self._on_calclat_started) + self.scrollable.add(content_box) + self.add(self.scrollable) + self.add(self.gutter) - GLib.timeout_add(200, self._check_result_queue) + @update_window_labels + def _on_back_clicked(self, button: Gtk.Button) -> None: + App.notebook.return_prior() - self.update_statusbar(default_tooltip) - self.status_right_label = Gtk.Label(label="") - self.bar.add(self.status_right_label) - self.update_right_statusbar() - self.attach(self.scrollable_treelist, 0, 0, 3, 1) - self.attach_next_to(self.bar, self.scrollable_treelist, Gtk.PositionType.BOTTOM, 3, 1) - self.attach_next_to(self.right_panel, self.scrollable_treelist, Gtk.PositionType.RIGHT, 1, 1) +class InfoEventBox(Gtk.EventBox): + def __init__(self, text: str): + super().__init__(margin_start=10) - def update_right_statusbar(self): - config_vals.clear() - for i in query_config(self): - config_vals.append(i) - _branch = config_vals[0] - _branch = _branch.upper() - _debug = config_vals[1] - if _debug == "": - _debug = "NORMAL" + self.text = text + + self.icon = Gtk.Image.new_from_icon_name( + "help-about-symbolic", Gtk.IconSize.LARGE_TOOLBAR + ) + self.icon.set_opacity(0.8) + box = Gtk.Box() + box.add(self.icon) + + self.connect("enter-notify-event", self._on_enter_tooltip) + self.connect("leave-notify-event", self._on_leave_tooltip) + self.add(box) + + def _on_enter_tooltip( + self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing + ) -> None: + self.icon.set_opacity(1) + App.grid.statusbar.set_text(self.text) + + def _on_leave_tooltip( + self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing + ) -> None: + self.icon.set_opacity(0.8) + App.grid.statusbar.set_text("") + + +class LeftLabel(Gtk.Label): + def __init__(self, text: str, tooltip: str = ""): + super().__init__( + label=text, + halign=Gtk.Align.START, + ) + self.set_tooltip_text(tooltip) + pass + + +class Options(Gtk.Box): + def __init__(self, self_update=True): + super().__init__( + orientation=Gtk.Orientation.VERTICAL, + margin_start=10, + margin_end=10, + ) + + self.DEFAULT_WIDTH = 1 + self.DEFAULT_HEIGHT = 1 + + label = Gtk.Label(label="Options") + label.set_halign(Gtk.Align.CENTER) + add_class(label, "page-heading") + self.add(label) + + self.steam_entry = None + self.bm_entry = None + + self.steam_box = self._make_submit_field( + "Enter your Steam API key", Preferences.STEAM, True + ) + self.bm_box = self._make_submit_field( + "Enter your Battlemetrics API key", Preferences.BM, True + ) + api_rows = [ + [LeftLabel("Steam API key"), self.steam_box], + [LeftLabel("Battlemetrics API key"), self.bm_box], + ] + + self.player_box = self._make_submit_field( + "Identifies you to other players in-game", Preferences.NAME + ) + self.fullscreen_toggle = self.make_binary_radio( + "Last used dimensions", + "Always fullscreen", + Preferences.WINDOW, + ) + + # TODO: gray out options if not available on system + client_store = Gtk.ListStore(str, str) + client_store.append(["steam", "steam"]) + client_store.append(["flatpak", "flatpak run com.valvesoftware.Steam"]) + client_store.append(["flatpak (container)", "flatpak-spawn --host flatpak run com.valvesoftware.Steam"]) + + renderer_text = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) + self.client_combo = Gtk.ComboBox.new_with_model(client_store) + self.client_combo.set_halign(Gtk.Align.START) + self.client_combo.pack_start(renderer_text, True) + self.client_combo.connect("changed", self._on_client_changed) + self.client_combo.add_attribute(renderer_text, "text", 0) + + pref_rows = [ + [LeftLabel("Steam client"), self.client_combo], + [LeftLabel("Window size at boot"), self.fullscreen_toggle], + [LeftLabel("Player name"), self.player_box], + ] + + self.mod_install_toggle = self.make_binary_radio( + "Manual", "Auto", Preferences.INSTALL + ) + self.force_button = Gtk.Button(label="Update") + self.force_button.connect("clicked", self._on_force_update_clicked) + # sensitivity state is set after config file is loaded + self.force_button.set_sensitive(False) + + msg = ( + "Manual: prompt to subscribe to mods in Steam. " + "Auto: unmanned downloads." + ) + eb = InfoEventBox(msg) + + msg = "Synchronize all local mods. Automatic mode must be enabled." + eb2 = InfoEventBox(msg) + + mod_rows = [ + [LeftLabel("Mod install mode"), self.mod_install_toggle, eb], + [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") + self.branch_combo.set_active(0) + self.branch_combo.connect("changed", self._on_branch_changed) + self.branch_combo.set_sensitive(self_update) + + if self_update is True: + msg = ( + "Stable: only contains stable features. " + "Testing: pre-release beta, contains new features." + ) else: - _debug = "DEBUG" - concat_label = "%s | %s | %s" %(_branch, _debug, self._version) + msg = ( + "In-app updates are disabled when installing " + "DZGUI via the system package manager." + ) + eb = InfoEventBox(msg) + + 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) + mods_grid = self._make_grid(mod_rows) + version_grid = self._make_grid(version_rows) + + col = 1 + row = 1 + grid = Gtk.Grid( + orientation=Gtk.Orientation.VERTICAL, + row_spacing=30, + hexpand=True, + ) + + for frame in [ + self.make_frame(api_grid, "API Keys"), + self.make_frame(prefs_grid, "Preferences"), + self.make_frame(mods_grid, "Mods"), + self.make_frame(version_grid, "Version"), + ]: + grid.attach( + frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT + ) + row += 1 + + self.scrollable = Gtk.ScrolledWindow(vexpand=True) + self.scrollable.add(grid) + self.add(self.scrollable) + + def _make_submit_field( + self, + placeholder: str, + context: Preferences, + private: bool = False, + ) -> Gtk.Box: + + entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True) + button = Gtk.Button(label="Save") + + entry.sibling = button + entry.get_property("buffer").sibling = button + + button.connect("clicked", self._on_save_clicked, entry, context) + entry.connect("insert-text", self._on_text_typed, context) + entry.connect("activate", self._on_field_activated, context) + entry.get_property("buffer").connect( + "deleted-text", self._on_text_deleted, context + ) + + if private: + entry.set_icon_from_icon_name( + Gtk.EntryIconPosition.SECONDARY, "view-reveal-symbolic" + ) + entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) + entry.connect("icon-release", self._on_icon_release) + entry.set_visibility(False) + + if context == Preferences.STEAM: + self.steam_entry = entry + else: + self.bm_entry = entry + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + box.add(entry) + box.add(button) + + return box + + def _on_field_activated( + self, entry: Gtk.Entry, context: Preferences + ) -> None: + text = entry.get_text() + button = entry.sibling + if not self._is_valid_text(text, context): + return + self._on_save_clicked(button, entry, context) + + def _make_grid(self, rows: list) -> Gtk.Grid: + grid = Gtk.Grid( + orientation=Gtk.Orientation.VERTICAL, + column_spacing=10, + row_spacing=5, + margin_start=5, + margin_end=5, + margin_top=10, + margin_bottom=10, + ) + row = 1 + for record in rows: + col = 1 + for el in record: + grid.attach( + el, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT + ) + col += 1 + row += 1 + return grid + + def _on_save_clicked( + self, button: Gtk.Button, entry: Gtk.Entry, context: Preferences + ) -> None: + show_wait_dialog = True + wait_msg = "Working" + button.set_sensitive(False) + match context: + case Preferences.NAME: + toggle = RowType.CHNG_PLAYER + show_wait_dialog = False + text = entry.get_text().strip() + case Preferences.STEAM: + toggle = RowType.CHNG_STEAM_API + text = "".join(entry.get_text().split()) + case Preferences.BM: + toggle = RowType.CHNG_BM_API + text = "".join(entry.get_text().split()) + cmd_string = toggle.dict["label"] + call_on_thread(show_wait_dialog, cmd_string, wait_msg, text) + + def revert(self, mode: Preferences) -> None: + if mode == Preferences.STEAM: + self.steam_entry.set_text(self.old_steam) + else: + self.bm_entry.set_text(self.old_bm) + pass + + def _on_force_update_clicked(self, button: Gtk.Button) -> None: + wait_msg = "Updating mods" + cmd = "Force update local mods" + show_wait_dialog = True + call_on_thread(show_wait_dialog, cmd, wait_msg, "") + + def _on_client_changed(self, combo: Gtk.ComboBox) -> None: + # prevent triggering on initial init + if App.treeview.subpage is not RowType.OPTIONS: + return + ind = combo.get_active() + mod = combo.get_model() + client = mod[ind][1] + call_bash_func("Change client", client) + + def _on_branch_changed(self, combo: Gtk.ComboBoxText) -> None: + # prevent triggering on initial init + if App.treeview.subpage is not RowType.OPTIONS: + return + process_toggle(RowType.TGL_BRANCH) + + def _on_radio_toggled( + self, button: Gtk.RadioButton, context: Preferences + ) -> None: + if App.treeview.subpage is not RowType.OPTIONS: + return + match context: + case Preferences.INSTALL: + toggle = RowType.TGL_INSTALL + state = button.get_group()[0].get_active() + self.force_button.set_sensitive(state) + case Preferences.WINDOW: + toggle = RowType.TGL_FULLSCREEN + process_toggle(toggle) + + def _is_valid_text(self, text: str, context: Preferences) -> bool: + if text.isspace(): + return False + if len(text) == 0: + return False + + match context: + case Preferences.NAME: + old = self.old_name + case Preferences.STEAM: + old = self.old_steam + case Preferences.BM: + old = self.old_bm + if text == old: + return False + return True + + def _on_text_deleted( + self, + buffer: Gtk.EntryBuffer, + position: int, + chars: int, + context: Preferences, + ) -> None: + + text = buffer.get_text() + state = self._is_valid_text(text, context) + buffer.sibling.set_sensitive(state) + + def _on_text_typed( + self, + entry: Gtk.Entry, + text: str, + length: int, + pos: int, + context: Preferences, + ) -> None: + + buffer = entry.get_property("buffer") + text = buffer.get_text() + text + state = self._is_valid_text(text, context) + entry.sibling.set_sensitive(state) + + def make_binary_radio( + self, + first_option: str, + second_option: str, + context: Preferences, + ) -> Gtk.Box: + + hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START) + radio1 = Gtk.RadioButton.new_with_label(None, first_option) + radio2 = Gtk.RadioButton.new_from_widget(radio1) + radio2.set_label(second_option) + radio1.connect("toggled", self._on_radio_toggled, context) + hbox.pack_start(radio1, False, False, 0) + hbox.pack_start(radio2, False, False, 0) + + return hbox + + def make_frame(self, widget: Gtk.Widget, text: str) -> Gtk.Box: + label = Gtk.Label(label=text) + label.set_halign(Gtk.Align.START) + add_class(label, "settings-subheading") + + frame = Gtk.Frame(hexpand=True) + frame.add(widget) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.add(label) + box.add(frame) + + return box + + def populate_settings(self) -> None: + if not os.path.isfile(config_file): + msg = ( + "DZGUI configuration file not found. " + "Please exit and restart to regenerate it." + ) + spawn_dialog(msg, Popup.QUIT) + config_vals.clear() + for i in query_config(): + config_vals.append(i) + + branch = config_vals[0] + install = config_vals[2] + 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] + bm = query_config("api_key")[0] + except IndexError: + return + + self.old_steam = steam + self.old_bm = bm + self.old_name = name + + self.steam_entry.set_text(steam) + self.bm_entry.set_text(bm) + self.player_box.get_children()[0].set_text(name) + + if install == "1": + radio = 1 + self.force_button.set_sensitive(True) + else: + radio = 0 + self.mod_install_toggle.get_children()[radio].set_active(True) + + for ind, row in enumerate(self.client_combo.get_model()): + if row[1] == client: + self.client_combo.set_active(ind) + + if fullscreen == "true": + radio = 1 + else: + radio = 0 + self.fullscreen_toggle.get_children()[radio].set_active(True) + + for field in ( + [name, self.player_box], + [steam, self.steam_box], + [bm, self.bm_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 + ) + vers = PeFile.get_dayz_version(pe_file_path) + dayz_version = PeFile.dayz_version_to_str(vers) + except Exception: + dayz_version = "-" + + try: + exp_file_path = PeFile.get_pefile_path( + default_steam_path, APPID_DAYZ_EXP + ) + vers = PeFile.get_dayz_version(exp_file_path) + dayz_exp_version = PeFile.dayz_version_to_str(vers) + 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: + self.branch_combo.set_active(0) + + def _on_icon_release( + self, + widget: Gtk.Entry, + icon_pos: Gtk.EntryIconPosition, + event: Gdk.Event, + ) -> None: + visible = widget.get_visibility() + if visible: + icon = "view-reveal-symbolic" + state = False + else: + icon = "view-conceal-symbolic" + state = True + widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) + widget.set_visibility(state) + + +class KeybindingsDialog(Gtk.Box): + """ + Notebook page holding a prearranged grid + of keybindings and their descriptions + """ + + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + + navigation = { + "Enter/space/double click": "select row item", + "Down arrow": "move down a row/scroll down", + "Up arrow": "move up a row/scroll up", + "Right arrow": "jump to sidebar from main area", + "Left arrow": "jump to main area from sidebar", + "Tab": "cycle forward through elements", + "Shift-tab": "cycle backward through elements", + "ESC/Enter": "close dialogs", + "?": "show/hide this dialog", + "Ctrl-q": "Quit", + } + servers = { + "Enter/space/double-click": "connect to server", + "Right-click/Ctrl-l": "additional context menus", + "Ctrl-r": "refresh players", + "Ctrl-p": "refresh ping", + "Ctrl-f": "jump to keyword search field", + "Ctrl-m": "jump to maps field", + "Ctrl-d": "toggle dry run (debug) mode", + "ESC": "return to table", + "1-9": "toggle filter 1-9 on/off", + "0": "toggle filter 10", + "Minus": "toggle filter 11", + "Backslash": "toggle filter 12", + } + vim = { + "j": "Move down a row/scroll up", + "k": "Move up a row/scroll down", + "l": "Jump to main area from sidebar", + "h": "Jump to sidebar from main area", + "gg": "Jump to first row/top of page", + "G": "Jump to last row/bottom of page", + } + + label = Gtk.Label(label="Keybindings") + add_class(label, "page-heading") + self.add(label) + + grid = self.build_grid([servers, navigation, vim]) + self.add(grid) + + def build_keys(self, items: list) -> Gtk.Grid: + grid = Gtk.Grid(row_spacing=10, column_homogeneous=True) + grid.set_halign(Gtk.Align.START) + row = 1 + col = 0 + w = 1 + h = 1 + sep = None + for item in items: + for k, v in item.items(): + desc = Gtk.Label(label=k) + desc.set_halign(Gtk.Align.START) + + key = Gtk.Label(label=v) + key.set_halign(Gtk.Align.CENTER) + + frame = Gtk.Frame() + frame.add(key) + add_class(frame, "frame") + + col = col + 1 + if col > 1: + row += 1 + col = 1 + if not sep: + grid.attach(desc, col, row, w, h) + else: + grid.attach_next_to( + desc, sep, Gtk.PositionType.BOTTOM, w, h + ) + row += 1 + sep = None + grid.attach_next_to(frame, desc, Gtk.PositionType.RIGHT, w, h) + + l_spacer = Gtk.Label(label="") + r_spacer = Gtk.Label(label="") + grid.attach(l_spacer, col, row + 1, w, h) + grid.attach_next_to( + r_spacer, l_spacer, Gtk.PositionType.RIGHT, w, h + ) + row += 1 + return grid + + def build_sidebar(self, categories: list) -> Gtk.Grid: + row = 0 + col = 0 + w = 1 + h = 1 + sidebar = Gtk.Grid( + row_homogeneous=True, orientation=Gtk.Orientation.VERTICAL + ) + for cat in categories: + label = Gtk.Label(label=cat) + add_class(label, "left-label") + row += 1 + sidebar.attach(label, col, row, w, h) + return sidebar + + def build_grid(self, items: list) -> Gtk.Grid: + grid = Gtk.Grid( + row_spacing=20, + halign=Gtk.Align.CENTER, + margin_top=20, + column_spacing=50, + ) + + row = 1 + column = 1 + w = 1 + h = 1 + sidebar = self.build_sidebar( + ["Servers", "Navigation", "Vim-style keys"] + ) + separator = Gtk.Separator() + keys_box = self.build_keys(items) + + grid.attach(sidebar, column, row, w, h) + grid.attach_next_to(separator, sidebar, Gtk.PositionType.RIGHT, w, h) + grid.attach_next_to(keys_box, separator, Gtk.PositionType.RIGHT, w, h) + return grid + + +class Changelog(Gtk.Box): + def __init__(self): + super().__init__() + + self.changelog_label = Gtk.Label() + self.add(self.changelog_label) + + def open_changelog(self, path: Path) -> None: + try: + changelog = path.read_text() + except OSError as e: + spawn_dialog(f"Something went wrong: {e}", Popup.NOTIFY) + logger.critical(e) + return Exception + formatted = self.format_pango(changelog) + self.changelog_label.set_markup(formatted) + App.grid.notebook.set_page_by_enum(NotebookPage.CHANGELOG) + + def format_pango(self, text: str) -> str: + medium = '' + large = '' + xlarge = '' + text = re.sub("^# ", xlarge, text, flags=re.M) + text = re.sub("^## ", large, text, flags=re.M) + text = re.sub("^### ", medium, text, flags=re.M) + text = re.sub(r"(", text) + return text + + +class Notebook(Gtk.Notebook): + def __init__(self): + super().__init__(show_tabs=False, show_border=False) + + self.changelog = Changelog() + self.clog = ScrollableNote(self.changelog) + self.clog.type = RowType.CHANGELOG + self.clog.show_all() + self.append_page(self.clog) + self.prior_page: int + + self.keys = ScrollableNote(KeybindingsDialog()) + self.keys.type = RowType.KEYBINDINGS + self.keys.show_all() + self.append_page(self.keys) + + self.settings = Options(self_update=True) + self.settings.type = RowType.OPTIONS + self.settings.show_all() + self.append_page(self.settings) + + self.connect("switch-page", self._on_page_changed) + self.connect("key-press-event", self._on_keypress) + + def open_changelog(self) -> None: + path = Path(changelog_path) + self.changelog.open_changelog(path) + + def _set_adjustment(self, adjustment: VAdjustment) -> None: + INCREMENT = 50 + page = self.get_page() + if not page: + return + allowed_contexts = [RowType.CHANGELOG, RowType.KEYBINDINGS] + if page.type not in allowed_contexts: + return + vadj = page.scrollable.get_vadjustment() + match adjustment: + case VAdjustment.TOP: + adj = vadj.get_lower() + case VAdjustment.BOTTOM: + adj = vadj.get_upper() + case VAdjustment.UP: + adj = vadj.get_value() - INCREMENT + case VAdjustment.DOWN: + adj = vadj.get_value() + INCREMENT + vadj.set_value(adj) + + def _on_keypress(self, widget: Gtk.Widget, event: Gdk.EventKey) -> None: + match event.keyval: + case Gdk.KEY_Return: + page = self.get_page() + if page: + page.back_button.clicked() + case Gdk.KEY_Right | Gdk.KEY_l: + if event.state is Gdk.ModifierType.CONTROL_MASK: + return + App.right_panel.focus_button_box() + case Gdk.KEY_question: + self.toggle_keybindings() + case Gdk.KEY_k | Gdk.KEY_Up: + self._set_adjustment(VAdjustment.UP) + case Gdk.KEY_Down | Gdk.KEY_j: + self._set_adjustment(VAdjustment.DOWN) + case Gdk.KEY_g: + self._set_adjustment(VAdjustment.TOP) + case Gdk.KEY_G: + self._set_adjustment(VAdjustment.BOTTOM) + + def return_prior(self) -> None: + page = self.get_nth_page(self.prior_page) + if hasattr(page, "steam_entry"): + """ + Gtk.Notebook focuses the first input field when changing pages; + this workaround unhighlights the selected region and makes entry + fields unfocusable prior to the page 'switch-page' signal, + then makes them focusable again + """ + entries = page.steam_entry, page.bm_entry + for entry in entries: + entry.set_position(-1) + entry.set_can_focus(False) + self.set_current_page(self.prior_page) + for entry in entries: + entry.set_can_focus(True) + self.set_current_page(self.prior_page) + + def toggle_keybindings(self) -> None: + if self.get_current_page() == NotebookPage.KEYS.value: + self.return_prior() + else: + self.prior_page = self.get_current_page() + self.set_page_by_enum(NotebookPage.KEYS) + + def focus_current(self) -> None: + widget = self.get_page() + if widget: + w = widget.get_children()[0] + w.grab_focus() + + def get_page(self) -> Gtk.Widget | None: + ind = self.get_current_page() + widget = self.get_nth_page(ind) + if not widget: + return None + return widget + + def set_page_by_enum(self, enum: NotebookPage) -> None: + self.prior_page = self.get_current_page() + self.set_current_page(enum.value) + self.focus_current() + self._set_adjustment(VAdjustment.TOP) + + @update_window_labels + def _on_page_changed( + self, notebook: "Notebook", page: Gtk.Widget, page_num: int + ) -> None: + App.treeview.subpage = page.type + + +class Statusbar(Gtk.Statusbar): + def __init__(self): + super().__init__() + + help_text = "Select a row to see its detailed description" + self.set_text(help_text) + self.status_right_label = Gtk.Label(label="") + self.add(self.status_right_label) + self.update_app_meta() + + self.players = "" + + def get_text(self) -> str: + area = self.get_message_area() + label = area.get_children()[0] + return label.get_text() + + def set_text(self, string: str) -> None: + if string is None: + return + meta = self.get_context_id("Statusbar") + self.push(meta, string) + + def refresh(self) -> None: + unsupported_contexts = [ + RowType.KEYBINDINGS, + RowType.SHOW_LOG, + RowType.CHANGELOG, + RowType.OPTIONS, + ] + if App.treeview.subpage in unsupported_contexts: + self.set_text("") + return + command = App.treeview.get_value_at_index(0) + formatted = format_metadata(command) + if len(formatted) > 0: + self.set_text(formatted) + + def append_distance(self, dist: str) -> None: + if dist == "Unknown": + dist = f"| Distance: {dist}" + else: + d = int(dist) + dist = f"| Distance: {d:n} km" + self.set_text(self.players + dist) + + def update_server_meta(self) -> None: + model = App.treeview.get_model() + if model is None: + players = 0 + hits = 0 + else: + hits = len(model) + players = 0 + for row in model: + players += row[4] + + players_pretty = pluralize("players", players) + hits_pretty = pluralize("matches", hits) + formatted = ( + f"Found {hits:n} {hits_pretty} with {players:n} {players_pretty}" + ) + suffix = "| Distance: calculating..." + + if players == 0: + suffix = "" + self.set_text(formatted + suffix) + self.players = formatted + + def update_app_meta(self) -> None: + config_vals.clear() + for i in query_config(): + config_vals.append(i) + concat_label = f"{_VERSION}" self.status_right_label.set_text(concat_label) - def terminate_treeview_process(self): + +class Grid(Gtk.Grid): + def __init__(self): + super().__init__() + self.set_column_homogeneous(True) + + self._version = f"{app_name} {_VERSION}" + + self.scrollable_treelist = ScrollableTree() + self.scrollable_treelist.set_hexpand(False) + self.scrollable_treelist.set_vexpand(True) + self.scrollable_treelist.treeview.connect( + "on_distcalc_started", self._on_calclat_started + ) + + self.right_panel = RightPanel() + self.sel_panel = ModSelectionPanel() + self.right_panel.pack_start(self.sel_panel, False, False, 0) + + """ + Note that due to historical reasons, Gtk.Notebook refuses to + switch to a page unless the child widget is visible. + Therefore, it is recommended to show child widgets + before adding them to a notebook. + + """ + self.show_all() + self.scrollable_treelist.type = None + self.notebook = Notebook() + self.notebook.insert_page(self.scrollable_treelist, None, 0) + + self.statusbar = Statusbar() + GLib.timeout_add(200, self._check_result_queue) + + self.breadcrumbs = Gtk.Label(label="Main menu", halign=Gtk.Align.START) + + self.attach(self.notebook, 0, 0, 3, 1) + self.attach_next_to( + self.breadcrumbs, self.notebook, Gtk.PositionType.TOP, 3, 1 + ) + self.attach_next_to( + self.statusbar, self.notebook, Gtk.PositionType.BOTTOM, 3, 1 + ) + self.attach_next_to( + self.right_panel, self.notebook, Gtk.PositionType.RIGHT, 1, 1 + ) + + def get_breadcrumbs(self) -> str: + return self.breadcrumbs.get_text() + + def set_breadcrumbs(self, text: str) -> None: + self.breadcrumbs.set_text(text) + + def terminate_treeview_process(self) -> None: self.scrollable_treelist.treeview.terminate_process() - def _on_calclat_started(self, treeview): - server_tooltip[0] = format_tooltip() - server_tooltip[1] = server_tooltip[0] + "| Distance: calculating..." - self.update_statusbar(server_tooltip[1]) + def _on_calclat_started(self, treeview: Gtk.TreeView) -> None: + App.grid.statusbar.update_server_meta() - def _check_result_queue(self): + def _check_result_queue(self) -> Literal[True]: latest_result = None result_queue = self.scrollable_treelist.treeview.queue while not result_queue.empty(): latest_result = result_queue.get() - if latest_result is not None: + if latest_result: addr = latest_result[0] km = latest_result[1] - ping = latest_result[2] - - cache[addr] = km, ping - - ping = format_ping(ping) - dist = format_distance(km) - tooltip = server_tooltip[1] = server_tooltip[0] + dist + ping - self.update_statusbar(tooltip) - + cache[addr] = km + self.statusbar.append_distance(km) return True - def update_statusbar(self, string): - if string is None: - return - meta = self.bar.get_context_id("Statusbar") - self.bar.push(meta, string) - - -def toggle_signal(owner, widget, func_name, bool): - func = getattr(owner, func_name) - if (bool): - logger.debug("Unblocking %s for %s" %(func_name, widget)) - widget.handler_unblock_by_func(func) - else: - logger.debug("Blocking %s for %s" %(func_name, widget)) - widget.handler_block_by_func(func) - class App(Gtk.Application): def __init__(self): - _isd = int(sys.argv[3]) + global IS_STEAM_DECK + global IS_GAME_MODE if _isd == 1: - is_steam_deck = True - is_game_mode = False + IS_STEAM_DECK = True + IS_GAME_MODE = False elif _isd == 2: - is_steam_deck = True - is_game_mode = True + IS_STEAM_DECK = True + IS_GAME_MODE = True else: - is_steam_deck = False - is_game_mode = False + IS_STEAM_DECK = False + IS_GAME_MODE = False GLib.set_prgname(app_name) - self.win = OuterWindow(is_steam_deck, is_game_mode) - self.win.set_icon_name("dzgui") - + self.win = OuterWindow() + self.win.set_icon_name("{app_name_lower}") accel = Gtk.AccelGroup() - accel.connect(Gdk.KEY_q, Gdk.ModifierType.CONTROL_MASK, Gtk.AccelFlags.VISIBLE, self._halt_window_subprocess) + accel.connect( + Gdk.KEY_q, + Gdk.ModifierType.CONTROL_MASK, + Gtk.AccelFlags.VISIBLE, + self._halt_window_subprocess, + ) self.win.add_accel_group(accel) - - GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint) + GLib.unix_signal_add( + GLib.PRIORITY_DEFAULT, signal.SIGINT, self._catch_sigint + ) Gtk.main() - def _catch_sigint(self): - self.win.halt_proc_and_quit(self.win, None) + def _catch_sigint(self) -> None: + self.win.halt_proc_and_quit() - def _halt_window_subprocess(self, accel_group, window, code, flag): - self.win.halt_proc_and_quit(self.win, None) + def _halt_window_subprocess( + self, + accel_group: Gtk.AccelGroup, + window: "OuterWindow", + code: Gdk.EventKey, + flag: Gdk.ModifierType, + ) -> None: + self.win.halt_proc_and_quit() -def save_res_and_quit(window): - if window.props.is_maximized: - Gtk.main_quit() - return - rect = window.get_size() - - def write_json(rect): - data = {"res": { "width": rect.width, "height": rect.height } } - j = json.dumps(data, indent=2) - with open(res_path, "w") as outfile: - outfile.write(j) - logger.info("Wrote initial window size to '%s'" %(res_path)) - - if os.path.isfile(res_path): - with open(res_path, "r") as infile: - try: - data = json.load(infile) - data["res"]["width"] = rect.width - data["res"]["height"] = rect.height - with open(res_path, "w") as outfile: - outfile.write(json.dumps(data, indent=2)) - except json.decoder.JSONDecodeError: - logger.critical("JSON decode error in '%s'" %(res_path)) - write_json(rect) - else: - write_json(rect) - - Gtk.main_quit() - class ModSelectionPanel(Gtk.Box): def __init__(self): super().__init__(spacing=6) self.set_orientation(Gtk.Orientation.VERTICAL) - labels = [ - {"label": "Select all", "tooltip": "Bulk selects all mods"}, - {"label": "Unselect all", "tooltip": "Bulk unselects all mods"}, - {"label": "Delete selected", "tooltip": "Deletes selected mods from the system"}, - {"label": "Highlight stale", "tooltip": "Shows locally-installed mods\nwhich are not used by any server\nin your Saved Servers"} - ] + {"label": "Select all", "tooltip": "Bulk selects all mods"}, + {"label": "Unselect all", "tooltip": "Bulk unselects all mods"}, + { + "label": "Delete selected", + "tooltip": "Deletes selected mods from the system", + }, + { + "label": "Highlight stale", + "tooltip": "Shows locally-installed mods which are not\n" + "used by any server in your Saved Servers", + }, + ] self.active_button = None - for l in labels: - button = Gtk.Button(label=l["label"]) - button.set_tooltip_text(l["tooltip"]) + for label in labels: + button = Gtk.Button(label=label["label"]) + button.set_tooltip_text(label["tooltip"]) button.set_margin_start(10) button.set_margin_end(10) button.connect("clicked", self._on_button_clicked) self.pack_start(button, False, True, 0) - def initialize(self): - l = len(self.get_children()) - last = self.get_children()[l-1] - last_label = last.get_label() + def initialize(self) -> None: for i in self.get_children(): match i.get_label(): case "Select stale": @@ -2530,12 +4720,10 @@ class ModSelectionPanel(Gtk.Box): case "Unhighlight stale": i.set_label("Highlight stale") - def _on_button_clicked(self, button): + def _on_button_clicked(self, button: Gtk.Button) -> None: self.active_button = button label = button.get_label() - widgets = relative_widget(self) - parent = widgets["outer"] - treeview = widgets["treeview"] + treeview = App.treeview (model, pathlist) = treeview.get_selection().get_selected_rows() match label: case "Select all": @@ -2548,77 +4736,73 @@ class ModSelectionPanel(Gtk.Box): return self._iterate_mod_deletion(model, pathlist, ct) case "Highlight stale": - process_tree_option([treeview.view, RowType.HIGHLIGHT], treeview) + process_tree_option(RowType.HIGHLIGHT) case "Unhighlight stale": self.colorize_cells(False) self._remove_last_button() case "Select stale": - for i in range (0, len(mod_store)): + for i in range(0, len(mod_store)): if mod_store[i][4] == "#FF0000": - path = Gtk.TreePath(i) + path = Gtk.TreePath.new_from_indices([i]) treeview.get_selection().select_path(path) - def _remove_last_button(self): + def _remove_last_button(self) -> None: children = self.get_children() - l = len(children) - tip = children[l-1] + tip = children[-1] label = tip.get_label() if label == "Select stale": tip.destroy() - - def toggle_select_stale_button(self, bool): - if bool is True: - button = Gtk.Button(label="Select stale") - button.set_tooltip_text("Bulk selects all currently highlighted mods") - button.set_margin_start(10) - button.set_margin_end(10) + def toggle_select_stale_button(self, state: bool) -> None: + if state: + button = Gtk.Button( + label="Select stale", margin_start=10, margin_end=10 + ) + text = "Bulk selects all currently highlighted mods" + button.set_tooltip_text(text) button.connect("clicked", self._on_button_clicked) self.pack_start(button, False, True, 0) self.show_all() - def colorize_cells(self, bool): + def colorize_cells(self, state: bool) -> None: def _colorize(path, color): mod_store[path][4] = color - - widgets = relative_widget(self) - parent = widgets["outer"] - treeview = widgets["treeview"] + + treeview = App.treeview (model, pathlist) = treeview.get_selection().get_selected_rows() - if bool is False: - for i in range (0, len(mod_store)): - path = Gtk.TreePath(i) + if not state: + for i in range(0, len(mod_store)): + path = Gtk.TreePath.new_from_indices([i]) it = mod_store.get_iter(path) _colorize(path, None) self.active_button.set_label("Highlight stale") return with open(stale_mods_temp_file, "r") as infile: - lines = [line.rstrip('\n') for line in infile] + lines = [line.rstrip("\n") for line in infile] - for i in range (0, len(mod_store)): + hits = 0 + for i, row in enumerate(mod_store): # type: ignore red = "#FF0000" - path = Gtk.TreePath(i) + path = Gtk.TreePath.new_from_indices([i]) it = mod_store.get_iter(path) if model.get_value(it, 2) not in lines: + hits += 1 _colorize(path, red) treeview.toggle_selection(False) + if hits > 0: self.active_button.set_label("Unhighlight stale") - self.active_button.set_tooltip_text("Clears highlights and reverts\nthe table to a default state") - - - def _iterate_mod_deletion(self, model, pathlist, ct): - widgets = relative_widget(self) - parent = widgets["outer"] - treeview = widgets["treeview"] + text = "Clears highlights and reverts the table to a default state" + self.active_button.set_tooltip_text(text) + self.toggle_select_stale_button(True) + def _iterate_mod_deletion( + self, model: Gtk.ListStore, pathlist: list, ct: int + ) -> None: pretty = pluralize("mods", ct) conf_msg = f"You are going to delete {ct} {pretty}. Proceed?" - success_msg = f"Successfully deleted {ct} {pretty}." - fail_msg = "An error occurred during deletion. Aborting." - - res = spawn_dialog(parent, conf_msg, Popup.CONFIRM) + res = spawn_dialog(conf_msg, Popup.CONFIRM) if res != 0: return @@ -2629,26 +4813,59 @@ class ModSelectionPanel(Gtk.Box): path = model.get_value(it, 2) concat = symlink + " " + path + "\n" mods.append(concat) - # hedge against large number of arguments passed to shell + # use a temp file to avoid passing too many args to shell with open(mods_temp_file, "w") as outfile: outfile.writelines(mods) - process_tree_option([treeview.view, RowType.DELETE_SELECTED], treeview) + process_tree_option(RowType.DELETE_SELECTED) class FilterPanel(Gtk.Box): def __init__(self): super().__init__(spacing=6) - for check in filters.keys(): - checkbutton = Gtk.CheckButton(label=check) - label = checkbutton.get_children() + self.default_filters = { + "1PP": True, + "Day": True, + "Empty": False, + "3PP": True, + "Night": True, + "Full": False, + "Low pop": True, + "Non-ASCII": False, + "Duplicate": False, + "Official": True, + "Unoffic.": True, + "Modded": True, + } + self.checks = [] + self.maps_hr = [] + self.enabled_filters = dict(self.default_filters) + self.keyword_filter = "" + self.selected_map = "All maps" + self.prior_map = "All maps" + + button_grid = Gtk.Grid( + halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True + ) + row = 1 + col = 0 + for check in self.default_filters.keys(): + checkbox = Gtk.CheckButton(label=check) + label = checkbox.get_children() label[0].set_ellipsize(Pango.EllipsizeMode.END) - if filters[check] is True: - checkbutton.set_active(True) - toggled_checks.append(check) - checkbutton.connect("toggled", self._on_check_toggle) - checks.append(checkbutton) + + if self.default_filters[check]: + checkbox.set_active(True) + + col = col + 1 + if col > 3: + row += 1 + col = 1 + button_grid.attach(checkbox, col, row, 1, 1) + + checkbox.connect("toggled", self._on_check_toggled) + self.checks.append(checkbox) self.connect("button-release-event", self._on_button_release) self.set_orientation(Gtk.Orientation.VERTICAL) @@ -2660,7 +4877,9 @@ class FilterPanel(Gtk.Box): self.keyword_entry = Gtk.Entry() self.keyword_entry.set_placeholder_text("Filter by keyword") self.keyword_entry.connect("activate", self._on_keyword_enter) - self.keyword_entry.connect("key-press-event", self._on_esc_pressed) + self.keyword_entry.connect( + "key-press-event", self._on_keyword_keypress + ) completion = Gtk.EntryCompletion(inline_completion=True) completion.set_text_column(0) @@ -2671,7 +4890,6 @@ class FilterPanel(Gtk.Box): self.maps_combo = Gtk.ComboBox.new_with_model_and_entry(map_store) self.maps_combo.set_entry_text_column(0) - # instantiate maps completer entry self.maps_entry = self.maps_combo.get_child() self.maps_entry.set_completion(completion) self.maps_entry.set_placeholder_text("Filter by map") @@ -2680,157 +4898,195 @@ class FilterPanel(Gtk.Box): self.maps_combo.pack_start(renderer_text, True) self.maps_combo.connect("changed", self._on_map_changed) - self.maps_combo.connect("key-press-event", self._on_esc_pressed) + self.maps_combo.connect("key-press-event", self._on_combo_keypress) self.pack_start(self.filters_label, False, False, 0) self.pack_start(self.keyword_entry, False, False, 0) self.pack_start(self.maps_combo, False, False, 0) - button_grid = Gtk.Grid() - row = 1 - col = 0 - for i, check in enumerate(checks[0:]): - col = col + 1 - if (col > 3): - row = row + 1 - col = 1 - button_grid.attach(checks[i], col, row, 1, 1) self.pack_start(button_grid, False, False, 0) - def _on_map_entry_keypress(self, entry, event): + def set_unique_maps(self, maps: list) -> None: + if len(maps) < 1: + return + u_maps = set([row[1] for row in maps]) # type: ignore + u_maps = sorted(u_maps) # type: ignore + for m in u_maps: + map_store.append([m]) + self.maps_hr.append(m) + + def get_filters(self) -> tuple: + filters = [] + filters.append(self.selected_map) + filters.append(self.keyword_filter) + for k in self.enabled_filters: + if not self.enabled_filters[k]: + filters.append(k) + return tuple(filters) + + # used on personal/local server lists + def enable_all_filters(self) -> None: + for check in self.checks: + check.set_active(True) + for k in self.enabled_filters: + self.enabled_filters[k] = True + + def reinit_panel(self) -> None: + self.keyword_entry.set_text("") + self.keyword_filter = "" + self.reinit_filters() + self.set_visible(False) + sel_panel = App.grid.sel_panel + if sel_panel.is_visible(): + sel_panel.set_visible(False) + + def reinit_filters(self) -> None: + self.enabled_filters = dict(self.default_filters) + for check in self.checks: + label = check.get_label() + state = self.default_filters[label] + check.set_active(state) + + def _on_map_entry_keypress( + self, entry: Gtk.Entry, event: Gdk.EventKey + ) -> None: match event.keyval: case Gdk.KEY_Return: text = entry.get_text() if text is None: return - # if entry is exact match for value in liststore, - # trigger map change function - for i in enumerate(map_store): + """ + If entry is exact match for value in liststore, + trigger map change function + """ + for i in enumerate(map_store): # type: ignore if text == i[1][0]: self.maps_combo.set_active(i[0]) self._on_map_changed(self.maps_combo) case Gdk.KEY_Escape: GLib.idle_add(self.restore_focus_to_treeview) - # TODO: this is a workaround for widget.grab_remove() - # set cursor position to SOL when unfocusing + """ + This is a workaround for widget.grab_remove() + Sets cursor position to SOL when unfocusing + """ text = self.maps_entry.get_text() self.maps_entry.set_position(len(text)) case _: return - def _on_completer_match(self, completion, model, iter): - self.maps_combo.set_active_iter(iter) + def _on_completer_match( + self, + completion: Gtk.EntryCompletion, + model: Gtk.ListStore, + it: Gtk.TreeIter, + ) -> None: + self.maps_combo.set_active_iter(it) def _on_map_completion(self, entry, editable): text = entry.get_text() completion = entry.get_completion() - if len(text) >= completion.get_minimum_key_length(): completion.set_model(map_store) - self._on_map_changed(self.maps_combo) - def grab_keyword_focus(self): - self.keyword_entry.grab_focus() - - def restore_focus_to_treeview(self): - grid = self.get_outer_grid() - grid.scrollable_treelist.treeview.grab_focus() + def restore_focus_to_treeview(self) -> Literal[False]: + App.treeview.grab_focus() return False - def _on_esc_pressed(self, entry, event): + def _on_keyword_keypress( + self, entry: Gtk.Entry, event: Gdk.EventKey + ) -> bool: match event.keyval: - case Gdk.KEY_Escape: - GLib.idle_add(self.restore_focus_to_treeview) case Gdk.KEY_Up: return True case Gdk.KEY_Down: return True + case Gdk.KEY_Escape: + GLib.idle_add(self.restore_focus_to_treeview) + return True + return False + + def _on_combo_keypress( + self, combo: Gtk.ComboBox, event: Gdk.EventKey + ) -> bool: + match event.keyval: + case Gdk.KEY_Down: + self.maps_combo.popup() + return True case _: return False - def get_outer_grid(self): - panel = self.get_parent() - grid = panel.get_parent() - return grid + def set_prior_map(self, mapname: str) -> None: + self.prior_map = mapname - def get_outer_window(self): - grid = self.get_outer_grid() - outer_window = grid.get_parent() - return outer_window + def get_prior_map(self) -> str: + return self.prior_map - def _on_keyword_enter(self, keyword_entry): - win = self.get_outer_window() - win.set_keep_below(False) - keyword = keyword_entry.get_text() - old_keyword = keyword_filter[0].split(delimiter)[1] - if keyword == old_keyword: + def get_selected_map(self) -> str: + return self.selected_map + + def get_keyword_filter(self) -> str: + return self.keyword_filter + + def _on_keyword_enter(self, entry: Gtk.Entry) -> None: + App.window.set_keep_below(False) + keyword = entry.get_text().lower() + if keyword == self.keyword_filter: return - logger.info("User filtered by keyword '%s'" %(keyword)) - keyword_filter.clear() - keyword_filter.append("Keyword␞" + keyword) - transient_parent = self.get_outer_window() - grid = self.get_outer_grid() - treeview = grid.scrollable_treelist.treeview - context = grid.scrollable_treelist.treeview.get_first_col() - filter_servers(transient_parent, self, treeview, context) + if keyword.isspace(): + return + logger.info(f"User filtered by keyword '{keyword}'") + self.keyword_filter = keyword + App.treeview.filter(FilterMode.KEYWORD, keyword) - def _on_button_release(self, window, button): + def _on_button_release(self, window, button) -> Literal[True]: return True - def set_active_combo(self): - self.maps_combo.set_active(0) + def get_active_combo(self) -> int: + return self.maps_combo.get_active() - def toggle_check(self, button): - if button.get_active(): - button.set_active(False) - else: - button.set_active(True) + def set_active_combo(self, row: int) -> None: + self.maps_combo.set_active(row) - def _on_check_toggle(self, button): - grid = self.get_outer_grid() - treeview = grid.scrollable_treelist.treeview - context = grid.scrollable_treelist.treeview.get_first_col() + def toggle_check(self, digit: int) -> None: + check = self.checks[digit] + state = check.get_active() + check.set_active(not state) + + def _on_check_toggled(self, button: Gtk.CheckButton) -> None: + if not App.treeview.is_server_context(App.treeview.view): + return label = button.get_label() state = button.get_active() - - if context == "Mod": - return - if state is True: - toggled_checks.append(label) + logger.info(f"User toggled button '{label}' to {state}") + if state: + mode = FilterMode.TOGGLE_ON else: - toggled_checks.remove(label) + mode = FilterMode.TOGGLE_OFF - logger.info("User toggled button '%s' to %s" %(label, state)) - transient_parent = self.get_outer_window() - filter_servers(transient_parent, self, treeview, context) - - def _on_map_changed(self, combo): - grid = self.get_outer_grid() - transient_parent = self.get_outer_window() - treeview = grid.scrollable_treelist.treeview - context = grid.scrollable_treelist.treeview.get_first_col() + self.enabled_filters[label] = state + App.treeview.filter(mode, label) + def _on_map_changed(self, combo: Gtk.ComboBox) -> None: + old_sel = self.selected_map + model = combo.get_model() tree_iter = combo.get_active_iter() - if tree_iter is not None: - # take no action if completer query is same as current map sel - old_sel = selected_map[0].split("Map=")[1] - model = combo.get_model() - selection = model[tree_iter][0] - if selection == old_sel: - return - - selected_map.clear() - if selection is not None: - selected_map.append("Map=" + selection) - logger.info("User selected map '%s'" %(selection)) - filter_servers(transient_parent, self, treeview, context) - self.maps_entry.set_text(selection) + if tree_iter is None: + return + selection = model[tree_iter][0] + if selection == old_sel: + return + if not selection: + return + logger.info(f"User selected map '{selection}'") + self.prior_map = self.selected_map + self.selected_map = selection + self.maps_entry.set_text(selection) + App.treeview.filter(FilterMode.MAP) def main(): - def usage(): - text = "UI constructor must be run via DZGUI" + text = "UI helper must be run via DZGUI" logger.critical(text) print(text) sys.exit(1) @@ -2842,8 +5098,11 @@ def main(): usage() logger.info("Spawned UI from DZGUI setup process") + global _VERSION + _VERSION = sys.argv[2] App() -if __name__ == '__main__': +ModelManager = ModelManagerSingleton() +if __name__ == "__main__": main() diff --git a/images/tutorial/01.png b/images/tutorial/01.png deleted file mode 100644 index a8bb742..0000000 Binary files a/images/tutorial/01.png and /dev/null differ diff --git a/images/tutorial/02.png b/images/tutorial/02.png deleted file mode 100644 index 0aaf795..0000000 Binary files a/images/tutorial/02.png and /dev/null differ diff --git a/images/tutorial/03.png b/images/tutorial/03.png deleted file mode 100644 index 0a852ca..0000000 Binary files a/images/tutorial/03.png and /dev/null differ diff --git a/images/tutorial/04.png b/images/tutorial/04.png deleted file mode 100644 index 3f25729..0000000 Binary files a/images/tutorial/04.png and /dev/null differ diff --git a/images/tutorial/05.png b/images/tutorial/05.png deleted file mode 100644 index 9700a85..0000000 Binary files a/images/tutorial/05.png and /dev/null differ diff --git a/images/tutorial/06.png b/images/tutorial/06.png deleted file mode 100644 index 66bbbe7..0000000 Binary files a/images/tutorial/06.png and /dev/null differ diff --git a/images/tutorial/07.png b/images/tutorial/07.png deleted file mode 100644 index 1210080..0000000 Binary files a/images/tutorial/07.png and /dev/null differ diff --git a/images/tutorial/08.png b/images/tutorial/08.png deleted file mode 100644 index 2318770..0000000 Binary files a/images/tutorial/08.png and /dev/null differ diff --git a/images/tutorial/09.png b/images/tutorial/09.png deleted file mode 100644 index d2a1808..0000000 Binary files a/images/tutorial/09.png and /dev/null differ