From d41082b1d4f67ef6f9f845351d4b51a00eefc708 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 12 Jan 2024 13:39:04 +0900 Subject: [PATCH] feat: GTK UI --- dzgui.sh | 2703 +++++++++++-------------------------------- helpers/funcs | 1200 +++++++++++++++++++ helpers/query_v2.py | 75 ++ helpers/ui.py | 1635 ++++++++++++++++++++++++++ 4 files changed, 3598 insertions(+), 2015 deletions(-) create mode 100644 helpers/funcs create mode 100644 helpers/query_v2.py create mode 100644 helpers/ui.py diff --git a/dzgui.sh b/dzgui.sh index 708fd90..ece043f 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -1,163 +1,177 @@ #!/usr/bin/env bash - set -o pipefail -version=4.2.0.rc-1 +version="4.2.0.rc-1" +src_path=$(realpath "$0") + +#CONSTANTS aid=221100 game="dayz" +app_name="dzgui" +app_name_upper="DZGUI" workshop="steam://url/CommunityFilePage/" -api="https://api.battlemetrics.com/servers" sd_res="--width=1280 --height=800" -config_path="$HOME/.config/dztui/" -config_file="${config_path}dztuirc" -hist_file="${config_path}history" -tmp=/tmp/dzgui.tmp -fifo=/tmp/table.tmp -debug_log="$PWD/DZGUI_DEBUG.log" -separator="%%" -check_config_msg="Check config values and restart." -issues_url="https://github.com/aclist/dztui/issues" -url_prefix="https://raw.githubusercontent.com/aclist/dztui" -stable_url="$url_prefix/dzgui" -testing_url="$url_prefix/testing" -releases_url="https://github.com/aclist/dztui/releases/download/browser" -help_url="https://aclist.github.io/dzgui/dzgui" -sponsor_url="https://github.com/sponsors/aclist" +steamsafe_zenity="/usr/bin/zenity" +zenity_flags=("--width=500" "--title=DZGUI") +declare -A deps +deps=([awk]="5.1.1" [curl]="7.80.0" [jq]="1.6" [tr]="9.0" [$steamsafe_zenity]="3.42.1") + +#CONFIG +config_path="$HOME/.config/dztui" +config_file="$config_path/dztuirc" + +#PATHS +state_path="$HOME/.local/state/$app_name" +cache_path="$HOME/.cache/$app_name" +share_path="$HOME/.local/share/$app_name" +script_path="$share_path/dzgui.sh" +helpers_path="$share_path/helpers" + +#LOGS +log_path="$state_path/logs" +debug_log="$log_path/DZGUI_DEBUG.log" + +#STATE FILES +prefix="dzg" +history_file="$state_path/$prefix.history" +versions_file="$state_path/$prefix.versions" +lock_file="$state_path/$prefix.lock" + +#CACHE FILES +coords_file="$cache_path/$prefix.coords" + +#legacy paths +hist_file="$config_path/history" +version_file="$config_path/versions" + +#XDG freedesktop_path="$HOME/.local/share/applications" -sd_install_path="$HOME/.local/share/dzgui" -helpers_path="$sd_install_path/helpers" + +#HELPERS +ui_helper="$helpers_path/ui.py" geo_file="$helpers_path/ips.csv" km_helper="$helpers_path/latlon" sums_path="$helpers_path/sums.md5" -scmd_file="$helpers_path/scmd.sh" +func_helper="$helpers_path/funcs" + +#URLS +author="aclist" +repo="dztui" +url_prefix="https://raw.githubusercontent.com/$author/$repo" +stable_url="$url_prefix/dzgui" +testing_url="$url_prefix/testing" +releases_url="https://github.com/$author/$repo/releases/download/browser" km_helper_url="$releases_url/latlon" -db_file="$releases_url/ips.csv.gz" -sums_url="$testing_url/helpers/sums.md5" -scmd_url="$testing_url/helpers/scmd.sh" -vdf2json_url="$testing_url/helpers/vdf2json.py" -forum_url="https://github.com/aclist/dztui/discussions" -version_file="$config_path/versions" -steamsafe_zenity="/usr/bin/zenity" +geo_file_url="$releases_url/ips.csv.gz" +#TODO: normalize +vdf2json_url="$stable_url/helpers/vdf2json.py" +#TODO: move adoc to index +help_url="https://$author.github.io/dzgui/dzgui" +sponsor_url="https://github.com/sponsors/$author" -update_last_seen(){ - local news_sum="$1" - seen_news="$news_sum" - update_config +logger(){ + local date="$(date "+%F %T,%3N")" + local tag="$1" + local string="$2" + local self="${BASH_SOURCE[0]}" + local caller="${FUNCNAME[1]}" + local line="${BASH_LINENO[0]}" + printf "%s␞%s␞%s::%s()::%s␞%s\n" "$date" "$tag" "$self" "$caller" "$line" "$string" >> "$debug_log" } -check_news(){ - logger INFO "${FUNCNAME[0]}" - [[ $branch == "stable" ]] && news_url="$stable_url/news" - [[ $branch == "testing" ]] && news_url="$testing_url/news" - local result=$(curl -Ls "$news_url") - local sum=$(echo -n "$result" | md5sum | awk '{print $1}') - printf "%s\n%s\n" "$result" "$sum" - logger INFO "News: $result" +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 + done } -print_news(){ - readarray -t news_result < <(check_news) - local news_marquee=${news_result[0]} - local news_sum=${news_result[1]} - local hchar - local news - logger INFO "${FUNCNAME[0]}" - if [[ $news_sum == $seen_news ]] || [[ -z $news_marquee ]]; then - return 1 +setup_state_files(){ + if [[ -f "$debug_log" ]]; then + rm "$debug_log" && touch $debug_log + logger INFO "Initializing DZGUI version $version" + fi + if [[ -f "$version_file" ]]; then + mv "$version_file" "$versions_file" && + 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 + rm "$file" + done + logger INFO "Wiped cache files" fi - hchar="─" - news="$news_marquee\n$(awk -v var="$hchar" 'BEGIN{for(c=0;c<90;c++) printf var;}')\n" - update_last_seen "$news_sum" - echo "$news" } +print_config_vals(){ + local keys=( + "branch" + "seen_news" + "name" + "fav_server" + "fav_label" + "auto_install" + "steam_path" + "default_steam_path" + "preferred_client" + ) + for i in "${keys[@]}"; do + logger INFO "Read key '$i': '${!i}'" + done + if [[ ${#ip_list[@]} -lt 1 ]]; then + logger WARN "No IPs in saved server list" + fi -declare -A deps -deps=([awk]="5.1.1" [curl]="7.80.0" [jq]="1.6" [tr]="9.0" [$steamsafe_zenity]="3.42.1" [fold]="9.0") -changelog(){ - build(){ - local mdbranch - case "$branch" in - "stable") - mdbranch="dzgui" - ;; - *) - mdbranch="testing" - ;; - esac - local md="https://raw.githubusercontent.com/aclist/dztui/${mdbranch}/CHANGELOG.md" - prefix="This window can be scrolled." - echo $prefix - echo "" - curl -Ls "$md" - } - build | $steamsafe_zenity --text-info $sd_res --title="DZGUI" 2>/dev/null } - -depcheck(){ - for dep in "${!deps[@]}"; do - command -v "$dep" 2>&1>/dev/null || (printf "Requires %s >=%s\n" "$dep" ${deps[$dep]}; exit 1) - done +test_gobject(){ + python3 -c "import gi" + if [[ ! $? -eq 0 ]]; then + logger CRITICAL "Missing PyGObject" + fdialog "Requires PyGObject" + exit 1 + fi + logger INFO "Found PyGObject in Python env" } -watcher_deps(){ - logger INFO "${FUNCNAME[0]}" - if [[ ! $(command -v wmctrl) ]] && [[ ! $(command -v xdotool) ]]; then - echo "100" - warn "Missing dependency: requires 'wmctrl' or 'xdotool'.\nInstall from your system's package manager." - logger ERROR "Missing watcher dependencies" - exit 1 - fi +update_config(){ + # handling for legacy files + [[ -z $branch ]] && branch="stable" + [[ -f $config_file ]] && mv $config_file ${config_file}.old + write_config > $config_file && return 90 || return 1 + logger INFO "Updated config file at '$config_file'" } -init_items(){ - #array order determines menu selector; this is destructive - #change favorite index affects setup() and add_by_fav() -items=( - "[Connect]" - " Server browser" - " My servers" - " Quick connect to favorite server" - " Connect by ID" - " Connect by IP" - " Recent servers (last 10)" - "[Manage servers]" - " Add server by ID" - " Add server by IP" - " Add favorite server" - " Delete server" - "[Options]" - " List installed mods" - " View changelog" - " Advanced options" - "[Help]" - " Help file ⧉" - " Report bug ⧉" - " Forum ⧉" - " Sponsor ⧉" - " Hall of fame ⧉" - ) +setup_steam_client(){ + local flatpak + local steam + local steam_cmd + [[ -n $preferred_client ]] && return 0 + [[ $(command -v flatpak) ]] && flatpak=$(flatpak list | grep valvesoftware.Steam) + steam=$(command -v steam) + if [[ -z "$steam" ]] && [[ -z "$flatpak" ]]; then + raise_error_and_quit "Requires Steam or Flatpak Steam" + elif [[ -n "$steam" ]] && [[ -n "$flatpak" ]]; then + preferred_client="steam" + elif [[ -n "$steam" ]]; then + preferred_client="steam" + else + steam_cmd="flatpak run com.valvesoftware.Steam" + fi + update_config && logger INFO "Preferred client set to '$steam_cmd'" || return 1 } -warn(){ - logger WARN "$1" - $steamsafe_zenity --info --title="DZGUI" --text="$1" --width=500 --icon-name="dialog-warning" 2>/dev/null -} -info(){ - $steamsafe_zenity --info --title="DZGUI" --text="$1" --width=500 2>/dev/null -} -set_api_params(){ - logger INFO "${FUNCNAME[0]}" - response=$(curl -s "$api" -H "Authorization: Bearer "$api_key"" -G -d "sort=-players" \ - -d "filter[game]=$game" -d "filter[ids][whitelist]=$list_of_ids") - list_response=$response - first_entry=1 +print_ip_list(){ + [[ ${#ip_list[@]} -eq 0 ]] && return 1 + printf "\t\"%s\"\n" "${ip_list[@]}" } write_config(){ -cat <<-END +cat <<-END #Path to DayZ installation steam_path="$steam_path" -#Your unique API key +#Battlemetrics API key api_key="$api_key" #Favorited server IP:PORT array ip_list=( - $(print_ip_list) +$(print_ip_list) ) #Favorite server to fast-connect to (limit one) @@ -192,1945 +206,604 @@ default_steam_path="$default_steam_path" #Preferred Steam launch command (for Flatpak support) preferred_client="$preferred_client" - END + +#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 + local msg="Requires $dep >= ${deps[$dep]}" + raise_error_and_quit "$msg" + fi + done + 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" + fi + logger INFO "Found Python version: $pyver" +} +watcher_deps(){ + if [[ ! $(command -v wmctrl) ]] && [[ ! $(command -v xdotool) ]]; then + raise_error_and_quit "Missing dependency: requires 'wmctrl' or 'xdotool'" + exit 1 + fi + logger INFO "Found DZG Watcher dependencies" +} +format_version_url(){ + echo FORMAT + case "$branch" in + "stable") + version_url="$stable_url/dzgui.sh" + ;; + "testing") + version_url="$testing_url/dzgui.sh" + ;; + esac + echo "$version_url" } write_desktop_file(){ -cat <<-END +cat <<-END [Desktop Entry] Version=1.0 Type=Application Terminal=false -Exec=$sd_install_path/dzgui.sh -Name=DZGUI -Comment=dzgui -Icon=$sd_install_path/dzgui +Exec=$share_path/dzgui.sh +Name=$app_name_upper +Comment=$appname +Icon=$share_path/$appname Categories=Game - END +END } freedesktop_dirs(){ - mkdir -p "$sd_install_path" - mkdir -p "$freedesktop_path" - curl -s "$version_url" > "$sd_install_path/dzgui.sh" - chmod +x "$sd_install_path/dzgui.sh" - img_url="$testing_url/images" - for i in dzgui grid.png hero.png logo.png; do - curl -s "$img_url/$i" > "$sd_install_path/$i" - done - write_desktop_file > "$freedesktop_path/dzgui.desktop" - if [[ $is_steam_deck -eq 1 ]]; then - write_desktop_file > "$HOME/Desktop/dzgui.desktop" - fi -} -find_library_folder(){ - logger INFO "${FUNCNAME[0]}" - logger INFO "User picked directory: '$1'" - steam_path="$(python3 "$helpers_path/vdf2json.py" -i "$1/steamapps/libraryfolders.vdf" | jq -r '.libraryfolders[]|select(.apps|has("221100")).path')" - logger INFO "Steam path resolved to: $steam_path" -} -file_picker(){ - logger INFO "${FUNCNAME[0]}" - local path=$($steamsafe_zenity --file-selection --directory 2>/dev/null) - logger INFO "File picker path resolve to: $path" - if [[ -z "$path" ]]; then - logger INFO "Path was empty" - return - else - default_steam_path="$path" - find_library_folder "$default_steam_path" - fi -} -create_config(){ - logger INFO "${FUNCNAME[0]}" - check_pyver - write_to_config(){ - mkdir -p $config_path - write_config > $config_file - info "Config file created at $config_file." - source $config_file - } - while true; do - player_input="$($steamsafe_zenity --forms --add-entry="Player name (required for some servers)" --add-entry="Steam API key" --add-entry="BattleMetrics API key (optional)" --title="DZGUI" --text="DZGUI" $sd_res --separator="@" 2>/dev/null)" - #explicitly setting IFS crashes $steamsafe_zenity in loop - #and mapfile does not support high ascii delimiters - #so split fields with newline - readarray -t args < <(echo "$player_input" | sed 's/@/\n/g') - name="${args[0]}" - steam_api="${args[1]}" - api_key="${args[2]}" - - [[ -z $player_input ]] && exit - if [[ -z $steam_api ]]; then - warn "Steam API key cannot be empty" - continue - elif [[ $(test_steam_api) -eq 1 ]]; then - warn "Invalid Steam API key" - continue - fi - if [[ -n $api_key ]] && [[ $(test_bm_api $api_key) -eq 1 ]]; then - warn "Invalid BM API key" - continue - fi - while true; do - logger INFO "steamsafe_zenity is $steamsafe_zenity" - if [[ -n $steam_path ]]; then - write_to_config - return - fi - find_default_path - find_library_folder "$default_steam_path" - if [[ -z $steam_path ]]; then - logger WARN "Steam path was empty" - zenity --question --text="DayZ not found or not installed at the chosen path." --ok-label="Choose path manually" --cancel-label="Exit" - if [[ $? -eq 0 ]]; then - logger INFO "User selected file picker" - file_picker - else - exit - fi - else - write_to_config - return - fi - done - done - -} -err(){ - printf "[ERROR] %s\n" "$1" -} -varcheck(){ - if [[ ! -d $steam_path ]] || [[ ! -d $game_dir ]]; then - echo 1 - fi -} -run_depcheck(){ - logger INFO "${FUNCNAME[0]}" - if [[ -n $(depcheck) ]]; then - echo "100" - logger ERROR "Missing dependencies, quitting" - $steamsafe_zenity --warning --ok-label="Exit" --title="DZGUI" --text="$(depcheck)" - exit - fi -} -logger(){ - local date="$(date "+%F %T")" - local tag="$1" - local string="$2" - printf "[%s] [%s] %s\n" "$date" "$tag" "$string" >> "$debug_log" -} -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 - warn "Requires python >=3.10" && - exit - fi -} -run_varcheck(){ - logger INFO "${FUNCNAME[0]}" - source $config_file - workshop_dir="$steam_path/steamapps/workshop/content/$aid" - game_dir="$steam_path/steamapps/common/DayZ" - if [[ $(varcheck) -eq 1 ]]; then - $steamsafe_zenity --question --cancel-label="Exit" --text="Malformed config file. This is probably user error.\nStart first-time setup process again?" --width=500 2>/dev/null - code=$? - if [[ $code -eq 1 ]]; then - logger ERROR "Malformed config vars" - exit - else - create_config - fi - fi -} -config(){ - logger INFO "${FUNCNAME[0]}" - if [[ ! -f $config_file ]]; then - logger WARN "Config file missing" - logger INFO "steamsafe_zenity is $steamsafe_zenity" - $steamsafe_zenity --width=500 --info --text="Config file not found. Click OK to proceed to first-time setup." 2>/dev/null - code=$? - logger INFO "Return code $code" - #TODO: prevent progress if user hits ESC - if [[ $code -eq 1 ]]; then - exit - else - create_config - fi - else - source $config_file - fi - -} -steam_deck_mods(){ - until [[ -z $diff ]]; do - next=$(echo -e "$diff" | head -n1) - $steamsafe_zenity --question --ok-label="Open" --cancel-label="Cancel" --title="DZGUI" --text="Missing mods. Click [Open] to open mod $next in Steam Workshop and subscribe to it by clicking the green Subscribe button. After the mod is downloaded, return to this menu to continue validation." --width=500 2>/dev/null - rc=$? - if [[ $rc -eq 0 ]]; then - echo "[DZGUI] Opening ${workshop}$next" - $steam_cmd steam://url/CommunityFilePage/$next 2>/dev/null & - $steamsafe_zenity --info --title="DZGUI" --ok-label="Next" --text="Click [Next] to continue mod check." --width=500 2>/dev/null - else - return 1 - fi - compare - done -} -test_display_mode(){ - pgrep -a gamescope | grep -q "generate-drm-mode" - if [[ $? -eq 0 ]]; then - echo gm - else - echo dm - fi -} -foreground(){ - if [[ $(command -v wmctrl) ]]; then - wmctrl -a "DZG Watcher" - else - local window_id=$(xdotool search --name "DZG Watcher") - xdotool windowactivate $window_id - fi -} -manual_mod_install(){ - local ip="$1" - local gameport="$2" - - local ex="/tmp/dzc.tmp" - [[ -f $ex ]] && rm $ex - watcher(){ - readarray -t stage_mods <<< "$diff" - for((i=0;i<${#stage_mods[@]};i++)); do - [[ -f $ex ]] && return 1 - local downloads_dir="$steam_path/steamapps/workshop/downloads/$aid" - local workshop_dir="$steam_path/steamapps/workshop/content/$aid" - $steam_cmd "steam://url/CommunityFilePage/${stage_mods[$i]}" - echo "# Opening workshop page for ${stage_mods[$i]}. If you see no progress after subscribing, try unsubscribing and resubscribing again until the download commences." - sleep 1s - foreground - until [[ -d $downloads_dir/${stage_mods[$i]} ]]; do - [[ -f $ex ]] && return 1 - sleep 0.1s - if [[ -d $workshop_dir/${stage_mods[$i]} ]]; then - break - fi - done - foreground - echo "# Steam is downloading ${stage_mods[$i]} (mod $((i+1)) of ${#stage_mods[@]})" - until [[ -d $workshop_dir/${stage_mods[$i]} ]]; do - [[ -f $ex ]] && return 1 - sleep 0.1s - done - foreground - echo "# ${stage_mods[$i]} moved to mods dir" + local version_url=$(format_version_url) + local img_url="$stable_url/images" + curl -s "$version_url" > "$script_path" + chmod +x "$script_path" + for i in dzgui grid.png hero.png logo.png; do + curl -s "$img_url/$i" > "$share_path/$i" done - echo "100" - } - watcher > >($steamsafe_zenity --pulsate --progress --auto-close --title="DZG Watcher" --width=500 2>/dev/null; rc=$?; [[ $rc -eq 1 ]] && touch $ex) - compare - if [[ -z $diff ]]; then - passed_mod_check > >($steamsafe_zenity --pulsate --progress --auto-close --width=500 2>/dev/null) - launch "$ip" "$gameport" - else - return 1 + write_desktop_file > "$freedesktop_path/$appname.desktop" + [[ $is_steam_deck -eq 0 ]] && return + write_desktop_file > "$HOME/Desktop/$appname.desktop" +} +legacy_vars(){ + local suffix="fav" + local hr_msg="Config file contains values based on old API. Please update and re-run setup." + local msg="Config file contains legacy API value: '$suffix'" + if [[ -n $fav ]]; then + logger WARN "$msg" + fdialog "$hr_msg" + exit 1 fi -} -encode(){ - echo "$1" | md5sum | cut -c -8 -} -stale_symlinks(){ - logger INFO "${FUNCNAME[0]}" - for l in $(find "$game_dir" -xtype l); do - unlink $l - done -} -legacy_symlinks(){ - for d in "$game_dir"/*; do - if [[ $d =~ @[0-9]+-.+ ]]; then - unlink "$d" - fi - done - for d in "$workshop_dir"/*; do - local id=$(awk -F"= " '/publishedid/ {print $2}' "$d"/meta.cpp | awk -F\; '{print $1}') - local encoded_id=$(echo "$id" | awk '{printf("%c",$1)}' | base64 | sed 's/\//_/g; s/=//g; s/+/]/g') - if [[ -h "$game_dir/@$encoded_id" ]]; then - unlink "$game_dir/@$encoded_id" - fi - done -} -symlinks(){ - for d in "$workshop_dir"/*; do - id=$(awk -F"= " '/publishedid/ {print $2}' "$d"/meta.cpp | awk -F\; '{print $1}') - encoded_id=$(encode "$id") - mod=$(awk -F\" '/name/ {print $2}' "$d"/meta.cpp | sed -E 's/[^[:alpha:]0-9]+/_/g; s/^_|_$//g') - link="@$encoded_id" - if [[ -h "$game_dir/$link" ]]; then - : - else - printf "[DZGUI] Creating symlink for $mod\n" - ln -fs "$d" "$game_dir/$link" - fi - done -} -passed_mod_check(){ - echo "[DZGUI] Passed mod check" - echo "# Preparing symlinks" - legacy_symlinks - symlinks - echo "100" - -} -auto_mod_install(){ - local ip="$1" - local gameport="$2" - popup 300 - rc=$? - if [[ $rc -eq 1 ]]; then - manual_mod_install "$ip" "$gameport" - return + if [[ -n $whitelist ]]; then + suffix="whitelist" + logger WARN "$msg" + fdialog "$hr_msg" + exit 1 fi - log="$default_steam_path/logs/content_log.txt" - [[ -f "/tmp/dz.status" ]] && rm "/tmp/dz.status" - touch "/tmp/dz.status" - console_dl "$diff" && - $steam_cmd steam://open/downloads && 2>/dev/null 1>&2 - foreground - until [[ -z $(comm -23 <(printf "%s\n" "${modids[@]}" | sort) <(ls -1 $workshop_dir | sort)) ]]; do - local missing=$(comm -23 <(printf "%s\n" "${modids[@]}" | sort) <(ls -1 $workshop_dir | sort) | wc -l) - echo "# Downloaded $((${#modids[@]}-missing)) of ${#modids[@]} mods. ESC cancels" - done | $steamsafe_zenity --pulsate --progress --title="DZG Watcher" --auto-close --no-cancel --width=500 2>/dev/null - compare - [[ $force_update -eq 1 ]] && { unset force_update; return; } - if [[ -z $diff ]]; then - check_timestamps - passed_mod_check > >($steamsafe_zenity --pulsate --progress --title="DZGUI" --auto-close --width=500 2>/dev/null) - launch "$ip" "$gameport" - else - manual_mod_install "$ip" "$gameport" - fi -} -get_local_stamps(){ - concat(){ - for ((i=0;i<$max;i++)); do - echo "publishedfileids[$i]=${local_modlist[$i]}&" - done | awk '{print}' ORS='' - } - payload(){ - echo -e "itemcount=${max}&$(concat)" - } - post(){ - curl -s -X POST -H "Content-Type:application/x-www-form-urlencoded" \ - -d "$(payload)" 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json' - } - post -} -update_stamps(){ - for((i=0;i<${#local_modlist[@]};i++)); do - mod=${local_modlist[$i]} - stamp=${stamps[$i]} - printf "%s\t%s\n" "$mod" "$stamp" >> $version_file - done -} -check_timestamps(){ - readarray -t local_modlist < <(ls -1 $workshop_dir) - max=${#local_modlist[@]} - [[ $max -eq 0 ]] && return - readarray -t stamps < <(get_local_stamps | jq -r '.response.publishedfiledetails[].time_updated') - if [[ ! -f $version_file ]]; then - update_stamps - return - else - needs_update=() - for((i=0;i<${#local_modlist[@]};i++)); do - mod=${local_modlist[$i]} - stamp=${stamps[$i]} - if [[ ! $(awk -v var=$mod '$1 == var' $version_file) ]]; then - echo -e "$mod\t$stamp" >> $version_file - elif [[ $(awk -v var=$mod -v var2=$stamp '$1 == var && $2 == var2' $version_file) ]]; then - : - else - awk -v var=$mod -v var2=$stamp '$1 == var {$2=var2;print $1"\t"$2; next;};{print}' $version_file > $version_file.new - mv $version_file.new $version_file - needs_update+=($mod) - fi - done - fi -} -merge_modlists(){ - echo "# Aligning modlists" - [[ $force_update -eq 1 ]] && echo "# Checking mod versions" - check_timestamps - if [[ -z "$diff" ]] && [[ ${#needs_update[@]} -gt 0 ]]; then - diff=$(printf "%s\n" "${needs_update[@]}") - elif [[ -z "$diff" ]] && [[ ${#needs_update[@]} -eq 0 ]]; then - diff= - elif [[ -n "$diff" ]] && [[ ${#needs_update[@]} -eq 0 ]]; then - : - else - diff="$(printf "%s\n%s\n" "$diff" "${needs_update[@]}")" - fi - [[ $force_update -eq 1 ]] && echo "100" -} -update_history(){ - local ip="$1" - local gameport="$2" - local qport="$3" - [[ -n $(grep "$ip:$gameport:$qport" $hist_file) ]] && return - if [[ -f $hist_file ]]; then - old=$(tail -n9 "$hist_file") - old="$old\n" - fi - echo -e "${old}${ip}:${gameport}:${qport}" > "$hist_file" -} -connect(){ - local ip=$1 - local gameport=$2 - local qport=$3 - logger INFO "Querying $ip:$gameport:$qport" - connect_dialog(){ - echo "# Querying modlist" - local remote - remote=$(a2s "$ip" "$qport" rules) - if [[ $? -eq 1 ]]; then - echo "100" - popup 1200 - return 1 - fi - logger INFO "Server returned modlist: $(<<< $remote tr '\n' ' ')" - echo "# Checking for defunct mods" - query_defunct "$remote" - } - (connect_dialog "$ip" "$qport") | pdialog - rc=$? - [[ $rc -eq 1 ]] && return - readarray -t newlist < /tmp/dz.modlist - compare - [[ $auto_install -eq 2 ]] && merge_modlists > >(pdialog) - if [[ -n $diff ]]; then - if [[ $is_steam_deck -eq 1 ]] && [[ $(test_display_mode) == "gm" ]]; then - popup 1400 - return 1 - fi - case $auto_install in - 1|2) auto_mod_install "$ip" "$gameport" ;; - *) manual_mod_install "$ip" "$gameport" ;; - esac - else - passed_mod_check > >(pdialog) - update_history "$ip" "$gameport" "$qport" - launch "$ip" "$gameport" "$qport" - fi -} -update_config(){ - mv $config_file ${config_file}.old - write_config > $config_file - source $config_file -} -prepare_ip_list(){ - local res="$1" - local ct=$(<<< "$res" jq '[.response.servers[]]|length' 2>/dev/null) - if [[ -n $ct ]]; then - for((i=0;i<$ct;i++));do - readarray -t json_arr < <(<<< $res jq --arg i $i -r '[.response.servers[]][($i|tonumber)]|"\(.name)\n\(.addr)\n\(.players)\n\(.max_players)\n\(.gameport)\n\(.gametype)"') - local name=${json_arr[0]} - local addr=${json_arr[1]} - local ip=$(<<< $addr awk -F: '{print $1}') - local qport=$(<<< $addr awk -F: '{print $2}') - local current=${json_arr[2]} - local max=${json_arr[3]} - local players="${current}/${max}" - local gameport="${json_arr[4]}" - local gametime="${json_arr[5]}" - gametime=$(<<< "$gametime" grep -o '[0-9][0-9]:[0-9][0-9]') - - echo "$name" - echo "${ip}:${gameport}" - echo "$players" - echo "$gametime" - echo "$qport" - done - fi -} -ip_table(){ - local sel - local res="$1" - while true; do - sel=$(prepare_ip_list "$res" | $steamsafe_zenity --width 1200 --height 800 --text="Multiple maps found at this server. Select map from the list below" --title="DZGUI" --list --column=Name --column=IP --column=Players --column=Gametime --column=Qport --print-column=1,2,5 --separator=%% 2>/dev/null) - [[ $? -eq 1 ]] && return 1 - echo "$sel" - return 0 - done -} -fetch_ip_metadata(){ - local ip="$1" - source $config_file - local url="https://api.steampowered.com/IGameServersService/GetServerList/v1/?filter=\appid\221100\gameaddr\\$ip&key=$steam_api" - curl -Ls "$url" -} - -validate_local_ip(){ - <<< "$1" grep -qP '(^127.\d+.\d+.\d+:\d+$)|(^10\.\d+.\d+.\d+:\d+$)|(^172\.1[6-9]\.\d+.\d+:\d+$)|(^172\.2[0-9]\.\d+.\d+:\d+$)|(^172\.3[0-1]\.\d+.\d+:\d+$)|(^192\.168\.\d+.\d+:\d+$)' -} -test_steam_api(){ - local url="https://api.steampowered.com/IGameServersService/GetServerList/v1/?filter=\appid\221100&limit=10&key=$steam_api" - local code=$(curl -ILs "$url" | grep -E "^HTTP") - [[ $code =~ 403 ]] && echo 1 - [[ $code =~ 200 ]] && echo 0 -} -test_bm_api(){ - local api_key="$1" - [[ -z $api_key ]] && return 1 - local code=$(curl -ILs "$api" -H "Authorization: Bearer "$api_key"" -G \ - -d "filter[game]=$game" | grep -E "^HTTP") - [[ $code =~ 401 ]] && echo 1 - [[ $code =~ 200 ]] && echo 0 -} -add_steam_api(){ - [[ $(test_steam_api) -eq 1 ]] && return 1 - mv $config_file ${config_path}dztuirc.old - nr=$(awk '/steam_api=/ {print NR}' ${config_path}dztuirc.old) - steam_api="steam_api=\"$steam_api\"" - awk -v "var=$steam_api" -v "nr=$nr" 'NR==nr {$0=var}{print}' ${config_path}dztuirc.old > ${config_path}dztuirc - echo "[DZGUI] Added Steam API key" - $steamsafe_zenity --info --title="DZGUI" --text="Added Steam API key to:\n\n${config_path}dztuirc\nIf errors occur, you can restore the file:\n${config_path}dztuirc.old" --width=500 2>/dev/null - source $config_file -} -check_steam_api(){ - if [[ -z $steam_api ]]; then - steam_api=$($steamsafe_zenity --entry --text="Key 'steam_api' not present in config file. Enter Steam API key:" --title="DZGUI" 2>/dev/null) - if [[ $? -eq 1 ]] ; then - return - elif [[ ${#steam_api} -lt 32 ]] || [[ $(test_steam_api) -eq 1 ]]; then - $steamsafe_zenity --warning --title="DZGUI" --text="Check API key and try again." 2>/dev/null - return 1 - else - add_steam_api - fi - fi -} -validate_ip(){ - echo "$1" | grep -qP '^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$' -} -connect_by_id(){ - local ip - ip=$(add_by_id "connect") - [[ $? -eq 1 ]] && return - readarray -t address < <(format_config_address "$ip") - local ip="${address[0]}" - local gameport="${address[1]}" - local qport="${address[2]}" - unset address - - connect "$ip" "$gameport" "$qport" -} -connect_by_ip(){ - local sel - sel=$(parse_ips) - [[ -z $sel ]] && return - - readarray -t address < <(format_table_results "$sel") - local ip="${address[1]}" - local gameport="${address[2]}" - local qport="${address[3]}" - - connect "$ip" "$gameport" "$qport" -} -parse_ips(){ - local res - source $config_file - check_steam_api - [[ $? -eq 1 ]] && return - while true; do - local ip - ip=$(edialog "Enter server IP (for LAN servers, include query port in IP:PORT format)") - [[ $? -eq 1 ]] && return 1 - if [[ $ip =~ ':' ]]; then - if ! validate_local_ip "$ip"; then - warn "Invalid local IP. Check IP:PORT combination and try again." - continue - fi - local lan_ip=$(<<< $ip awk -F: '{print $1}') - local lan_qport=$(<<< $ip awk -F: '{print $2}') - logger INFO "Given LAN IP was $lan_ip" - logger INFO "Given LAN port was $lan_qport" - res=$(a2s $lan_ip $lan_qport info) - if [[ ! $? -eq 0 ]] || [[ $(<<< $res jq 'length') -eq 0 ]]; then - warn "Failed to retrieve server metadata. Check IP:PORT combination and try again." - return 1 - fi - logger INFO "$res" - local name=$(<<< $res jq -r '.name') - local address=$(<<< $res jq -r '.address') - local ip=$(<<< $address awk -F: '{print $1}') - local gameport=$(<<< $address awk -F: '{print $2}') - local qport=$(<<< $res jq -r '.qport') - logger INFO "Found '${name}' at ${ip}:${gameport}:${qport}" - echo "${name}%%${ip}:${gameport}%%${qport}" - return 0 - else - if validate_ip "$ip"; then - res=$(fetch_ip_metadata "$ip") - if [[ ! $? -eq 0 ]] || [[ $(<<< $res jq '.response|length') -eq 0 ]]; then - warn "Failed to retrieve server metadata. Check IP or API key and try again." - return 1 - fi - local ct=$(<<< "$res" jq '.response.servers|length') - if [[ $ct -eq 1 ]]; then - local name=$(<<< $res jq -r '.response.servers[].name') - local address=$(<<< $res jq -r '.response.servers[].addr') - local ip=$(<<< "$address" awk -F: '{print $1}') - local qport=$(<<< "$address" awk -F: '{print $2}') - local gameport=$(<<< $res jq -r '.response.servers[].gameport') - echo "${name}%%${ip}:${gameport}%%${qport}" - return 0 - fi - ip_table "$res" - return 0 - else - warn "Invalid IP" - fi - fi - done -} -query_defunct(){ - readarray -t modlist <<< "$@" - local max=${#modlist[@]} - concat(){ - for ((i=0;i<$max;i++)); do - echo "publishedfileids[$i]=${modlist[$i]}&" - done | awk '{print}' ORS='' - } - payload(){ - echo -e "itemcount=${max}&$(concat)" - } - post(){ - curl -s \ - -X POST \ - -H "Content-Type:application/x-www-form-urlencoded"\ - -d "$(payload)" 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json' - } - local result=$(post | jq -r '.[].publishedfiledetails[] | select(.result==1) | "\(.file_size) \(.publishedfileid)"') - <<< "$result" awk '{print $2}' > /tmp/dz.modlist -} -server_modlist(){ - for i in "${newlist[@]}"; do - printf "$i\n" - done -} -compare(){ - diff=$(comm -23 <(server_modlist | sort -u) <(installed_mods | sort)) -} - -installed_mods(){ - ls -1 "$workshop_dir" -} -concat_mods(){ - readarray -t serv <<< "$(server_modlist)" - for i in "${serv[@]}"; do - id=$(awk -F"= " '/publishedid/ {print $2}' "$workshop_dir"/$i/meta.cpp | awk -F\; '{print $1}') - encoded_id=$(encode $id) - link="@$encoded_id;" - echo -e "$link" - done | tr -d '\n' | perl -ple 'chop' -} -launch(){ - local ip="$1" - local gameport="$2" - local qport="$3" - source $config_file - mods=$(concat_mods) - if [[ ! ${ip_list[@]} =~ "$ip:$gameport:$qport" ]]; then - qdialog "Before connecting, add this server to My Servers?" - if [[ $? -eq 0 ]]; then - ip_list+=("$ip:$gameport:$qport") - update_config - fi - fi - if [[ $debug -eq 1 ]]; then - launch_options="$steam_cmd -applaunch $aid -connect=$ip:$gameport -nolauncher -nosplash -name=$name -skipintro \"-mod=$mods\"" - print_launch_options="$(printf "This is a dry run.\nThese options would have been used to launch the game:\n\n$launch_options\n" | fold -w 60)" - $steamsafe_zenity --question --title="DZGUI" --ok-label="Write to file" --cancel-label="Back"\ - --text="$print_launch_options" 2>/dev/null - if [[ $? -eq 0 ]]; then - source_script=$(realpath "$0") - source_dir=$(dirname "$source_script") - echo "$launch_options" > "$source_dir"/options.log - echo "[DZGUI] Wrote launch options to $source_dir/options.log" - $steamsafe_zenity --info --width=500 --title="DZGUI" --text="Wrote launch options to \n$source_dir/options.log" 2>/dev/null - fi - else - $steamsafe_zenity --width=500 --title="DZGUI" --info --text="Launch conditions satisfied.\nDayZ will now launch after clicking [OK]." 2>/dev/null - $steam_cmd -applaunch $aid -connect=$ip:$gameport -nolauncher -nosplash -skipintro -name=$name \"-mod=$mods\" - fi -} -browser(){ - if [[ $is_steam_deck -eq 1 ]]; then - steam steam://openurl/"$1" 2>/dev/null - elif [[ $is_steam_deck -eq 0 ]]; then - if [[ -n "$BROWSER" ]]; then - "$BROWSER" "$1" 2>/dev/null - else - xdg-open "$1" 2>/dev/null - fi - fi -} -report_bug(){ - browser "$issues_url" -} -forum(){ - browser "$forum_url" -} -help_file(){ - browser "$help_url" -} -sponsor(){ - browser "$sponsor_url" -} -hof(){ - browser "${help_url}#_hall_of_fame" -} -set_mode(){ - logger INFO "${FUNCNAME[0]}" - if [[ $debug -eq 1 ]]; then - mode=debug - else - mode=normal - fi - logger INFO "Mode is $mode" -} -delete_by_ip(){ - local to_delete="$1" - for (( i=0; i<${#ip_list[@]}; ++i )); do - if [[ ${ip_list[$i]} == "$to_delete" ]]; then - unset ip_list[$i] - fi - done - if [[ ${#ip_list} -gt 0 ]]; then - readarray -t ip_list < <(printf "%s\n" "${ip_list[@]}") - fi - update_config - info "Removed $to_delete from:\n${config_path}dztuirc\nIf errors occur, you can restore the file:\n${config_path}dztuirc.old" -} -format_table_results(){ - local sel="$1" - local name=$(<<< "$sel" awk -F"%%" '{print $1}') - local address=$(<<< "$sel" awk -F"%%" '{print $2}') - local ip=$(<<< "$address" awk -F":" '{print $1}') - local gameport=$(<<< "$address" awk -F":" '{print $2}') - local qport=$(<<< "$sel" awk -F"%%" '{print $3}') - printf "%s\n%s\n%s\n%s\n" "$name" "$ip" "$gameport" "$qport" -} -delete_or_connect(){ - local sel="$1" - local mode="$2" - - readarray -t address < <(format_table_results "$sel") - local server_name="${address[0]}" - local ip="${address[1]}" - local gameport="${address[2]}" - local qport="${address[3]}" - unset address - - case "$mode" in - "delete") - qdialog "Delete this server?\n$server_name" - [[ $? -eq 1 ]] && return - - delete_by_ip "$ip:$gameport:$qport" - source $config_file - - local str="^$ip:$gameport$" - local nr=$(awk -v v="$str" '$1 ~ v {print NR}' $tmp) - local st=$((nr-1)) - local en=$((st+5)) - sed -i "${st},${en}d" $tmp - # if [[ ${#ip_list[@]} -eq 0 ]]; then - # return 1 - # fi - ;; - "connect"|"history") - connect "$ip" "$gameport" "$qport" - return - esac -} -populate(){ - local switch="$1" - while true; do - cols="--column="Server" --column="IP" --column="Players" --column="Gametime" --column="Distance" --column="Qport"" - set_header "$switch" - rc=$? - if [[ $rc -eq 0 ]]; then - if [[ -z $sel ]]; then - warn "No item was selected." - else - delete_or_connect "$sel" "$switch" - fi - else - return 1 - fi - done -} -list_mods(){ - if [[ -z $(installed_mods) ]] || [[ -z $(find $workshop_dir -maxdepth 2 -name "*.cpp" | grep .cpp) ]]; then - $steamsafe_zenity --info --text="94: No mods currently installed or incorrect path given" $sd_res 2>/dev/null - else - for d in $(find $game_dir/* -maxdepth 1 -type l); do - dir=$(basename $d) - awk -v d=$dir -F\" '/name/ {printf "%s\t%s\t", $2,d}' "$gamedir"/$d/meta.cpp - printf "%s\n" "$(basename $(readlink -f $game_dir/$dir))" - done | sort -k1 - fi -} -connect_to_fav(){ - logger INFO "${FUNCNAME[0]}" - - local fav="$1" - [[ -z $fav ]] && { popup 1300; return; } - - readarray -t address < <(format_config_address "$fav") - local ip="${address[0]}" - local gameport="${address[1]}" - local qport="${address[2]}" - - unset address - connect "$ip" "$gameport" "$qport" - [[ $? -eq 1 ]] && return 1 -} -set_header(){ - local switch="$1" - logger INFO "${FUNCNAME[0]}" - logger INFO "Header mode is $1" - local news - news=$(print_news) - [[ $auto_install -eq 2 ]] && install_mode="auto" - [[ $auto_install -eq 1 ]] && install_mode="headless" - [[ $auto_install -eq 0 ]] && install_mode=manual - case "$switch" in - "delete") - [[ -z $(< $tmp) ]] && return 1 - sel=$(< $tmp $steamsafe_zenity $sd_res --list $cols --title="DZGUI" \ - --text="DZGUI $version | Mode: $mode | Branch: $branch | Mods: $install_mode | Fav: $fav_label" \ - --separator="$separator" --print-column=1,2,6 --ok-label="Delete" 2>/dev/null) - ;; - - "connect"|"history") - sel=$(< $tmp $steamsafe_zenity $sd_res --list $cols --title="DZGUI" \ - --text="DZGUI $version | Mode: $mode | Branch: $branch | Mods: $install_mode | Fav: $fav_label" \ - --separator="$separator" --print-column=1,2,6 --ok-label="Connect" 2>/dev/null) - ;; - - "main_menu") - sel=$($steamsafe_zenity $sd_res --list --title="DZGUI" \ - --text="${news}DZGUI $version | Mode: $mode | Branch: $branch | Mods: $install_mode | Fav: $fav_label" \ - --cancel-label="Exit" --ok-label="Select" --column="Select launch option" --hide-header "${items[@]}" 2>/dev/null) - ;; - esac -} -toggle_branch(){ - mv $config_file ${config_path}dztuirc.old - nr=$(awk '/branch=/ {print NR}' ${config_path}dztuirc.old) - if [[ $branch == "stable" ]]; then - branch="testing" - else - branch="stable" - fi - flip_branch="branch=\"$branch\"" - awk -v "var=$flip_branch" -v "nr=$nr" 'NR==nr {$0=var}{print}' ${config_path}dztuirc.old > $config_file - source $config_file -} -generate_log(){ - cat <<-DOC - Distro: $(< /etc/os-release grep -w NAME | awk -F\" '{print $2}') - Kernel: $(uname -mrs) - Version: $version - Branch: $branch - Mode: $mode - Auto: $auto_hr - Servers: - $(print_ip_list) - Steam path: $steam_path - Workshop path: $workshop_dir - Game path: $game_dir - - Mods: - $(list_mods) - DOC -} -focus_beta_client(){ - steam steam://open/library 2>/dev/null 1>&2 && - steam steam://open/console 2>/dev/null 1>&2 && - sleep 1s - wid(){ - wmctrl -ilx |\ - awk 'tolower($3) == "steamwebhelper.steam"' |\ - awk '$5 ~ /^Steam|Steam Games List/' |\ - awk '{print $1}' - } - until [[ -n $(wid) ]]; do - : - done - wmctrl -ia $(wid) - sleep 0.1s - 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 - sleep 0.5s - xdotool key Tab -} -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 -} -find_default_path(){ - logger INFO "${FUNCNAME[0]}" - discover(){ - echo "# Searching for Steam" - default_steam_path=$(find / -type d \( -path "/proc" -o -path "*/timeshift" -o -path \ - "/tmp" -o -path "/usr" -o -path "/boot" -o -path "/proc" -o -path "/root" \ - -o -path "/sys" -o -path "/etc" -o -path "/var" -o -path "/lost+found" \) -prune \ - -o -regex ".*/Steam/ubuntu12_32$" -print -quit 2>/dev/null | sed 's@/ubuntu12_32@@') - } - if [[ $is_steam_deck -eq 1 ]]; then - default_steam_path="$HOME/.local/share/Steam" - else - local def_path - local ub_path - local flat_path - def_path="$HOME/.local/share/Steam" - ub_path="$HOME/.steam/steam" - flat_path="$HOME/.var/app/com.valvesoftware.Steam/data/Steam" - - if [[ -d "$def_path" ]]; then - default_steam_path="$def_path" - elif [[ -d "$ub_path" ]]; then - default_steam_path="$ub_path" - elif [[ -d $flat_path ]]; then - default_steam_path="$flat_path" - else - local res=$(echo -e "Let DZGUI auto-discover Steam path (accurate, slower)\nSelect the Steam path manually (less accurate, faster)" | $steamsafe_zenity --list --column="Choice" --title="DZGUI" --hide-header --text="Steam is not installed in a standard location." $sd_res) - case "$res" in - *auto*) discover ;; - *manual*) - zenity --info --text="\nSelect the top-level entry point to the location where Steam (not DayZ)\nis installed and before entering the \"steamapps\" path.\n\nE.g., if Steam is installed at:\n\"/media/mydrive/Steam\"\n\nCorrect:\n- \"/media/mydrive/Steam\"\n\nIncorrect:\n- \"/media/mydrive/Steam/steamapps/common/DayZ\"\n- \"/media/mydrive/\"" --width=500 && - file_picker ;; - esac - fi - fi -} -fold_message(){ - echo "$1" | fold -s -w40 -} -popup(){ - pop(){ - $steamsafe_zenity --info --text="$1" --title="DZGUI" --width=500 2>/dev/null - } - case "$1" in - 100) pop "This feature requires xdotool and wmctrl.";; - 200) pop "This feature is not supported on Gaming Mode.";; - 300) pop "$(fold_message 'The Steam console will now open and briefly issue commands to download the workshop files, then return to the download progress page. Ensure that the Steam console has keyboard and mouse focus (keep hands off keyboard) while the commands are being issued. Depending on the number if mods, it may take some time to queue the downloads. If a popup or notification window steals focus, it could obstruct the process.')" ;; - 400) pop "$(fold_message 'Automod install enabled. Auto-downloaded mods will not appear in your Steam Workshop subscriptions, but DZGUI will track the version number of downloaded mods internally and trigger an update if necessary.')" ;; - 500) pop "$(fold_message 'Automod install disabled. Switched to manual mode.')" ;; - 600) pop "No preferred servers set." ;; - 700) pop "Toggled to Flatpak Steam." ;; - 800) pop "Toggled to native Steam." ;; - 900) pop "This feature is not supported on Steam Deck." ;; - 1000) pop "No recent history." ;; - 1100) pop "No results found." ;; - 1200) pop "Timed out. Server may be temporarily offline or not responding to queries." ;; - 1300) pop "No favorite server configured." ;; - 1400) pop "To install missing mods, run DZGUI via Desktop Mode on Steam Deck, preferably via the desktop shortcut." ;; - esac -} -toggle_console_dl(){ - [[ $is_steam_deck -eq 1 ]] && { popup 900; return; } - [[ ! $(command -v xdotool) ]] && { popup 100; return; } - [[ ! $(command -v wmctrl) ]] && { popup 100; return; } - mv $config_file ${config_path}dztuirc.old - local nr=$(awk '/auto_install=/ {print NR}' ${config_path}dztuirc.old) - if [[ $auto_install == "2" ]]; then - auto_install="0" - popup 500 - else - auto_install="2" - popup 400 - fi - local flip_state="auto_install=\"$auto_install\"" - awk -v "var=$flip_state" -v "nr=$nr" 'NR==nr {$0=var}{print}' ${config_path}dztuirc.old > $config_file - source $config_file -} -force_update_mods(){ - if [[ -f $version_file ]]; then - awk '{OFS="\t"}{$2="000"}1' $version_file > /tmp/versions - mv /tmp/versions $version_file - fi -} -toggle_steam_binary(){ - case "$steam_cmd" in - steam) - steam_cmd="flatpak run com.valvesoftware.Steam" - update_steam_cmd - popup 700 - ;; - flatpak*) - steam_cmd="steam" - update_steam_cmd - popup 800;; - esac -} -options_menu(){ - init_options_list(){ - source $config_file - set_mode - case "$auto_install" in - 0|1|"") auto_hr="OFF"; ;; - 2) auto_hr="ON"; ;; - esac - [[ -z $name ]] && name="null" - debug_list=( - "Toggle branch [current: $branch]" - "Toggle debug mode [current: $mode]" - "Toggle auto mod install [current: $auto_hr]" - "Change player name [current: $name]" - "Output system info" - ) - #TODO: tech debt: drop old flags - [[ $auto_install -eq 2 ]] || [[ $auto_install -eq 1 ]] && debug_list+=("Force update local mods") - case "$steam_cmd" in - steam) steam_hr=Steam ;; - flatpak*) steam_hr=Flatpak ;; - esac - [[ $toggle_steam -eq 1 ]] && debug_list+=("Toggle native Steam or Flatpak [$steam_hr]") - } - while true; do - init_options_list - debug_sel=$($steamsafe_zenity --list --width=1280 --height=800 --column="Options" --title="DZGUI" --hide-header "${debug_list[@]}" 2>/dev/null) - [[ -z $debug_sel ]] && return - case "$debug_sel" in - Toggle[[:space:]]branch*) - enforce_dl=1 - toggle_branch && - check_version - ;; - Toggle[[:space:]]debug*) toggle_debug ;; - "Output system info") - source_script=$(realpath "$0") - source_dir=$(dirname "$source_script") - output(){ - echo "# Generating log" - generate_log > "$source_dir/DZGUI.log" - } - (output) | pdialog - [[ $? -eq 1 ]] && return - info_dialog "Wrote log file to: $source_dir/DZGUI.log" - ;; - Toggle[[:space:]]auto*) toggle_console_dl ;; - "Force update local mods") - force_update=1 - force_update_mods - (merge_modlists) | pdialog - auto_mod_install - ;; - Toggle[[:space:]]native*) toggle_steam_binary ;; - Change[[:space:]]player[[:space:]]name*) change_name - ;; - esac - done -} -info_dialog(){ - local title="DZGUI" - $steamsafe_zenity --info --width=500 --title="$title" --text="$1" 2>/dev/null -} -a2s(){ - local ip="$1" - local qport="$2" - local mode="$3" - logger A2S "Querying '$ip:$qport' with mode '$mode'" - python3 $helpers_path/query.py "$ip" "$qport" "$mode" -} -format_config_address(){ - local address="$1" - parse(){ - local ind="$1" - <<< $address awk -F: "{print \$$ind}" - } - local ip=$(parse 1) - local gameport=$(parse 2) - local qport=$(parse 3) - printf "%s\n%s\n%s" "$ip" "$gameport" "$qport" -} -query_and_connect(){ - source $config_file - local switch="$1" - local ips="$2" - case "$switch" in - "history") - if [[ -z $2 ]]; then - warn "No recent servers in history" - return 1 - fi - readarray -t ip_arr <<< "$ips" - ;; - "connect"|"delete") - if [[ ${#ip_list[@]} -eq 0 ]]; then - warn "No servers currently saved" - return 1 - fi - ips="$(printf "%s\n" "${ip_list[@]}")" - readarray -t ip_arr <<< "$ips" - ;; - esac - [[ ${#ip_arr[@]} -lt 1 ]] && { popup 600; return; } - > $tmp - q(){ - for (( i = 0; i < ${#ip_arr[@]}; ++i )); do - - local address="${ip_arr[$i]}" - readarray -t address < <(format_config_address "$address") - local ip="${address[0]}" - local gameport="${address[1]}" - local qport="${address[2]}" - unset address - - local info - echo "# Querying $ip:$qport" - info=$(a2s "$ip" "$qport" info) - [[ $? -eq 1 ]] && continue - local keywords=$(<<< $info jq -r '.keywords') - local vars=("name" "address" "count" "time" "dist" "qport") - for j in ${vars[@]}; do - local -n var=$j - case "$j" in - "time") - var=$(<<< "$keywords" grep -o '[0-9][0-9]:[0-9][0-9]') - ;; - "name") - var=$(<<< "$info" jq -r --arg arg $j '.[$arg]') - if [[ "${#var}" -gt 50 ]]; then - var="$(<<< "$var" awk '{print substr($0,1,50) "..."}')" - fi - ;; - "dist") - check_geo_file - local_latlon - var=$(get_dist $(<<< $address awk -F: '{print $1}')) - ;; - *) - var=$(<<< "$info" jq -r --arg arg $j '.[$arg]') - ;; - esac - printf "%s\n" "$var" >> $tmp - done - unset $j - done - } - - (q) | pdialog - [[ $? -eq 1 ]] && return - populate "$switch" -} -exclude_fpp(){ - response=$(<<< "$response" jq '[.[]|select(.gametype|split(",")|any(. == "no3rd")|not)]') -} -exclude_tpp(){ - response=$(<<< "$response" jq '[.[]|select(.gametype|split(",")|any(. == "no3rd"))]') -} -exclude_full(){ - response=$(echo "$response" | jq '[.[]|select(.players!=.max_players)]') -} -exclude_empty(){ - response=$(echo "$response" | jq '[.[]|select(.players!=0)]') -} -filter_maps(){ - echo "# Filtering maps" - [[ $ret -eq 98 ]] && return - local maps=$(echo "$response" | jq -r '.[].map//empty|ascii_downcase' | sort -u) - local map_ct=$(echo "$maps" | wc -l) - local map_sel=$(echo "$maps" | $steamsafe_zenity --list --column="Check" --width=1200 --height=800 2>/dev/null --title="DZGUI" --text="Found $map_ct map types") - echo "[DZGUI] Selected '$map_sel'" - if [[ -z $map_sel ]]; then - ret=97 - return - fi - echo "100" - response=$(echo "$response" | jq --arg map "$map_sel" '[.[]|select(.map)//empty|select(.map|ascii_downcase == $map)]') -} -exclude_daytime(){ - response=$(echo "$response" | jq '[.[]|select(.gametype|test(",[0][6-9]:|,[1][0-6]:")|not)]') -} -exclude_nighttime(){ - response=$(echo "$response" | jq '[.[]|select(.gametype|test(",[1][7-9]:|,[2][0-4]:|[0][0-5]:")|not)]') -} -keyword_filter(){ - response=$(echo "$response" | jq --arg search "$search" '[.[]|select(.name|ascii_downcase | contains($search))]') -} -exclude_lowpop(){ - response=$(echo "$response" | jq '[.[]|select(.players > 9)]') -} -exclude_nonascii(){ - response=$(echo "$response" | jq -r '[.[]|select(.name|test("^([[:ascii:]])*$"))]') -} -strip_null(){ - response=$(echo "$response" | jq -r '[.[]|select(.map//empty)]') -} -local_latlon(){ - if [[ -z $(command -v dig) ]]; then - local local_ip=$(curl -Ls "https://ipecho.net/plain") - else - 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),\(.lon)"') - local_lat=$(echo "$res" | awk -F, '{print $1}') - local_lon=$(echo "$res" | awk -F, '{print $2}') -} -disabled(){ - if [[ -z ${disabled[@]} ]]; then - printf "%s" "-" - else - for((i=0;i<${#disabled[@]};i++)); do - if [[ $i < $((${#disabled[@]}-1)) ]]; then - printf "%s, " "${disabled[$i]}" - else - printf "%s" "${disabled[$i]}" - fi - - done - fi -} -pagination(){ - if [[ ${#qport[@]} -eq 1 ]]; then - entry=server - else - entry=servers - fi - printf "DZGUI %s | " "$version" - printf "Mode: %s |" "$mode" - printf "Fav: %s " "$fav_label" - printf "\nIncluded: %s | " "$filters" - printf "Excluded: %s " "$(disabled)" - if [[ -n $search ]]; then - printf "| Keyword: %s " "$search" - fi - printf "\nReturned: %s %s of %s | " "${#qport[@]}" "$entry" "$total_servers" - printf "Players in-game: %s" "$players_online" -} -check_geo_file(){ - local gzip="$helpers_path/ips.csv.gz" - curl -Ls "$sums_url" > "$sums_path" - cd "$helpers_path" - md5sum -c "$sums_path" 2>/dev/null 1>&2 - local res=$? - cd $OLDPWD - [[ $res -eq 0 ]] && return - update(){ - mkdir -p "$helpers_path" - echo "# Fetching new geolocation DB" - curl -Ls "$db_file" > "$gzip" - echo "# Extracting coordinates" - #force overwrite - gunzip -f "$gzip" - echo "# Preparing helper file" - curl -Ls "$km_helper_url" > "$km_helper" - chmod +x $km_helper - echo "100" - } - update > >(pdialog) -} -choose_filters(){ - if [[ $is_steam_deck -eq 0 ]]; then - sd_res="--width=1920 --height=1080" - fi - sels=$($steamsafe_zenity --title="DZGUI" --text="Server search" --list --checklist --column "Check" --column "Option" --hide-header TRUE "All maps (untick to select from map list)" TRUE "Daytime" TRUE "Nighttime" TRUE "1PP" TRUE "3PP" False "Empty" False "Full" TRUE "Low population" FALSE "Non-ASCII titles" FALSE "Keyword search" $sd_res 2>/dev/null) - if [[ $sels =~ Keyword ]]; then - local search - while true; do - search=$(edialog "Search (case insensitive)" | awk '{print tolower($0)}') - [[ $? -eq 1 ]] && return 1 - [[ -z $search ]] && warn "Cannot submit an empty keyword" - [[ -n $search ]] && break - done - fi - [[ -z $sels ]] && return - echo "$sels" | sed 's/|/, /g;s/ (untick to select from map list)//' - echo "$search" -} -get_dist(){ - local given_ip="$1" - local network="$(<<< "$given_ip" awk -F. '{OFS="."}{print $1"."$2}')" - local binary=$(grep -E "^$network\." $geo_file) - local three=$(<<< $given_ip awk -F. '{print $3}') - local host=$(<<< $given_ip awk -F. '{print $4}') - local res=$(<<< "$binary" awk -F[.,] -v three=$three -v host=$host '$3 <=three && $7 >= three{if($3>three || ($3==three && $4 > host) || $7 < three || ($7==three && $8 < host)){next}{print}}' | awk -F, '{print $7,$8}') - local remote_lat=$(<<< "$res" awk '{print $1}') - local remote_lon=$(<<< "$res" awk '{print $2}') - if [[ -z $remote_lat ]]; then - local dist="Unknown" - echo "$dist" - else - local dist=$($km_helper "$local_lat" "$local_lon" "$remote_lat" "$remote_lon") - LC_NUMERIC=C printf "%05.0f %s" "$dist" "km" - fi -} -prepare_filters(){ - local sels="$1" - local search="$2" - [[ ! "$sels" =~ "Full" ]] && { exclude_full; disabled+=("Full") ; } - [[ ! "$sels" =~ "Empty" ]] && { exclude_empty; disabled+=("Empty") ; } - [[ ! "$sels" =~ "Daytime" ]] && { exclude_daytime; disabled+=("Daytime") ; } - [[ ! "$sels" =~ "Nighttime" ]] && { exclude_nighttime; disabled+=("Nighttime") ; } - [[ ! "$sels" =~ "Low population" ]] && { exclude_lowpop; disabled+=("Low-pop") ; } - [[ ! "$sels" =~ "Non-ASCII titles" ]] && { exclude_nonascii; disabled+=("Non-ASCII") ; } - [[ ! "$sels" =~ "1PP" ]] && { exclude_fpp; disabled+=("FPP") ; } - [[ ! "$sels" =~ "3PP" ]] && { exclude_tpp; disabled+=("TPP") ; } - [[ -n "$search" ]] && keyword_filter - strip_null -} -munge_servers(){ - local sels="$1" - local search="$2" - write_fifo(){ - [[ -p $fifo ]] && rm $fifo - mkfifo $fifo - local dist - for((i=0;i<${#qport[@]};i++)); do - dist=$(get_dist ${addr[$i]}) - - printf "%s\n%s\n%s\n%s\n%03d\n%03d\n%s\n%s:%s\n%s\n" "${name[$i]}" "${map[$i]}" "${fpp[$i]}" "${gametime[$i]}" \ - "${players[$i]}" "${max[$i]}" "$dist" "${addr[$i]}" "${gameport[$i]}" "${qport[$i]}" >> $fifo - done - } - response="$(cat /tmp/dz.servers)" - if [[ ! "$sels" =~ "All maps" ]]; then - filter_maps > >(pdialog) - [[ $? -eq 1 ]] && return - disabled+=("All maps") - fi - [[ $ret -eq 97 ]] && return - prepare_filters "$sels" "$search" - [[ $? -eq 1 ]] && return - if [[ $(echo "$response" | jq 'length') -eq 0 ]]; then - $steamsafe_zenity --error --text="No matching servers" 2>/dev/null - return - fi - #jq bug #1788, raw output (-r) cannot be used with ASCII - local name=$(<<< "$response" jq -a '.[].name' | sed 's/\\u[0-9a-z]\{4\}//g;s/^"//;s/"$//') - local map=$(<<< "$response" jq -r '.[].map|if type == "string" then ascii_downcase else "null" end') - local gametime=$(<<< "$response" jq -r '.[]|(if .gametype == null then "null" else .gametype end)|scan("[0-9]{2}:[0-9]{2}$")') - local fpp=$(<<< "$response" jq -r '.[].gametype|split(",")|if any(. == "no3rd") then "1PP" else "3PP" end') - local players=$(<<< "$response" jq -r '.[].players') - local max=$(<<< "$response" jq -r '.[].max_players') - local addr=$(<<< "$response" jq -r '.[].addr|split(":")[0]') - local gameport=$(<<< "$response" jq -r '.[]|(if .gameport == null then "null" else .gameport end)') - local qport=$(<<< "$response" jq -r '.[].addr|split(":")[1]') - - readarray -t qport <<< $qport - readarray -t gameport <<< $gameport - readarray -t addr <<< $addr - readarray -t name <<< $name - readarray -t fpp <<< $fpp - readarray -t players <<< $players - readarray -t map <<< $map - readarray -t max <<< $max - readarray -t gametime <<< $gametime - - if [[ $is_steam_deck -eq 0 ]]; then - sd_res="--width=1920 --height=1080" - fi - write_fifo & - pid=$! - local sel=$($steamsafe_zenity --text="$(pagination)" --title="DZGUI" --list --column=Name --column=Map --column=PP --column=Gametime --column=Players --column=Max --column=Distance --column=IP --column=Qport $sd_res --print-column=1,8,9 --separator=%% 2>/dev/null < <(while true; do cat $fifo; done)) - if [[ -z $sel ]]; then - rm $fifo - kill -9 $pid 2>/dev/null - return 1 - else - rm $fifo - kill -9 $pid - echo $sel - fi -} -debug_servers(){ - debug_res=$(curl -Ls "https://api.steampowered.com/IGameServersService/GetServerList/v1/?filter=\appid\221100&limit=10&key=$steam_api") - local len=$(<<< "$debug_res" jq '[.response.servers[]]|length') - if [[ $len -eq 0 ]]; then - return 1 - else - return 0 - fi -} -server_browser(){ - unset ret - local filters="$(<<< "$1" awk 'NR==1 {print $0}')" - local keywords="$(<<< "$1" awk 'NR==2 {print $0}')" - echo "# Checking Steam API" - check_steam_api - [[ $? -eq 1 ]] && return - echo "# Checking geolocation file" - check_geo_file - echo "# Calculating server distances" - local_latlon - [[ $ret -eq 97 ]] && return - - local limit=20000 - local url="https://api.steampowered.com/IGameServersService/GetServerList/v1/?filter=\appid\221100&limit=$limit&key=$steam_api" - - echo "# Getting server list" - curl -Ls "$url" | jq -r '.response.servers' > /tmp/dz.servers - total_servers=$(< /tmp/dz.servers jq 'length' | numfmt --grouping) - players_online=$(curl -Ls "https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=$aid" \ - | jq '.response.player_count' | numfmt --grouping) - debug_servers - [[ $? -eq 1 ]] && { popup 1100; return 1; } - - echo "100" - local sel=$(munge_servers "$filters" "$keywords") - if [[ -z $sel ]]; then - unset filters - unset search - ret=98 - sd_res="--width=1280 --height=800" - return 1 - fi - - readarray -t address < <(format_table_results "$sel") - local ip="${address[1]}" - local gameport="${address[2]}" - local qport="${address[3]}" - unset address - - connect "$ip" "$gameport" "$qport" - sd_res="--width=1280 --height=800" -} -mods_disk_size(){ - printf "Total size on disk: %s | " $(du -sh "$workshop_dir" | awk '{print $1}') - printf "%s mods | " $(ls -1 "$workshop_dir" | wc -l) - printf "Location: %s/steamapps/workshop/content/221100" "$steam_path" -} -main_menu(){ - logger INFO "${FUNCNAME[0]}" - logger INFO "Setting mode" - set_mode - while true; do - set_header "main_menu" - rc=$? - logger INFO "set_header rc is $rc" - if [[ $rc -eq 0 ]]; then - case "$sel" in - "") warn "No item was selected." ;; - " Server browser") - local filters=$(choose_filters) - [[ -z $filters ]] && continue - (server_browser "$filters") | pdialog ;; - " My servers") query_and_connect "connect" ;; - " Quick connect to favorite server") connect_to_fav "$fav_server" ;; - " Connect by ID") connect_by_id ;; - " Connect by IP") connect_by_ip ;; - " Recent servers (last 10)") query_and_connect "history" "$(cat $hist_file)" ;; - " Add server by ID") add_by_id ;; - " Add server by IP") add_by_ip ;; - " Add favorite server") add_by_fav ;; - " Change favorite server") add_by_fav ;; - " Delete server") query_and_connect "delete" ;; - " List installed mods") - list_mods | sed 's/\t/\n/g' | $steamsafe_zenity --list --column="Mod" --column="Symlink" --column="Dir" \ - --title="DZGUI" $sd_res --text="$(mods_disk_size)" \ - --print-column="" 2>/dev/null - ;; - " View changelog") changelog ;; - " Advanced options") - options_menu - main_menu - return - ;; - " Help file ⧉") help_file ;; - " Report bug ⧉") report_bug ;; - " Forum ⧉") forum ;; - " Sponsor ⧉") sponsor ;; - " Hall of fame ⧉") hof ;; - esac - else - logger INFO "Returning from main menu" - return - fi - done -} -set_fav(){ - local fav="$1" - logger INFO "${FUNCNAME[0]}" - - readarray -t address < <(format_config_address "$fav") - local ip="${address[0]}" - local gameport="${address[1]}" - local qport="${address[2]}" - unset address - - local info=$(a2s "$ip" "$qport" info) - local name=$(<<< $info jq -r '.name') - echo "'$name'" -} -check_unmerged(){ - logger INFO "${FUNCNAME[0]}" - if [[ -f ${config_path}.unmerged ]]; then - merge_config - rm ${config_path}.unmerged - fi } merge_config(){ - source $config_file - legacy_fav - legacy_ids - [[ -z $staging_dir ]] && staging_dir="/tmp" + [[ -z $staging_dir ]] && staging_dir="/tmp" update_config - tdialog "Wrote new config format to \n${config_file}\nIf errors occur, you can restore the file:\n${config_file}.old" + tdialog "Wrote new config format to \n${config_file}\nIf errors occur, you can restore the file:\n${config_file}.old" } -download_new_version(){ - if [[ $is_steam_deck -eq 1 ]]; then - freedesktop_dirs - fi - source_script=$(realpath "$0") - source_dir=$(dirname "$source_script") - mv $source_script $source_script.old - echo "# Downloading version $upstream" - curl -Ls "$version_url" > $source_script - rc=$? - if [[ $rc -eq 0 ]]; then - echo "[DZGUI] Wrote $upstream to $source_script" - chmod +x $source_script - touch ${config_path}.unmerged - echo "100" - $steamsafe_zenity --question --width=500 --title="DZGUI" --text "DZGUI $upstream successfully downloaded.\nTo view the changelog, select Changelog.\nTo use the new version, select Exit and restart." --ok-label="Changelog" --cancel-label="Exit" 2>/dev/null - code=$? - if [[ $code -eq 0 ]]; then - changelog - exit - elif [[ $code -eq 1 ]]; then - exit - fi - else - echo "100" - mv $source_script.old $source_script - $steamsafe_zenity --info --title="DZGUI" --text "[ERROR] 99: Failed to download new version." 2>/dev/null - return - fi - -} -check_branch(){ - logger INFO "${FUNCNAME[0]}" - if [[ $branch == "stable" ]]; then - version_url="$stable_url/dzgui.sh" - elif [[ $branch == "testing" ]]; then - version_url="$testing_url/dzgui.sh" - fi - logger INFO "Branch is $branch" - upstream=$(curl -Ls "$version_url" | awk -F= '/^version=/ {print $2}') - logger INFO "Upstream version is $version" -} -enforce_dl(){ - download_new_version > >(pdialog) -} -prompt_dl(){ - $steamsafe_zenity --question --title="DZGUI" --text "Version conflict.\n\nYour branch:\t\t\t$branch\nYour version:\t\t\t$version\nUpstream version:\t\t$upstream\n\nVersion updates introduce important bug fixes and are encouraged.\n\nAttempt to download latest version?" --width=500 --ok-label="Yes" --cancel-label="No" 2>/dev/null - rc=$? - if [[ $rc -eq 1 ]]; then - return - else - echo "100" - download_new_version > >(pdialog) - fi +check_unmerged(){ + if [[ -f ${config_path}.unmerged ]]; then + merge_config + rm ${config_path}.unmerged + fi } check_version(){ - logger INFO "${FUNCNAME[0]}" - [[ -f $config_file ]] && source $config_file - [[ -z $branch ]] && branch="stable" - check_branch - [[ ! -f "$freedesktop_path/dzgui.desktop" ]] && freedesktop_dirs - if [[ $version == $upstream ]]; then - logger INFO "Local version is same as upstream" - check_unmerged - else - logger INFO "Local and remote version mismatch" - if [[ $enforce_dl -eq 1 ]]; then - enforce_dl - else - prompt_dl - fi - fi + local version_url=$(format_version_url) + local upstream=$(curl -Ls "$version_url" | awk -F= '/^version=/ {print $2}') + logger INFO "Local branch: '$branch', local version: $version" + if [[ $branch == "stable" ]]; then + version_url="$stable_url/dzgui.sh" + elif [[ $branch == "testing" ]]; then + version_url="$testing_url/dzgui.sh" + fi + local upstream=$(curl -Ls "$version_url" | awk -F= '/^version=/ {print $2}') + [[ ! -f "$freedesktop_path/$appname.desktop" ]] && freedesktop_dirs + if [[ $version == $upstream ]]; then + logger INFO "Local version is same as upstream" + check_unmerged + else + logger WARN "Local and remote version mismatch: $version != $upstream" + prompt_dl + fi +} +download_new_version(){ + local version_url="$(format_version_url)" + mv "$src_path" "$src_path.old" + curl -Ls "$version_url" > "$src_path" + rc=$? + if [[ $rc -eq 0 ]]; then + logger INFO "Wrote new version to $src_path" + chmod +x "$src_path" + touch "${config_path}.unmerged" + fdialog "DZGUI $upstream successfully downloaded. To use the new version, select Exit and restart." + logger INFO "User exited after version upgrade" + exit 0 + else + mv "$src_path.old" "$src_path" + logger WARN "curl failed to fetch new version. Rolling back" + fdialog "Failed to download the new version. Restoring old version" + return 1 + fi + dl_changelog +} +prompt_dl(){ + _text(){ + cat <<-EOF + Version conflict. + + Your branch: $branch + Your version: $version + Upstream version: $upstream + + Version updates introduce important bug fixes and are encouraged. Attempt to download the latest version? + EOF + } + qdialog "$(_text)" "Yes" "No" + if [[ $? -eq 1 ]]; then + return 0 + else + download_new_version + fi +} +dl_changelog(){ + local mdbranch + [[ $branch == "stable" ]] && mdbranch="dzgui" + [[ $branch == "" ]] && mdbranch="testing" + local md="https://raw.githubusercontent.com/$author/dztui/${mdbranch}/CHANGELOG.md" + curl -Ls "$md" > "$state_path/CHANGELOG.md" } check_architecture(){ - logger INFO "${FUNCNAME[0]}" - cpu=$(cat /proc/cpuinfo | grep "AMD Custom APU 0405") - if [[ -n "$cpu" ]]; then - is_steam_deck=1 - logger INFO "Setting architecture to 'Steam Deck'" - else - is_steam_deck=0 - logger INFO "Setting architecture to 'desktop'" - fi + local cpu=$(< /proc/cpuinfo grep "AMD Custom APU 0405") + if [[ -n "$cpu" ]]; then + is_steam_deck=1 + logger INFO "Setting architecture to 'Steam Deck'" + else + is_steam_deck=0 + logger INFO "Setting architecture to 'desktop'" + fi } -print_ip_list(){ - [[ ${#ip_list} -eq 0 ]] && return - printf "\t\"%s\"\n" "${ip_list[@]}" +check_map_count(){ + [[ $is_steam_deck -eq 1 ]] && return 0 + local count=1048576 + local conf_file="/etc/sysctl.d/dayz.conf" + if [[ -f $conf_file ]]; then + logger DEBUG "System map count is already $count or higher" + return 0 + fi + qdialog "sudo password required to check system vm map count." "OK" "Cancel" + if [[ $? -eq 0 ]]; then + local pass + logger INFO "Prompting user for sudo escalation" + pass=$($steamsafe_zenity --password) + if [[ $? -eq 1 ]]; then + logger WARN "User aborted password prompt" + return 1 + fi + local ct=$(sudo -S <<< "$pass" sh -c "sysctl -q vm.max_map_count | awk -F'= ' '{print \$2}'") + logger DEBUG "Old map count is $ct" + local new_ct + [[ $ct -lt $count ]] && ct=$count + sudo -S <<< "$pass" sh -c "echo 'vm.max_map_count=$ct' > $conf_file" + sudo sysctl -p "$conf_file" + logger DEBUG "Updated map count to $count" + else + logger WARN "User aborted map count prompt" + return 1 + fi +} +qdialog(){ + local text="$1" + local ok="$2" + local cancel="$3" + $steamsafe_zenity --question --text="$1" --ok-label="$ok" --cancel-label="$cancel" "${zenity_flags[@]}" +} +pdialog(){ + $steamsafe_zenity --progress --pulsate --auto-close "${zenity_flags[@]}" +} +fdialog(){ + $steamsafe_zenity --warning --ok-label="Exit" --text="$1" "${zenity_flags[@]}" +} +tdialog(){ + $steamsafe_zenity --info --text="$1" "${zenity_flags[@]}" +} +steam_deps(){ + local flatpak + local steam + [[ $(command -v flatpak) ]] && flatpak=$(flatpak list | grep valvesoftware.Steam) + steam=$(command -v steam) + if [[ -z "$steam" ]] && [[ -z "$flatpak" ]]; then + 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 cp $config_file $config_path/dztuirc.oldapi - rm $hist_file fi + logger INFO "Migrated old API file" + [[ -f $hist_file ]] && rm $hist_file + logger INFO "Wiped old history file" } -legacy_fav(){ - source $config_file - [[ -z $fav ]] && return - local res=$(map_fav_to_ip "$fav") - source $config_file -} -legacy_ids(){ - source $config_file - [[ -z $whitelist ]] && return - local res=$(map_id_to_ip "$whitelist") - source $config_file -} -map_fav_to_ip(){ - local to_add="$1" - fav_server=$(curl -s "$api" -H "Authorization: Bearer "$api_key"" \ - -G -d "sort=-players" \ - -d "filter[game]=$game" \ - -d "filter[ids][whitelist]=$to_add" \ - | jq -r '.data[].attributes|"\(.ip):\(.port):\(.portQuery)"') - update_config - fav_label=$(set_fav "$fav_server") -} -map_id_to_ip(){ - local to_add="$1" - local mode="$2" - local res=$(curl -s "$api" -H "Authorization: Bearer "$api_key"" \ - -G -d "sort=-players" \ - -d "filter[game]=$game" \ - -d "filter[ids][whitelist]=$to_add") - local len=$(<<< "$res" jq '.data|length') - [[ $len -eq 0 ]] && return 1 - local ip=$(<<< "$res" jq -r '.data[].attributes|"\(.ip):\(.port):\(.portQuery)"') - if [[ $mode == "connect" ]]; then - echo "$ip" - return 0 - fi - for i in $ip; do - if [[ ${ip_list[@]} =~ $i ]]; then - [[ ! $len -eq 1 ]] && continue - warn "This server is already in your list" - return 2 - fi - ip_list+=("$i") - update_config +stale_symlinks(){ + local game_dir="$steam_path/steamapps/common/DayZ" + for l in $(find "$game_dir" -xtype l); do + logger DEBUG "Updating stale symlink '$l'" + unlink $l done - echo $i } -add_by_ip(){ - local sel=$(parse_ips) - [[ -z $sel ]] && return - - readarray -t address < <(format_table_results "$sel") - local ip="${address[1]}" - local gameport="${address[2]}" - local qport="${address[3]}" - unset address - - if [[ ${ip_list[@]} =~ "$ip:$gameport:$qport" ]]; then - warn "This server is already in your favorites" - return +check_news(){ + [[ $branch == "stable" ]] && news_url="$stable_url/news" + [[ $branch == "testing" ]] && news_url="$testing_url/news" + local result=$(curl -Ls "$news_url") + local sum=$(<<< "$result" md5sum | awk '{print $1}') + if [[ $sum != "$seen_news" ]]; then + logger WARN "Local news checksum '$seen_news' != '$sum'" + seen_news="$sum" + update_config + echo "$result" fi - - ip_list+=("$ip:$gameport:$qport") - update_config - info "Added $ip:$gameport:$qport to:\n${config_path}dztuirc\nIf errors occurred, you can restore the file:\n${config_path}dztuirc.old" } -pdialog(){ - $steamsafe_zenity --progress --pulsate --auto-close --title="DZGUI" --width=500 2>/dev/null -} -edialog(){ - if [[ $is_steam_deck -eq 1 ]] && [[ $(test_display_mode) == "gm" ]]; then - kdialog --inputbox "$1" --title "DZGUI" --geometry 500 2>/dev/null +local_latlon(){ + if [[ -z $(command -v dig) ]]; then + local local_ip=$(curl -Ls "https://ipecho.net/plain") else - $steamsafe_zenity --entry --text="$1" --width=500 --title="DZGUI" 2>/dev/null + local local_ip=$(dig -4 +short myip.opendns.com @resolver1.opendns.com) fi -} -tdialog(){ - $steamsafe_zenity --info --text="$1" --width=500 --title="DZGUI" 2>/dev/null -} -qdialog(){ - $steamsafe_zenity --question --text="$1" --width=500 --title="DZGUI" 2>/dev/null -} -add_by_id(){ - local mode="$1" - if [[ -z $api_key ]]; then - qdialog "Requires Battlemetrics API key. Set one now?" - [[ $? -eq 1 ]] && return 1 - while true; do - api_key=$(edialog "Battlemetrics API key") - [[ $? -eq 1 ]] && return 1 - [[ -z $api_key ]] && { warn "Invalid API key"; continue; } - if [[ $(test_bm_api $api_key) -eq 1 ]]; then - warn "Invalid API key" - unset api_key - continue - fi - update_config - break - done + local url="http://ip-api.com/json/$local_ip" + local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"') + if [[ -z "$res" ]]; then + logger WARN "Failed to get local coordinates" + return 1 fi - while true; do - local id - id=$(edialog "Enter server ID") - [[ $? -eq 1 ]] && return 1 - if [[ ! $id =~ ^[0-9]+$ ]]; then - warn "Invalid ID" - else - local ip - ip=$(map_id_to_ip "$id" "$mode") - case "$?" in - 1) - warn "Invalid ID" - continue - ;; - 2) - continue - ;; - *) - if [[ $mode == "connect" ]]; then - echo "$ip" - return 0 - fi - tdialog "Added $ip to:\n${config_path}dztuirc\nIf errors occurred, you can restore the file:\n${config_path}dztuirc.old" - return 0 - ;; - esac - fi - done -} -toggle_debug(){ - if [[ $debug -eq 1 ]]; then - debug=0 - else - debug=1 - fi - update_config - -} -setup(){ - logger INFO "${FUNCNAME[0]}" - [[ -z $fav_server ]] && return - items[10]=" Change favorite server" - [[ -n $fav_label ]] && return - fav_label=$(set_fav $fav_server) - update_config -} -check_map_count(){ - logger INFO "${FUNCNAME[0]}" - [[ $is_steam_deck -eq 1 ]] && return - local count=1048576 - logger INFO "Checking system map count" - echo "[DZGUI] Checking system map count" - if [[ ! -f /etc/sysctl.d/dayz.conf ]]; then - $steamsafe_zenity --question --width=500 --title="DZGUI" --cancel-label="Cancel" --ok-label="OK" --text "sudo password required to check system vm map count." - local rc=$? - logger INFO "Return code is $rc" - if [[ $rc -eq 0 ]]; then - local pass - logger INFO "Prompting user for sudo escalation" - pass=$($steamsafe_zenity --password) - local rc - logger INFO "Return code is $rc" - [[ $rc -eq 1 ]] && exit 1 - local ct=$(sudo -S <<< "$pass" sh -c "sysctl -q vm.max_map_count | awk -F'= ' '{print \$2}'") - local new_ct - [[ $ct -lt $count ]] && ct=$count - logger INFO "Updating map count" - sudo -S <<< "$pass" sh -c "echo 'vm.max_map_count=$ct' > /etc/sysctl.d/dayz.conf" - sudo sysctl -p /etc/sysctl.d/dayz.conf - else - logger INFO "Zenity dialog failed or user exit" - exit 1 - fi - fi -} -change_name(){ - while true; do - local name=$($steamsafe_zenity --entry --text="Enter desired in-game name" --title="DZGUI" 2>/dev/null) - [[ -z "${name//[[:blank:]]/}" ]] && continue - update_config - info "Changed name to: '$name'.\nIf errors occur, you can restore the file '${config_path}dztuirc.old'." - return - done -} -add_by_fav(){ - local sel=$(parse_ips) - [[ -z $sel ]] && return - - readarray -t address < <(format_table_results "$sel") - local ip="${address[1]}" - local gameport="${address[2]}" - local qport="${address[3]}" - unset address - fav_server="$ip:$gameport:$qport" - fav_label=$(set_fav "$fav_server") - - update_config - info "Added $fav_server to:\n${config_path}dztuirc\nIf errors occurred, you can restore the file:\n${config_path}dztuirc.old" - - items[10]=" Change favorite server" + echo "$res" > "$coords_file" } lock(){ - [[ ! -d $config_path ]] && return - if [[ ! -f ${config_path}.lockfile ]]; then - touch ${config_path}.lockfile - fi - pid=$(cat ${config_path}.lockfile) - ps -p $pid -o pid= >/dev/null 2>&1 - res=$? - if [[ $res -eq 0 ]]; then - info "DZGUI already running ($pid)" - exit - elif [[ $pid == $$ ]]; then - : - else - echo $$ > ${config_path}.lockfile - fi + [[ ! -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)" + raise_error_and_quit "$msg" + elif [[ $pid == $$ ]]; then + : + else + echo $$ > $lock_file + fi +} +get_hash(){ + local file="$1" + md5sum "$1" | awk '{print $1}' } fetch_a2s(){ - [[ -d $helpers_path/a2s ]] && return - local sha=c7590ffa9a6d0c6912e17ceeab15b832a1090640 - local author="yepoleb" - local repo="python-a2s" - local url="https://github.com/$author/$repo/tarball/$sha" - local prefix="${author^}-$repo-${sha:0:7}" - local file="$prefix.tar.gz" - curl -Ls "$url" > "$helpers_path/$file" - tar xf "$helpers_path/$file" -C "$helpers_path" "$prefix/a2s" --strip=1 - rm "$helpers_path/$file" + [[ -d $helpers_path/a2s ]] && { logger INFO "A2S helper is current"; return 0; } + local sha=c7590ffa9a6d0c6912e17ceeab15b832a1090640 + local author="yepoleb" + local repo="python-a2s" + local url="https://github.com/$author/$repo/tarball/$sha" + local prefix="${author^}-$repo-${sha:0:7}" + local file="$prefix.tar.gz" + curl -Ls "$url" > "$helpers_path/$file" + tar xf "$helpers_path/$file" -C "$helpers_path" "$prefix/a2s" --strip=1 + rm "$helpers_path/$file" + logger INFO "Updated A2S helper to sha '$sha'" } fetch_dzq(){ - [[ -f $helpers_path/dayzquery.py ]] && return - local sha=ccc4f71b48610a1885706c9d92638dbd8ca012a5 - local author="yepoleb" - local repo="dayzquery" - local url="https://raw.githubusercontent.com/$author/$repo/$sha/$repo.py" - curl -Ls "$url" > $helpers_path/a2s/$repo.py -} -fetch_query(){ - local sum="7cbae12ae68b526e7ff376b638123cc7" - local file="$helpers_path/query.py" - if [[ -f $file ]] && [[ $(md5sum $file | awk '{print $1}') == $sum ]]; then - return + local sum="232f42b98a3b50a0dd6e73fee55521b2" + 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=ccc4f71b48610a1885706c9d92638dbd8ca012a5 + local author="yepoleb" + local repo="dayzquery" + local url="https://raw.githubusercontent.com/$author/$repo/$sha/dayzquery.py" + curl -Ls "$url" > "$file" + logger INFO "Updated DZQ to sha '$sha'" +} +fetch_helpers_by_sum(){ + declare -A sums + sums=( + ["ui.py"]="8c430f1465995cbd98f4ad8e2580a79d" + ["query_v2.py"]="1822bd1769ce7d7cb0d686a60f9fa197" + ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" + ["funcs"]="b04a0e34141e4e11da0a061802a822e3" + ) + local author="aclist" + local repo="dztui" + local branch="$branch" + local url="https://raw.githubusercontent.com/$author/$repo/$branch/helpers/$file" + + for i in "${!sums[@]}"; do + file="$i" + sum="${sums[$i]}" + full_path="$helpers_path/$file" + if [[ -f "$full_path" ]] && [[ $(get_hash "$helpers_path/$file") == $sum ]]; then + logger INFO "'$file' is current" + else + logger WARN "File '$full_path' checksum != '$sum'" + curl -Ls "$url" > "$full_path/$file" + if [[ ! $? -eq 0 ]]; then + raise_error_and_quit "Failed to fetch the file '$file'. Possible timeout?" + fi + fi + [[ $file == "funcs" ]] && chmod +x "$full_path" + logger INFO "Updated '$full_path' to sum '$sum'" + done + return 0 +} +fetch_geo_file(){ + # for binary releases + local geo_sum="e7f3b25223ac4dfd5e30a0b55bb3ff6c" + local km_sum="b038fdb8f655798207bd28de3a004706" + local gzip="$helpers_path/ips.csv.gz" + if [[ ! -f $geo_file ]] || [[ $(get_hash $geo_file) != $geo_sum ]]; then + curl -Ls "$geo_file_url" > "$gzip" + #force overwrite + gunzip -f "$gzip" + fi + if [[ ! -f $km_helper ]] || [[ $(get_hash $km_helper) != $km_sum ]]; then + curl -Ls "$km_helper_url" > "$km_helper" + chmod +x "$km_helper" fi - local author="aclist" - local repo="dzgui" - local url="https://raw.githubusercontent.com/$author/dztui/$repo/helpers/query.py" - curl -Ls "$url" > "$helpers_path/query.py" } fetch_helpers(){ - logger INFO "${FUNCNAME[0]}" - mkdir -p "$helpers_path" - [[ ! -f "$helpers_path/vdf2json.py" ]] && curl -Ls "$vdf2json_url" > "$helpers_path/vdf2json.py" fetch_a2s fetch_dzq - fetch_query + fetch_geo_file + fetch_helpers_by_sum } -update_steam_cmd(){ - preferred_client="$steam_cmd" - update_config +raise_error_and_quit(){ + local msg="$1" + logger CRITICAL "$msg" + fdialog "$msg" + exit 1 } -steam_deps(){ - logger INFO "${FUNCNAME[0]}" - local flatpak steam - [[ $(command -v flatpak) ]] && flatpak=$(flatpak list | grep valvesoftware.Steam) - steam=$(command -v steam) - if [[ -z "$steam" ]] && [[ -z "$flatpak" ]]; then - warn "Requires Steam or Flatpak Steam" - logger ERROR "Steam was missing" - exit - elif [[ -n "$steam" ]] && [[ -n "$flatpak" ]]; then - toggle_steam=1 - steam_cmd="steam" - [[ -n $preferred_client ]] && steam_cmd="$preferred_client" - [[ -z $preferred_client ]] && update_steam_cmd - elif [[ -n "$steam" ]]; then - steam_cmd="steam" - else - steam_cmd="flatpak run com.valvesoftware.Steam" - fi - logger INFO "steam_cmd set to $steam_cmd" +test_steam_api(){ + local key="$1" + [[ -z $key ]] && return 1 + local url="https://api.steampowered.com/IGameServersService/GetServerList/v1/?filter=\appid\221100&limit=10&key=$key" + local code=$(curl -ILs "$url" | grep -E "^HTTP") + [[ $code =~ 403 ]] && echo 1 + [[ $code =~ 200 ]] && echo 0 } -update_steam_cmd(){ - local new_cmd - preferred_client="$steam_cmd" - new_cmd="preferred_client=\"$preferred_client\"" - mv $config_file ${config_path}dztuirc.old - nr=$(awk '/preferred_client=/ {print NR}' ${config_path}dztuirc.old) - awk -v "var=$new_cmd" -v "nr=$nr" 'NR==nr {$0=var}{print}' ${config_path}dztuirc.old > ${config_path}dztuirc +test_bm_api(){ + local bm_api="https://api.battlemetrics.com/servers" + local key="$1" + [[ -z $key ]] && return 1 + local code=$(curl -ILs "$bm_api" \ + -H "Authorization: Bearer "$key"" -G \ + -d "filter[game]=$game" \ + | grep -E "^HTTP") + [[ $code =~ 401 ]] && echo 1 + [[ $code =~ 200 ]] && echo 0 } -steam_deps(){ - logger INFO "${FUNCNAME[0]}" - local flatpak steam - [[ $(command -v flatpak) ]] && flatpak=$(flatpak list | grep valvesoftware.Steam) - steam=$(command -v steam) - if [[ -z "$steam" ]] && [[ -z "$flatpak" ]]; then - warn "Requires Steam or Flatpak Steam" - logger ERROR "Steam was missing" - exit - elif [[ -n "$steam" ]] && [[ -n "$flatpak" ]]; then - toggle_steam=1 - steam_cmd="steam" - [[ -n $preferred_client ]] && steam_cmd="$preferred_client" - [[ -z $preferred_client ]] && update_steam_cmd - elif [[ -n "$steam" ]]; then - steam_cmd="steam" - else - steam_cmd="flatpak run com.valvesoftware.Steam" - fi - logger INFO "steam_cmd set to $steam_cmd" +find_default_path(){ + _discover(){ + default_steam_path=$(find / -type d \( -path "/proc" -o -path "*/timeshift" -o -path \ + "/tmp" -o -path "/usr" -o -path "/boot" -o -path "/proc" -o -path "/root" \ + -o -path "/sys" -o -path "/etc" -o -path "/var" -o -path "/lost+found" \) -prune \ + -o -regex ".*/Steam/ubuntu12_32$" -print -quit 2>/dev/null | sed 's@/ubuntu12_32@@') + } + if [[ $is_steam_deck -eq 1 ]]; then + default_steam_path="$HOME/.local/share/Steam" + logger INFO "Set default Steam path to $default_steam_path" + return 0 + fi + local def_path + local ubuntu_path + local flatpak_path + local debian_path + def_path="$HOME/.local/share/Steam" + ubuntu_path="$HOME/.steam/steam" + debian_path="$HOME/.steam/debian-installation" + flatpak_path="$HOME/.var/app/com.valvesoftware.Steam/data/Steam" + + for i in "$def_path" "$ubuntu_path" "$debian_path" "$flatpak_path"; do + if [[ -d "$i" ]]; then + default_steam_path="$i" + return 0 + fi + done + + local msg="Let DZGUI auto-discover Steam path (accurate, slower)\nSelect the Steam path manually (less accurate, faster)" + echo -e "$msg" | $steamsafe_zenity --list \ + --column="Choice" \ + --title="DZGUI" \ + --hide-header \ + --text="Steam is not installed in a standard location." \ + $sd_res + + case "$res" in + *auto*) discover ;; + *manual*) + zenity --info --text="\nSelect the top-level entry point to the location where Steam (not DayZ)\nis installed and before entering the \"steamapps\" path.\n\nE.g., if Steam is installed at:\n\"/media/mydrive/Steam\"\n\nCorrect:\n- \"/media/mydrive/Steam\"\n\nIncorrect:\n- \"/media/mydrive/Steam/steamapps/common/DayZ\"\n- \"/media/mydrive/\"" --width=500 && + file_picker ;; + esac +} +file_picker(){ + local path=$($steamsafe_zenity --file-selection --directory) + logger INFO "File picker path resolved to: $path" + if [[ -z "$path" ]]; then + logger WARN "Steam path selection was empty" + return + else + default_steam_path="$path" + fi +} +find_library_folder(){ + local search_path="$1" + steam_path="$(python3 "$helpers_path/vdf2json.py" -i "$1/steamapps/libraryfolders.vdf" \ + | jq -r '.libraryfolders[]|select(.apps|has("221100")).path')" + if [[ ! $? -eq 0 ]]; then + logger WARN "Failed to parse Steam path using '$search_path'" + return 1 + fi + logger INFO "Steam path resolved to: $steam_path" +} +create_config(){ + while true; do + local player_input="$($steamsafe_zenity \ + --forms \ + --add-entry="Player name (required for some servers)" \ + --add-entry="Steam API key" \ + --add-entry="BattleMetrics API key (optional)" \ + --title="DZGUI" \ + --text="DZGUI" $sd_res \ + --separator="@")" + #explicitly setting IFS crashes $steamsafe_zenity in loop + #and mapfile does not support high ascii delimiters + #so split fields with newline + readarray -t args < <(<<< "$player_input" sed 's/@/\n/g') + name="${args[0]}" + steam_api="${args[1]}" + api_key="${args[2]}" + + if [[ -z $player_input ]]; then + logger WARN "User aborted setup process" + exit 1 + fi + if [[ -z $steam_api ]]; then + tdialog "Steam API key canot be empty" + continue + elif [[ "${#steam_api}" -lt 32 ]] || [[ $(test_steam_api "$steam_api") -eq 1 ]]; then + tdialog "Invalid Steam API key" + continue + fi + if [[ -n $api_key ]] && [[ $(test_bm_api $api_key) -eq 1 ]]; then + tdialog "Invalid BM API key" + continue + fi + while true; do + if [[ -n $steam_path ]]; then + write_config + [[ $? -eq 0 ]] && logger INFO "Successfully created config file" + return 0 + fi + find_default_path + find_library_folder "$default_steam_path" + if [[ -z $steam_path ]]; then + logger raise_error "Steam path was empty" + zenity --question --text="DayZ not found or not installed at the chosen path." --ok-label="Choose path manually" --cancel-label="Exit" + if [[ $? -eq 0 ]]; then + logger INFO "User selected file picker" + file_picker + find_library_folder "$default_steam_path" + else + fdialog "Failed to find Steam at the provided location" + exit 1 + fi + else + branch="stable" + update_config + logger INFO "Wrote config to '$config_file'" + return 0 + fi + done + done +} +varcheck(){ + if [[ ! -f $config_file ]]; then + local msg="Config file '$config_file' missing. Start first-time setup now?" + qdialog "$msg" "Yes" "Exit" + if [[ $? -eq 1 ]]; then + logger CRITICAL "Config file missing, but user aborted setup" + exit 1 + fi + create_config + fi + source "$config_file" + local workshop_dir="$steam_path/steamapps/workshop/content/$aid" + local game_dir="$steam_path/steamapps/common/DayZ" + if [[ ! -d $steam_path ]] || [[ ! -d $game_dir ]]; then + logger WARN "DayZ path resolved to $game_dir" + logger WARN "Workshop path resolved to $workshop_dir" + pdialog "$msg" + if [[ $? -eq 1 ]]; then + logger CRITICAL "Malformed Steam path, but user aborted setup" + exit 1 + fi + create_config + return 0 + fi +} +is_dzg_downloading(){ + if [[ -d $steam_path ]] && [[ -d $steam_path/downloading/$aid ]]; then + logger WARN "DayZ may be scheduling updates" + return 0 + fi +} +is_steam_running(){ + local res=$(ps aux | grep "steamwebhelper" | grep -v grep) + if [[ -z $res ]]; then + logger WARN "Steam may not be running" + tdialog "Is Steam running? For best results, make sure Steam is open in the background." + return 0 + fi } initial_setup(){ - echo "# Initial setup" - run_depcheck - check_pyver - watcher_deps - check_architecture - check_version - check_map_count - fetch_helpers - config - steam_deps - run_varcheck + setup_dirs + setup_state_files + depcheck + check_pyver + test_gobject + watcher_deps + check_architecture + varcheck + source "$config_file" + lock + legacy_vars + check_version + check_map_count + steam_deps migrate_files - stale_symlinks - init_items - setup - echo "100" + stale_symlinks + #TODO: test fetching + fetch_helpers + local_latlon + is_steam_running + is_dzg_downloading + print_config_vals } main(){ - local parent=$(cat /proc/$PPID/comm) - [[ -f "$debug_log" ]] && rm "$debug_log" - lock - local zenv=$(zenity --version 2>/dev/null) - [[ -z $zenv ]] && { echo "Failed to find zenity"; logger "Missing zenity"; exit 1; } - initial_setup > >(pdialog) - main_menu - #TODO: tech debt: cruddy handling for steam forking - [[ $? -eq 1 ]] && pkill -f dzgui.sh + local zenv=$(zenity --version 2>/dev/null) + [[ -z $zenv ]] && { echo "Requires zenity <= 3.44.1"; exit 1; } + + printf "Initializing setup...\n" + initial_setup + local news=$(check_news) + + printf "All OK. Kicking off UI...\n" + [[ -n $news ]] && python3 "ui.py" "--init-ui" "$news" "$version" + [[ -z $news ]] && python3 "ui.py" "--init-ui" "null" "$version" } main +#TODO: tech debt: cruddy handling for steam forking +[[ $? -eq 1 ]] && pkill -f dzgui.sh diff --git a/helpers/funcs b/helpers/funcs new file mode 100644 index 0000000..0b09c00 --- /dev/null +++ b/helpers/funcs @@ -0,0 +1,1200 @@ +#!/usr/bin/env bash +set -o pipefail +version="5.0.0.rc-1" + +#CONSTANTS +aid=221100 +game="dayz" +app_name="dzgui" +app_name_upper="DZGUI" +workshop="steam://url/CommunityFilePage/" +sd_res="--width=1280 --height=800" +steamsafe_zenity="/usr/bin/zenity" +separator="␞" + +##CONFIG +config_path="$HOME/.config/dztui" +config_file="$config_path/dztuirc" +source $config_file + +#PATHS +state_path="$HOME/.local/state/$app_name" +cache_path="$HOME/.cache/$app_name" +share_path="$HOME/.local/share/$app_name" +script_path="$share_path/dzgui.sh" +helpers_path="$share_path/helpers" +prefix="dzg" + +#LOGS +log_path="$state_path/logs" +debug_log="$log_path/DZGUI_DEBUG.log" +system_log="$log_path/DZGUI_SYSTEM.log" + +#STATE FILES +history_file="$state_path/$prefix.history" +versions_file="$state_path/$prefix.versions" +lock_file="$state_path/$prefix.lock" + +#CACHE +cache_dir="$HOME/.cache/$appname" +_cache_servers="$cache_dir/$prefix.servers" +_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_coords="$cache_path/$prefix.coords" + +#XDG +freedesktop_path="$HOME/.local/share/applications" + +#HELPERS +ui_helper="$helpers_path/ui.py" +geo_file="$helpers_path/ips.csv" +km_helper="$helpers_path/latlon" +sums_path="$helpers_path/sums.md5" +query_helper="$helpers_path/query_v2.py" +func_helper="$helpers_path/funcs" + +#STEAM PATHS +workshop_path="$steam_path/steamapps/workshop" +workshop_dir="$workshop_path/content/$aid" +downloads_dir="$workshop_path/downloads/$aid" +game_dir="$steam_path/steamapps/common/DayZ" + +#URLS +author="aclist" +repo="dztui" +gh_prefix="https://github.com" +issues_url="$gh_prefix/$author/$repo/issues" +url_prefix="https://raw.githubusercontent.com/$author/$repo" +stable_url="$url_prefix/dzgui" +testing_url="$url_prefix/testing" +releases_url="$gh_prefix/$author/$repo/releases/download/browser" +km_helper_url="$releases_url/latlon" +db_file="$releases_url/ips.csv.gz" +sums_url="$stable_url/helpers/sums.md5" +vdf2json_url="$stable_url/helpers/vdf2json.py" +#TODO: move adoc to index +help_url="https://$author.github.io/dzgui/dzgui" +forum_url="$gh_prefix/$author/$repo/discussions" +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 + +#TODO: dump servers methods can be merged +declare -A funcs=( +["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 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" +["toggle"]="toggle" +["Open link"]="open_link" +["filter"]="dump_servers" +["dump_servers"]="dump_servers" +["get_unique_maps"]="get_unique_maps" +["get_dist"]="get_dist" +["query_config"]="query_config" +["list_mods"]="list_mods" +["delete"]="delete_local_mod" +["show_server_modlist"]="show_server_modlist" +["is_in_favs"]="is_in_favs" +["show_log"]="show_log" +["gen_log"]="generate_log" +["open_workshop_page"]="open_workshop_page" +["Add to my servers"]="update_favs_from_table" +["Remove from favorites"]="update_favs_from_table" +["Remove from history"]="remove_from_history" +["force_update"]="force_update" +["Handshake"]="final_handshake" +) + +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" +} +force_update(){ + if [[ ! $auto_install -eq 1 ]]; then + printf "Only available when mod auto-install is ON" + return 1 + fi + #TODO: force update + printf "Currently does nothing" + return 0 +} +map_id_to_ip(){ + local id="$1" + local res=$(curl -s "$bm_api" -H "Authorization: Bearer "$api_key"" \ + -G -d "sort=-players" \ + -d "filter[game]=$game" \ + -d "filter[ids][whitelist]=$id") + local len=$(<<< "$res" jq '.data|length') + [[ $len -eq 0 ]] && return 1 + local record=$(<<< "$res" jq -r '.data[].attributes|"\(.ip):\(.port):\(.portQuery)"') + echo "$record" +} +add_record(){ + local context="$1" + local addr="$2" + local record + if [[ $context != "Add server by ID" ]] && [[ $(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}" + + case "$context" in + "Add server by IP") + if [[ ${ip_list[*]} =~ $record ]]; then + printf "Already in favorites list" + return 1 + fi + add_to_favs "$record" + ;; + "Change favorite server") + fav_label=$(<<< "$res" jq -r '.[].name') + fav_server="$record" + update_config + return 90 + ;; + "Add server 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" + if [[ ${ip_list[*]} =~ $record ]]; then + printf "Already in favorites list" + return 1 + fi + add_to_favs "$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 +} +initialize_remote_servers(){ + local file="$_cache_servers" + [[ -f $file ]] && rm "$file" + local res + res=$(get_remote_servers) + parse_server_json "$res" >> "$file" +} +a2s(){ + local ip="$1" + local qport="$2" + local mode="$3" + logger INFO "Querying '$ip:$qport' with mode '$mode'" + python3 "$query_helper" "$ip" "$qport" "$mode" +} +is_in_favs(){ + shift + local record="$1" + for (( i = 0; i < ${#ip_list[@]}; i++ )); do + if [[ ${ip_list[$i]} == "$record" ]]; then + logger INFO "'$record' is in favorites list" + return 0 + fi + done + return 1 +} +list_mods(){ + local symlink + local sep + local name + local base_dir + local size + if [[ -z $(installed_mods) ]] || [[ -z $(find $workshop_dir -maxdepth 2 -name "*.cpp" | grep .cpp) ]]; then + echo "No mods currently installed or incorrect path set." + logger WARN "Found no locally installed mods" + return 1 + else + for dir in $(find $game_dir/* -maxdepth 1 -type l); do + symlink=$(basename $dir) + sep="␞" + name=$(awk -F\" '/name/ {print $2}' "${dir}/meta.cpp") + base_dir=$(basename $(readlink -f $game_dir/$symlink)) + size=$(du -s "$(readlink -f "$game_dir/$symlink")" | awk '{print $1}') + size=$(echo "scale=4; ($size / 1024) + .005" | bc) + printf "%s$sep%s$sep%s$sep%3.3f\n" "$name" "$symlink" "$base_dir" "$size" + done | sort -k1 + fi +} +installed_mods(){ + ls -1 "$workshop_dir" +} +open_url(){ + local context="$1" + local url + case "$context" in + "Help file ⧉") + url="$help_url" + ;; + "Report a bug ⧉") + url="$issues_url" + ;; + "Forum ⧉") + url="$forum_url" + ;; + "Sponsor ⧉") + url="$sponsor_url" + ;; + "Hall of fame ⧉") + url="${help_url}#_hall_of_fame" + ;; + esac + + if [[ -n "$BROWSER" ]]; then + "$BROWSER" "$url" 2>/dev/null + return + fi + xdg-open "$url" 2>/dev/null +} +local_latlon(){ + local url="http://ip-api.com/json/$local_ip" + local local_ip + + if [[ -z $(command -v dig) ]]; then + local_ip=$(curl -Ls "https://ipecho.net/plain") + else + local_ip=$(dig -4 +short myip.opendns.com @resolver1.opendns.com) + fi + curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"' +} +get_dist(){ + shift + local given_ip="$1" + readarray -t coords < "$_cache_coords" + readarray -t n < <(<<< "$given_ip" awk 'BEGIN{RS="."}{$1=$1}1') + + local local_lat="${coords[0]}" + local local_lon="${coords[1]}" + local network="^${n[0]}.${n[1]}\." + local three="${n[2]}" + local host="${n[3]}" + + local binary=$(grep -E "$network" $geo_file) + local res=$(<<< "$binary" awk -F[.,] -v three=$three -v host=$host ' + $3 <=three && $7 >= three{if($3>three || ($3==three && $4 > host) || $7 < three || ($7==three && $8 < host)){next}{print}}' \ + | awk -F, '{print $7,$8}') + local remote_lat=$(<<< "$res" awk '{print $1}') + local remote_lon=$(<<< "$res" awk '{print $2}') + if [[ -z $remote_lat ]]; then + logger WARN "Failed to find geolocation candidate in IP database" + local dist="Unknown" + printf "Unknown" + 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" + logger INFO "Distance: $dist km" + fi +} +get_remote_servers(){ + local limit=20000 + local url="https://api.steampowered.com/IGameServersService/GetServerList/v1/?filter=\appid\221100&limit=$limit&key=$steam_api" + curl -Ls "$url" | jq -r '.response.servers' +} +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(){ + local key="$1" + keys=( + "branch" + "debug" + "auto_install" + "name" + "fav_label" + "preferred_client" + ) + if [[ -n $key ]]; then + if [[ -n ${!key} ]]; then + echo "${!key}" + return 0 + else + return 1 + fi + fi + for i in "${keys[@]}"; do + 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" KEYWORD="$keyword" awk -F$separator 'BEGIN{IGNORECASE=1} $0 ~ ENVIRON["KEYWORD"] {print $0}' + } + filter_duplicates(){ + if [[ ${filters[*]} =~ Duplicate ]]; then + echo -n "$filtered" + else + <<< "$filtered" awk -F$separator '!seen[$1]++' + 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) + + 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" + <<< "$response" 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)␞" + + "\(.addr|split(":")[0]):\(if .gameport == null then "XXXX" else .gameport end)␞" + + "\(.addr|split(":")[1])" + ' | sort -k1 +} +delete_local_mod(){ + shift + local symlink="$1" + local dir="$2" + [[ ! -d $workshop_dir/$dir ]] && return 1 + [[ ! -L $game_dir/$symlink ]] && return 1 + #SC2115 + rm -rf "${workshop_dir:?}/$dir" && unlink "$game_dir/$symlink" || return 1 +} +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 + ;; + esac + shift + logger INFO "Server context is '$subcontext', reading from file '$file'" + filter_servers "$file" "$@" +} +logger(){ + local date="$(date "+%F %T,%3N")" + local tag="$1" + local string="$2" + local self="${BASH_SOURCE[0]}" + local caller="${FUNCNAME[1]}" + local line="${BASH_LINENO[0]}" + printf "%s␞%s␞%s::%s()::%s␞%s\n" "$date" "$tag" "$self" "$caller" "$line" "$string" >> "$debug_log" +} +show_server_modlist(){ + shift + local ip="$1" + local qport="$2" + local res=$(a2s $ip $qport names) + [[ -z $res ]] && return 1 + [[ $(<<< $res jq '.ids|length') -lt 1 ]] && return 1 + local names=$(<<< "$res" jq -r '.names[]') + local ids=$(<<< "$res" jq -r '.ids[]') + local icon + local flag + local label + readarray -t names <<< "$names" + readarray -t ids <<< "$ids" + readarray -t mods < <(installed_mods) + for ((i=0; i<${#ids[@]}; ++i)); do + icon= + flag="WARN" + label="MISSING" + for j in "${mods[@]}"; do + if [[ $j == "${ids[$i]}" ]]; then + icon=✓ + flag="INFO" + label="installed" + break + fi + done + logger $flag "Mod '${names[$i]}' is $label" + printf "%s␞%s␞%s\n" "${names[$i]}" "${ids[i]}" "$icon" + done | sort -k1 +} +print_ip_list(){ + [[ ${#ip_list[@]} -eq 0 ]] && return 1 + printf "\t\"%s\"\n" "${ip_list[@]}" +} +write_config(){ +cat <<-END +#Path to DayZ installation +steam_path="$steam_path" + +#Battlemetrics API key +api_key="$api_key" + +#Favorited server IP:PORT array +ip_list=( +$(print_ip_list) +) + +#Favorite server to fast-connect to (limit one) +fav_server="$fav_server" + +#Favorite server label (human readable) +fav_label="$fav_label" + +#Custom player name (optional, required by some servers) +name="$name" + +#Set to 1 to perform dry-run and print launch options +debug="$debug" + +#Toggle stable/testing branch +branch="$branch" + +#Last seen news item +seen_news="$seen_news" + +#Steam API key +steam_api="$steam_api" + +#Auto-install mods +auto_install="$auto_install" + +#Automod staging directory +staging_dir="$staging_dir" + +#Path to default Steam client +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(){ + echo FORMAT + case "$branch" in + "stable") + version_url="$stable_url/dzgui.sh" + ;; + "testing") + version_url="$testing_url/dzgui.sh" + ;; + esac + echo "$version_url" +} +download_new_version(){ + local version_url="$(format_version_url)" + mv "$src_path" "$src_path.old" + curl -Ls "$version_url" > "$src_path" + rc=$? + if [[ $rc -eq 0 ]]; then + logger INFO "Wrote new version to $src_path" + chmod +x "$src_path" + touch "${config_path}.unmerged" + printf "New version downloaded. Please exit to apply changes" + logger INFO "User exited after version upgrade" + return 255 + else + mv "$src_path.old" "$src_path" + printf "Failed to fetch new version. Rolling back" + logger WARN "curl failed to fetch new version. Rolling back" + return 1 + fi + dl_changelog +} +dl_changelog(){ + local mdbranch + local file="CHANGELOG.md" + [[ $branch == "stable" ]] && mdbranch="dzgui" + [[ $branch == "" ]] && mdbranch="testing" + local md="https://raw.githubusercontent.com/$author/$repo/${mdbranch}/$file" + curl -Ls "$md" > "$state_path/$file" +} +toggle(){ + shift + local context="$1" + case "$context" in + Toggle[[:space:]]release[[:space:]]branch) + if [[ $branch == "stable" ]]; then + branch="testing" + else + branch="stable" + fi + update_config + download_new_version + ;; + Toggle[[:space:]]mod[[:space:]]install[[:space:]]mode) + if [[ -z $auto_install ]]; then + staging_dir="/tmp" + auto_install="1" + else + auto_install="" + fi + ;; + Toggle[[:space:]]debug[[:space:]]mode) + if [[ -z $debug ]]; then + debug="1" + else + debug="" + fi + ;; + Toggle[[:space:]]Steam/Flatpak) + if [[ $preferred_client == "steam" ]]; then + preferred_client="flatpak" + else + preferred_client="steam" + fi + esac + update_config + +} +add_to_favs(){ + local record="$1" + ip_list+=("$record") + update_config + logger INFO "Added the record $record to saved servers" +} +remove_from_history(){ + shift + local record="$1" + # remove ip from history file + local hist_cache=$(< "$history_file") + <<< "$hist_cache" grep -v "$record" > "$history_file" + # update cache + local cache=$(< "$_cache_history") + local r=$(<<< "$record" awk -F: '{print $1":"$2"␞"$3}') + <<< "$cache" grep -v -P "$r$" > "$_cache_history" +} +remove_from_favs(){ + local record="$1" + for (( i=0; i<${#ip_list[@]}; ++i )); do + if [[ ${ip_list[$i]} == "$record" ]]; then + unset ip_list[$i] + break + fi + done + if [[ ${#ip_list} -gt 0 ]]; then + readarray -t ip_list < <(printf "%s\n" "${ip_list[@]}") + fi + update_config + local r=$(<<< "$record" awk -F: '{print $1":"$2"␞"$3}') + local cache="$(< "$_cache_my_servers")" + <<< "$cache" grep -v -P "$r$" > $_cache_my_servers + logger INFO "Removed the record $record from saved servers" +} +update_favs_from_table(){ + local context="$1" + local record="$2" + if [[ $context =~ Remove ]]; then + remove_from_favs "$record" + else + add_to_favs "$record" + fi + return 0 +} +update_config(){ + # handling for legacy files + [[ -z $branch ]] && branch="stable" + mv $config_file ${config_file}.old + write_config > $config_file + if [[ $? -eq 0 ]]; then + printf "Updated config file" + return 90 + else + return 1 + fi + logger INFO "Updated config file at '$config_file'" +} +validate_ip(){ + local ip="$1" + local res + <<< "$ip" grep -qP '^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}:[0-9]+$' && echo 0 || echo 1 +} +test_steam_api(){ + local key="$1" + [[ -z $key ]] && return 1 + local url="https://api.steampowered.com/IGameServersService/GetServerList/v1/?filter=\appid\221100&limit=10&key=$key" + local code=$(curl -ILs "$url" | grep -E "^HTTP") + [[ $code =~ 403 ]] && echo 1 + [[ $code =~ 200 ]] && echo 0 +} +test_bm_api(){ + local key="$1" + if [[ ! $key =~ ^[0-9]+$ ]]; then + return + fi + [[ -z $key ]] && return 1 + local code=$(curl -ILs "$bm_api" \ + -H "Authorization: Bearer "$key"" -G \ + -d "filter[game]=$game" \ + | grep -E "^HTTP") + [[ $code =~ 401 ]] && echo 1 + [[ $code =~ 200 ]] && echo 0 +} +update_config_val(){ + local context="$1" + local value="$2" + case $1 in + "Change player name") + key="name" + ;; + "Change Steam API key") + key="steam_api" + if [[ ${#value} -lt 32 ]] || [[ $(test_steam_api "$value") -eq 1 ]]; then + printf "Invalid API key" + return 2 + fi + ;; + "Change Battlemetrics API key") + key="api_key" + if [[ $(test_bm_api "$value") -eq 1 ]]; then + printf "Invalid API key" + return 2 + fi + ;; + esac + declare -n nr=$key + nr="$value" + update_config +} +show_log(){ + < "$debug_log" sed 's/Keyword␞/Keyword/' +} +open_workshop_page(){ + shift + local id="$1" + local workshop_uri="steam://url/CommunityFilePage/$id" + $steam_cmd "$workshop_uri" $id +} +open_link(){ + shift + local destination="$1" + local url + case "$destination" in + "Open Battlemetrics") + url="$battlemetrics_server_url" + ;; + "Open Steam API page") + url="$steam_api_url" + ;; + "Open Battlemetrics API page") + url="$battlemetrics_api_url" + ;; + esac + +#if [[ $is_steam_deck -eq 1 ]]; then +#$steam_cmd steam://openurl/"$1" 2>/dev/null + if [[ -n "$BROWSER" ]]; then + logger INFO "Opening '$url' in '$BROWSER'" + "$BROWSER" "$url" + else + logger INFO "Opening '$url' with xdg-open" + 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 + Distro: $(< /etc/os-release grep -w NAME | awk -F\" '{print $2}') + Kernel: $(uname -mrs) + Version: $version + Branch: $branch + Mode: $(if [[ -z $debug ]]; then echo normal; else echo debug; fi) + Auto: $(if [[ -z $auto_install ]]; then echo normal; else echo auto; fi) + Steam path: $steam_path + Workshop path: $workshop_dir + Game path: $game_dir + Servers: +$(print_ip_list | sed 's/"//g') + Mods: +$(list_mods | sed 's/^/\t/g') + DOC + printf "Wrote system log to %s" "$system_log" + return 0 +} +query_defunct(){ + readarray -t modlist <<< "$@" + local max=${#modlist[@]} + concat(){ + for ((i=0;i<$max;i++)); do + echo "publishedfileids[$i]=${modlist[$i]}&" + done | awk '{print}' ORS='' + } + payload(){ + echo -e "itemcount=${max}&$(concat)" + } + post(){ + curl -s \ + -X POST \ + -H "Content-Type:application/x-www-form-urlencoded"\ + -d "$(payload)" 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json' + } + local result=$(post | jq -r '.[].publishedfiledetails[] | select(.result==1) | "\(.file_size) \(.publishedfileid)"') + local result2=$(post | jq -r '') + echo "$result2" > $HOME/json + <<< "$result" awk '{print $2}' +} +encode(){ + echo "$1" | md5sum | cut -c -8 +} +compare(){ + local modlist="$@" + diff=$(comm -23 <(<<< "$modlist" sort -u) <(installed_mods | sort)) + echo "$diff" +} +legacy_symlinks(){ + for d in "$game_dir"/*; do + if [[ $d =~ @[0-9]+-.+ ]]; then + unlink "$d" + fi + done + for d in "$workshop_dir"/*; do + local id=$(awk -F"= " '/publishedid/ {print $2}' "$d"/meta.cpp | awk -F\; '{print $1}') + local encoded_id=$(echo "$id" | awk '{printf("%c",$1)}' | base64 | sed 's/\//_/g; s/=//g; s/+/]/g') + if [[ -h "$game_dir/@$encoded_id" ]]; then + unlink "$game_dir/@$encoded_id" + fi + done +} +symlinks(){ + for d in "$workshop_dir"/*; do + id=$(awk -F"= " '/publishedid/ {print $2}' "$d"/meta.cpp | awk -F\; '{print $1}') + encoded_id=$(encode "$id") + link="@$encoded_id" + if [[ -h "$game_dir/$link" ]]; then + logger INFO "Symlink already exists: '$link' for mod '$id'" + continue + fi + ln -fs "$d" "$game_dir/$link" + logger INFO "Created symlink '$link' for mod '$id'" + done +} +update_history(){ + local record="$1" + local old + [[ -n $(grep "$record" $history_file) ]] && return + if [[ -f $history_file ]]; then + old=$(tail -n9 "$history_file") + rm $history_file + echo "$old" > "$history_file" + fi + echo "$record" >> "$history_file" +} +update_symlinks(){ + legacy_symlinks + symlinks +} +try_connect(){ + local record="$1" + local ip=$(<<< $record awk -F: '{print $1}') + local gameport=$(<<< $record awk -F: '{print $2}') + local qport=$(<<< $record awk -F: '{print $3}') + local remote_mods + remote_mods=$(a2s $ip $qport rules) + if [[ $? -eq 1 ]]; then + printf "Failed to fetch server modlist, possibly timed out" + return 1 + fi + logger INFO "Server returned modlist: $(<<< $remote_mods tr '\n' ' ')" + local sanitized_mods=$(query_defunct "$remote_mods") + local diff=$(compare "$sanitized_mods") + + logger INFO "Connection attempt for $ip:$qport" + update_history "$record" + # + if [[ -n $auto_install ]]; then + #TODO: remove when ready + printf "Auto install mode currently disabled" + return 1 + fi + #TODO: publishedfileid,timestamp + #if [[ -z $auto_install ]]; then + # merge_modlists + #fi + # + if [[ -n $diff ]]; then + if [[ $is_steam_deck -eq 1 ]] && [[ $(test_display_mode) == "gm" ]]; then + printf "Use Desktop Mode to download mods on Steam Deck" + return 1 + fi + case $auto_install in + "") manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods";; + 1|2) auto_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" ;; + esac + else + launch "$ip" "$gameport" "$sanitized_mods" + fi +} +concat_mods(){ + readarray -t concat_arr <<< "$@" + local id + local encoded_id + local link + for i in "${concat_arr[@]}"; do + id=$(awk -F"= " '/publishedid/ {print $2}' "$workshop_dir"/$i/meta.cpp | awk -F\; '{print $1}') + encoded_id=$(encode $id) + link="@$encoded_id;" + echo -e "$link" + done | tr -d '\n' | perl -ple 'chop' +} +is_dayz_running(){ + local proc=$(ps aux | grep "DayZ.*\.exe" | grep -v grep) + if [[ -n $proc ]]; then + echo 1 + else + echo 0 + fi +} +launch(){ + local ip="$1" + local gameport="$2" + local mods="$3" + local concat + if [[ -n $mods ]]; then + concat=$(concat_mods "$mods") + else + concat="" + fi + + update_symlinks + if [[ $debug -eq 1 ]]; then + local launch_options="$steam_cmd -applaunch $aid -connect=$ip:$gameport -nolauncher -nosplash -name=$name -skipintro \"-mod=$concat\"" + printf "Dry-run 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" + printf "Launch conditions satisfied. DayZ will now launch after you confirm this dialog." + return 100 +} +final_handshake(){ + local saved_mods=$(< "$_cache_launch") + local saved_address=$(< "$_cache_address") + local res=$(is_dayz_running) + if [[ $res -eq 1 ]]; then + printf "Is DayZ already running? DZGUI cannot launch DayZ if another process is using it." + return 1 + fi + $steam_cmd -applaunch $aid -connect=$saved_address -nolauncher -nosplash -skipintro -name=$name \"-mod=$saved_mods\" & + until [[ $(is_dayz_running) -eq 1 ]]; do + sleep 0.1s + done + return 6 +} +manual_mod_install(){ + local ip="$1" + local gameport="$2" + local diff="$3" + local sanitized_mods="$4" + local ex="$state_path/dzg.watcher" + + readarray -t stage_mods <<< "$diff" + [[ -f $ex ]] && rm $ex + + _watcher(){ + for((i=0;i<${#stage_mods[@]};i++)); do + [[ -f $ex ]] && return 1 + log ${stage_mods[$i]} + + $steam_cmd "steam://url/CommunityFilePage/${stage_mods[$i]}" + echo "# Opening workshop page for ${stage_mods[$i]}. If you see no progress after subscribing, try unsubscribing and resubscribing again until the download commences." + sleep 1s + foreground + + until [[ -d $downloads_dir/${stage_mods[$i]} ]]; do + [[ -f $ex ]] && return 1 + sleep 0.1s + if [[ -d $workshop_dir/${stage_mods[$i]} ]]; then + break + fi + done + + foreground + echo "# Steam is downloading ${stage_mods[$i]} (mod $((i+1)) of ${#stage_mods[@]})" + until [[ -d $workshop_dir/${stage_mods[$i]} ]]; do + [[ -f $ex ]] && return 1 + sleep 0.1s + done + + foreground + echo "# ${stage_mods[$i]} moved to mods dir" + done + echo "100" + } + _watcher > >($steamsafe_zenity --pulsate --progress --auto-close --title="DZG Watcher" --width=500 2>/dev/null; rc=$?; [[ $rc -eq 1 ]] && touch $ex) + + # compare latest installed mods to modlist + local diff=$(compare "$sanitized_mods") + if [[ -z $diff ]]; then + launch "$ip" "$gameport" "$sanitized_mods" + else + printf "Some mods may have failed to download. Try connecting again to resync" + exit 1 + fi +} +test_display_mode(){ + pgrep -a gamescope | grep -q "generate-drm-mode" + if [[ $? -eq 0 ]]; then + echo gm + else + echo dm + fi +} +foreground(){ + if [[ $(command -v wmctrl) ]]; then + wmctrl -a "DZG Watcher" + else + local wid=$(xdotool search --name "DZG Watcher") + xdotool windowactivate $wid + fi +} +main(){ + local params="$(printf '"%s", ' "$@")" + logger INFO "Received request from UI constructor with params [${params::-2}]" + func=${funcs["$1"]} + [[ -z $func ]] && return 1 + if [[ -z $2 ]]; then + shift + $func + else + $func "$@" + fi +} +main "$@" diff --git a/helpers/query_v2.py b/helpers/query_v2.py new file mode 100644 index 0000000..4ed62e9 --- /dev/null +++ b/helpers/query_v2.py @@ -0,0 +1,75 @@ +import sys +import a2s +import math +import json +from a2s import dayzquery +sys.path.append('a2s') + +def get_info(ip, qport): + try: + info = a2s.info((ip, int(qport))) + + name = info.server_name + map = info.map_name + address = ip + ":" + qport + gameport = str(info.port) + players = info.player_count + max_players = info.max_players + keywords = info.keywords + ping = (info.ping*1000) + ping = math.floor(ping) + + res = {} + + res['name'] = name + res['map'] = map + res['gametype'] = keywords + res['players'] = players + res['max_players'] = max_players + res['addr'] = address + res['gameport'] = gameport + res['stat'] = "online" + res['qport'] = qport + res['ping'] = str(ping) + "ms" + + j = json.dumps([res]) + + print(j) + except: + sys.exit(1) + +def get_rules(ip, qport): + try: + mods = dayzquery.dayz_rules((ip, int(qport))).mods + for k in mods: + print(k.workshop_id) + except: + sys.exit(1) + +def get_names(ip, qport): + try: + mods = dayzquery.dayz_rules((ip, int(qport))).mods + ids = [] + names = [] + for mod in mods: + names.append(mod.name) + ids.append(mod.workshop_id) + res = {} + res['names'] = names + res['ids'] = ids + j = json.dumps(res) + print(j) + except: + sys.exit(1) + +ip = sys.argv[1] +qport = sys.argv[2] +mode = sys.argv[3] + +match mode: + case "info": + get_info(ip, qport) + case "rules": + get_rules(ip, qport) + case "names": + get_names(ip, qport) diff --git a/helpers/ui.py b/helpers/ui.py new file mode 100644 index 0000000..ebc044d --- /dev/null +++ b/helpers/ui.py @@ -0,0 +1,1635 @@ +import csv +import gi +import locale +import logging +import os +import signal +import multiprocessing +import re +import subprocess +import sys +import threading +import time + +locale.setlocale(locale.LC_ALL, '') +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, Gdk, GObject, Pango + +# 5.0.0-rc.1 +app_name = "DZGUI" + +cache = {} +config_vals = [] +stored_keys = [] +toggled_checks = [] +server_filters = [] +delimiter = "␞" +selected_map = ["Map=All maps"] +keyword_filter = ["Keyword%s" %(delimiter)] + +checks = list() +map_store = Gtk.ListStore(str) +row_store = Gtk.ListStore(str) +modlist_store = Gtk.ListStore(str, str, str) +# Name, Symlink, ID, Size +mod_store = Gtk.ListStore(str, str, str, float) +# Timestamp, Flag, Trace, Message +log_store = Gtk.ListStore(str, str, str, str) +# Name, Map, Perspective, Gametime, Players, Max, IP, Qport +server_store = Gtk.ListStore(str, str, str, str, int, int, str, int) + +default_tooltip = "Select a row to see its detailed description" +server_tooltip = [None, None] + +user_path = os.path.expanduser('~') +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) +funcs = '%s/funcs' %(helpers_path) + +logger = logging.getLogger(__name__) +log_file = '%s/DZGUI_DEBUG.log' %(log_path) +system_log = '%s/DZGUI_SYSTEM.log' %(log_path) +FORMAT = "%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s" +logging.basicConfig(filename=log_file, + format=FORMAT, +level=logging.DEBUG) + +browser_cols = [ + "Name", + "Map", + "Perspective", + "Gametime", + "Players", + "Maximum", + "IP", + "Qport", +] +mod_cols = [ + "Mod", + "Symlink", + "Dir", + "Size (MiB)" +] +log_cols = [ + "Timestamp", + "Flag", + "Traceback", + "Message" +] +connect = [ + ("Server browser",), + ("My saved servers",), + ("Quick-connect to favorite server",), + ("Recent servers",), + ("Connect by IP",), + ("Connect by ID",) +] +manage = [ + ("Add server by IP",), + ("Add server by ID",), + ("Change favorite server",), +] +options = [ + ("List installed mods",), + ("Toggle release branch",), + ("Toggle mod install mode",), + ("Toggle Steam/Flatpak",), + ("Change player name",), + ("Change Steam API key",), + ("Change Battlemetrics API key",), + ("Force update local mods",), + ("Output system info to log file",) +] +help = [ + ("View changelog",), + ("Show debug log",), + ("Help file ⧉",), + ("Report a bug ⧉",), + ("Forum ⧉",), + ("Sponsor ⧉",), + ("Hall of fame ⧉",), +] +filters = { + "1PP": True, + "3PP": True, + "Day": True, + "Night": True, + "Empty": False, + "Full": False, + "Low population": True, + "Non-ASCII": False, + "Duplicate names": False +} +side_buttons = [ + "Connect", + "Manage", + "Options", + "Help", + "Exit" +] +status_tooltip = { + "Server browser": "Used to browse the global server list", + "My saved servers": "Browse your saved servers", + "Quick-connect to favorite server": "Connect to your favorite server", + "Recent servers": "Shows the last 10 servers you connected to", + "Connect by IP": "Connect to a server by IP", + "Connect by ID": "Connect to a server by Battlemetrics ID", + "Add server by IP": "Add a server by IP", + "Add server by ID": "Add a server by Battlemetrics ID", + "Change favorite server": "Update your quick-connect server", + "List installed mods": "Browse a list of locally-installed mods", + "Toggle release branch": "Switch between stable and testing branches", + "Toggle mod install mode": "Switch between manual and auto mod installation", + "Toggle Steam/Flatpak": "Switch the preferred client to use for launching DayZ", + "Change player name": "Update your in-game name (required by some servers)", + "Change Steam API key": "Can be used if you revoked an old API key", + "Change Battlemetrics API key": "Can be used if you revoked an old API key", + "Force update local mods": "Attempts to update any local mods out of synch with remote versions (experimental)", + "Output system info to log file": "Generates a system log for troubleshooting", + "View changelog": "Opens the DZGUI changelog in a dialog window", + "Show debug log": "Read the DZGUI log generated since startup", + "Help file ⧉": "Opens the DZGUI documentation in a browser", + "Report a bug ⧉": "Opens the DZGUI issue tracker in a browser", + "Forum ⧉": "Opens the DZGUI discussion forum in a browser", + "Sponsor ⧉": "Sponsor the developer of DZGUI", + "Hall of fame ⧉": "A list of significant contributors and testers", +} + + +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 + + +def set_surrounding_margins(widget, margin): + widget.set_margin_top(margin) + widget.set_margin_start(margin) + widget.set_margin_end(margin) + + +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 + +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) + +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) + try: + rows = [[row[0], row[1], row[2], locale.atof(row[3], func=float)] 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): + sum = 0 + lines = data.stdout.splitlines() + reader = csv.reader(lines, delimiter=delimiter) + hits = len(lines) + try: + rows = [[row[0], row[1], row[2], row[3], int(row[4]), int(row[5]), row[6], int(row[7])] for row in reader if row] + except IndexError: + return 1 + for row in rows: + server_store.append(row) + players = int(row[4]) + sum += players + return [sum, hits] + + +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): + match code: + #TODO: add logger output to each + case 0: + # success with notice popup + spawn_dialog(transient_parent, msg, "NOTIFY") + pass + case 1: + # error with notice popup + if msg == "": + msg = "Something went wrong" + spawn_dialog(transient_parent, msg, "NOTIFY") + pass + case 2: + # warn and recurse (e.g. validation failed) + spawn_dialog(transient_parent, msg, "NOTIFY") + treeview = transient_parent.grid.scrollable_treelist.treeview + process_tree_option(original_input, treeview) + case 4: + # for BM only + spawn_dialog(transient_parent, msg, "NOTIFY") + treeview = transient_parent.grid.scrollable_treelist.treeview + process_tree_option(["Options", "Change Battlemetrics API key"], treeview) + case 5: + # for steam only + spawn_dialog(transient_parent, msg, "NOTIFY") + treeview = transient_parent.grid.scrollable_treelist.treeview + process_tree_option(["Options", "Change Steam API key"], 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, "NOTIFY") + return + case 100: + # final handoff before launch + final_conf = spawn_dialog(transient_parent, msg, "CONFIRM") + treeview = transient_parent.grid.scrollable_treelist.treeview + if final_conf == 1 or final_conf is None: + return + process_tree_option(["Handshake", ""], treeview) + case 255: + spawn_dialog(transient_parent, "Update complete. Please close DZGUI and restart.", "NOTIFY") + Gtk.main_quit() + + +def process_tree_option(input, treeview): + context = input[0] + command = input[1] + logger.info("Parsing tree option '%s' for the context '%s'" %(command, context)) + + transient_parent = treeview.get_outer_window() + toggle_contexts = ["Toggle mod install mode", "Toggle release branch", "Toggle Steam/Flatpak"] + + def call_on_thread(bool, subproc, msg, args): + def _background(subproc, args, dialog): + def _load(): + wait_dialog.destroy() + msg = proc.stdout + 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, "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 + msg = proc.stdout + process_shell_return_code(transient_parent, msg, rc, input) + + + match context: + case "Help": + if command == "View changelog": + diag = ChangelogDialog(transient_parent, '', "Changelog -- content can be scrolled") + diag.run() + diag.destroy() + else: + # non-blocking subprocess + subprocess.Popen(['/usr/bin/env', 'bash', funcs, "Open link", command]) + case "Handshake": + call_on_thread(True, context, "Waiting for DayZ", command) + case _: + if command == "Output system info to log file": + call_on_thread(True, "gen_log", "Generating log", "") + elif command == "Force update local mods": + call_on_thread(True, "force_update", "Updating mods", "") + elif command == "Quick-connect to favorite server": + call_on_thread(True, command, "Working", "") + elif command in toggle_contexts: + if command == "Toggle release branch": + call_on_thread(False, "toggle", "Updating DZGUI branch", command) + else: + proc = call_out(transient_parent, "toggle", command) + grid = treeview.get_parent().get_parent() + grid.update_right_statusbar() + tooltip = format_metadata(command) + transient_parent.grid.update_statusbar(tooltip) + else: + # This branch is only used by interactive dialogs + match command: + case "Connect by IP" | "Add server by IP" | "Change favorite server": + flag = True + link_label = "" + prompt = "Enter IP in IP:Queryport format\nE.g. 192.168.1.1:27016" + case "Connect by ID" | "Add server by ID": + flag = True + link_label = "Open Battlemetrics" + prompt = "Enter server ID" + case "Change player name": + flag = False + link_label = "" + prompt = "Enter new nickname" + case "Change Steam API key": + flag = True + link_label = "Open Steam API page" + prompt = "Enter new API key" + case "Change Battlemetrics API key": + flag = True + link_label = "Open Battlemetrics API page" + prompt = "Enter new API key" + + user_entry = EntryDialog(transient_parent, prompt, "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)) + + call_on_thread(flag, command, "Working", res) + + +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): + def __init__(self): + super().__init__() + + self.connect("destroy", self.halt_proc_and_quit) + self.connect("delete-event", self.halt_proc_and_quit) + # Deprecated in GTK 4.0 + 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() + self.add(self.grid) + self.hb = AppHeaderBar() + self.set_titlebar(self.hb) + + # Hide FilterPanel on main menu + self.show_all() + self.grid.right_panel.set_filter_visibility(False) + self.grid.scrollable_treelist.treeview.grab_focus() + + def halt_proc_and_quit(self, window): + self.grid.terminate_treeview_process() + Gtk.main_quit() + + +class ScrollableTree(Gtk.ScrolledWindow): + def __init__(self): + super().__init__() + self.set_vexpand(False) + + self.treeview = TreeView() + self.add(self.treeview) + + +class RightPanel(Gtk.Box): + def __init__(self): + super().__init__(spacing=6) + self.set_orientation(Gtk.Orientation.VERTICAL) + + self.button_vbox = ButtonBox() + 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.question_label = Gtk.Label(label="Type ? for keybindings") + self.question_label.set_ellipsize(Pango.EllipsizeMode.END) + self.question_label.set_margin_top(100) + self.question_label.set_margin_start(10) + self.pack_start(self.question_label, False, True, 0) + + 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): + super().__init__(spacing=6) + self.set_orientation(Gtk.Orientation.VERTICAL) + set_surrounding_margins(self, 10) + + self.buttons = list() + for side_button in side_buttons: + button = Gtk.Button(label=side_button) + button.set_size_request(100, 100) + button.set_opacity(0.6) + self.buttons.append(button) + button.connect("clicked", self._on_selection_button_clicked) + self.pack_start(button, False, False, True) + + 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)) + treeview = self.get_treeview() + right_panel = self.get_parent() + right_panel.set_filter_visibility(False) + + """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) + for i, column_title in enumerate([context]): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + treeview.append_column(column) + treeview.set_model(row_store) + treeview.grab_focus() + + def _populate(self, array_context): + row_store.clear() + status = array_context[0][0] + treeview = self.get_treeview() + grid = self.get_parent().get_parent() + + for items in array_context: + row_store.append(list(items)) + grid.update_statusbar(status_tooltip[status]) + 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_label() + logger.info("User clicked '%s'" %(context)) + + if context == "Exit": + logger.info("Normal user exit") + sys.exit(1) + 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) + + match context: + case 'Manage': self._populate(manage) + case 'Connect': self._populate(connect) + case 'Options': self._populate(options) + case 'Help': self._populate(help) + + 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, result_queue, cache): + super().__init__() + + self.widget = widget + self.result_queue = result_queue + self.addr = addr + 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]]) + return + proc = call_out(self.widget, "get_dist", self.ip) + km = proc.stdout + self.result_queue.put([self.addr, km]) + + +class TreeView(Gtk.TreeView): + __gsignals__ = {"on_distcalc_started": (GObject.SignalFlags.RUN_FIRST, None, ())} + + def __init__(self): + super().__init__() + + 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 rows in connect: + row_store.append(list(rows)) + self.set_model(row_store) + + for i, column_title in enumerate( + ["Connect"] + ): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + self.append_column(column) + # TODO: change font size on the fly + # renderer.set_property("size-points", 20) + + 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): + 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 favorites": + record = "%s:%s" %(self.get_column_at_index(6), self.get_column_at_index(7)) + call_out(parent, context_menu_label, record) + if context == "Name (My saved servers)": + iter = self.get_current_iter() + server_store.remove(iter) + res = spawn_dialog(parent, "Added %s to favorites" %(record), "NOTIFY") + case "Remove from history": + record = "%s:%s" %(self.get_column_at_index(6), self.get_column_at_index(7)) + 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(6) + qport = self.get_column_at_index(7) + ip = addr.split(':')[0] + record = "%s:%s" %(ip, qport) + self.clipboard.set_text(record, -1) + case "Show server-side mods": + record = "%s:%s" %(self.get_column_at_index(6), self.get_column_at_index(7)) + 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, "CONFIRM") + symlink = self.get_column_at_index(1) + dir = self.get_column_at_index(2) + if res == 0: + proc = call_out(parent, "delete", symlink, dir) + if proc.returncode == 0: + spawn_dialog(parent, success_msg, "NOTIFY") + self._update_quad_column("List installed mods") + else: + spawn_dialog(parent, fail_msg, "NOTIFY") + case "Open in Steam Workshop": + record = self.get_column_at_index(2) + call_out(parent, "open_workshop_page", record) + + def _on_button_release(self, widget, event): + 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 + + if event.type is Gdk.EventType.BUTTON_RELEASE and event.button != 3: + return + 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"], + "My saved servers": ["Remove from favorites", "Copy IP to clipboard", "Show server-side mods"], + "Recent servers": ["Remove from history", "Copy IP to clipboard", "Show server-side mods"], + } + # submenu hierarchy https://stackoverflow.com/questions/52847909/how-to-add-a-sub-menu-to-a-gtk-menu + if context == "Mod": + 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" and item == "Add to my servers": + record = "%s:%s" %(self.get_column_at_index(6), self.get_column_at_index(7)) + proc = call_out(widget, "is_in_favs", record) + if proc.returncode == 0: + item = "Remove from favorites" + 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: + self.menu.popup_at_widget(widget, Gdk.Gravity.CENTER, Gdk.Gravity.WEST) + else: + self.menu.popup_at_pointer(event) + + 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): + grid = self.get_outer_grid() + context = self.get_first_col() + row_sel = self.get_column_at_index(0) + if context == "Mod" or context == "Timestamp": + return + logger.info("Tree selection for context '%s' changed to '%s'" %(context, row_sel)) + + if self.current_proc and self.current_proc.is_alive(): + self.current_proc.terminate() + + if "Name" in context: + addr = self.get_column_at_index(6) + if addr is None: + return + if addr in cache: + dist = format_distance(cache[addr]) + + tooltip = server_tooltip[0] + dist + grid.update_statusbar(tooltip) + return + self.emit("on_distcalc_started") + self.current_proc = CalcDist(self, addr, self.queue, cache) + self.current_proc.start() + elif None: + return + else: + tooltip = format_metadata(row_sel) + grid.update_statusbar(tooltip) + + def _on_keypress_main_menu(self, treeview, event): + window = self.get_outer_window() + grid = self.get_outer_grid() + match event.keyval: + case Gdk.KEY_Right: + grid.right_panel.focus_button_box() + case Gdk.KEY_question: + if event.state is Gdk.ModifierType.SHIFT_MASK: + diag = KeysDialog(window, '', "Keybindings") + diag.run() + diag.destroy() + case Gdk.KEY_f: + if event.state is Gdk.ModifierType.CONTROL_MASK: + return True + case _: + return False + + def _on_keypress(self, treeview, event): + if self.get_first_col == "Mod": + return + 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_d: + debug = grid.right_panel.filters_vbox.debug_toggle + if debug.get_active(): + debug.set_active(False) + else: + debug.set_active(True) + case Gdk.KEY_l: + self._on_button_release(self, event) + case Gdk.KEY_f: + grid.right_panel.filters_vbox.grab_keyword_focus() + case Gdk.KEY_m: + grid.right_panel.filters_vbox.maps_combo.grab_focus() + grid.right_panel.filters_vbox.maps_combo.popup() + case _: + return False + elif keyname.isnumeric() and int(keyname) > 0: + digit = (int(keyname) - 1) + grid.right_panel.filters_vbox.toggle_check(checks[digit]) + else: + return False + + 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(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() + + 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) + row_metadata = parse_server_rows(data) + sum = row_metadata[0] + hits = row_metadata[1] + server_tooltip[0] = format_tooltip(sum, hits) + 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): + def load(): + dialog.destroy() + self.set_model(mod_store) + self.grab_focus() + size = locale.format_string('%.3f', total_size, grouping=True) + grid.update_statusbar("Found %s mods taking up %s MiB" %(f'{total_mods:n}', size)) + + grid = self.get_outer_grid() + right_panel = grid.right_panel + + right_panel.set_filter_visibility(False) + data = call_out(self, "list_mods", mode) + result = parse_mod_rows(data) + total_size = result[0] + total_mods = result[1] + GLib.idle_add(load) + + 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 + toggle_signal(self, self.selected_row, '_on_tree_selection_changed', False) + # toggle_signal(self, self.selected_row, '_on_check_toggled', False) + for column in self.get_columns(): + self.remove_column(column) + row_store.clear() + for i, column_title in enumerate(browser_cols): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + column.set_sort_column_id(i) + """Prevent columns from auto-adjusting""" + if ("Name" in column_title): + column.set_fixed_width(800) + if (column_title == "Map"): + column.set_fixed_width(300) + self.append_column(column) + + self.update_first_col(mode) + transient_parent = self.get_outer_window() + + 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) + + wait_dialog = GenericDialog(transient_parent, "Fetching server metadata", "WAIT") + wait_dialog.show_all() + thread = threading.Thread(target=self._background, args=(wait_dialog, mode)) + 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) + return + + 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) + + mod_store.clear() + log_store.clear() + + if mode == "List installed 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) + if mode == "List installed mods": + if i == 3: + column.set_cell_data_func(renderer, self._format_float, func_data=None) + column.set_sort_column_id(i) + if (column_title == "Name"): + column.set_fixed_width(600) + self.append_column(column) + + if mode == "List installed mods": + pass + else: + data = call_out(self, "show_log") + res = parse_log_rows(data) + if res == 1: + spawn_dialog(self.get_outer_window(), "Failed to load log file, possibly corrupted", "NOTIFY") + return + + transient_parent = self.get_outer_window() + + wait_dialog = GenericDialog(transient_parent, "Checking mods", "WAIT") + wait_dialog.show_all() + thread = threading.Thread(target=self._background_quad, args=(wait_dialog, mode)) + thread.start() + + def _background_connection(self, dialog, record): + def load(): + dialog.destroy() + transient = self.get_outer_window() + process_shell_return_code(transient, proc.stdout, proc.returncode, record) + + proc = call_out(self, "Connect from table", record) + GLib.idle_add(load) + + + def _attempt_connection(self): + transient_parent = self.get_outer_window() + addr = self.get_column_at_index(6) + qport = self.get_column_at_index(7) + record = "%s:%s" %(addr, str(qport)) + + wait_dialog = GenericDialog(transient_parent, "Querying server and aligning mods", "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) + output = context, chosen_row + if context == "Mod" or context == "Timestamp": + return + logger.info("User selected '%s' for the context '%s'" %(chosen_row, context)) + + outer = self.get_outer_window() + right_panel = outer.grid.right_panel + filters_vbox = right_panel.filters_vbox + + valid_contexts = ["Server browser", "My saved servers", "Recent servers"] + if chosen_row in valid_contexts: + # server contexts share the same model type + for check in checks: + toggle_signal(filters_vbox, check, '_on_check_toggle', False) + + if chosen_row == "Server browser": + reinit_checks() + else: + for check in checks: + if check.get_label() not in toggled_checks: + toggled_checks.append(check.get_label()) + check.set_active(True) + self._update_multi_column(chosen_row) + + 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() + elif chosen_row == "List installed mods" or chosen_row == "Show debug log": + toggle_signal(self, self.selected_row, '_on_tree_selection_changed', False) + self._update_quad_column(chosen_row) + toggle_signal(self, self.selected_row, '_on_tree_selection_changed', True) + elif any(map(context.__contains__, valid_contexts)): + # implies activated row on any server list subcontext + self._attempt_connection() + else: + # implies any other non-server option selected from main menu + process_tree_option(output, self) + + +def format_metadata(row_sel): + prefix = status_tooltip[row_sel] + 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] + } + match row_sel: + case "Quick-connect to favorite server" | "Change favorite server": + default = "unset" + val = "fav_label" + case "Change player name": + val = "name" + case "Toggle mod install mode": + default = "manual" + alt = "auto" + val = "auto_install" + case "Toggle debug mode": + default = "normal" + alt = "debug" + val = "debug" + case "Toggle release branch": + val = "branch" + case "Toggle Steam/Flatpak": + val = "preferred_client" + case _: + return prefix + + try: + cur_val = vals[val] + if cur_val == "": + return "%s | Current: %s" %(prefix, default) + # TODO: migrate to human readable config values + elif cur_val == "1": + return "%s | Current: %s" %(prefix, alt) + else: + return "%s | Current: '%s'" %(prefix, cur_val) + except KeyError: + return prefix + + +def format_tooltip(sum, hits): + if hits == 1: + hit_suffix = "match" + else: + hit_suffix = "matches" + if sum == 1: + player_suffix = "player" + else: + player_suffix = "players" + tooltip = "Found %s %s with %s %s" %(f'{hits:n}', hit_suffix, f'{sum:n}', player_suffix) + return tooltip + + +def filter_servers(transient_parent, filters_vbox, treeview, context): + def filter(dialog): + def clear_and_destroy(): + row_metadata = parse_server_rows(data) + sum = row_metadata[0] + hits = row_metadata[1] + server_tooltip[0] = format_tooltip(sum, hits) + transient_parent.grid.update_statusbar(server_tooltip[0]) + + 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() + + server_filters = toggled_checks + keyword_filter + selected_map + data = call_out(transient_parent, "filter", context, *server_filters) + GLib.idle_add(clear_and_destroy) + + # 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) + + dialog = GenericDialog(transient_parent, "Filtering results", "WAIT") + dialog.show_all() + server_store.clear() + + thread = threading.Thread(target=filter, args=(dialog,)) + thread.start() + + +class AppHeaderBar(Gtk.HeaderBar): + def __init__(self): + super().__init__() + Gtk.HeaderBar() + self.props.title = app_name + self.set_show_close_button(True) + + +class NewsHeader(Gtk.Box): + def __init__(self, news): + super().__init__() + Gtk.Box(spacing=1) + self.set_valign(Gtk.Align.START) + news_label = Gtk.Label(label=news) + news_label.set_ellipsize(Pango.EllipsizeMode.END) + self.pack_start(news_label, False, False, 0) + + +class GenericDialog(Gtk.MessageDialog): + def __init__(self, parent, text, mode): + + def _on_dialog_delete(self, response_id): + """Passively ignore user-input""" + return True + + match mode: + case "WAIT": + dialog_type = Gtk.MessageType.INFO + button_type = Gtk.ButtonsType.NONE + header_text = "Please wait" + case "NOTIFY": + dialog_type = Gtk.MessageType.INFO + button_type = Gtk.ButtonsType.OK + header_text = "Notice" + case "CONFIRM": + dialog_type = Gtk.MessageType.QUESTION + button_type = Gtk.ButtonsType.OK_CANCEL + header_text = "Confirmation" + case "ENTRY": + dialog_type = Gtk.MessageType.QUESTION + button_type = Gtk.ButtonsType.OK_CANCEL + header_text = "User input required" + case _: + dialog_type = Gtk.MessageType.INFO + button_type = Gtk.ButtonsType.OK + header_text = mode + + Gtk.MessageDialog.__init__( + self, + transient_for=parent, + flags=0, + message_type=dialog_type, + text=header_text, + secondary_text=text, + buttons=button_type, + title=app_name, + modal=True, + ) + + if mode == "WAIT": + dialogBox = self.get_content_area() + spinner = Gtk.Spinner() + dialogBox.pack_end(spinner, False, False, 0) + spinner.start() + self.connect("delete-event", _on_dialog_delete) + + 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.format_secondary_text(text) + + +def ChangelogDialog(parent, text, mode): + + 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 + + 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 dropdown + Ctrl-d: toggle dry run (debug) mode + 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 ModDialog(GenericDialog): + def __init__(self, parent, text, mode, record): + super().__init__(parent, text, mode) + + 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.scrollable.set_vexpand(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() + column = Gtk.TreeViewColumn(column_title, renderer, text=i) + self.view.append_column(column) + column.set_sort_column_id(i) + dialogBox.pack_end(self.scrollable, True, True, 0) + + wait_dialog = GenericDialog(parent, "Fetching modlist", "WAIT") + wait_dialog.show_all() + thread = threading.Thread(target=self._background, args=(wait_dialog, parent, record)) + thread.start() + + def _background(self, dialog, parent, record): + def _load(): + dialog.destroy() + if data.returncode == 1: + spawn_dialog(parent, "Server has no mods installed", "NOTIFY") + return + self.show_all() + self.set_markup("Modlist (%s mods)" %(mod_count)) + res = 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) + self.view.set_model(modlist_store) + GLib.idle_add(_load) + + def popup(self): + pass + + def _on_row_activated(self, treeview, tree_iter, col): + 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, 1) + call_out(treeview, "open_workshop_page", mod_id) + +class EntryDialog(GenericDialog): + def __init__(self, parent, text, mode, link): + super().__init__(parent, text, mode) + + """ Returns user input as a string or None """ + """ If user does not input text it returns None, NOT AN EMPTY STRING. """ + + 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) + + 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) + + 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]) + + def get_input(self): + self.dialog.show_all() + response = self.dialog.run() + text = self.userEntry.get_text() + self.dialog.destroy() + if (response == Gtk.ResponseType.OK) and (text != ''): + return text + else: + return None + + +class Grid(Gtk.Grid): + def __init__(self): + super().__init__() + Gtk.Grid() + self.set_column_homogeneous(True) + #self.set_row_homogeneous(True) + + self.scrollable_treelist = ScrollableTree() + self.scrollable_treelist.set_vexpand(True) + self.right_panel = RightPanel() + + _news = sys.argv[2] + self._version = "%s %s" %(app_name, sys.argv[3]) + + if _news != "null": + self.news = NewsHeader(_news) + self.attach(self.news, 0, -1, 8, 10) + + self.bar = Gtk.Statusbar() + self.scrollable_treelist.treeview.connect("on_distcalc_started", self._on_calclat_started) + + GLib.timeout_add(200, self._check_result_queue) + + 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, 8, 10) + self.attach_next_to(self.bar, self.scrollable_treelist, Gtk.PositionType.BOTTOM, 8, 1) + self.attach_next_to(self.right_panel, self.scrollable_treelist, Gtk.PositionType.RIGHT, 1, 1) + + 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" + else: + _debug = "DEBUG" + concat_label = "%s | %s | %s" %(_branch, _debug, self._version) + self.status_right_label.set_text(concat_label) + + def terminate_treeview_process(self): + self.scrollable_treelist.treeview.terminate_process() + + def _on_calclat_started(self, treeview): + server_tooltip[1] = server_tooltip[0] + "| Distance: calculating..." + self.update_statusbar(server_tooltip[1]) + + def _check_result_queue(self): + 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: + addr = latest_result[0] + km = latest_result[1] + cache[addr] = km + dist = format_distance(km) + tooltip = server_tooltip[1] = server_tooltip[0] + dist + self.update_statusbar(tooltip) + + return True + + def update_statusbar(self, string): + 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): + + self.win = OuterWindow() + + # TODO: steam deck + # self.win.set_size_request(1280,800) + self.win.fullscreen() + + accel = Gtk.AccelGroup() + 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, Gtk.main_quit) + Gtk.main() + + def _halt_window_subprocess(self, accel_group, window, code, flag): + self.win.halt_proc_and_quit(self) + + +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() + + 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) + + self.connect("button-release-event", self._on_button_release) + self.set_orientation(Gtk.Orientation.VERTICAL) + set_surrounding_margins(self, 10) + + self.filters_label = Gtk.Label(label="Filters") + + 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) + + renderer_text = Gtk.CellRendererText(ellipsize=Pango.EllipsizeMode.END) + self.maps_combo = Gtk.ComboBox.new_with_model(map_store) + self.maps_combo.pack_start(renderer_text, True) + self.maps_combo.add_attribute(renderer_text, "text", 0) + self.maps_combo.connect("changed", self._on_map_changed) + self.maps_combo.connect("key-press-event", self._on_esc_pressed) + + self.debug_toggle = Gtk.ToggleButton(label="Debug mode") + self.debug_toggle.connect("toggled", self._on_button_toggled, "Toggle debug mode") + set_surrounding_margins(self.debug_toggle, 10) + + self.pack_start(self.filters_label, False, False, True) + self.pack_start(self.keyword_entry, False, False, True) + self.pack_start(self.maps_combo, False, False, True) + + for i, check in enumerate(checks[0:]): + self.pack_start(checks[i], False, False, True) + + self.pack_start(self.debug_toggle, False, False, 0) + + def _on_button_toggled(self, button, command): + transient_parent = self.get_outer_window() + grid = self.get_outer_grid() + call_out(transient_parent, "toggle", command) + grid.update_right_statusbar() + grid.scrollable_treelist.treeview.grab_focus() + + 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() + return False + + def _on_esc_pressed(self, entry, event): + keyname = Gdk.keyval_name(event.keyval) + if keyname == "Escape": + GLib.idle_add(self.restore_focus_to_treeview) + + def get_outer_grid(self): + panel = self.get_parent() + grid = panel.get_parent() + return grid + + def get_outer_window(self): + grid = self.get_outer_grid() + outer_window = grid.get_parent() + return outer_window + + def _on_keyword_enter(self, keyword_entry): + keyword = keyword_entry.get_text() + old_keyword = keyword_filter[0].split(delimiter)[1] + if keyword == old_keyword: + 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) + + def _on_button_release(self, window, button): + return True + + def set_active_combo(self): + self.maps_combo.set_active(0) + + def toggle_check(self, button): + if button.get_active(): + button.set_active(False) + else: + button.set_active(True) + + def _on_check_toggle(self, button): + grid = self.get_outer_grid() + treeview = grid.scrollable_treelist.treeview + context = grid.scrollable_treelist.treeview.get_first_col() + label = button.get_label() + state = button.get_active() + + if context == "Mod": + return + if state is True: + toggled_checks.append(label) + else: + toggled_checks.remove(label) + + 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() + + tree_iter = combo.get_active_iter() + if tree_iter is not None: + selected_map.clear() + model = combo.get_model() + selection = model[tree_iter][0] + selected_map.append("Map=" + selection) + logger.info("User selected map '%s'" %(selection)) + filter_servers(transient_parent, self, treeview, context) + + +def main(): + + def usage(): + text = "UI constructor must be run via DZGUI" + logger.critical(text) + print(text) + sys.exit(1) + + expected_flag = "--init-ui" + if len(sys.argv) < 2: + usage() + if sys.argv[1] != expected_flag: + usage() + + logger.info("Spawned UI from DZGUI setup process") + App() + + +if __name__ == '__main__': + main()