1
0
Fork 0
mirror of https://github.com/aclist/dztui.git synced 2024-12-28 13:22:35 +01:00
dztui/helpers/funcs
2024-12-13 10:45:39 +09:00

1555 lines
46 KiB
Bash
Executable file

#!/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 "$@"