Compare commits

...

32 Commits

Author SHA1 Message Date
aclist
998b15d5e8
Merge pull request #228 from aclist/prerelease/6.0.0-beta.6
Some checks failed
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
Prerelease/6.0.0-beta.6
2025-09-12 19:05:37 +09:00
aclist
9d8bbec03a chore: update changelog
Some checks failed
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2025-09-12 18:03:38 +09:00
aclist
6425f3d8e3 chore: update checksums
Some checks are pending
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2025-09-11 18:35:12 +09:00
aclist
ad64ba75c9 chore: reword failure message 2025-09-11 18:34:12 +09:00
aclist
47f7622606 fix: return codes in manual mode 2025-09-11 18:33:59 +09:00
aclist
81c2d19a09 chore: update checksums
Some checks are pending
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2025-09-11 17:46:32 +09:00
aclist
b500806815 chore: drop dependency 2025-09-11 17:45:58 +09:00
aclist
60ce184d8f fix: whitespace 2025-09-11 17:10:16 +09:00
aclist
2edb81247b chore: update checksums 2025-09-11 17:09:59 +09:00
aclist
7b67222885 fix: move docstring 2025-09-11 17:09:28 +09:00
aclist
4ddb33cabb chore: update checksums 2025-09-11 17:08:06 +09:00
aclist
364f0b3482 chore: update dataclass params 2025-09-11 17:06:39 +09:00
aclist
5482f296a5 chore: formatting 2025-09-11 17:01:57 +09:00
aclist
0d52c83ed9 chore: refactor pefile methods 2025-09-11 16:58:58 +09:00
aclist
b92e337016 chore: bump version 2025-09-10 16:57:36 +09:00
aclist
ee73c2cfa3 feat: parse pefile 2025-09-10 16:45:49 +09:00
aclist
8ffb1c7ea6 fix: version file not available yet 2025-09-10 16:43:14 +09:00
aclist
9040977447 chore: drop defunct methods 2025-09-10 16:42:09 +09:00
aclist
da6cc9c5c3 chore: reword log message 2025-09-10 16:39:43 +09:00
aclist
c40d767dcc chore: drop extraneous logging 2025-09-10 16:39:25 +09:00
aclist
6f3b272bfe fix: floating point syntax with printf 2025-09-10 16:38:47 +09:00
aclist
ffe3dac3a8 fix: mods downloading too fast 2025-09-10 16:38:06 +09:00
aclist
5fa8498c56 fix: clear linting errors 2025-09-10 16:36:42 +09:00
aclist
c0b5c5bd89 feat: parse hyperlinks 2025-09-10 16:34:58 +09:00
aclist
6d428ebb0d fix: ease padding on mod table 2025-09-10 16:34:05 +09:00
aclist
479be05452 chore: clarify comments 2025-09-10 16:32:33 +09:00
aclist
95a775cd74 fix: newline in tooltip 2025-09-10 16:32:14 +09:00
aclist
1bfb054be2 fix: ease padding on mod table 2025-09-10 16:31:54 +09:00
aclist
6b31bb3ab3 fix: resolution shrinking by default 2025-09-10 16:30:02 +09:00
aclist
c5b13ff0f3 fix: strip newlines in history file 2025-09-10 16:29:22 +09:00
aclist
5a33591d6a chore: drop extraneous path expansion 2025-09-10 16:29:03 +09:00
aclist
62b00507e5 fix: unblock signals during continuous keypress 2025-09-10 16:28:33 +09:00
6 changed files with 1054 additions and 531 deletions

View File

