#!/usr/bin/env bash set -o pipefail version=5.5.0 #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/$app_name" _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" _cache_cooldown="$cache_path/$prefix.cooldown" _cache_lan="$cache_path/$prefix.lan" #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" lan_helper="$helpers_path/lan" #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" 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 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" ["test_cooldown"]="test_cooldown" ["query_config"]="query_config" ["start_cooldown"]="start_cooldown" ["list_mods"]="list_mods" ["delete"]="delete_local_mod" ["show_server_modlist"]="show_server_modlist" ["test_ping"]="test_ping" ["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 my servers"]="update_favs_from_table" ["Remove from history"]="remove_from_history" ["force_update"]="force_update" ["Handshake"]="final_handshake" ["get_player_count"]="get_player_count" ["lan_scan"]="lan_scan" ) lan_scan(){ local port="$1" local res res=$("$lan_helper" "$port") if [[ -z $res ]]; then printf "\n" else printf "%s\n" "$res" fi } get_player_count(){ shift local res local ip="$1" local qport="$2" res=$(a2s $ip $qport info) [[ ! $? -eq 0 ]] && return 1 local players="$(<<< $res jq -r '.[].players')" local queue="$(<<< $res jq -r '.[].gametype|split("lqs")[1]|split(",")[0]')" printf "%s\n%s" "$players" "$queue" } validate_and_connect(){ local context="$1" local addr="$2" local record case "$context" in "Connect by ID") if [[ -z "$api_key" ]]; then printf "No Battlemetrics API key set" return 4 fi record=$(map_id_to_ip "$addr") if [[ $? -eq 1 ]]; then logger WARN "Not a valid record: '$addr'" printf "Not a valid ID" return 2 fi logger INFO "Battlemetrics ID resolved to IP $record" ;; "Connect by IP") if [[ $(validate_ip "$addr") -eq 1 ]]; then printf "Not a valid IP format. Supply IP:Queryport" return 2 fi local ip=$(<<< $addr awk -F: '{print $1}') local qport=$(<<< $addr awk -F: '{print $2}') local res res=$(a2s $ip $qport info) if [[ ! $? -eq 0 ]]; then printf "Timed out when querying the server. Is this a valid server?" return 2 fi local gameport="$(<<< $res jq -r '.[].gameport')" record="${ip}:${gameport}:${qport}" logger INFO "Record resolved to $record" esac try_connect "$record" } map_id_to_ip(){ local id="$1" local res=$(curl -s "$bm_api" -H "Authorization: Bearer "$api_key"" \ -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 } start_cooldown(){ logger WARN "API response empty. Started 60s cooldown at $(date +%s)" date +%s > $_cache_cooldown } initialize_remote_servers(){ local file="$_cache_servers" [[ -f $file ]] && rm "$file" local res res=$(get_remote_servers) parse_server_json "$res" >> "$file" } is_dlc(){ local dlc local ip="$1" local gport="$2" local res="$(curl -Ls "https://api.steampowered.com/IGameServersService/GetServerList/v1/?filter=\gameaddr\\${ip}:${gport}\appid\221100&key=$steam_api")" dlc=$(<<< "$res" jq '.response.servers[].gametype|contains("isDLC")') printf "%s\n" "$dlc" } a2s(){ local ip="$1" local qport="$2" local mode="$3" logger INFO "Querying '$ip:$qport' with mode '$mode'" local res res=$(python3 "$query_helper" "$ip" "$qport" "$mode") if [[ $? -eq 1 ]]; then res=$(try_fallback "$ip" "$qport" "$mode") if [[ $? -eq 1 ]]; then return 1 fi fi printf "%s\n" "$res" } 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=$(python3 -c "n=($size/1024) +.005; print(round(n,4))") LC_NUMERIC=C 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" } 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(){ [[ -n $2 ]] && local key=$2 keys=( "branch" "debug" "auto_install" "name" "fav_label" "preferred_client" "fullscreen" ) if [[ -n $key ]]; then if [[ -n ${!key} ]]; then echo "${!key}" return 0 else echo "" 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" awk -F$separator -v keyword="$keyword" 'tolower($0) ~ tolower(keyword)' } filter_duplicates(){ if [[ ${filters[*]} =~ Duplicate ]]; then echo -n "$filtered" else <<< "$filtered" awk -F$separator '!seen[$1]++' fi } 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" # some servers pad SOH in name <<< "$response" sed 's/\\u0001//g' | jq -r ' .[]|"\(.name)␞" + "\(.map|if type == "string" then ascii_downcase else "null" end)␞" + "\(if .gametype == null then "null" else (.gametype|split(",")|if any(. == "no3rd") then "1PP" else "3PP" end) end)␞" + "\(if .gametype == null then "null" else (.gametype as $time|$time|test("[0-9]{2}:[0-9]{2}$") as $match|(if $match == true then ($time|scan("[0-9]{2}:[0-9]{2}$")) else "XXXX" end)) end)␞" + "\(.players)␞" + "\(.max_players)␞" + "\(if .gametype == null then "0" elif .gametype|split("lqs")[1] == null then "0" else .gametype|split("lqs")[1]|split(",")[0] end)␞" + "\(.addr|split(":")[0]):\(if .gameport == null then "XXXX" else .gameport end)␞" + "\(.addr|split(":")[1])" ' | 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 } test_cooldown(){ [[ ! -f $_cache_cooldown ]] && return 0 local old_time=$(< $_cache_cooldown) local cur_time=$(date +%s) local delta=$(($cur_time - $old_time)) local suffix="seconds" if [[ $delta -lt 60 ]]; then local remains=$((60 - $delta)) [[ $remains -eq 1 ]] && suffix="second" printf "Global API cooldown in effect. Please wait %s %s." "$remains" "$suffix" exit 1 fi } dump_servers(){ local context="$1" local subcontext="$2" local ip local qport local res _iterate(){ local file="$1" shift for server in "$@"; do ip=$(<<< $server awk -F: '{print $1}') qport=$(<<< $server awk -F: '{print $3}') res=$(a2s "$ip" "$qport" info) if [[ ! $? -eq 0 ]]; then continue fi parse_server_json "$res" >> "$file" done } case "$subcontext" in *Server[[:space:]]browser*) local file="$_cache_servers" if [[ ! $subcontext =~ Name ]]; then initialize_remote_servers fi ;; *My[[:space:]]saved[[:space:]]servers*) local file="$_cache_my_servers" if [[ ! $subcontext =~ Name ]]; then [[ -f $file ]] && rm $file _iterate "$file" "${ip_list[@]}" fi ;; *Recent[[:space:]]servers*) local file="$_cache_history" if [[ ! $subcontext =~ Name ]]; then [[ -f $file ]] && rm $file readarray -t iters < <(cat $history_file) _iterate "$file" "${iters[@]}" fi ;; *Scan[[:space:]]LAN[[:space:]]servers*) local port=$(<<< "$subcontext" awk -F: '{print $2}') local file="$_cache_lan" if [[ ! $subcontext =~ Name ]]; then [[ -f $file ]] && rm $file local lan=$(lan_scan $port) readarray -t iters <<< "$lan" _iterate "$file" "${iters[@]}" fi ;; esac shift logger INFO "Server context is '$subcontext', reading from file '$file'" filter_servers "$file" "$@" } 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" } test_ping(){ shift local ip="$1" local qport="$2" local res res=$(ping -c1 -4 -W0.5 $1 | grep time= | awk -F= '{print $4}') [[ ! $? -eq 0 ]] && res="Unreachable" printf "%s" "$res" } 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" #Start in fullscreen fullscreen="$fullscreen" #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(){ case "$branch" in "stable") version_url="$stable_url/dzgui.sh" ;; "testing") version_url="$testing_url/dzgui.sh" ;; esac echo "$version_url" } get_response_code(){ local url="$1" curl -Ls -I -o /dev/null -w "%{http_code}" "$url" } test_connection(){ source "$config_file" declare -A hr local res1 local res2 local str="No connection could be established to the remote server" hr=( ["github.com"]="https://github.com/$author" ["codeberg.org"]="https://codeberg.org/$author" ) res=$(get_response_code "${hr["github.com"]}") if [[ $res -ne 200 ]]; then logger WARN "Remote host '${hr["github.com"]}' unreachable', trying fallback" remote_host=cb res=$(get_response_code "${hr["codeberg.org"]}") if [[ $res -ne 200 ]]; then return 1 fi fi logger INFO "Set remote host to '${hr["codeberg.org"]}'" if [[ $remote_host == "cb" ]]; then url_prefix="https://codeberg.org/$author/$repo/raw/branch" releases_url="https://codeberg.org/$author/$repo/releases/download/browser" stable_url="$url_prefix/dzgui" testing_url="$url_prefix/testing" fi } download_new_version(){ test_connection rc=$? if [[ $rc -eq 1 ]]; then printf "Remote resource unavailable. Aborting" logger WARN "Remote resource unavailable" return 1 fi local version_url="$(format_version_url)" mv "$src_path" "$src_path.old" curl -Ls "$version_url" > "$src_path" rc=$? if [[ $rc -eq 0 ]]; then dl_changelog 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(){ local mdbranch local file="CHANGELOG.md" [[ $branch == "stable" ]] && mdbranch="dzgui" [[ $branch == "testing" ]] && mdbranch="testing" local md="$url_prefix/${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 [[ $? -eq 1 ]] && return 1 return 255 ;; 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 ;; Toggle[[:space:]]DZGUI[[:space:]]fullscreen[[:space:]]boot) if [[ $fullscreen == "true" ]]; then fullscreen="false" else fullscreen="true" fi ;; esac update_config return 90 } add_to_favs(){ local record="$1" ip_list+=("$record") update_config logger INFO "Added the record $record to saved servers" printf "Added %s to saved servers" $record return 90 } 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" return 90 } update_favs_from_table(){ local context="$1" local record="$2" if [[ $context =~ Remove ]]; then remove_from_favs "$record" echo "Removed $record from saved servers" 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 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 echo "Updated the key '$key' to '$value'" return 90 } 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" ;; "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 [[ $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) CPU: $(< /proc/cpuinfo awk -F": " '/model name/ {print $2; exit}') 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) | select(.filename|contains("screenshot")|not) | "\(.file_size) \(.publishedfileid)"') <<< "$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_fallback(){ local ip="$1" local qport="$2" local mode="$3" if [[ $mode != "rules" ]] && [[ $mode != "names" ]]; then logger WARN "Fallback query API called with an invalid mode: '$mode'" return 1 fi [[ -z $api_key ]] && return 1 logger INFO "Using fallback query API on '$ip:$qport' with mode: '$mode'" local res=$(curl -s "$bm_api" -H "Authorization: Bearer "$api_key"" \ -G -d "filter[game]=$game" \ -d "filter[search]=%22${ip}:${qport}%22") [[ -z $res ]] && return 1 local len=$(<<< "$res" jq '.data|length') [[ $len -eq 0 ]] && return 1 # cull defunct entries local record=$(<<< "$res" jq -r ' .data[].attributes |select(.status != "removed")') # reverse lookup gport local dlc local gport=$(<<< "$record" jq -r '.port') dlc=$(is_dlc "$ip" "$gport") if [[ $dlc == "true" ]]; then logger FAIL "Fallback query API called on DLC-enabled server" return 1 fi case "$mode" in "rules") <<< "$record" jq '.details.modIds[]' ;; "names") <<< "$record" jq ' .details|[.modNames, .modIds] as [$n, $i] | {names: $n, ids: $i}' ;; esac } 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 logger INFO "Merging modlists" diff=$(merge_modlists "$diff") diff=$(query_defunct "$diff") fi if [[ -n $diff ]]; then if [[ $(check_architecture) -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) manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "auto" ;; esac else launch "$ip" "$gameport" "$sanitized_mods" fi } check_architecture(){ local cpu=$(< /proc/cpuinfo grep "AMD Custom APU 0405") if [[ -n "$cpu" ]]; then echo 1 else echo 0 fi } focus_beta_client(){ _wid(){ wmctrl -ilx |\ awk 'tolower($3) == "steamwebhelper.steam"' |\ awk '$5 ~ /^Steam|Steam Games List/' |\ awk '{print $1}' } $steam_cmd steam://open/library 2>/dev/null 1>&2 && $steam_cmd steam://open/console 2>/dev/null 1>&2 && sleep 1s until [[ -n $(_wid) ]]; do sleep 0.1s done wmctrl -ia $(_wid) sleep 0.1s local wid=$(xdotool getactivewindow) local geo=$(xdotool getwindowgeometry $wid) local pos=$(<<< "$geo" awk 'NR==2 {print $2}' | sed 's/,/ /') local dim=$(<<< "$geo" awk 'NR==3 {print $2}' | sed 's/x/ /') local pos1=$(<<< "$pos" awk '{print $1}') local pos2=$(<<< "$pos" awk '{print $2}') local dim1=$(<<< "$dim" awk '{print $1}') local dim2=$(<<< "$dim" awk '{print $2}') local dim1=$(((dim1/2)+pos1)) local dim2=$(((dim2/2)+pos2)) xdotool mousemove $dim1 $dim2 xdotool click 1 $steam_cmd steam://open/library 2>/dev/null 1>&2 && $steam_cmd steam://open/console 2>/dev/null 1>&2 } auto_mod_install(){ # currently unused, merged with manual method local ip="$1" local gameport="$2" local diff="$3" local sanitized_mods="$4" console_dl "$diff" && $steam_cmd steam://open/downloads local total=$(<<< "$diff" wc -l) until [[ -z $(compare "$diff") ]]; do local missing=$(compare "$diff" | wc -l) echo "# Downloaded $(($total-missing)) of $total mods. ESC cancels" done | $steamsafe_zenity --pulsate --progress --title="DZG Watcher" --auto-close --no-cancel --width=500 2>/dev/null if [[ ! $? -eq 0 ]]; then echo "User aborted connect process. Steam may have mods pending for download." exit 1 fi local diff=$(compare "$sanitized_mods") if [[ -z $diff ]]; then #wipe old version file and replace with latest stamps rm "$versions_file" check_timestamps logger INFO "Local modlist matches remote, initiating launch request" launch "$ip" "$gameport" "$sanitized_mods" fi } force_update(){ if [[ ! $auto_install -eq 1 ]]; then printf "Only available when mod auto-install is ON" return 1 fi rm "$versions_file" local update=$(check_timestamps) manual_mod_install "null" "null" "$update" "null" "force" echo "Finished requesting mod updates." return 0 } console_dl(){ readarray -t modids <<< "$@" focus_beta_client sleep 1.5s for i in "${modids[@]}"; do xdotool type --delay 0 "workshop_download_item $aid $i" sleep 0.5s xdotool key Return sleep 0.5s done } get_local_stamps(){ readarray -t modlist < <(printf "%s\n" "$@") local max="${#modlist[@]}" _concat(){ for ((i=0;i<$max;i++)); do printf "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' } _post } update_stamps(){ readarray -t stamps <<< "$1" for((i=0;i<${#stamps[@]};i++)); do printf "%s\n" "${stamps[$i]}" >> $versions_file done } check_timestamps(){ readarray -t local_modlist < <(ls -1 $workshop_dir) local max=${#local_modlist[@]} logger INFO "Local mod count: $max" [[ $max -eq 0 ]] && return 1 local local_stamps=$(get_local_stamps "${local_modlist[@]}") if [[ -z $local_stamps ]]; then logger WARN "Timestamp query returned empty response" return 1 fi local aligned=$(<<< "$local_stamps" jq -r '.response.publishedfiledetails[]|"\(.publishedfileid),\(.time_updated)"') readarray -t remote_ids < <(<<< "$aligned" awk -F, '{print $1}') readarray -t remote_times < <(<<< "$aligned" awk -F, '{print $2}') readarray -t old_ids < <(< $versions_file awk -F, '{print $1}') readarray -t old_times < <(< $versions_file awk -F, '{print $2}') if [[ ! -f $versions_file ]]; then logger INFO "No prior versions file found, creating" update_stamps "$aligned" #force refresh all mods if versions file was missing printf "%s\n" "${remote_ids[@]}" return 0 fi declare -A remote_version declare -A local_version for((i = 0; i < ${#remote_ids[@]}; ++i)); do remote_version[${remote_ids[$i]}]=${remote_times[$i]} done needs_update=() for((i=0;i<${#old_ids[@]};i++)); do local id=${old_ids[$i]} local time=${old_times[$i]} if [[ $time != ${remote_version[$id]} ]]; then logger WARN "Mod '$id' timestamp '$time' != '${remote_version[$id]}'" needs_update+=($id) fi done printf "%s\n" "${needs_update[@]}" } merge_modlists(){ local diff="$1" _sort(){ printf "%s\n" "$diff" printf "%s\n" "${needs_update[@]}" } readarray -t needs_update < <(check_timestamps) if [[ -z ${needs_update[@]} ]]; then echo "$diff" return 0 fi if [[ -z "$diff" ]] && [[ ${#needs_update[@]} -gt 0 ]]; then printf "%s\n" "${needs_update[@]}" else # remove duplicates _sort | sort -u 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_x64.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 "Debug mode: these options would have been used to launch the game: $launch_options" return 0 fi echo "$concat" > "$_cache_launch" echo "$ip:$gameport" > "$_cache_address" logger INFO "Saved launch params: '$concat'" printf "Launch conditions satisfied. DayZ will now launch after you confirm this dialog." return 100 } final_handshake(){ local saved_mods=$(< "$_cache_launch") local saved_address=$(< "$_cache_address") local res=$(is_dayz_running) if [[ $res -eq 1 ]]; then logger WARN "DayZ appears to already be running" printf "Is DayZ already running? DZGUI cannot launch DayZ if another process is using it." return 1 fi logger INFO "Kicking off Steam launch" local params=() params+=("-connect=$saved_address") params+=("-nolauncher") params+=("-nosplash") params+=("-skipintro") params+=("-name=$name") params+=("-mod=$saved_mods") $steam_cmd -applaunch $aid "${params[@]}" & until [[ $(is_dayz_running) -eq 1 ]]; do sleep 0.1s done logger INFO "Caught DayZ process" printf "\n" return 6 } manual_mod_install(){ local ip="$1" local gameport="$2" local diff="$3" local sanitized_mods="$4" local mode="$5" 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]} if [[ $mode == "auto" ]] || [[ $mode == "force" ]]; then $steam_cmd "steam://url/CommunityFilePage/${stage_mods[$i]}+workshop_download_item $aid ${stage_mods[$i]}" echo "# Opening workshop page for ${stage_mods[$i]}. If you see no progress, you may be out of disk space." else $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." fi 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 if [[ $mode == "auto" ]] || [[ $mode == "force" ]]; then echo "# Steam is downloading ${stage_mods[$i]} (mod $((i+1)) of ${#stage_mods[@]}). You do not need to manually Subscribe." else echo "# Steam is downloading ${stage_mods[$i]} (mod $((i+1)) of ${#stage_mods[@]})" fi 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) if [[ $mode == "force" ]]; then rm "$versions_file" check_timestamps return 0 fi local diff=$(compare "$sanitized_mods") if [[ -z $diff ]]; then if [[ $mode == "auto" ]]; then rm "$versions_file" check_timestamps fi launch "$ip" "$gameport" "$sanitized_mods" else printf "User aborted download process, or 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 "$@"