@ -1,5 +1,21 @@
# Changelog # Changelog
## [6.0.0-beta.5] 2025-09-12
## Added
- Support DayZ Experimental
- Show additional client information in Options menu
- Warn user of client version mismatches
- Support clickable hyperlinks
## Fixed
- Mods rarely not appearing in local mod list if download completed too quickly
- Statusbar not updating when clicking a row after spamming keyboard input
- Extraneous logs being generated when subscribing to mods
- Narrow width of columns in modlist dialogs occluding text
- Newline terminators in history file
- Floating point number calculation
- Window resizing too small if no prior resolution was set
## [6.0.0-beta.5] 2025-08-20 ## [6.0.0-beta.5] 2025-08-20
## Fixed ## Fixed
- Servers returning malformed A2S_INFO blocking server browser from loading - Servers returning malformed A2S_INFO blocking server browser from loading

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -o pipefail set -o pipefail
version=6.0.0.beta-5 version=6.0.0.beta-6
#CONSTANTS #CONSTANTS
aid=221100 aid=221100
@ -585,11 +585,12 @@ fetch_helpers_by_sum(){
[[ -f "$config_file" ]] && source "$config_file" [[ -f "$config_file" ]] && source "$config_file"
declare -A sums declare -A sums
sums=( sums=(
["funcs"]="01d45663b7517eae866010df0eba746a" ["funcs"]="a286cc402bfccd39493fe32c53148a95"
["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["query_v2.py"]="55d339ba02512ac69de288eb3be41067"
["servers.py"]="ea5648df7121bb9dfeead9874bfcafcf" ["servers.py"]="7f83d5c1ca54acb12f1bd6657feb2ecf"
["ui.py"]="38b589e4b4fd9a9d3e049e7dcbdc8593" ["ui.py"]="cd9f4b3bc9b1922bb10cbc0c579cf2c0"
["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397"
["pefile.py"]="21531f2c0d9dfa5f110cf6779f9d22c0"
) )
local author="aclist" local author="aclist"
local repo="dztui" local repo="dztui"

View File

@ -1,9 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -o pipefail set -o pipefail
version="6.0.0-beta.5" version="6.0.0-beta.6"
#CONSTANTS #CONSTANTS
aid=221100 aid=221100
exp=1024020
game="dayz" game="dayz"
app_name="dzgui" app_name="dzgui"
app_name_upper="DZGUI" app_name_upper="DZGUI"
@ -46,6 +47,7 @@ _cache_my_servers="$cache_dir/$prefix.my_servers"
_cache_history="$cache_dir/$prefix.history" _cache_history="$cache_dir/$prefix.history"
_cache_launch="$cache_dir/$prefix.launch_mods" _cache_launch="$cache_dir/$prefix.launch_mods"
_cache_address="$cache_dir/$prefix.launch_address" _cache_address="$cache_dir/$prefix.launch_address"
_cache_binary="$cache_dir/$prefix.binary"
_cache_coords="$cache_path/$prefix.coords" _cache_coords="$cache_path/$prefix.coords"
_cache_cooldown="$cache_path/$prefix.cooldown" _cache_cooldown="$cache_path/$prefix.cooldown"
_cache_lan="$cache_path/$prefix.lan" _cache_lan="$cache_path/$prefix.lan"
@ -82,7 +84,6 @@ forum_url="https://old.reddit.com/r/dzgui"
sponsor_url="$gh_prefix/sponsors/$author" sponsor_url="$gh_prefix/sponsors/$author"
battlemetrics_server_url="https://www.battlemetrics.com/servers/dayz" battlemetrics_server_url="https://www.battlemetrics.com/servers/dayz"
steam_api_url="https://steamcommunity.com/dev/apikey" steam_api_url="https://steamcommunity.com/dev/apikey"
#TODO: update link in docs
battlemetrics_api_url="https://www.battlemetrics.com/developers" battlemetrics_api_url="https://www.battlemetrics.com/developers"
bm_api="https://api.battlemetrics.com/servers" bm_api="https://api.battlemetrics.com/servers"
@ -94,23 +95,16 @@ fi
declare -A funcs=( declare -A funcs=(
["Highlight stale"]="find_stale_mods" ["Highlight stale"]="find_stale_mods"
["My servers"]="dump_servers"
["Change player name"]="update_config_val" ["Change player name"]="update_config_val"
["Change Steam API key"]="update_config_val" ["Change Steam API key"]="update_config_val"
["Change Battlemetrics API key"]="update_config_val" ["Change Battlemetrics API key"]="update_config_val"
["Change favorite server"]="add_record" ["Change favorite server"]="add_record"
["Quick-connect to favorite server"]="quick_connect"
["Add server by IP"]="add_record" ["Add server by IP"]="add_record"
["Add server by ID"]="add_record" ["Add server by ID"]="add_record"
["Connect by IP"]="validate_and_connect" ["try_connect"]="try_connect"
["Connect by ID"]="validate_and_connect"
["Connect from table"]="connect_from_table"
["find_id"]="find_id" ["find_id"]="find_id"
["toggle"]="toggle" ["toggle"]="toggle"
["Open link"]="open_link" ["Open link"]="open_link"
["filter"]="dump_servers"
["dump_servers"]="dump_servers"
["get_unique_maps"]="get_unique_maps"
["get_dist"]="get_dist" ["get_dist"]="get_dist"
["test_cooldown"]="test_cooldown" ["test_cooldown"]="test_cooldown"
["query_config"]="query_config" ["query_config"]="query_config"
@ -131,21 +125,25 @@ declare -A funcs=(
["Remove from history"]="remove_from_history" ["Remove from history"]="remove_from_history"
["Force update local mods"]="force_update" ["Force update local mods"]="force_update"
["Resolve IP"]="resolve_ip" ["Resolve IP"]="resolve_ip"
["Handshake"]="final_handshake" ["Handshake"]="handshake"
["Handshake_EXP"]="handshake_exp"
["get_player_count"]="get_player_count" ["get_player_count"]="get_player_count"
["lan_scan"]="lan_scan"
["update_symlinks"]="update_symlinks" ["update_symlinks"]="update_symlinks"
) )
lan_scan(){ clone_symlinks(){
local port="$1" local path="$(< $_cache_binary)"
local res path=$(dirname "$path")
res=$("$lan_helper" "$port") for dir in $(find $game_dir -type l); do
if [[ -z $res ]]; then local link=$(basename $dir)
printf "\n" ln -sf "${dir}" "${path}/${link}"
else done
printf "%s\n" "$res" }
fi handshake(){
final_handshake "$aid"
}
handshake_exp(){
final_handshake "$exp"
} }
query_favorites(){ query_favorites(){
if [[ -z "${ip_list[@]}" ]]; then if [[ -z "${ip_list[@]}" ]]; then
@ -207,44 +205,6 @@ get_player_count(){
printf "%s\n%s" "$players" "$queue" 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(){ map_id_to_ip(){
local id="$1" local id="$1"
local res=$(curl -s "$bm_api" -H "Authorization: Bearer "$api_key"" \ local res=$(curl -s "$bm_api" -H "Authorization: Bearer "$api_key"" \
@ -310,32 +270,10 @@ add_record(){
;; ;;
esac 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(){ start_cooldown(){
logger WARN "API response empty. Started 60s cooldown at $(date +%s)" logger WARN "API response empty. Started 60s cooldown at $(date +%s)"
date +%s > $_cache_cooldown 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(){ is_dlc(){
local dlc local dlc
local ip="$1" local ip="$1"
@ -453,60 +391,10 @@ get_dist(){
else else
logger INFO "Resolved remote server geolocation to '$remote_lat, $remote_lon'" logger INFO "Resolved remote server geolocation to '$remote_lat, $remote_lon'"
local dist=$($km_helper "$local_lat" "$local_lon" "$remote_lat" "$remote_lon") local dist=$($km_helper "$local_lat" "$local_lon" "$remote_lat" "$remote_lon")
LC_NUMERIC=C printf "%d" "$dist" LC_NUMERIC=C printf "%.0f" "$dist"
logger INFO "Distance: $dist km" logger INFO "Distance: $dist km"
fi fi
} }
get_remote_servers(){
params=(
"\\nor\1\map\chernarusplus\\nor\1\map\sakhal\\nor\1\map\enoch\empty\1\\nor\1\map\namalsk"
"\map\namalsk\empty\1"
"\map\namalsk\noplayers\1"
"\map\chernarusplus\empty\1"
"\map\chernarusplus\noplayers\1"
"\map\\sakhal\empty\1"
"\map\\sakhal\noplayers\1"
"\map\\enoch\empty\1"
"\map\\enoch\noplayers\1"
)
local limit=10000
local url="https://api.steampowered.com/IGameServersService/GetServerList/v1/?"
_fetch(){
local param="$1"
curl -LsG "$url" \
-d filter="\appid\221100${param}" \
-d limit=$limit \
-d key=$steam_api \
| jq -M -r '.response.servers'
}
for ((i=0; i <${#params[@]}; i++ )); do
_fetch "${params[$i]}" > $_cache_temp.${i}
done
# ubuntu variants do not support jq 1.7 chained operators
# https://github.com/jqlang/jq/releases/tag/jq-1.7
jq -n '[ [inputs]|add ][]' $_cache_temp.* && rm $_cache_temp.*
}
get_unique_maps(){
shift
local context="$1"
local filter_file
case "$context" in
"My saved servers")
filter_file="$_cache_my_servers"
;;
"Server browser")
filter_file="$_cache_servers"
;;
"Recent servers")
filter_file="$_cache_history"
esac
logger INFO "Map filter context is: '$context', using cached file at '$filter_file'"
< "$filter_file" awk -F$separator '{print $2}' | sort -u
}
query_config(){ query_config(){
[[ -n $2 ]] && local key=$2 [[ -n $2 ]] && local key=$2
keys=( keys=(
@ -517,6 +405,7 @@ query_config(){
"fav_label" "fav_label"
"preferred_client" "preferred_client"
"fullscreen" "fullscreen"
"default_steam_path"
) )
if [[ -n $key ]]; then if [[ -n $key ]]; then
if [[ -n ${!key} ]]; then if [[ -n ${!key} ]]; then
@ -531,154 +420,6 @@ query_config(){
echo "${!i}" echo "${!i}"
done done
} }
filter_servers(){
local filtered="$(< "$1")"
shift
readarray -t filters < <(printf "%s\n" "$@")
for ((i=0; i< ${#filters[@]}; ++i)); do
if [[ ${filters[$i]} =~ Keyword ]]; then
keyword=$(<<< ${filters[$i]} awk -F␞ '{print $2}')
elif [[ ${filters[$i]} =~ Map ]]; then
map=$(<<< ${filters[$i]} awk -F= '{print $2}')
fi
done
filter_ascii(){
if [[ ${filters[*]} =~ Non ]]; then
echo -n "$filtered"
else
<<< "$filtered" sed 's/␞/@@DZGUI_PLACEHOLDER@@/g' | grep -v -P '[^[:ascii:]]' | sed 's/@@DZGUI_PLACEHOLDER@@/␞/g'
fi
}
filter_time(){
if [[ ${filters[*]} =~ Day ]] && [[ ${filters[*]} =~ Night ]]; then
echo -n "$filtered"
elif [[ ${filters[*]} =~ Day ]]; then
<<< "$filtered" awk -F$separator '$4~/^([0][6-9]:|[1][0-6])/'
elif [[ ${filters[*]} =~ Night ]]; then
<<< "$filtered" awk -F$separator '$4~/^([1][7-9]:|[2][0-3]:|[0][0-5])/'
else
echo -n ""
fi
}
filter_perspective(){
if [[ ${filters[*]} =~ 1PP ]] && [[ ${filters[*]} =~ 3PP ]]; then
echo -n "$filtered"
elif [[ ${filters[*]} =~ 1PP ]]; then
<<< "$filtered" awk '!/3PP/'
elif [[ ${filters[*]} =~ 3PP ]]; then
<<< "$filtered" awk '!/1PP/'
else
echo -n ""
fi
}
filter_lowpop(){
if [[ ${filters[*]} =~ Low ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '{if (($5 > 0) && ($5/$6)*100 >=30){print $0}}'
fi
}
filter_full(){
if [[ ${filters[*]} =~ Full ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '$5 != $6'
fi
}
filter_empty(){
if [[ ${filters[*]} =~ Empty ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '$5 != "0"'
fi
}
filter_map(){
if [[ $map == "All maps" ]]; then
echo "$filtered"
else
<<< "$filtered" awk -v var="$map" -F$separator '$2 == var'
fi
}
filter_keyword(){
keyword=$(sanitize "$keyword")
<<< "$filtered" awk -F$separator -v keyword="$keyword" 'tolower($0) ~ tolower(keyword)'
}
filter_duplicates(){
if [[ ${filters[*]} =~ Duplicate ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '!seen[$1]++'
fi
}
filter_official(){
if [[ ${filters[*]} =~ Official ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '$10 == "Community"'
fi
}
filter_community(){
if [[ ${filters[*]} =~ Unoffic ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '$10 == "Official"'
fi
}
filtered=$(filter_perspective)
filtered=$(filter_full)
filtered=$(filter_empty)
filtered=$(filter_time)
filtered=$(filter_map)
filtered=$(filter_lowpop)
filtered=$(filter_ascii)
filtered=$(filter_duplicates)
filtered=$(filter_keyword)
filtered=$(filter_official)
filtered=$(filter_community)
if [[ -z "$filtered" ]]; then
logger WARN "Filter result is empty"
echo -n ""
return
fi
logger INFO "Returning sorted server list back to UI"
printf "%s\n" "$filtered" | sort -k1
}
sanitize(){
echo "$1" | sed \
-e 's/\//\\\//g' \
-e 's/\$/\\$/g' \
-e 's/\[/\\[/g' \
-e 's/\]/\\]/g' \
-e 's/\#/\\#/g' \
-e 's/\./\\./g' \
-e 's/\^/\\^/g' \
-e 's/\=/\\=/g' \
-e 's/|/\\|/g' \
-e 's/\+/\\+/g' \
-e 's/(/\\(/g' \
-e 's/)/\\)/g'
}
parse_server_json(){
local response="$1"
# some servers pad SOH in name
<<< "$response" sed 's/\\u0001//g' | jq -r '
.[]|"\(.name)␞" +
"\(.map|if type == "string" then ascii_downcase else "null" end)␞" +
"\(if .gametype == null then "null" else (.gametype|split(",")|if any(. == "no3rd") then "1PP" else "3PP" end) end)␞" +
"\(if .gametype == null then "null" else (.gametype as $time|$time|test("[0-9]{2}:[0-9]{2}$") as $match|(if $match == true then ($time|scan("[0-9]{2}:[0-9]{2}$")) else "XXXX" end)) end)␞" +
"\(.players)␞" +
"\(.max_players)␞" +
"\(if .gametype == null then "0" elif .gametype|split("lqs")[1] == null then "0" else .gametype|split("lqs")[1]|split(",")[0] end)␞" +
"\(.addr|split(":")[0]):\(if .gameport == null then "XXXX" else .gameport end)␞" +
"\(.addr|split(":")[1])␞" +
"\(if .gametype == null then "null" else (.gametype|split(",")|if any(. == "external") then "Community" else "Official" end) end)"
' | sort -k1
}
align_versions_file(){ align_versions_file(){
shift shift
local mod="$1" local mod="$1"
@ -737,62 +478,6 @@ test_cooldown(){
exit 1 exit 1
fi 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 [[ ! $context =~ filter ]]; then
initialize_remote_servers
fi
;;
*My[[:space:]]saved[[:space:]]servers*)
local file="$_cache_my_servers"
if [[ ! $context =~ filter ]]; then
[[ -f $file ]] && rm $file
_iterate "$file" "${ip_list[@]}"
fi
;;
*Recent[[:space:]]servers*)
local file="$_cache_history"
if [[ ! $context =~ filter ]]; 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 [[ ! $context =~ filter ]]; then
[[ -f $file ]] && rm $file
local lan=$(lan_scan $port)
readarray -t iters <<< "$lan"
_iterate "$file" "${iters[@]}"
fi
;;
esac
shift
logger INFO "Server context is '$subcontext', reading from file '$file'"
filter_servers "$file" "$@"
}
redact(){ redact(){
sed 's@\(/home/\)[^/]*@\1REDACTED@g' sed 's@\(/home/\)[^/]*@\1REDACTED@g'
} }
@ -1201,24 +886,6 @@ open_link(){
xdg-open "$url" xdg-open "$url"
fi 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(){ generate_log(){
source $config_file source $config_file
cat <<-DOC > $system_log cat <<-DOC > $system_log
@ -1408,10 +1075,17 @@ try_fallback(){
esac esac
} }
try_connect(){ try_connect(){
shift
local record="$1" local record="$1"
local appid="$2"
local binary="$3"
local ip=$(<<< $record awk -F: '{print $1}') local ip=$(<<< $record awk -F: '{print $1}')
local gameport=$(<<< $record awk -F: '{print $2}') local gameport=$(<<< $record awk -F: '{print $2}')
local qport=$(<<< $record awk -F: '{print $3}') local qport=$(<<< $record awk -F: '{print $3}')
[[ $appid -eq $exp ]] && echo "$binary" > $_cache_binary
local remote_mods local remote_mods
remote_mods=$(a2s $ip $qport rules) remote_mods=$(a2s $ip $qport rules)
if [[ $? -eq 1 ]]; then if [[ $? -eq 1 ]]; then
@ -1436,11 +1110,11 @@ try_connect(){
return 1 return 1
fi fi
case $auto_install in case $auto_install in
"") manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods";; "") manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "" "$appid";;
1|2) manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "auto" ;; 1|2) manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "auto" "$appid" ;;
esac esac
else else
launch "$ip" "$gameport" "$sanitized_mods" launch "$ip" "$gameport" "$sanitized_mods" "$appid"
fi fi
} }
check_architecture(){ check_architecture(){
@ -1451,68 +1125,6 @@ check_architecture(){
echo 0 echo 0
fi 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(){ force_update(){
if [[ ! $auto_install -eq 1 ]]; then if [[ ! $auto_install -eq 1 ]]; then
printf "Only available when mod auto-install is ON" printf "Only available when mod auto-install is ON"
@ -1520,21 +1132,10 @@ force_update(){
fi fi
rm "$versions_file" rm "$versions_file"
local update=$(check_timestamps) local update=$(check_timestamps)
manual_mod_install "null" "null" "$update" "null" "force" manual_mod_install "null" "null" "$update" "null" "force" "null"
echo "Finished requesting mod updates." echo "Finished requesting mod updates."
return 0 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(){ get_local_stamps(){
readarray -t modlist < <(printf "%s\n" "$@") readarray -t modlist < <(printf "%s\n" "$@")
local max="${#modlist[@]}" local max="${#modlist[@]}"
@ -1573,8 +1174,6 @@ check_timestamps(){
local aligned=$(<<< "$local_stamps" jq -r '.response.publishedfiledetails[]|"\(.publishedfileid),\(.time_updated)"') local aligned=$(<<< "$local_stamps" jq -r '.response.publishedfiledetails[]|"\(.publishedfileid),\(.time_updated)"')
readarray -t remote_ids < <(<<< "$aligned" awk -F, '{print $1}') readarray -t remote_ids < <(<<< "$aligned" awk -F, '{print $1}')
readarray -t remote_times < <(<<< "$aligned" awk -F, '{print $2}') 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 if [[ ! -f $versions_file ]]; then
logger INFO "No prior versions file found, creating" logger INFO "No prior versions file found, creating"
@ -1584,6 +1183,9 @@ check_timestamps(){
return 0 return 0
fi fi
readarray -t old_ids < <(< $versions_file awk -F, '{print $1}')
readarray -t old_times < <(< $versions_file awk -F, '{print $2}')
declare -A remote_version declare -A remote_version
declare -A local_version declare -A local_version
@ -1626,6 +1228,9 @@ concat_mods(){
local encoded_id local encoded_id
local link local link
for i in "${concat_arr[@]}"; do for i in "${concat_arr[@]}"; do
until [[ -f $workshop_dir/$i/meta.cpp ]]; do
sleep 0.1s
done
id=$(awk -F"= " '/publishedid/ {print $2}' "$workshop_dir"/$i/meta.cpp | awk -F\; '{print $1}') id=$(awk -F"= " '/publishedid/ {print $2}' "$workshop_dir"/$i/meta.cpp | awk -F\; '{print $1}')
encoded_id=$(encode $id) encoded_id=$(encode $id)
link="@$encoded_id;" link="@$encoded_id;"
@ -1644,6 +1249,8 @@ launch(){
local ip="$1" local ip="$1"
local gameport="$2" local gameport="$2"
local mods="$3" local mods="$3"
local appid="$4"
local concat local concat
if [[ -n $mods ]]; then if [[ -n $mods ]]; then
concat=$(concat_mods "$mods") concat=$(concat_mods "$mods")
@ -1652,18 +1259,22 @@ launch(){
fi fi
update_symlinks update_symlinks
[[ $appid -eq $exp ]] && clone_symlinks
if [[ $debug -eq 1 ]]; then if [[ $debug -eq 1 ]]; then
local launch_options="$steam_cmd -applaunch $aid -connect=$ip:$gameport -nolauncher -nosplash -name=$name -skipintro -mod=$concat" local launch_options="$steam_cmd -applaunch $appid -connect=$ip:$gameport -nolauncher -nosplash -name=$name -skipintro -mod=$concat"
printf "Debug mode: these options would have been used to launch the game: $launch_options" printf "Debug mode: these options would have been used to launch the game: $launch_options"
return 0 return 0
fi fi
echo "$concat" > "$_cache_launch" echo "$concat" > "$_cache_launch"
echo "$ip:$gameport" > "$_cache_address" echo "$ip:$gameport" > "$_cache_address"
logger INFO "Saved launch params: '$concat'" logger INFO "Saved launch params: '$concat'"
printf "Launch conditions satisfied. DayZ will now launch after you confirm this dialog." printf "Launch conditions satisfied. DayZ will launch after you confirm this dialog."
return 100 [[ $appid == "$aid" ]] && return 100
[[ $appid == "$exp" ]] && return 101
} }
final_handshake(){ final_handshake(){
local appid="$1"
local saved_mods=$(< "$_cache_launch") local saved_mods=$(< "$_cache_launch")
local saved_address=$(< "$_cache_address") local saved_address=$(< "$_cache_address")
local res=$(is_dayz_running) local res=$(is_dayz_running)
@ -1680,7 +1291,7 @@ final_handshake(){
params+=("-skipintro") params+=("-skipintro")
params+=("-name=$name") params+=("-name=$name")
params+=("-mod=$saved_mods") params+=("-mod=$saved_mods")
$steam_cmd -applaunch $aid "${params[@]}" & $steam_cmd -applaunch $appid "${params[@]}" &
until [[ $(is_dayz_running) -eq 1 ]]; do until [[ $(is_dayz_running) -eq 1 ]]; do
sleep 0.1s sleep 0.1s
done done
@ -1694,6 +1305,7 @@ manual_mod_install(){
local diff="$3" local diff="$3"
local sanitized_mods="$4" local sanitized_mods="$4"
local mode="$5" local mode="$5"
local appid="$6"
local ex="$state_path/dzg.watcher" local ex="$state_path/dzg.watcher"
readarray -t stage_mods <<< "$diff" readarray -t stage_mods <<< "$diff"
@ -1702,7 +1314,6 @@ manual_mod_install(){
_watcher(){ _watcher(){
for((i=0;i<${#stage_mods[@]};i++)); do for((i=0;i<${#stage_mods[@]};i++)); do
[[ -f $ex ]] && return 1 [[ -f $ex ]] && return 1
log ${stage_mods[$i]}
if [[ $mode == "auto" ]] || [[ $mode == "force" ]]; then if [[ $mode == "auto" ]] || [[ $mode == "force" ]]; then
$steam_cmd "steam://url/CommunityFilePage/${stage_mods[$i]}+workshop_download_item $aid ${stage_mods[$i]}" $steam_cmd "steam://url/CommunityFilePage/${stage_mods[$i]}+workshop_download_item $aid ${stage_mods[$i]}"
@ -1752,7 +1363,7 @@ manual_mod_install(){
rm "$versions_file" rm "$versions_file"
check_timestamps check_timestamps
fi fi
launch "$ip" "$gameport" "$sanitized_mods" launch "$ip" "$gameport" "$sanitized_mods" "$appid"
else else
printf "User aborted download process, or some mods may have failed to download. Try connecting again to resync." printf "User aborted download process, or some mods may have failed to download. Try connecting again to resync."
exit 1 exit 1
@ -1776,7 +1387,7 @@ foreground(){
} }
main(){ main(){
local params="$(printf '"%s", ' "$@")" local params="$(printf '"%s", ' "$@")"
logger INFO "Received request from UI constructor with params [${params::-2}]" logger INFO "Received request from UI with params [${params::-2}]"
func=${funcs["$1"]} func=${funcs["$1"]}
[[ -z $func ]] && return 1 [[ -z $func ]] && return 1
if [[ -z $2 ]]; then if [[ -z $2 ]]; then

492
helpers/pefile.py Normal file
View File

@ -0,0 +1,492 @@
import json
import struct
import typing # noqa
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from shlex import shlex
from typing import BinaryIO, Union
# https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
endian = "<"
IMAGE_DIRECTORY_ENTRY = 2
VERSION_RESOURCE = 16
RESOURCE_NODE = ".rsrc"
PE32_x86 = "0x10b"
PE32_x64 = "0x20b"
VS_VERSION_INFO_MAGIC = "0xfeef04bd0000"
VS_VERSION_INFO_ID = "VS_VERSION_INFO"
class VersionMatch(Enum):
LOCAL_OLDER = 1
LOCAL_NEWER = 2
SAME_VERSION = 3
class u8:
fmt = "B"
class u16:
fmt = "H"
class u32:
fmt = "L"
class u64:
fmt = "Q"
class i8:
fmt = "b"
class i16:
fmt = "h"
class i32:
fmt = "l"
class i64:
fmt = "q"
class PackedData:
@classmethod
def unpack(cls, data: BinaryIO):
r = []
for key, value in cls.__annotations__.items():
if value == str:
f = data.read(8).rstrip(b"\x00\x00").decode()
else:
fmt = endian + (value.fmt)
size = struct.calcsize(fmt)
f = struct.unpack(fmt, data.read(size))[0]
r.append(f)
return cls(*r)
@dataclass(slots=True, frozen=True)
class COFF_FILE_HDR(PackedData):
machine_type: u16
number_of_sections: u16
timestamp: u32
pointer_to_symbol_table: u32
number_of_symbols: u32
size_of_optional_header: u16
characteristics: u16
@dataclass(slots=True, frozen=True)
class OPTIONAL_HDR_X86(PackedData):
magic: u16
major_linker_ver: u8
minor_linker_ver: u8
size_of_code: u32
size_of_initialized_data: u32
size_of_uninitialized_data: u32
address_of_entry_point: u32
base_of_code: u32
base_of_data: u32
@dataclass(slots=True, frozen=True)
class OPTIONAL_HDR_X64(PackedData):
magic: u16
major_linker_ver: u8
minor_linker_ver: u8
size_of_code: u32
size_of_initialized_data: u32
size_of_uninitialized_data: u32
address_of_entry_point: u32
base_of_code: u32
@dataclass(slots=True, frozen=True)
class OPTIONAL_HDR_WIN_X86(PackedData):
image_base: u32
section_alignment: u32
file_alignment: u32
major_operating_system_version: u16
minor_operating_system_version: u16
major_image_version: u16
minor_image_version: u16
major_subsystem_version: u16
minor_subsystem_version: u16
win32_version_value: u32
size_of_image: u32
size_of_headers: u32
checksum: u32
subsystem: u16
dll_characteristics: u16
size_of_stack_reserve: u32
size_of_stack_commit: u32
size_of_heap_reserve: u32
size_of_heap_commit: u32
loader_flags: u32
number_of_rva_and_sizes: u32
@dataclass(slots=True, frozen=True)
class OPTIONAL_HDR_WIN_X64(PackedData):
image_base: u64
section_alignment: u32
file_alignment: u32
major_operating_system_version: u16
minor_operating_system_version: u16
major_image_version: u16
minor_image_version: u16
major_subsystem_version: u16
minor_subsystem_version: u16
win32_version_value: u32
size_of_image: u32
size_of_headers: u32
checksum: u32
subsystem: u16
dll_characteristics: u16
size_of_stack_reserve: u64
size_of_stack_commit: u64
size_of_heap_reserve: u64
size_of_heap_commit: u64
loader_flags: u32
number_of_rva_and_sizes: u32
@dataclass(slots=True, frozen=True)
class DATA_DIR(PackedData):
virtual_address: u32
size: u32
@dataclass(slots=True, frozen=True)
class SECTION_HDR(PackedData):
name: str
virtual_size: u32
virtual_address: u32
size_of_raw_data: u32
pointer_to_raw_data: u32
pointer_to_relocations: u32
pointer_to_line_numbers: u32
number_of_relocations: u16
number_of_line_numbers: u16
characteristics: u32
@dataclass(slots=True, frozen=True)
class RESOURCE_DIRECTORY_TABLE(PackedData):
characteristics: u32
timestamp: u32
major_version: u16
minor_version: u16
number_of_name_entries: u16
number_of_id_entries: u16
@dataclass(slots=True, frozen=True)
class RESOURCE_DIRECTORY_ENTRY(PackedData):
"""
This field is either a string identifying a data leaf
(if the high bit is set) or an ID to another nested directory
(if the high bit is clear). The outermost level is always a
directory. If it is a name, the lower 31 bits are the offset from the
beginning of the resource section's raw data to the name
(the name consists of 16 bits length and trailing wide characters,
in Unicode, not 0-terminated).
"""
name_or_id: u32
data_or_subdir: u32
@dataclass(slots=True, frozen=True)
class RESOURCE_DATA_ENTRY(PackedData):
data_rva: u32
size: u32
codepage: u32
reserved: u32
@dataclass(slots=True, frozen=True)
class VS_VERSION_INFO_HDR(PackedData):
size: u16
value_length: u16
value_type: u16
@dataclass(slots=True, frozen=True)
class DayZVersion:
major: int
minor: int
patch: int
@dataclass(slots=True, frozen=True)
class FileVersion:
major: int
minor: int
build: int
revision: int
@dataclass(slots=True, frozen=True)
class Result:
local: Union[str, None]
remote: str
build: str
path: Union[Path, None]
match: VersionMatch
error: Union[Exception, None]
class PeFileError(Exception):
"""Expected contents missing from headers or resource nodes"""
pass
class AppNotInstalledError(Exception):
"""App not present in user's libraryfolders"""
pass
class AppMovedError(Exception):
"""VDF points to a nonexistent location on disk"""
pass
class VDFLoadError(Exception):
"""Malformed VDF or JSON conversion"""
pass
def parse_version_number(data: BinaryIO):
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
revision = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
return FileVersion(major, minor, build, revision)
def seek_to_hex(address: str, data: BinaryIO) -> None:
data.seek(int(address, base=16))
def seek_to_pe_stub(data: BinaryIO) -> None:
MAGIC = "0x3c"
seek_to_hex(MAGIC, data)
e_lfanew = hex(struct.unpack("<h", data.read(2))[0])
seek_to_hex(e_lfanew, data)
pe_stub = data.read(4).rstrip(b"\x00\x00\x00\x00").decode()
if pe_stub != "PE":
raise PeFileError("missing PE header data")
def get_dayz_version(file: Path) -> DayZVersion | Exception:
try:
version = get_version(file)
except Exception as e:
return e
patch = str(version.build) + str(version.revision)
dz_vers = DayZVersion(version.major, version.minor, int(patch))
return dz_vers
def dayz_version_to_str(v: DayZVersion) -> str:
return ".".join(str(el) for el in [v.major, v.minor, v.patch])
def dayz_version_from_str(v: str) -> DayZVersion:
vers = v.split(".")
assert len(vers) == 3
return DayZVersion(*[int(el) for el in vers])
def get_version(file):
with open(file, "rb") as f:
seek_to_pe_stub(f)
COFF = COFF_FILE_HDR.unpack(f)
pos = f.tell()
blob = f.read()
magic = hex(struct.unpack("<H", (blob[0:2]))[0])
f.seek(pos)
if magic == PE32_x86:
OPTIONAL_HDR_X86.unpack(f)
OBJW = OPTIONAL_HDR_WIN_X86.unpack(f)
else:
OPTIONAL_HDR_X64.unpack(f)
OBJW = OPTIONAL_HDR_WIN_X64.unpack(f)
if OBJW.number_of_rva_and_sizes < 1:
raise PeFileError("no data resource directory")
data_dirs = []
for rva in range(OBJW.number_of_rva_and_sizes):
data_dir = DATA_DIR.unpack(f)
data_dirs.append(data_dir)
res_dir = data_dirs[IMAGE_DIRECTORY_ENTRY]
dir_va = res_dir.virtual_address
for section in range(COFF.number_of_sections):
hdr = SECTION_HDR.unpack(f)
if hdr.name == RESOURCE_NODE:
va = hdr.virtual_address
ptr = hdr.pointer_to_raw_data
offset = dir_va - va + ptr
seek_to_hex(hex(offset), f)
break
if hdr.name != RESOURCE_NODE:
raise PeFileError("no root resource node found")
table = RESOURCE_DIRECTORY_TABLE.unpack(f)
total = table.number_of_name_entries + table.number_of_id_entries
for entry in range(total):
entry = RESOURCE_DIRECTORY_ENTRY.unpack(f)
if entry.name_or_id == VERSION_RESOURCE:
while entry.data_or_subdir & (1 << 31):
shift = entry.data_or_subdir & ~(1 << 31)
seek_to_hex(hex(offset + shift), f)
table = RESOURCE_DIRECTORY_TABLE.unpack(f)
total = (
table.number_of_name_entries
+ table.number_of_id_entries
)
for entry in range(total):
entry = RESOURCE_DIRECTORY_ENTRY.unpack(f)
break
if entry.name_or_id > VERSION_RESOURCE:
raise PeFileError("no version info node found")
seek_to_hex(hex(offset + entry.data_or_subdir), f)
data = RESOURCE_DATA_ENTRY.unpack(f)
# https://stackoverflow.com/questions/2170843/va-virtual-address-rva-relative-virtual-address
offset = data.data_rva - hdr.virtual_address + hdr.pointer_to_raw_data
seek_to_hex(hex(offset), f)
hdr = VS_VERSION_INFO_HDR.unpack(f)
# https://learn.microsoft.com/en-us/windows/win32/menurc/vs-versioninfo
byte_len = len(VS_VERSION_INFO_ID.encode("utf-16le"))
label = f.read(byte_len).decode("utf-16le")
if label != VS_VERSION_INFO_ID:
raise PeFileError(f"header identifier != '{VS_VERSION_INFO_ID}'")
f.read(32 - byte_len)
identifier = hex(struct.unpack("<Q", f.read(8))[0])
if identifier != VS_VERSION_INFO_MAGIC:
raise PeFileError(
f"{VS_VERSION_INFO_ID} address != '{VS_VERSION_INFO_MAGIC}'"
)
try:
version = parse_version_number(f)
except Exception as e:
return PeFileError(e)
return version
def get_pefile_path(path: str, appid: int) -> Path:
binary = "DayZ_x64.exe"
identifier = {221100: "DayZ", 1024020: "DayZ Exp"}
name = identifier[appid]
pe_path = None
path = path + "/steamapps/libraryfolders.vdf"
with open(path, "r") as f:
try:
j = json.loads(vdf_to_json(f))
except Exception:
raise VDFLoadError("Failed to parse libraryfolders")
for obj in j["libraryfolders"]:
if str(appid) in j["libraryfolders"][obj]["apps"]:
pe_path = j["libraryfolders"][obj]["path"]
pe_path += f"/steamapps/common/{name}/{binary}"
break
if pe_path is None:
raise AppNotInstalledError(
f"Failed to find a libraryfolder for the appid '{appid}'"
)
pe_path = Path(pe_path)
if pe_path.exists() is False:
raise AppMovedError(
f"Path '{pe_path}' specified in libraryfolders does not exist"
)
return pe_path
def compare_versions(local: DayZVersion, remote: DayZVersion):
"""
packaging.version module is not available OOTB on some distributions
"""
if dayz_version_to_str(local) == dayz_version_to_str(remote):
return VersionMatch.SAME_VERSION
if local.major < remote.major:
return VersionMatch.LOCAL_OLDER
if local.major > remote.major:
return VersionMatch.LOCAL_NEWER
if local.major == remote.major:
if local.minor < remote.minor:
return VersionMatch.LOCAL_OLDER
if local.minor > remote.minor:
return VersionMatch.LOCAL_NEWER
if local.minor == remote.minor:
if local.patch < remote.patch:
return VersionMatch.LOCAL_OLDER
if local.patch > remote.patch:
return VersionMatch.LOCAL_NEWER
if local.patch == remote.patch:
return VersionMatch.SAME_VERSION
def vdf_to_json(stream):
def _istr(indent, string):
return (indent * " ") + string
jbuf = "{\n"
lex = shlex(stream)
indent = 1
while True:
tok = lex.get_token()
if not tok:
return jbuf + "}\n"
if tok == "}":
indent -= 1
jbuf += _istr(indent, "}")
ntok = lex.get_token()
lex.push_token(ntok)
if ntok and ntok != "}":
jbuf += ","
jbuf += "\n"
else:
ntok = lex.get_token()
if ntok == "{":
jbuf += _istr(indent, tok + ": {\n")
indent += 1
else:
jbuf += _istr(indent, tok + ": " + ntok)
ntok = lex.get_token()
lex.push_token(ntok)
if ntok != "}":
jbuf += ","
jbuf += "\n"

View File

@ -29,6 +29,18 @@ params = [
] ]
class BmAPIError(Exception):
pass
class BmIdError(Exception):
pass
class InvalidIpError(Exception):
pass
def get_netmask() -> str: def get_netmask() -> str:
hostname = os.uname()[1] hostname = os.uname()[1]
i = socket.gethostbyname(hostname) i = socket.gethostbyname(hostname)
@ -189,38 +201,57 @@ def query_direct(ip: str, qport: int, TIMEOUT=3.0) -> dict | None:
return None return None
@dataclass @dataclass(slots=True, frozen=True)
class Res: class Res:
status: int status: int
parsed: bool parsed: bool
json: Union[str, None] json: Union[str, None]
@dataclass @dataclass(slots=True, frozen=True)
class Ping: class Ping:
addr: str addr: str
iteration: int iteration: int
ping: int ping: int
@dataclass @dataclass(slots=True, frozen=True)
class Details: class Details:
data: Union[list, None] data: Union[list, None]
description: str description: str
success: bool success: bool
def is_passworded(ip: str, qport: int) -> bool: @dataclass(slots=True, frozen=True)
class Prereqs:
password: bool
gameport: int
appid: Union[int, None]
version: Union[str, None]
@dataclass(slots=True)
class Record:
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str
gameport: int
qport: int
def get_prereqs(ip: str, qport: int) -> Prereqs:
try: try:
info = a2s.info((ip, qport)) info = a2s.info((ip, qport))
except TimeoutError: except TimeoutError:
return False return Prereqs(False, 0, None, None)
try: gameport = getattr(info, "port", 0)
password = info.password_protected is_password = getattr(info, "password_protected", False)
except AttributeError: appid = getattr(info, "game_id", None)
return False version = getattr(info, "version", None)
return password
return Prereqs(is_password, gameport, appid, version)
def details(ip: str, qport: int) -> Details: def details(ip: str, qport: int) -> Details:
@ -249,10 +280,10 @@ def details(ip: str, qport: int) -> Details:
for keyword in keywords: for keyword in keywords:
if "etm" in keyword: if "etm" in keyword:
day_accel = float(keyword.lstrip("etm")) day_accel = float(keyword.lstrip("etm"))
day_accel = f"{day_accel:g}" day_accel = float(f"{day_accel:g}")
if "entm" in keywords: if "entm" in keywords:
night_accel = float(keyword.lstrip("entm")) night_accel = float(keyword.lstrip("entm"))
night_accel = f"{night_accel:g}" night_accel = float(f"{night_accel:g}")
try: try:
password = info.password_protected password = info.password_protected
@ -343,12 +374,12 @@ def ping(iteration: int, row: list) -> Ping:
return Ping(addr, iteration, ping) return Ping(addr, iteration, ping)
def query_api(key: str, param: str) -> Res: def query_api(key: str, appid: int, param: str) -> Res:
LIMIT = 10000 LIMIT = 10000
url = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?" url = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?"
payload: dict[str, Union[int, str]] = { payload: dict[str, Union[int, str]] = {
"filter": r"\appid\221100" + param, "filter": r"\appid" + fr"\{appid}" + param,
"limit": LIMIT, "limit": LIMIT,
"key": key, "key": key,
} }
@ -375,3 +406,62 @@ def query_api(key: str, param: str) -> Res:
data = None data = None
finally: finally:
return Res(status, parsed, data) return Res(status, parsed, data)
def query_bm_api(api_key: str, bm_id: str) -> Record:
if bm_id.isnumeric() is False:
raise BmIdError("ID must be numeric only")
payload: dict[str, Union[int, str]] = {
"sort": "-players",
"filter[game]": "dayz",
"filter[ids][whitelist]": bm_id,
}
url = "https://api.battlemetrics.com/servers?"
par = parse.urlencode(payload)
url = f"{url}{par}"
hdr = {"Authorization": "Bearer " + api_key}
r = request.Request(url, headers=hdr)
try:
with request.urlopen(r) as response:
try:
j = json.load(response)
except json.decoder.JSONDecodeError:
raise BmAPIError("Malformed response from Battlemetrics")
if len(j["data"]) < 1:
raise BmAPIError("Not a valid Battlemetrics ID")
j = j["data"][0]["attributes"]
return Record(j["ip"], j["port"], j["portQuery"])
except HTTPError:
raise BmAPIError("Failed to query Battlemetrics")
def validate_ip(addr: str):
fields = addr.split(":")
if len(fields) != 2:
raise InvalidIpError("Address must be formatted as IP:Queryport")
ip = fields[0]
port = fields[1]
try:
int(port)
except ValueError:
raise InvalidIpError(f"'{port}' is not a valid port")
if int(port) > 65535 or int(port) < 0:
raise InvalidIpError(f"'{port}' is not a valid port")
try:
socket.inet_aton(ip)
except OSError:
raise InvalidIpError(f"'{ip}' is not a valid IP")
ip = addr.split(":")[0]
qport = int(addr.split(":")[1])
record = Record(ip, 0, qport)
return record

View File

@ -13,22 +13,30 @@ import threading
import typing # noqa import typing # noqa
import warnings import warnings
from dataclasses import dataclass
from enum import Enum from enum import Enum
from collections.abc import Callable
from concurrent.futures import wait from concurrent.futures import wait
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from collections.abc import Callable
from typing import Literal, Self, Any from typing import Literal, Self, Any
sys.path.append("servers")
import servers as Servers # noqa E402 import servers as Servers # noqa E402
import pefile as PeFile # noqa E402
from pefile import (
VDFLoadError,
AppNotInstalledError,
AppMovedError,
PeFileError,
)
from pefile import VersionMatch, DayZVersion
locale.setlocale(locale.LC_ALL, "") locale.setlocale(locale.LC_ALL, "")
import gi # noqa E402 import gi # noqa E402
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa: E402 from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402
# https://bugzilla.gnome.org/show_bug.cgi?id=708676 # https://bugzilla.gnome.org/show_bug.cgi?id=708676
warnings.filterwarnings("ignore", ".*g_value_get_int", Warning) warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
@ -38,6 +46,9 @@ app_name_lower = app_name.lower()
app_name_abbr = "dzg" app_name_abbr = "dzg"
delimiter = "" delimiter = ""
APPID_DAYZ = 221100
APPID_DAYZ_EXP = 1024020
cache: dict[str, int] = {} cache: dict[str, int] = {}
config_vals: list[str] = [] config_vals: list[str] = []
@ -88,6 +99,13 @@ If this issue persists, your API key may be defunct.
""" """
@dataclass(slots=True)
class Record:
ip: str
gameport: int
qport: int
class Preferences(Enum): class Preferences(Enum):
STEAM = 1 STEAM = 1
BM = 2 BM = 2
@ -350,6 +368,12 @@ class RowType(EnumWithAttrs):
"wait_msg": "Waiting for DayZ", "wait_msg": "Waiting for DayZ",
"type": Command.ONESHOT, "type": Command.ONESHOT,
} }
HANDSHAKE_EXP = {
"label": "Handshake_EXP",
"tooltip": None,
"wait_msg": "Waiting for DayZ",
"type": Command.ONESHOT,
}
DELETE_SELECTED = { DELETE_SELECTED = {
"label": "Delete selected mods", "label": "Delete selected mods",
"tooltip": None, "tooltip": None,
@ -667,13 +691,15 @@ def save_res_and_quit(*args) -> None:
def suppress_signal( def suppress_signal(
owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool owner: Gtk.Widget, widget: Gtk.Widget, func_name: str, state: bool
) -> None: ) -> None:
func = getattr(owner, func_name) func = getattr(owner, func_name)
if state: if state:
logger.debug(f"Unblocking {func_name} for {widget}") logger.debug(f"Blocking {func_name} for {widget}")
widget.handler_block_by_func(func) widget.handler_block_by_func(func)
else: else:
logger.debug(f"Blocking {func_name} for {widget}") logger.debug(f"Unblocking {func_name} for {widget}")
widget.handler_unblock_by_func(func) widget.handler_unblock_by_func(func)
App.treeview.sel_blocked = state
def pluralize(plural: str, count: int) -> str: def pluralize(plural: str, count: int) -> str:
@ -705,6 +731,7 @@ def format_metadata(row_sel: str) -> str:
"fav_label": config_vals[4], "fav_label": config_vals[4],
"preferred_client": config_vals[5], "preferred_client": config_vals[5],
"fullscreen": config_vals[6], "fullscreen": config_vals[6],
"default_steam_path": config_vals[7],
} }
if row is None: if row is None:
return "" return ""
@ -780,7 +807,7 @@ def set_surrounding_margins(widget: Gtk.Widget, margin: int) -> None:
def query_history() -> list | None: def query_history() -> list | None:
try: try:
with open(history_file, "r") as f: with open(history_file, "r") as f:
rows = [row for row in f] rows = [row.rstrip("\n") for row in f]
except OSError: except OSError:
rows = None rows = None
finally: finally:
@ -899,6 +926,11 @@ def process_shell_return_code(
if final_conf == 1 or final_conf is None: if final_conf == 1 or final_conf is None:
return return
process_tree_option(RowType.HANDSHAKE) process_tree_option(RowType.HANDSHAKE)
case 101: # final handshake, exp
final_conf = spawn_dialog(msg, Popup.CONFIRM)
if final_conf == 1 or final_conf is None:
return
process_tree_option(RowType.HANDSHAKE_EXP)
case 255: # dzgui version update case 255: # dzgui version update
msg = "Update complete. Please close DZGUI and restart." msg = "Update complete. Please close DZGUI and restart."
spawn_dialog(msg, Popup.QUIT) spawn_dialog(msg, Popup.QUIT)
@ -907,6 +939,10 @@ def process_shell_return_code(
def call_on_thread( def call_on_thread(
state: bool, subproc: str, msg: str, args: str, choice: RowType = None state: bool, subproc: str, msg: str, args: str, choice: RowType = None
) -> None: ) -> None:
"""
Exclusively used for threaded subprocesses
"""
def _background(subproc: str, args: str, dialog): def _background(subproc: str, args: str, dialog):
def _load() -> None: def _load() -> None:
wait_dialog.destroy() wait_dialog.destroy()
@ -970,6 +1006,22 @@ def process_tree_option(choice: RowType) -> None:
App.grid.notebook.set_page_by_enum(NotebookPage.CHANGELOG) App.grid.notebook.set_page_by_enum(NotebookPage.CHANGELOG)
return return
if command == RowType.QUICK_CONNECT:
record = query_config("fav_server")[0]
if record == "":
spawn_dialog("No favorite server currently set", Popup.NOTIFY)
return
record = str_to_record(record)
thread_new_with_dialog(
App.treeview.prepare_connection,
parse_shell_output,
"Querying server",
command,
[record],
)
return
match command.dict["type"]: match command.dict["type"]:
case Command.HELP: case Command.HELP:
call_bash_func("Open link", cmd_string) call_bash_func("Open link", cmd_string)
@ -986,6 +1038,60 @@ def process_tree_option(choice: RowType) -> None:
return return
def parse_shell_output(proc: subprocess.CompletedProcess, row: RowType):
out = proc.stdout.splitlines()
try:
msg = out[-1]
except IndexError:
msg = ""
process_shell_return_code(msg, proc.returncode, row)
def thread_new_with_dialog(
func: Callable,
callback: Callable | None,
msg: str,
row: RowType | None,
args: list,
) -> None:
"""
Pop a GenericDialog transient to App.treeview and
call a function on a thread, with optional callback.
Chiefly used for connection-related subprocesses.
After completion, the dialog is destroyed in the main event loop
and additional exception handling occurs.
This is intended as a bridge between legacy shell methods and the UI.
A more abstracted version of call_on_thread() for when extra threaded
processing occurs before calls to shell subprocesses.
"""
def background(*args):
def cleanup():
App.treeview.dialog_hide()
if exception is not None:
spawn_dialog(str(exception), Popup.NOTIFY)
process_user_input(row)
return
if callback is not None and proc is not None:
callback(proc, row)
exception = None
proc = None
try:
proc = func(*args)
except Exception as e:
exception = e
GLib.idle_add(cleanup)
return
GLib.idle_add(cleanup)
App.treeview.dialog_show(msg)
thread = threading.Thread(target=background, args=(args))
thread.start()
def process_toggle(command: RowType) -> None: def process_toggle(command: RowType) -> None:
cmd_string = command.dict["label"] cmd_string = command.dict["label"]
match command: match command:
@ -1010,11 +1116,54 @@ def process_toggle(command: RowType) -> None:
proc = call_out("toggle", cmd_string) proc = call_out("toggle", cmd_string)
def str_to_record(record: str) -> Record | None:
r = record.split(":")
if len(r) != 3:
return None
return Record(r[0], int(r[1]), int(r[2]))
def record_to_str(record: Record) -> str:
return f"{record.ip}:{record.gameport}:{record.qport}"
def connect_by_ip(enum: RowType, response: str) -> None:
def _prep(response: str) -> None:
record = Servers.validate_ip(response)
proc = App.treeview.prepare_connection(record)
return proc
thread_new_with_dialog(
_prep, parse_shell_output, "Querying IP", enum, [response]
)
return
def connect_by_id(enum: RowType, response: str, key: str) -> None:
def _prep(key: str, response: str) -> None:
record = Servers.query_bm_api(key, response)
proc = App.treeview.prepare_connection(record)
return proc
thread_new_with_dialog(
_prep, parse_shell_output, "Querying API", enum, [key, response]
)
return
def process_user_input(enum: RowType) -> None: def process_user_input(enum: RowType) -> None:
prompt = enum.dict["prompt"] prompt = enum.dict["prompt"]
link_label = enum.dict["link_label"] link_label = enum.dict["link_label"]
cmd_string = enum.dict["label"] cmd_string = enum.dict["label"]
if enum == RowType.CONN_BY_ID:
key = query_config("api_key")[0]
if len(key) == 0:
spawn_dialog(
"No Battlemetrics API key is set; see Options", Popup.NOTIFY
)
return
user_entry = EntryDialog(prompt, Popup.ENTRY, link_label) user_entry = EntryDialog(prompt, Popup.ENTRY, link_label)
response = user_entry.get_input() response = user_entry.get_input()
@ -1023,6 +1172,14 @@ def process_user_input(enum: RowType) -> None:
return return
logger.info(f"User entered: '{response}'") logger.info(f"User entered: '{response}'")
if enum == RowType.CONN_BY_IP:
connect_by_ip(enum, response)
return
if enum == RowType.CONN_BY_ID:
connect_by_id(enum, response, key)
return
show_wait_dialog = True show_wait_dialog = True
wait_msg = "Working" wait_msg = "Working"
call_on_thread( call_on_thread(
@ -1107,6 +1264,8 @@ class OuterWindow(Gtk.Window):
w, h = res["width"], res["height"] w, h = res["width"], res["height"]
logger.info(f"Restoring window size to {w},{h}") logger.info(f"Restoring window size to {w},{h}")
self.set_default_size(w, h) self.set_default_size(w, h)
else:
self.set_default_size(1400, 800)
def _on_delete_event( def _on_delete_event(
self, window: "OuterWindow", event: Gdk.EventKey self, window: "OuterWindow", event: Gdk.EventKey
@ -1650,6 +1809,7 @@ class TreeView(Gtk.TreeView):
self.view = WindowContext.MAIN_MENU self.view = WindowContext.MAIN_MENU
self.page = WindowContext.MAIN_MENU self.page = WindowContext.MAIN_MENU
self.subpage = None self.subpage = None
self.sel_blocked = False
self.set_fixed_height_mode(True) self.set_fixed_height_mode(True)
@ -1783,7 +1943,7 @@ class TreeView(Gtk.TreeView):
it = self.get_current_iter() it = self.get_current_iter()
name = model.get_value(it, 0) name = model.get_value(it, 0)
record = self.get_record_dict() record = self.get_record_dict()
DetailsDialog(name, record["ip"], record["qport"]) DetailsDialog(name, record.ip, record.qport)
def show_mods(self) -> None: def show_mods(self) -> None:
record = self.get_record_string() record = self.get_record_string()
@ -1970,7 +2130,7 @@ class TreeView(Gtk.TreeView):
if not record: if not record:
grid.statusbar.update_server_meta() grid.statusbar.update_server_meta()
return return
ip = record["ip"] ip = record.ip
if ip in cache: if ip in cache:
km = cache[ip] km = cache[ip]
grid.statusbar.append_distance(km) grid.statusbar.append_distance(km)
@ -2038,12 +2198,13 @@ class TreeView(Gtk.TreeView):
return False return False
else: else:
if is_navkey(event.keyval): if is_navkey(event.keyval):
suppress_signal( if self.sel_blocked is False:
App.treeview, suppress_signal(
App.treeview.selected_row, App.treeview,
"_on_tree_selection_changed", App.treeview.selected_row,
True, "_on_tree_selection_changed",
) True,
)
if keyname.isnumeric() and int(keyname) > 0: if keyname.isnumeric() and int(keyname) > 0:
digit = int(keyname) - 1 digit = int(keyname) - 1
grid.right_panel.filters_vbox.toggle_check(digit) grid.right_panel.filters_vbox.toggle_check(digit)
@ -2078,12 +2239,13 @@ class TreeView(Gtk.TreeView):
Suppresses spamming on keydown Suppresses spamming on keydown
""" """
if is_navkey(event.keyval): if is_navkey(event.keyval):
suppress_signal( if self.sel_blocked is True:
App.treeview, suppress_signal(
App.treeview.selected_row, App.treeview,
"_on_tree_selection_changed", App.treeview.selected_row,
False, "_on_tree_selection_changed",
) False,
)
selection = self.get_selection() selection = self.get_selection()
self._on_tree_selection_changed(selection) self._on_tree_selection_changed(selection)
@ -2127,8 +2289,8 @@ class TreeView(Gtk.TreeView):
addr = model[path][7] addr = model[path][7]
qport = model[path][8] qport = model[path][8]
ip = addr.split(":")[0] ip = addr.split(":")[0]
qport = str(qport) gameport = int(addr.split(":")[1])
return {"ip": ip, "qport": qport} return Record(ip, gameport, qport)
def update_players(self, players: int) -> None: def update_players(self, players: int) -> None:
model = self.get_model() model = self.get_model()
@ -2175,9 +2337,7 @@ class TreeView(Gtk.TreeView):
record = self.get_record_dict() record = self.get_record_dict()
if not record: if not record:
return return
ip = record["ip"] data = call_out("get_player_count", record.ip, str(record.qport))
qport = record["qport"]
data = call_out("get_player_count", ip, qport)
if data.returncode == 1: if data.returncode == 1:
wait_dialog.destroy() wait_dialog.destroy()
return return
@ -2187,10 +2347,13 @@ class TreeView(Gtk.TreeView):
key = query_config("steam_api")[0] key = query_config("steam_api")[0]
job = Servers.query_api job = Servers.query_api
params = Servers.params params = Servers.params
serv = []
with ThreadPoolExecutor() as executor: with ThreadPoolExecutor() as executor:
futures = [executor.submit(job, key, param) for param in params] futures = [
executor.submit(job, key, APPID_DAYZ, param)
for param in params
]
wait(futures) wait(futures)
serv = []
for future in futures: for future in futures:
res = future.result() res = future.result()
if res.status != 200 or not res.parsed: if res.status != 200 or not res.parsed:
@ -2200,7 +2363,13 @@ class TreeView(Gtk.TreeView):
return return
j = res.json j = res.json
serv += j["response"]["servers"] serv += j["response"]["servers"]
parsed = Servers.parse_json(serv)
res = Servers.query_api(key, APPID_DAYZ_EXP, "")
if res.status == 200 and res.parsed is True:
j = res.json
serv += j["response"]["servers"]
parsed = Servers.parse_json(serv)
return parsed return parsed
def _dump_lan(self, port: int) -> list | None: def _dump_lan(self, port: int) -> list | None:
@ -2554,6 +2723,10 @@ class TreeView(Gtk.TreeView):
column.set_cell_data_func( column.set_cell_data_func(
renderer, self._format_float, func_data=None renderer, self._format_float, func_data=None
) )
if column_title == "Mod":
column.set_fixed_width(500)
else:
column.set_fixed_width(150)
else: else:
# WindowContext.TABLE_LOG uses undecorated columns # WindowContext.TABLE_LOG uses undecorated columns
column = Gtk.TreeViewColumn(column_title, renderer, text=i) column = Gtk.TreeViewColumn(column_title, renderer, text=i)
@ -2637,27 +2810,15 @@ class TreeView(Gtk.TreeView):
) )
thread.start() thread.start()
def _background_connection( def dialog_hide(self) -> None:
self, dialog: "GenericDialog", record: str if hasattr(self, "wait_dialog"):
) -> None: self.wait_dialog.destroy()
def load():
dialog.destroy()
out = proc.stdout.splitlines()
msg = out[-1]
process_shell_return_code(msg, proc.returncode, record)
proc = call_out("Connect from table", record) def dialog_show(self, msg: str) -> None:
GLib.idle_add(load) if hasattr(self, "wait_dialog"):
self.wait_dialog.destroy()
def _attempt_connection(self) -> None: self.wait_dialog = GenericDialog(msg, Popup.WAIT)
record = self.get_record_string() self.wait_dialog.show_all()
msg = "Querying server and aligning mods"
wait_dialog = GenericDialog(msg, Popup.WAIT)
wait_dialog.show_all()
thread = threading.Thread(
target=self._background_connection, args=(wait_dialog, record)
)
thread.start()
def is_row_to_server_context(self, view: RowType) -> bool: def is_row_to_server_context(self, view: RowType) -> bool:
"""Row activation that jumps into a server table""" """Row activation that jumps into a server table"""
@ -2697,6 +2858,117 @@ class TreeView(Gtk.TreeView):
def get_view(self): def get_view(self):
return self.view return self.view
def prepare_connection(
self, record: Record
) -> subprocess.CompletedProcess | None:
"""
Always called on a thread with a dialog on the transient parent window
"""
prereqs = Servers.get_prereqs(record.ip, record.qport)
if prereqs.appid is None:
logger.warning(f"Query to '{record.ip}:{record.qport}' timed out")
msg = "Timed out when querying server, check IP or try again later"
spawn_dialog(msg, Popup.NOTIFY)
return None
build = "DayZ" if prereqs.appid == APPID_DAYZ else "DayZ Experimental"
steam_path = query_config("default_steam_path")[0]
if len(steam_path) < 1:
logger.critical(
"Config file has no value set for 'default_steam_path'"
)
msg = f"Local Steam installation is not set, possibly malformed config file."
spawn_dialog(msg, Popup.NOTIFY)
return None
try:
pefile_path = PeFile.get_pefile_path(steam_path, prereqs.appid)
except AppNotInstalledError:
logger.critical(
f"'{prereqs.appid}' not found in user's libraryfolders"
)
msg = (
f"This server is running {build}. You can install "
f"{build} by searching for it in your Steam library. "
f"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
)
spawn_dialog(msg, Popup.NOTIFY)
return None
except AppMovedError:
logger.critical(
f"Library folder synch error for '{prereqs.appid}'"
)
msg = (
f"Steam is reporting that {build} is installed at a non-existent location. "
f"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
)
spawn_dialog(msg, Popup.NOTIFY)
return None
except (VDFLoadError, PeFileError, Exception) as e:
logger.critical(e)
msg = "Steam settings or DayZ installation may be corrupted. Try restarting Steam."
spawn_dialog(msg, Popup.NOTIFY)
return None
try:
local_vers = PeFile.get_dayz_version(pefile_path)
except (PeFileError, Exception) as e:
"""
Currently permissive; file exists, but was unparseable.
"""
logger.warning(f"Failed to parse PE file: {e}")
local_vers = None
try:
remote_vers = PeFile.dayz_version_from_str(prereqs.version)
except Exception:
remote_vers = None
if local_vers is not None and remote_vers is not None:
match = PeFile.compare_versions(local_vers, remote_vers)
match match:
case VersionMatch.LOCAL_OLDER:
msg = (
f"This server is running a newer build ({prereqs.version}) of {build} than "
f"your local version. You may be unable to connect. Proceed anyway?"
)
res = spawn_dialog(msg, Popup.CONFIRM)
if res is True:
return None
case VersionMatch.LOCAL_NEWER:
msg = (
f"This server is running an out-of-date build ({prereqs.version}) of {build}. "
"You may be unable to connect. Proceed anyway?"
)
res = spawn_dialog(msg, Popup.CONFIRM)
if res is True:
return None
case VersionMatch.SAME_VERSION:
pass
if prereqs.password is True:
msg = (
"This server is password-protected and you will be "
"prompted when connecting. Do you want to proceed?"
)
res = spawn_dialog(msg, Popup.CONFIRM)
if res is True:
return None
"""
When using RowType.CONN_BY_IP, the gameport needs to be interpolated
"""
record.gameport = prereqs.gameport
addr = record_to_str(record)
proc = call_out(
"try_connect", addr, str(prereqs.appid), str(pefile_path)
)
return proc
@signal_emission @signal_emission
@update_window_labels @update_window_labels
def _on_row_activated( def _on_row_activated(
@ -2765,15 +3037,13 @@ class TreeView(Gtk.TreeView):
record = self.get_record_dict() record = self.get_record_dict()
if record is None: if record is None:
return return
if Servers.is_passworded(record["ip"], int(record["qport"])): thread_new_with_dialog(
msg = ( self.prepare_connection,
"This server is password-protected and you will be " parse_shell_output,
"prompted when connecting. Do you want to proceed?" "Querying server",
) None,
res = spawn_dialog(msg, Popup.CONFIRM) [record],
if res is True: )
return
self._attempt_connection()
case _: # any other non-server option from the main menu case _: # any other non-server option from the main menu
process_tree_option(output) process_tree_option(output)
@ -3030,7 +3300,7 @@ class LanDialog(Gtk.MessageDialog):
class DetailsDialog(GenericDialog): class DetailsDialog(GenericDialog):
def __init__(self, server_name: str, ip: str, qport: str): def __init__(self, server_name: str, ip: str, qport: int):
super().__init__(server_name, Popup.DETAILS) super().__init__(server_name, Popup.DETAILS)
dialog_box = self.get_content_area() dialog_box = self.get_content_area()
@ -3038,7 +3308,7 @@ class DetailsDialog(GenericDialog):
self.set_size_request(800, 700) self.set_size_request(800, 700)
self.ip = ip.split(":")[0] self.ip = ip.split(":")[0]
self.qport = int(qport) self.qport = qport
self.store = Gtk.ListStore(str, str, Pango.Weight) self.store = Gtk.ListStore(str, str, Pango.Weight)
self.view = Gtk.TreeView( self.view = Gtk.TreeView(
@ -3121,11 +3391,22 @@ class DetailsDialog(GenericDialog):
for row in response.data: for row in response.data:
self.store.append(row + [Pango.Weight.BOLD]) self.store.append(row + [Pango.Weight.BOLD])
self.view.set_model(self.store) self.view.set_model(self.store)
self.description.set_text(response.description)
text = response.description
reg = r"\s(www\.*?)"
text = re.sub(reg, " http://" + r"\1", text)
reg2 = r"(http.*?)([ ,\r\n]|$)"
text = re.sub(reg2, comp(r"\1") + r"\2", text)
self.description.set_markup(text)
self.success = response.success self.success = response.success
GLib.idle_add(self._load) GLib.idle_add(self._load)
def comp(string):
return f'<a href="{string}">{string}</a>'
class ModDialog(GenericDialog): class ModDialog(GenericDialog):
def __init__(self, record: str): def __init__(self, record: str):
msg = "Enter/double click a row to open in Steam Workshop." msg = "Enter/double click a row to open in Steam Workshop."
@ -3150,7 +3431,13 @@ class ModDialog(GenericDialog):
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
self.view.append_column(column) self.view.append_column(column)
column.set_sort_column_id(i) column.set_sort_column_id(i)
column.set_fixed_width(350) match column_title:
case "Mod":
column.set_fixed_width(350)
case "ID":
column.set_fixed_width(200)
case _:
pass
dialogBox.pack_end(self.scrollable, True, True, 0) dialogBox.pack_end(self.scrollable, True, True, 0)
wait_dialog = GenericDialog("Fetching modlist", Popup.WAIT) wait_dialog = GenericDialog("Fetching modlist", Popup.WAIT)
@ -3174,12 +3461,10 @@ class ModDialog(GenericDialog):
self.run() self.run()
self.destroy() self.destroy()
addr = App.treeview.get_record_dict() record = App.treeview.get_record_dict()
if not addr: if not record:
return return
ip = addr["ip"] data = call_out("show_server_modlist", record.ip, str(record.qport))
qport = addr["qport"]
data = call_out("show_server_modlist", ip, qport)
mod_count = self._parse_modlist_rows(data) mod_count = self._parse_modlist_rows(data)
self.view.set_model(modlist_store) self.view.set_model(modlist_store)
GLib.idle_add(_load) GLib.idle_add(_load)
@ -3458,6 +3743,9 @@ class Options(Gtk.Box):
[LeftLabel("Force update local mods"), self.force_button, eb2], [LeftLabel("Force update local mods"), self.force_button, eb2],
] ]
self.dayz_version_label = Gtk.Label(label="-")
self.dayz_exp_version_label = Gtk.Label(label="-")
self.branch_combo = Gtk.ComboBoxText() self.branch_combo = Gtk.ComboBoxText()
self.branch_combo.append_text("Stable") self.branch_combo.append_text("Stable")
self.branch_combo.append_text("Testing") self.branch_combo.append_text("Testing")
@ -3470,7 +3758,11 @@ class Options(Gtk.Box):
) )
eb = InfoEventBox(msg) eb = InfoEventBox(msg)
version_rows = [[LeftLabel("Branch"), self.branch_combo, eb]] version_rows = [
[LeftLabel("DayZ"), self.dayz_version_label],
[LeftLabel("DayZ Experimental"), self.dayz_exp_version_label],
[LeftLabel("DZGUI branch"), self.branch_combo, eb],
]
api_grid = self._make_grid(api_rows) api_grid = self._make_grid(api_rows)
prefs_grid = self._make_grid(pref_rows) prefs_grid = self._make_grid(pref_rows)
@ -3714,6 +4006,7 @@ class Options(Gtk.Box):
name = config_vals[3] name = config_vals[3]
client = config_vals[5] client = config_vals[5]
fullscreen = config_vals[6] fullscreen = config_vals[6]
default_steam_path = config_vals[7]
try: try:
steam = query_config("steam_api")[0] steam = query_config("steam_api")[0]
@ -3756,6 +4049,27 @@ class Options(Gtk.Box):
if field[0] == "": if field[0] == "":
field[1].get_children()[1].set_sensitive(False) field[1].get_children()[1].set_sensitive(False)
try:
pe_file_path = PeFile.get_pefile_path(
default_steam_path, APPID_DAYZ
)
vers = PeFile.get_dayz_version(pe_file_path)
dayz_version = PeFile.dayz_version_to_str(vers)
except Exception:
dayz_version = "-"
try:
exp_file_path = PeFile.get_pefile_path(
default_steam_path, APPID_DAYZ_EXP
)
vers = PeFile.get_dayz_version(exp_file_path)
dayz_exp_version = PeFile.dayz_version_to_str(vers)
except Exception:
dayz_exp_version = "-"
self.dayz_version_label.set_text(dayz_version)
self.dayz_exp_version_label.set_text(dayz_exp_version)
if branch == "testing": if branch == "testing":
self.branch_combo.set_active(1) self.branch_combo.set_active(1)
else: else:
@ -4011,8 +4325,8 @@ class Notebook(Gtk.Notebook):
if hasattr(page, "steam_entry"): if hasattr(page, "steam_entry"):
""" """
Gtk.Notebook focuses the first input field when changing pages; Gtk.Notebook focuses the first input field when changing pages;
this workaround unhighlights the selected region and makes entry fields this workaround unhighlights the selected region and makes entry
unfocusable prior to the page 'switch-page' signal, fields unfocusable prior to the page 'switch-page' signal,
then makes them focusable again then makes them focusable again
""" """
entries = page.steam_entry, page.bm_entry entries = page.steam_entry, page.bm_entry
@ -4265,9 +4579,8 @@ class ModSelectionPanel(Gtk.Box):
}, },
{ {
"label": "Highlight stale", "label": "Highlight stale",
"tooltip": """Shows locally-installed mods "tooltip": "Shows locally-installed mods which are not\n"
which are not used by any server "used by any server in your Saved Servers",
in your Saved Servers""",
}, },
] ]