Compare commits

..

11 Commits

Author SHA1 Message Date
aclist
2228cc26dd
Update CHANGELOG.md
Some checks failed
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2025-12-27 23:59:47 +09:00
aclist
9913e48524
Merge pull request #251 from aclist/fix/setup-whitespaces
fix: enclose whitespaces during setup
2025-12-27 23:46:05 +09:00
aclist
a2dc935ffc
Update bug_report.yml 2025-12-27 21:04:38 +09:00
aclist
1f9159792d
Merge pull request #250 from aclist/chore/template-disclaimer
Chore/template disclaimer
2025-12-27 21:03:54 +09:00
aclist
d0d05cfc2b Merge branch 'main' into chore/template-disclaimer 2025-12-27 21:02:37 +09:00
aclist
8578cad702 chore: add disclaimer 2025-12-27 21:01:59 +09:00
aclist
b1cca7bb69
Update trouble.yml 2025-12-27 21:01:04 +09:00
aclist
a7486ccea9
Update trouble.yml 2025-12-27 21:00:44 +09:00
aclist
ff678dccaf
Update trouble.yml 2025-12-27 21:00:24 +09:00
aclist
243e92b660
Merge pull request #249 from aclist/chore/template-disclaimer
chore: add disclaimer
2025-12-27 20:58:21 +09:00
aclist
51f69e8b4e chore: add disclaimer 2025-12-27 20:57:59 +09:00
21 changed files with 2683 additions and 5833 deletions

3
.gitignore vendored
View File

@ -1,4 +1 @@
tags
__pycache__
pre-commit
tests/

View File

@ -1,110 +1,5 @@
# Changelog
## [6.0.2] 2026-01-26
## Fixed
- Explicitly use Python 3.13 when calling subprocesses
- Raise error correctly from module
- Do not spawn dialogs from inside of thread when fetching prereqs
## Changed
- Optimize time complexity of startup checks
- Optimize coordinate calculation by using local records first
- Rename some internal functions
- Wrap entire startup process in dialog
## [6.0.1] 2026-01-16
## Fixed
- Explicitly use Python 3.13 when calling subprocesses
## [6.0.0] 2026-01-14
## Added
- Support DayZ Experimental
- Ping display on server tables
- Save descriptive text notes on a per-server basis
- Speed up load time and navigation of server tables
- More robust threading and cache system when filtering servers
- Show additional DayZ client information in Options menu
- Warn user of DayZ client version mismatches
- Support clickable hyperlinks in dialogs
- Rich server metadata details dialog (right-click on server table)
- Filter by modded servers
- Breadcrumbs showing current menu context
- Dedicated changelog page
- Dedicated keybindings page
- Dedicated settings page ("Options")
- Support sandboxed version of Flatpak
- Vim-style navigation keybindings
- Added "Return to main menu" button to dialog windows when failing to load server table
- Dynamic context menus for modded servers
- Additional keybindings for new filter toggles
- If a server is locked, prompt user when connecting
- Internal flag to allow distro-packaged releases to disable in-app updates
- Pre-boot checks to test whether script was invoked directly
- Commandline usage help text (GaryBlackbourne)
- More descriptive help dialogs when initial dependencies are missing
## Fixed
- Script failing to start when remote endpoints are unavailable (GaryBlackbourne)
- Key stickiness when quickly navigating through entries in tables
- Servers returning malformed A2S_INFO blocking server browser from loading
- Normalized buttons in dialogs and restored proper padding
- GTK errors being emitted to stdout when inserting debug table
- Entry dialog sensitivity when validating API keys
- Additional validation on entry dialogs to prevent submitting empty text
- Suppress errors during pre-boot checks when mods are not installed
- Centered filter checkboxes within panel
- Do not pop unhighlight/select stale buttons if no stale mods exist
- Improved keybinding interaction with side panels
- Suppress typeahead search in mod dialogs
- Do not trigger global API cooldown if no LAN/favorite servers are found
- Fix table column expansion in server mod dialogs
- Missing parameters when closing application via window decorations
- Statusbar contents not updating on certain pages
- Prevent debug button from activating when in certain input fields
- Window size not updating correctly when unmaximizing window after changing "fullscreen at boot" setting
- Branch toggle signal being emitted when entering Options menu from other contexts
- Mods rarely not appearing in local mod list if download completed too quickly
- Statusbar not updating when clicking a row after spamming keyboard input
- Extraneous logs being generated when subscribing to mods
- Narrow width of columns in modlist dialogs occluding text
- Newline terminators in history file
- Floating point number calculation
- Window resizing too small if no prior resolution was set
- ESC key destroying wait dialogs while a thread is pending
- Tooltip signals being erroneously emitted on main menu
- UI not being constructed correctly if CHANGELOG.md was missing
- Config file erroneously getting updated when populating settings menu
- Leaky variable name in dialog titles
- Prevent extraneous signals from propagating when column width is adjusted
- Server filter panel not being hidden when entering other page contexts
- Path to remote changelog being constructed incorrectly
- Dialogs with newlines breaking output in log table
- Path discovery during first-time setup when parsing filepaths with whitespaces
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
## Changed
- Require Python 3.13
- Reduced global API cooldown from 60s to 30s
- Clarify dialog messages when DayZ path could not be found
- Auto-focus first item when opening context menus
- Opacity setting on side buttons when in a different context
- Refactored BM API key validation to account for new key format
- State file serialization methods
- Optimize time complexity of pre-boot checks (GaryBlackbourne)
- Rewrote distance calculation module (GaryBlackbourne)
- Changed preferred client setting from radio toggle to combobox
## Dropped
- Ping readout in statusbar
- Extraneous information from right statusbar
## [5.8.3] 2026-01-04
## Fixed
- Normalize checksum numbers and dates
## [5.8.2] 2026-01-04
## Fixed
- Fix regression in symlink routine introduced by previous hotfix
## [5.8.1] 2025-12-27
## Fixed
- Path discovery during first-time setup when parsing filepaths with whitespaces
@ -388,7 +283,7 @@ As part of this change, version 4.0.0 introduces the ability to connect to or ad
If you encounter any problems with this new release or with the migration of configs, please do not hesitate to submit a bug report.
Attention Fedora 38 users: problems with upstream GNOME packages causing crashes have been reported to GNOME development and a fix has been issued. You have the choice of compiling the zenity package
Attention Fedora 38 users: problems with upstream GNOME packages causing crashes have been reported to GNOME development and a fix has been issued. You have the choice of compiling the zenity package
from source or waiting until the latest version is merged into Fedora's package manager.
### Added
@ -413,7 +308,7 @@ from source or waiting until the latest version is merged into Fedora's package
- Store complete IP:Port instead of server IDs
- Make Battlemetrics API key optional: this is only used for the 'Connect by ID' and 'Add server by ID' methods and is not required. If you prefer, you can simply connect/add by IP.
- Prevent the application from launching in Game Mode on Steam Deck: Steam Deck's kiosk mode has problems sending keyboard input to third party applications. To prevent unintended usage, DZGUI now warns the user to launch the app in Desktop Mode if they attempt to use it from Game Mode. Adding DZGUI as a Non-Steam Game does work on desktop PCs, but is not recommended due to the way Steam handles subshells. For best results, launch DZGUI directly via the script/applications menu (PC) or via the desktop icon (Steam Deck).
- Omit null servers from list: servers that time out or send an empty response are now omitted entirely from the My Servers list, as they will not return meaningful metadata unless they are online.
- Omit null servers from list: servers that time out or send an empty response are now omitted entirely from the My Servers list, as they will not return meaningful metadata unless they are online.
The My Servers list thus shows online and accessible servers
## [3.3.0] 2023-05-16
@ -432,7 +327,7 @@ from source or waiting until the latest version is merged into Fedora's package
- First-time setup: sudo escalation when checking system map count for the first time
### Fixed
- Steam Deck: non-ASCII delimiter causing setup menu to despawn on some devices
- Steam Deck: non-ASCII delimiter causing setup menu to despawn on some devices
- Don't add items in My Servers multiple times to array when the list of favorites is paginated
- Trigger progress dialogs sooner and in sequence to reduce appearance of visual lag
- First-time setup: break out of dialogs correctly when user backs out

View File

@ -11,15 +11,6 @@ This overcomes certain limitations in the Linux client and helps prepare the gam
- Concatenate launch options to pass to Steam
- Connect to mod-enabled LAN servers
## Design philosophy
DZGUI follows a specific set of guidelines, enumerated below:
- Provide a free and open-source tool under GPL
- Do not use undocumented, third-party servers to aggregate data; no central "DZGUI" server
- All connections go to Steam and DayZ servers directly; the tool should function in perpetuity without intermediary endpoints
- Target Linux desktop and Steam deck specifically; not a cross-platform tool
## Setup and usage
Refer to the documentation for installation and setup instructions:
@ -27,6 +18,7 @@ Refer to the documentation for installation and setup instructions:
- [GitHub](https://aclist.github.io/dzgui/index.html)
- [Mirror (Codeberg)](https://aclist.codeberg.page)
![A screenshot of DZGUI](/images/example.png)
## Attribution

400
dzgui.sh
View File

@ -1,8 +1,7 @@
#!/usr/bin/env bash
set -o pipefail
src_path="$(readlink -e "$0")"
version=6.0.2
version=5.8.1
#CONSTANTS
aid=221100
@ -40,7 +39,6 @@ cols_file="$state_path/$prefix.cols.json"
#CACHE FILES
coords_file="$cache_path/$prefix.coords"
src_path_file="$cache_path/$prefix.src"
#legacy paths
hist_file="$config_path/history"
@ -65,14 +63,17 @@ testing_url="$url_prefix/testing"
releases_url="https://github.com/$author/$repo/releases/download/browser"
km_helper_url="$releases_url/latlon"
set_im_module(){
#TODO: drop pending SteamOS changes
if pgrep -a gamescope | grep -q "generate-drm-mode"; then
unset GTK_IM_MODULE
pgrep -a gamescope | grep -q "generate-drm-mode"
if [[ $? -eq 0 ]]; then
GTK_IM_MODULE=""
logger INFO "Detected Steam Deck (Game Mode), unsetting GTK_IM_MODULE"
else
return
fi
}
redact(){
sed 's@\(/home/\)[^/]*@\1REDACTED@g'
}
@ -86,21 +87,13 @@ logger(){
printf "%s␞%s␞%s::%s()::%s␞%s\n" "$date" "$tag" "$self" "$caller" "$line" "$string" \
| redact >> "$debug_log"
}
setup_dirs(){
directories=()
directories+=("$state_path")
directories+=("$cache_path")
directories+=("$share_path")
directories+=("$helpers_path")
directories+=("$freedesktop_path")
directories+=("$config_path")
directories+=("$log_path")
for dir in "${directories[@]}"; do
mkdir -p "$dir"
for dir in "$state_path" "$cache_path" "$share_path" "$helpers_path" "$freedesktop_path" "$config_path" "$log_path"; do
if [[ ! -d $dir ]]; then
mkdir -p "$dir"
fi
done
}
setup_state_files(){
if [[ -f "$debug_log" ]]; then
rm "$debug_log" && touch $debug_log
@ -111,13 +104,13 @@ setup_state_files(){
logger INFO "Migrating legacy version file"
fi
# wipe cache files
if [[ $(ls -A "$cache_path") ]]; then
for file in "$cache_path"/*; do
local path="$cache_path"
if find "$path" -mindepth 1 -maxdepth 1 | read; then
for file in $path/*; do
rm "$file"
done
logger INFO "Wiped cache files"
fi
echo "$src_path" > "$src_path_file"
}
print_config_vals(){
local keys=(
@ -139,10 +132,10 @@ print_config_vals(){
}
test_gobject(){
python3.13 -c "import gi"
python3 -c "import gi"
if [[ ! $? -eq 0 ]]; then
logger CRITICAL "Missing PyGObject"
quit_with_help_dialog "Requires PyGObject (python-gobject)"
fdialog "Requires PyGObject (python-gobject)"
exit 1
fi
logger INFO "Found PyGObject in Python env"
@ -221,14 +214,17 @@ default_steam_path="$default_steam_path"
#Preferred Steam launch command (for Flatpak support)
preferred_client="$preferred_client"
#DZGUI source path
src_path="$src_path"
END
}
depcheck(){
for dep in "${!deps[@]}"; do
if ! command -v "$dep" &> /dev/null; then
command -v "$dep" 2>&1>/dev/null
if [[ $? -eq 1 ]]; then
local msg="Requires $dep >= ${deps[$dep]}"
echo "$msg"
exit 1
raise_error_and_quit "$msg"
fi
done
local jqmsg="jq must be compiled with support for oniguruma"
@ -237,31 +233,14 @@ depcheck(){
[[ $? -ne 0 ]] && raise_error_and_quit "$jqmsg"
logger INFO "Initial dependencies satisfied"
}
open_url(){
url="$1"
if [[ -n "$BROWSER" ]]; then
logger INFO "Opening '$url' in '$BROWSER'"
"$BROWSER" "$url"
else
logger INFO "Opening '$url' with xdg-open"
xdg-open "$url"
fi
}
quit_with_help_dialog(){
local sel
msg="$1"
help_button="Open help page"
url="https://aclist.github.io/dzgui/installation.html"
logger CRITICAL "$msg"
sel=$(zenity --info --extra-button="$help_button" --text="$msg")
[[ $sel == "$help_button" ]] && open_url "$url" &
exit 1
}
check_pyver(){
if [[ ! $(python3.13 --version) ]]; then
local msg="Requires Python 3.13"
quit_with_help_dialog "$msg"
local pyver=$(python3 --version | awk '{print $2}')
local minor=$(<<< $pyver awk -F. '{print $2}')
if [[ -z $pyver ]] || [[ ${pyver:0:1} -lt 3 ]] || [[ $minor -lt 10 ]]; then
local msg="Requires Python >=3.10"
raise_error_and_quit "$msg"
fi
logger INFO "Found Python version: $pyver"
}
watcher_deps(){
if [[ ! $(command -v wmctrl) ]] && [[ ! $(command -v xdotool) ]]; then
@ -335,8 +314,6 @@ check_unmerged(){
fi
}
check_version(){
echo "# Checking version"
[[ -n $reference_branch ]] && return
local version_url=$(format_version_url)
local upstream=$(curl -Ls "$version_url" | awk -F= '/^version=/ {print $2}')
local res=$(get_response_code "$version_url")
@ -386,7 +363,7 @@ prompt_dl(){
Your branch: $branch
Your version: $version
Upstream version: $upstream
Version updates introduce important bug fixes and are encouraged. Attempt to download the latest version?
EOF
}
@ -400,11 +377,10 @@ prompt_dl(){
dl_changelog(){
local mdbranch
local md
source "$config_file"
[[ $branch == "stable" ]] && mdbranch="dzgui"
[[ $branch == "testing" ]] && mdbranch="testing"
local changelog="$url_prefix/${mdbranch}/CHANGELOG.md"
curl -Ls "$changelog" > "$state_path/CHANGELOG.md"
local md="$url_prefix/${mdbranch}/$file"
curl -Ls "$md" > "$state_path/CHANGELOG.md"
}
test_display_mode(){
pgrep -a gamescope | grep -q "generate-drm-mode"
@ -415,7 +391,6 @@ test_display_mode(){
fi
}
check_architecture(){
echo "# Checking system architecture"
local cpu=$(< /proc/cpuinfo awk -F": " '/AMD Custom APU [0-9]{4}$/ {print $2; exit}')
read -a APU_MODEL <<< "$cpu"
if [[ ${APU_MODEL[3]} != "0932" ]] && [[ ${APU_MODEL[3]} != "0405" ]]; then
@ -432,7 +407,6 @@ check_architecture(){
logger INFO "Setting architecture to 'Steam Deck'"
}
check_map_count(){
echo "# Checking map count"
[[ $is_steam_deck -gt 0 ]] && return 0
local map_count_file="/proc/sys/vm/max_map_count"
local min_count=1048576
@ -482,7 +456,6 @@ tdialog(){
$steamsafe_zenity --info --text="$1" "${zenity_flags[@]}"
}
steam_deps(){
echo "# Checking Steam"
local flatpak
local steam
[[ $(command -v flatpak) ]] && flatpak=$(flatpak list | grep valvesoftware.Steam)
@ -491,70 +464,56 @@ steam_deps(){
local msg="Found neither Steam nor Flatpak Steam"
raise_error_and_quit "$msg"
exit 1
elif [[ -n "$steam" ]] && [[ -n "$flatpak" ]]; then
[[ -n $preferred_client ]] && return 0
if [[ -z $preferred_client ]]; then
preferred_client="steam"
fi
elif [[ -n "$steam" ]]; then
preferred_client="steam"
else
preferred_client="flatpak"
fi
update_config
logger INFO "Preferred client set to '$preferred_client'"
}
migrate_files(){
if [[ ! -f $config_path/dztuirc.oldapi ]]; then
cp $config_file $config_path/dztuirc.oldapi
logger INFO "Migrated old API file"
fi
[[ ! -f $hist_file ]] && return
rm $hist_file
logger INFO "Wiped old history file"
}
stale_symlinks(){
echo "# Cleaning stale symlinks"
local game_dir="$steam_path/steamapps/common/DayZ"
readarray -t links < <(find "$game_dir" -xtype l)
for link in "${links[@]}"; do
logger DEBUG "Updating stale symlink '$link'"
unlink "$link"
for l in "$(find "$game_dir" -xtype l)"; do
logger DEBUG "Updating stale symlink '$l'"
unlink "$l"
done
}
check_availability() {
if [[ -z $1 ]]; then
return 1
fi
local url=$1
local timeout_sec="2"
if [[ $2 ]]; then
timeout_sec=$2
fi
if ! ping -w "$timeout_sec" "$url" > /dev/null 2>&1; then
logger WARN "Failed to reach $url, service may be down."
return 1
fi
}
local_latlon(){
echo "# Checking coordinates"
if [[ -z $(command -v dig) ]]; then
local url_ipecho="https://ipecho.net/plain"
if ! check_availability "ipecho.net"; then
logger WARN "Failed to get external ip address, ipecho.net service may be down."
return 1
fi
local local_ip=$(curl -Ls "$url_ipecho")
local local_ip=$(curl -Ls "https://ipecho.net/plain")
else
# TODO : implement checking remote
local local_ip=$(dig -4 +short myip.opendns.com @resolver1.opendns.com)
fi
if calc_local_coords "$local_ip"; then
return 0
fi
local url_ip_api="http://ip-api.com/json/$local_ip"
if ! check_availability "ip-api.com"; then
logger WARN "Failed to get local coordinates, ip-api.com service may be down."
return 1
fi
local res=$(curl -Ls "$url_ip_api" | jq -r '"\(.lat)\n\(.lon)"')
local url="http://ip-api.com/json/$local_ip"
local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"')
if [[ -z "$res" ]]; then
logger WARN "Failed to get local coordinates"
return 1
fi
echo "$res" > "$coords_file"
}
lock(){
echo "# Setting up lock file"
[[ ! -f $lock_file ]] && touch $lock_file
local pid=$(cat $lock_file)
ps -p $pid -o pid= >/dev/null 2>&1
res=$?
if [[ $res -eq 0 ]]; then
local msg="DZGUI is already running ($pid)"
local msg="DZGUI already running ($pid)"
raise_error_and_quit "$msg"
elif [[ $pid == $$ ]]; then
:
@ -583,13 +542,13 @@ fetch_a2s(){
logger INFO "Updated A2S helper to sha '$sha'"
}
fetch_dzq(){
local sum="0a334e1e144e76e560419d155435c91e"
local sum="9caed1445c45832f4af87736ba3f9637"
local file="$helpers_path/a2s/dayzquery.py"
if [[ -f $file ]] && [[ $(get_hash "$file") == $sum ]]; then
logger INFO "DZQ is current"
return 0
fi
local sha=a22a9f428cbe075d7dda62f78000296955eea92a
local sha=3088bbfb147b77bc7b6a9425581b439889ff3f7f
local author="yepoleb"
local repo="dayzquery"
local url="https://raw.githubusercontent.com/$author/$repo/$sha/dayzquery.py"
@ -626,12 +585,11 @@ fetch_helpers_by_sum(){
[[ -f "$config_file" ]] && source "$config_file"
declare -A sums
sums=(
["funcs"]="5523d933e9f0668fc14b3faf0d4aafd8"
["ui.py"]="f128a97e744e9e11036d707198feb8a8"
["query_v2.py"]="55d339ba02512ac69de288eb3be41067"
["servers.py"]="ed442c3aecf33f777d59dcf53650d263"
["ui.py"]="d09b9d8bed3854efd51377289786c5ac"
["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397"
["pefile.py"]="b452974a84bff1d821872fcebf59e380"
["funcs"]="94287c75a5ae0a06a95dd439711e6fba"
["lan"]="c62e84ddd1457b71a85ad21da662b9af"
)
local author="aclist"
local repo="dztui"
@ -650,10 +608,6 @@ fetch_helpers_by_sum(){
realbranch="dzgui"
fi
if [[ -n $reference_branch ]]; then
realbranch="$reference_branch"
fi
for i in "${!sums[@]}"; do
file="$i"
sum="${sums[$i]}"
@ -677,6 +631,7 @@ fetch_helpers_by_sum(){
logger INFO "Updated '$full_path' to sum '$sum'"
fi
[[ $file == "funcs" ]] && chmod +x "$full_path"
[[ $file == "lan" ]] && chmod +x "$full_path"
done
return 0
}
@ -691,12 +646,15 @@ fetch_km_helper(){
}
get_response_code(){
local url="$1"
curl --connect-timeout 3 -Ls -I -o /dev/null -w "%{http_code}" "$url"
curl -Ls -I -o /dev/null -w "%{http_code}" "$url"
}
raise_error_and_quit(){
echo "$1"
exit 1
}
fetch_ip_db(){
parse_dl_url(){
curl --connect-timeout 3 -Ls "$url" \
curl -Ls "$url" \
| grep "csv.gz" \
| awk -F"['']" '{print $2}'
}
@ -712,9 +670,6 @@ fetch_ip_db(){
fetch(){
logger INFO "Triggering fetch routine"
local url="$1"
local ip_file="${state_path}/ips.csv"
local base_file="${state_path}/${this_month}.csv.gz"
local extracted_file="${state_path}/${this_month}.csv"
curl -Ls "$url" > "$base_file"
if [[ $? -ne 0 ]]; then
@ -734,7 +689,7 @@ fetch_ip_db(){
logger WARN "Abnormal exit while parsing IPs"
return
fi
rm "${state_path}/${this_month}.csv"
rm "${state_path}/${month}.csv"
readarray -t records < <(cat $ip_file | awk -F, 'NR==1 {print $1} END {print $1}')
if [[ ${records[0]} != "0.0.0.0" ]] && [[ ${records[1]} != "224.0.0.0" ]]; then
@ -749,57 +704,55 @@ fetch_ip_db(){
rm "$ip_file"
fi
echo "$this_month" > "$month_file"
logger INFO "Wrote '$this_month' to stub '$month_file'"
echo "$month" > "$month_file"
logger INFO "Wrote '$month' to stub '$month_file'"
logger INFO "Updated '$ip_file'"
}
check_remote(){
local url="$1"
local res=$(get_response_code "$url")
if [[ $res -ne 200 ]]; then
logger WARN "Failed to retrieve remote resource: '$url' ($res)"
return
fi
logger INFO "Resolved remote URL: '$url'"
# test dl url
local dl_url="$(parse_dl_url)"
local res=$(get_response_code "$dl_url")
if [[ $res -ne 200 ]]; then
logger WARN "Remote resource unavailable: '$dl_url' ($res)"
return
fi
logger INFO "Resolved download URL: '$dl_url'"
fetch "$dl_url"
}
local url="https://db-ip.com/db/download/ip-to-city-lite"
month_file="${state_path}/.month"
this_month=$(date +%Y-%m)
local month_file="${state_path}/.month"
local ip_file="${state_path}/ips.csv"
# test main url
local res=$(get_response_code "$url")
if [[ $res -ne 200 ]]; then
logger WARN "Failed to retrieve remote resource: '$url' ($res)"
return
fi
logger INFO "Resolved remote URL: '$url'"
# test dl url
local dl_url="$(parse_dl_url)"
local res=$(get_response_code "$dl_url")
if [[ $res -ne 200 ]]; then
logger WARN "Remote resource unavailable: '$dl_url' ($res)"
return
fi
logger INFO "Resolved download URL: '$dl_url'"
local month=$(parse_dl_url_date "$dl_url")
local base_file="${state_path}/${month}.csv.gz"
local extracted_file="${state_path}/${month}.csv"
# no stub file
if [[ ! -f $month_file ]]; then
logger WARN "No stub file '$month_file' present"
check_remote "$url"
return
fi
# if stub is same date, abort
local last_month=$(< "$month_file")
if [[ $last_month == "$this_month" ]]; then
logger INFO "Local stub '$last_month' is identical to remote, skipping"
fetch "$dl_url"
return
fi
# local needs update
logger WARN "Local stub '$last_month' does not match '$this_month'"
check_remote "$url"
return
local last_month=$(< "$month_file")
if [[ $last_month != "$month" ]]; then
logger WARN "Local stub '$last_month' does not match remote stub '$month'"
fetch "$dl_url"
return
fi
# if stub is same date, abort
logger INFO "Local stub '$last_month' is identical to remote, skipping"
}
fetch_helpers(){
echo "# Checking helper files"
fetch_a2s
fetch_dzq
fetch_km_helper
@ -898,7 +851,7 @@ file_picker(){
}
find_library_folder(){
local search_path="$1"
readarray -t paths < <(python3.13 "$helpers_path/vdf2json.py" -i "$1/steamapps/libraryfolders.vdf" \
readarray -t paths < <(python3 "$helpers_path/vdf2json.py" -i "$1/steamapps/libraryfolders.vdf" \
| jq -r '.libraryfolders[]|select(.apps|has("221100")).path')
if [[ ! $? -eq 0 ]]; then
logger WARN "Failed to parse Steam path using '$search_path'"
@ -926,7 +879,6 @@ create_config(){
unset default_steam_path
unset steam_path
preferred_client="steam"
while true; do
local player_input="$($steamsafe_zenity \
--forms \
@ -987,9 +939,8 @@ create_config(){
done
}
varcheck(){
echo "# Checking config file"
local msg="Config file '$config_file' missing. Start first-time setup now?"
local msg2="The Steam paths set in your config file appear to be invalid (DayZ was moved or uninstalled). Restart first-time setup now?"
local msg2="The Steam paths set in your config file appear to be invalid. Restart first-time setup now?"
if [[ ! -f $config_file ]]; then
qdialog "$msg" "Yes" "Exit"
if [[ $? -eq 1 ]]; then
@ -1012,16 +963,18 @@ varcheck(){
create_config
return 0
fi
if [[ $src_path != $(realpath "$0") ]]; then
src_path=$(realpath "$0")
update_config
fi
}
is_dzg_downloading(){
echo "# Checking DayZ"
if [[ -d $steam_path ]] && [[ -d $steam_path/downloading/$aid ]]; then
logger WARN "DayZ may be scheduling updates"
return 0
fi
}
is_steam_running(){
echo "# Checking Steam"
local res=$(ps aux | grep "steamwebhelper" | grep -v grep)
if [[ -z $res ]]; then
logger WARN "Steam may not be running"
@ -1034,7 +987,6 @@ get_response_code(){
curl -Ls -I -o /dev/null -w "%{http_code}" "$url"
}
test_connection(){
echo "# Testing connection"
declare -A hr
local res1
local res2
@ -1068,8 +1020,6 @@ legacy_cols(){
mv $cols_file.new $cols_file
}
stale_mod_signatures(){
echo "# Checking stale mods"
[[ ! -f "$versions_file" ]] && return
local workshop_dir="$steam_path/steamapps/workshop/content/$aid"
if [[ -d $workshop_dir ]]; then
readarray -t old_mod_ids < <(awk -F, '{print $1}' $versions_file)
@ -1082,48 +1032,18 @@ stale_mod_signatures(){
}
create_new_links(){
echo "# Setting up symlinks"
"$func_helper" "update_symlinks"
}
calc_local_coords(){
local ip="$1"
[[ ! -f "$geo_helper" ]] && return 1
IFS="." read -ra split <<< "$ip"
[[ ${#split[@]} -ne 4 ]] && return 1
prefix="^${split[0]}.${split[1]}."
readarray -t res < <(grep "$prefix" "$geo_helper")
for address in "${res[@]}"; do
IFS="," read -ra fields <<< "$address"
upper="${fields[1]}"
IFS="." read -ra upper_digits <<< "$upper"
if [[ ${split[2]} -gt ${upper_digits[2]} ]]; then
continue
fi
if [[ ${split[2]} -eq ${upper_digits[2]} ]]; then
if [[ ${split[3]} -gt ${upper_digits[3]} ]]; then
continue
fi
if [[ ${split[3]} -eq ${upper_digits[3]} ]]; then
echo "${fields[-2]}" >> "$coords_file"
echo "${fields[-1]}" >> "$coords_file"
return 0
fi
fi
if [[ ${split[2]} -lt ${upper_digits[2]} ]]; then
echo "${fields[-2]}" >> "$coords_file"
echo "${fields[-1]}" >> "$coords_file"
return 0
fi
done
return 1
}
initial_setup(){
setup_dirs
setup_state_files
depcheck
check_pyver
test_gobject
watcher_deps
check_architecture
test_connection
fetch_helpers
fetch_helpers > >(pdialog "Checking helper files")
varcheck
source "$config_file"
lock
@ -1132,12 +1052,14 @@ initial_setup(){
check_version
check_map_count
steam_deps
migrate_files
stale_symlinks
stale_mod_signatures
create_new_links
local_latlon
is_steam_running
is_dzg_downloading
print_config_vals
}
uninstall(){
_full(){
@ -1181,82 +1103,22 @@ uninstall(){
rm "$self"
echo "Uninstall routine complete"
}
usage(){
cat <<- EOM
DZGUI - Free and Open Source DayZ launcher
Usage:
dzgui.sh [options]
Description:
When no option is provided, the script launches DZGUI.
Options:
-u, --uninstall:
Uninstalls the software
-v, --version:
Prints the version
-h, --help:
Prints this message
EOM
}
main(){
# setup zenity environment
local zenv=""
zenv=$(zenity --version 2>/dev/null)
local zenv=$(zenity --version 2>/dev/null)
[[ -z $zenv ]] && { echo "Requires zenity >= ${deps[$steamsafe_zenity]}"; exit 1; }
# parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
"--uninstall" | "-u")
uninstall
exit 0
# shift
;;
"--version" | "-v")
echo $version
exit 0
# shift
;;
"--help" | "-h")
usage
exit 0
# shift
;;
*)
echo "Unrecognized command!"
return 1
esac
done
if [[ $1 == "--uninstall" ]] || [[ $1 == "-u" ]]; then
uninstall &&
exit 0
fi
set_im_module
printf "Checking dependencies...\n"
setup_dirs
setup_state_files
depcheck
check_pyver
test_gobject
watcher_deps
initial_setup > >(pdialog "Initializing setup")
printf "Initializing setup...\n"
initial_setup
printf "All OK. Kicking off UI...\n"
python3.13 "$ui_helper" "--init-ui" "$version" "$is_steam_deck"
python3 "$ui_helper" "--init-ui" "$version" "$is_steam_deck"
}
if [[ $(basename "$0") == "dzgui.sh" ]]; then
main "$@"
#TODO: tech debt: cruddy handling for steam forking
[[ $? -eq 1 ]] && pkill -f dzgui.sh
fi
main "$@"
#TODO: tech debt: cruddy handling for steam forking
[[ $? -eq 1 ]] && pkill -f dzgui.sh

View File

@ -1,10 +1,9 @@
#!/usr/bin/env bash
set -o pipefail
version="6.0.2"
version="5.8.0"
#CONSTANTS
aid=221100
exp=1024020
game="dayz"
app_name="dzgui"
app_name_upper="DZGUI"
@ -47,11 +46,9 @@ _cache_my_servers="$cache_dir/$prefix.my_servers"
_cache_history="$cache_dir/$prefix.history"
_cache_launch="$cache_dir/$prefix.launch_mods"
_cache_address="$cache_dir/$prefix.launch_address"
_cache_binary="$cache_dir/$prefix.binary"
_cache_coords="$cache_path/$prefix.coords"
_cache_cooldown="$cache_path/$prefix.cooldown"
_cache_lan="$cache_path/$prefix.lan"
_cache_src_path="$cache_path/$prefix.src"
#XDG
freedesktop_path="$HOME/.local/share/applications"
@ -85,28 +82,38 @@ forum_url="https://old.reddit.com/r/dzgui"
sponsor_url="$gh_prefix/sponsors/$author"
battlemetrics_server_url="https://www.battlemetrics.com/servers/dayz"
steam_api_url="https://steamcommunity.com/dev/apikey"
#TODO: update link in docs
battlemetrics_api_url="https://www.battlemetrics.com/developers"
bm_api="https://api.battlemetrics.com/servers"
steam_cmd="$preferred_client"
if [[ $preferred_client == "steam" ]]; then
steam_cmd="steam"
else
steam_cmd="flatpak run com.valvesoftware.Steam"
fi
declare -A funcs=(
["Highlight stale"]="find_stale_mods"
["My servers"]="dump_servers"
["Change player name"]="update_config_val"
["Change Steam API key"]="update_config_val"
["Change Battlemetrics API key"]="update_config_val"
["Change client"]="update_config_val"
["Change favorite server"]="add_record"
["Quick-connect to favorite server"]="quick_connect"
["Add server by IP"]="add_record"
["Add server by ID"]="add_record"
["try_connect"]="try_connect"
["Connect by IP"]="validate_and_connect"
["Connect by ID"]="validate_and_connect"
["Connect from table"]="connect_from_table"
["find_id"]="find_id"
["toggle"]="toggle"
["Open link"]="open_link"
["filter"]="dump_servers"
["dump_servers"]="dump_servers"
["get_unique_maps"]="get_unique_maps"
["get_dist"]="get_dist"
["test_cooldown"]="test_cooldown"
["query_config"]="query_config"
["query_favorites"]="query_favorites"
["start_cooldown"]="start_cooldown"
["List installed mods"]="list_mods"
["Delete selected mods"]="delete_local_mod"
@ -123,31 +130,21 @@ declare -A funcs=(
["Remove from history"]="remove_from_history"
["Force update local mods"]="force_update"
["Resolve IP"]="resolve_ip"
["Handshake"]="handshake"
["Handshake_EXP"]="handshake_exp"
["Handshake"]="final_handshake"
["get_player_count"]="get_player_count"
["lan_scan"]="lan_scan"
["update_symlinks"]="update_symlinks"
)
clone_symlinks(){
local path="$(< $_cache_binary)"
path=$(dirname "$path")
for dir in $(find $game_dir -type l); do
local link=$(basename $dir)
ln -sf "${dir}" "${path}/${link}"
done
}
handshake(){
final_handshake "$aid"
}
handshake_exp(){
final_handshake "$exp"
}
query_favorites(){
if [[ -z "${ip_list[@]}" ]]; then
return 1
lan_scan(){
local port="$1"
local res
res=$("$lan_helper" "$port")
if [[ -z $res ]]; then
printf "\n"
else
printf "%s\n" "$res"
fi
printf "%s\n" "${ip_list[@]}"
}
find_stale_mods(){
local res
@ -203,6 +200,44 @@ get_player_count(){
printf "%s\n%s" "$players" "$queue"
}
validate_and_connect(){
local context="$1"
local addr="$2"
local record
case "$context" in
"Connect by ID")
if [[ -z "$api_key" ]]; then
printf "No Battlemetrics API key set"
return 4
fi
record=$(map_id_to_ip "$addr")
if [[ $? -eq 1 ]]; then
logger WARN "Not a valid record: '$addr'"
printf "Not a valid ID"
return 2
fi
logger INFO "Battlemetrics ID resolved to IP $record"
;;
"Connect by IP")
if [[ $(validate_ip "$addr") -eq 1 ]]; then
printf "Not a valid IP format. Supply IP:Queryport"
return 2
fi
local ip=$(<<< $addr awk -F: '{print $1}')
local qport=$(<<< $addr awk -F: '{print $2}')
local res
res=$(a2s $ip $qport info)
if [[ ! $? -eq 0 ]]; then
printf "Timed out when querying the server. Is this a valid server?"
return 2
fi
local gameport="$(<<< $res jq -r '.[].gameport')"
record="${ip}:${gameport}:${qport}"
logger INFO "Record resolved to $record"
esac
try_connect "$record"
}
map_id_to_ip(){
local id="$1"
local res=$(curl -s "$bm_api" -H "Authorization: Bearer "$api_key"" \
@ -268,10 +303,32 @@ add_record(){
;;
esac
}
connect_by_id(){
if [[ $(validate_ip "$addr") -eq 1 ]]; then
printf "Not a valid IP format. Supply IP:Queryport"
return 2
fi
local ip=$(<<< $addr awk -F: '{print $1}')
local qport=$(<<< $addr awk -F: '{print $2}')
local res
res=$(a2s $ip $qport info)
if [[ ! $? -eq 0 ]]; then
printf "Timed out when querying the server. Is this a valid server?"
return 2
fi
#res contains modlist
}
start_cooldown(){
logger WARN "API response empty. Started 60s cooldown at $(date +%s)"
date +%s > $_cache_cooldown
}
initialize_remote_servers(){
local file="$_cache_servers"
[[ -f $file ]] && rm "$file"
local res
res=$(get_remote_servers)
parse_server_json "$res" >> "$file"
}
is_dlc(){
local dlc
local ip="$1"
@ -286,7 +343,7 @@ a2s(){
local mode="$3"
logger INFO "Querying '$ip:$qport' with mode '$mode'"
local res
res=$(python3.13 "$query_helper" "$ip" "$qport" "$mode")
res=$(python3 "$query_helper" "$ip" "$qport" "$mode")
if [[ $? -eq 1 ]]; then
res=$(try_fallback "$ip" "$qport" "$mode")
if [[ $? -eq 1 ]]; then
@ -309,7 +366,7 @@ is_in_favs(){
find_id(){
local file="$default_steam_path/config/loginusers.vdf"
[[ ! -f $file ]] && return 1
local res=$(python3.13 "$helpers_path/vdf2json.py" \
local res=$(python3 "$helpers_path/vdf2json.py" \
-i "$file" \
| jq -r '.users
|to_entries[]
@ -338,7 +395,7 @@ list_mods(){
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.13 -c "n=($size/1024) +.005; print(round(n,4))")
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)
# user may have manually pruned mods out-of-band
@ -367,10 +424,6 @@ local_latlon(){
get_dist(){
shift
local given_ip="$1"
if [[ ! -f $_cache_coords ]]; then
printf "Unknown"
return
fi
readarray -t coords < "$_cache_coords"
readarray -t n < <(<<< "$given_ip" awk 'BEGIN{RS="."}{$1=$1}1')
@ -393,11 +446,60 @@ get_dist(){
else
logger INFO "Resolved remote server geolocation to '$remote_lat, $remote_lon'"
local dist=$($km_helper "$local_lat" "$local_lon" "$remote_lat" "$remote_lon")
LC_NUMERIC=C printf "%.0f" "$dist"
LC_NUMERIC=C printf "%d" "$dist"
logger INFO "Distance: $dist km"
fi
}
get_remote_servers(){
params=(
"\\nor\1\map\chernarusplus\\nor\1\map\sakhal\\nor\1\map\enoch\empty\1\\nor\1\map\namalsk"
"\map\namalsk\empty\1"
"\map\namalsk\noplayers\1"
"\map\chernarusplus\empty\1"
"\map\chernarusplus\noplayers\1"
"\map\\sakhal\empty\1"
"\map\\sakhal\noplayers\1"
"\map\\enoch\empty\1"
"\map\\enoch\noplayers\1"
)
local limit=10000
local url="https://api.steampowered.com/IGameServersService/GetServerList/v1/?"
_fetch(){
local param="$1"
curl -LsG "$url" \
-d filter="\appid\221100${param}" \
-d limit=$limit \
-d key=$steam_api \
| jq -M -r '.response.servers'
}
for ((i=0; i <${#params[@]}; i++ )); do
_fetch "${params[$i]}" > $_cache_temp.${i}
done
# ubuntu variants do not support jq 1.7 chained operators
# https://github.com/jqlang/jq/releases/tag/jq-1.7
jq -n '[ [inputs]|add ][]' $_cache_temp.* && rm $_cache_temp.*
}
get_unique_maps(){
shift
local context="$1"
local filter_file
case "$context" in
"My saved servers")
filter_file="$_cache_my_servers"
;;
"Server browser")
filter_file="$_cache_servers"
;;
"Recent servers")
filter_file="$_cache_history"
esac
logger INFO "Map filter context is: '$context', using cached file at '$filter_file'"
< "$filter_file" awk -F$separator '{print $2}' | sort -u
}
query_config(){
[[ -n $2 ]] && local key=$2
keys=(
@ -408,7 +510,6 @@ query_config(){
"fav_label"
"preferred_client"
"fullscreen"
"default_steam_path"
)
if [[ -n $key ]]; then
if [[ -n ${!key} ]]; then
@ -423,7 +524,154 @@ query_config(){
echo "${!i}"
done
}
filter_servers(){
local filtered="$(< "$1")"
shift
readarray -t filters < <(printf "%s\n" "$@")
for ((i=0; i< ${#filters[@]}; ++i)); do
if [[ ${filters[$i]} =~ Keyword ]]; then
keyword=$(<<< ${filters[$i]} awk -F␞ '{print $2}')
elif [[ ${filters[$i]} =~ Map ]]; then
map=$(<<< ${filters[$i]} awk -F= '{print $2}')
fi
done
filter_ascii(){
if [[ ${filters[*]} =~ Non ]]; then
echo -n "$filtered"
else
<<< "$filtered" sed 's/␞/@@DZGUI_PLACEHOLDER@@/g' | grep -v -P '[^[:ascii:]]' | sed 's/@@DZGUI_PLACEHOLDER@@/␞/g'
fi
}
filter_time(){
if [[ ${filters[*]} =~ Day ]] && [[ ${filters[*]} =~ Night ]]; then
echo -n "$filtered"
elif [[ ${filters[*]} =~ Day ]]; then
<<< "$filtered" awk -F$separator '$4~/^([0][6-9]:|[1][0-6])/'
elif [[ ${filters[*]} =~ Night ]]; then
<<< "$filtered" awk -F$separator '$4~/^([1][7-9]:|[2][0-3]:|[0][0-5])/'
else
echo -n ""
fi
}
filter_perspective(){
if [[ ${filters[*]} =~ 1PP ]] && [[ ${filters[*]} =~ 3PP ]]; then
echo -n "$filtered"
elif [[ ${filters[*]} =~ 1PP ]]; then
<<< "$filtered" awk '!/3PP/'
elif [[ ${filters[*]} =~ 3PP ]]; then
<<< "$filtered" awk '!/1PP/'
else
echo -n ""
fi
}
filter_lowpop(){
if [[ ${filters[*]} =~ Low ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '{if (($5 > 0) && ($5/$6)*100 >=30){print $0}}'
fi
}
filter_full(){
if [[ ${filters[*]} =~ Full ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '$5 != $6'
fi
}
filter_empty(){
if [[ ${filters[*]} =~ Empty ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '$5 != "0"'
fi
}
filter_map(){
if [[ $map == "All maps" ]]; then
echo "$filtered"
else
<<< "$filtered" awk -v var="$map" -F$separator '$2 == var'
fi
}
filter_keyword(){
keyword=$(sanitize "$keyword")
<<< "$filtered" awk -F$separator -v keyword="$keyword" 'tolower($0) ~ tolower(keyword)'
}
filter_duplicates(){
if [[ ${filters[*]} =~ Duplicate ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '!seen[$1]++'
fi
}
filter_official(){
if [[ ${filters[*]} =~ Official ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '$10 == "Community"'
fi
}
filter_community(){
if [[ ${filters[*]} =~ Unoffic ]]; then
echo -n "$filtered"
else
<<< "$filtered" awk -F$separator '$10 == "Official"'
fi
}
filtered=$(filter_perspective)
filtered=$(filter_full)
filtered=$(filter_empty)
filtered=$(filter_time)
filtered=$(filter_map)
filtered=$(filter_lowpop)
filtered=$(filter_ascii)
filtered=$(filter_duplicates)
filtered=$(filter_keyword)
filtered=$(filter_official)
filtered=$(filter_community)
if [[ -z "$filtered" ]]; then
logger WARN "Filter result is empty"
echo -n ""
return
fi
logger INFO "Returning sorted server list back to UI"
printf "%s\n" "$filtered" | sort -k1
}
sanitize(){
echo "$1" | sed \
-e 's/\//\\\//g' \
-e 's/\$/\\$/g' \
-e 's/\[/\\[/g' \
-e 's/\]/\\]/g' \
-e 's/\#/\\#/g' \
-e 's/\./\\./g' \
-e 's/\^/\\^/g' \
-e 's/\=/\\=/g' \
-e 's/|/\\|/g' \
-e 's/\+/\\+/g' \
-e 's/(/\\(/g' \
-e 's/)/\\)/g'
}
parse_server_json(){
local response="$1"
# some servers pad SOH in name
<<< "$response" sed 's/\\u0001//g' | jq -r '
.[]|"\(.name)␞" +
"\(.map|if type == "string" then ascii_downcase else "null" end)␞" +
"\(if .gametype == null then "null" else (.gametype|split(",")|if any(. == "no3rd") then "1PP" else "3PP" end) end)␞" +
"\(if .gametype == null then "null" else (.gametype as $time|$time|test("[0-9]{2}:[0-9]{2}$") as $match|(if $match == true then ($time|scan("[0-9]{2}:[0-9]{2}$")) else "XXXX" end)) end)␞" +
"\(.players)␞" +
"\(.max_players)␞" +
"\(if .gametype == null then "0" elif .gametype|split("lqs")[1] == null then "0" else .gametype|split("lqs")[1]|split(",")[0] end)␞" +
"\(.addr|split(":")[0]):\(if .gameport == null then "XXXX" else .gameport end)␞" +
"\(.addr|split(":")[1])␞" +
"\(if .gametype == null then "null" else (.gametype|split(",")|if any(. == "external") then "Community" else "Official" end) end)"
' | sort -k1
}
align_versions_file(){
shift
local mod="$1"
@ -475,13 +723,69 @@ test_cooldown(){
local old_time=$(< $_cache_cooldown)
local cur_time=$(date +%s)
local delta=$(($cur_time - $old_time))
if [[ $delta -lt 30 ]]; then
local remains=$((30 - $delta))
if [[ $delta -lt 60 ]]; then
local remains=$((60 - $delta))
local suffix=$(pluralize "seconds" $remains)
printf "Global API cooldown in effect. Please wait %s %s." "$remains" "$suffix"
exit 1
fi
}
dump_servers(){
local context="$1"
local subcontext="$2"
local ip
local qport
local res
_iterate(){
local file="$1"
shift
for server in "$@"; do
ip=$(<<< $server awk -F: '{print $1}')
qport=$(<<< $server awk -F: '{print $3}')
res=$(a2s "$ip" "$qport" info)
if [[ ! $? -eq 0 ]]; then
continue
fi
parse_server_json "$res" >> "$file"
done
}
case "$subcontext" in
*Server[[:space:]]browser*)
local file="$_cache_servers"
if [[ ! $subcontext =~ Name ]]; then
initialize_remote_servers
fi
;;
*My[[:space:]]saved[[:space:]]servers*)
local file="$_cache_my_servers"
if [[ ! $subcontext =~ Name ]]; then
[[ -f $file ]] && rm $file
_iterate "$file" "${ip_list[@]}"
fi
;;
*Recent[[:space:]]servers*)
local file="$_cache_history"
if [[ ! $subcontext =~ Name ]]; then
[[ -f $file ]] && rm $file
readarray -t iters < <(cat $history_file)
_iterate "$file" "${iters[@]}"
fi
;;
*Scan[[:space:]]LAN[[:space:]]servers*)
local port=$(<<< "$subcontext" awk -F: '{print $2}')
local file="$_cache_lan"
if [[ ! $subcontext =~ Name ]]; then
[[ -f $file ]] && rm $file
local lan=$(lan_scan $port)
readarray -t iters <<< "$lan"
_iterate "$file" "${iters[@]}"
fi
;;
esac
shift
logger INFO "Server context is '$subcontext', reading from file '$file'"
filter_servers "$file" "$@"
}
redact(){
sed 's@\(/home/\)[^/]*@\1REDACTED@g'
}
@ -589,6 +893,9 @@ 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(){
@ -642,7 +949,6 @@ download_new_version(){
return 1
fi
local version_url="$(format_version_url)"
local src_path="$(< "$_cache_src_path")"
mv "$src_path" "$src_path.old"
curl -Ls "$version_url" > "$src_path"
rc=$?
@ -699,6 +1005,13 @@ toggle(){
debug=""
fi
;;
Toggle[[:space:]]Steam/Flatpak)
if [[ $preferred_client == "steam" ]]; then
preferred_client="flatpak"
else
preferred_client="steam"
fi
;;
Toggle[[:space:]]DZGUI[[:space:]]fullscreen[[:space:]]boot)
if [[ $fullscreen == "true" ]]; then
fullscreen="false"
@ -782,10 +1095,10 @@ test_steam_api(){
}
test_bm_api(){
local key="$1"
if [[ -z $key ]]; then
echo 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" \
@ -797,35 +1110,29 @@ update_config_val(){
local context="$1"
local value="$2"
case $1 in
"Change client")
key="preferred_client"
;;
"Change player name")
key="name"
if [[ -z "${value// }" ]]; then
printf "Invalid name"
return 2
fi
;;
"Change Steam API key")
key="steam_api"
if [[ ${#value} -lt 32 ]] || [[ $(test_steam_api "$value") -eq 1 ]]; then
printf "Invalid API key"
return 78
return 2
fi
;;
"Change Battlemetrics API key")
key="api_key"
if [[ $(test_bm_api "$value") -eq 1 ]]; then
printf "Invalid API key"
return 79
return 2
fi
;;
esac
declare -n nr=$key
nr="$value"
update_config
return 80
echo "Updated the key '$key' to '$value'"
return 90
}
show_log(){
< "$debug_log" sed 's/Keyword␞/Keyword/'
@ -883,6 +1190,24 @@ open_link(){
xdg-open "$url"
fi
}
quick_connect(){
if [[ -z $fav_server ]]; then
printf "No favorite server currently set"
return 1
fi
try_connect "$fav_server"
}
connect_from_table(){
shift
local record="$1"
try_connect "$record"
}
pretty_print(){
while read -r line; do
printf "\t%s\n" "$line"
done < "$@"
}
generate_log(){
source $config_file
cat <<-DOC > $system_log
@ -1023,9 +1348,6 @@ update_history(){
echo "$record" >> "$history_file"
}
update_symlinks(){
if [[ ! -d "$workshop_dir" ]]; then
return
fi
legacy_symlinks
symlinks
}
@ -1072,17 +1394,10 @@ try_fallback(){
esac
}
try_connect(){
shift
local record="$1"
local appid="$2"
local binary="$3"
local ip=$(<<< $record awk -F: '{print $1}')
local gameport=$(<<< $record awk -F: '{print $2}')
local qport=$(<<< $record awk -F: '{print $3}')
[[ $appid -eq $exp ]] && echo "$binary" > $_cache_binary
local remote_mods
remote_mods=$(a2s $ip $qport rules)
if [[ $? -eq 1 ]]; then
@ -1107,11 +1422,11 @@ try_connect(){
return 1
fi
case $auto_install in
"") manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "" "$appid";;
1|2) manual_mod_install "$ip" "$gameport" "$diff" "$sanitized_mods" "auto" "$appid" ;;
"") 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" "$appid"
launch "$ip" "$gameport" "$sanitized_mods"
fi
}
check_architecture(){
@ -1122,6 +1437,68 @@ check_architecture(){
echo 0
fi
}
focus_beta_client(){
_wid(){
wmctrl -ilx |\
awk 'tolower($3) == "steamwebhelper.steam"' |\
awk '$5 ~ /^Steam|Steam Games List/' |\
awk '{print $1}'
}
$steam_cmd steam://open/library 2>/dev/null 1>&2 &&
$steam_cmd steam://open/console 2>/dev/null 1>&2 &&
sleep 1s
until [[ -n $(_wid) ]]; do
sleep 0.1s
done
wmctrl -ia $(_wid)
sleep 0.1s
local wid=$(xdotool getactivewindow)
local geo=$(xdotool getwindowgeometry $wid)
local pos=$(<<< "$geo" awk 'NR==2 {print $2}' | sed 's/,/ /')
local dim=$(<<< "$geo" awk 'NR==3 {print $2}' | sed 's/x/ /')
local pos1=$(<<< "$pos" awk '{print $1}')
local pos2=$(<<< "$pos" awk '{print $2}')
local dim1=$(<<< "$dim" awk '{print $1}')
local dim2=$(<<< "$dim" awk '{print $2}')
local dim1=$(((dim1/2)+pos1))
local dim2=$(((dim2/2)+pos2))
xdotool mousemove $dim1 $dim2
xdotool click 1
$steam_cmd steam://open/library 2>/dev/null 1>&2 &&
$steam_cmd steam://open/console 2>/dev/null 1>&2
}
auto_mod_install(){
# currently unused, merged with manual method
local ip="$1"
local gameport="$2"
local diff="$3"
local sanitized_mods="$4"
console_dl "$diff" &&
$steam_cmd steam://open/downloads
local total=$(<<< "$diff" wc -l)
until [[ -z $(compare "$diff") ]]; do
local missing=$(compare "$diff" | wc -l)
echo "# Downloaded $(($total-missing)) of $total mods. ESC cancels"
done | $steamsafe_zenity --pulsate --progress --title="DZG Watcher" --auto-close --no-cancel --width=500 2>/dev/null
if [[ ! $? -eq 0 ]]; then
echo "User aborted connect process. Steam may have mods pending for download."
exit 1
fi
local diff=$(compare "$sanitized_mods")
if [[ -z $diff ]]; then
#wipe old version file and replace with latest stamps
rm "$versions_file"
check_timestamps
logger INFO "Local modlist matches remote, initiating launch request"
launch "$ip" "$gameport" "$sanitized_mods"
fi
}
force_update(){
if [[ ! $auto_install -eq 1 ]]; then
printf "Only available when mod auto-install is ON"
@ -1129,10 +1506,21 @@ force_update(){
fi
rm "$versions_file"
local update=$(check_timestamps)
manual_mod_install "null" "null" "$update" "null" "force" "null"
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[@]}"
@ -1171,6 +1559,8 @@ check_timestamps(){
local aligned=$(<<< "$local_stamps" jq -r '.response.publishedfiledetails[]|"\(.publishedfileid),\(.time_updated)"')
readarray -t remote_ids < <(<<< "$aligned" awk -F, '{print $1}')
readarray -t remote_times < <(<<< "$aligned" awk -F, '{print $2}')
readarray -t old_ids < <(< $versions_file awk -F, '{print $1}')
readarray -t old_times < <(< $versions_file awk -F, '{print $2}')
if [[ ! -f $versions_file ]]; then
logger INFO "No prior versions file found, creating"
@ -1180,9 +1570,6 @@ check_timestamps(){
return 0
fi
readarray -t old_ids < <(< $versions_file awk -F, '{print $1}')
readarray -t old_times < <(< $versions_file awk -F, '{print $2}')
declare -A remote_version
declare -A local_version
@ -1225,9 +1612,6 @@ concat_mods(){
local encoded_id
local link
for i in "${concat_arr[@]}"; do
until [[ -f $workshop_dir/$i/meta.cpp ]]; do
sleep 0.1s
done
id=$(awk -F"= " '/publishedid/ {print $2}' "$workshop_dir"/$i/meta.cpp | awk -F\; '{print $1}')
encoded_id=$(encode $id)
link="@$encoded_id;"
@ -1246,8 +1630,6 @@ launch(){
local ip="$1"
local gameport="$2"
local mods="$3"
local appid="$4"
local concat
if [[ -n $mods ]]; then
concat=$(concat_mods "$mods")
@ -1256,22 +1638,18 @@ launch(){
fi
update_symlinks
[[ $appid -eq $exp ]] && clone_symlinks
if [[ $debug -eq 1 ]]; then
local launch_options="$steam_cmd -applaunch $appid -connect=$ip:$gameport -nolauncher -nosplash -name=$name -skipintro -mod=$concat"
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 launch after you confirm this dialog."
[[ $appid == "$aid" ]] && return 100
[[ $appid == "$exp" ]] && return 101
printf "Launch conditions satisfied. DayZ will now launch after you confirm this dialog."
return 100
}
final_handshake(){
local appid="$1"
local saved_mods=$(< "$_cache_launch")
local saved_address=$(< "$_cache_address")
local res=$(is_dayz_running)
@ -1288,7 +1666,7 @@ final_handshake(){
params+=("-skipintro")
params+=("-name=$name")
params+=("-mod=$saved_mods")
$steam_cmd -applaunch $appid "${params[@]}" &
$steam_cmd -applaunch $aid "${params[@]}" &
until [[ $(is_dayz_running) -eq 1 ]]; do
sleep 0.1s
done
@ -1302,7 +1680,6 @@ manual_mod_install(){
local diff="$3"
local sanitized_mods="$4"
local mode="$5"
local appid="$6"
local ex="$state_path/dzg.watcher"
readarray -t stage_mods <<< "$diff"
@ -1311,6 +1688,7 @@ manual_mod_install(){
_watcher(){
for((i=0;i<${#stage_mods[@]};i++)); do
[[ -f $ex ]] && return 1
log ${stage_mods[$i]}
if [[ $mode == "auto" ]] || [[ $mode == "force" ]]; then
$steam_cmd "steam://url/CommunityFilePage/${stage_mods[$i]}+workshop_download_item $aid ${stage_mods[$i]}"
@ -1360,7 +1738,7 @@ manual_mod_install(){
rm "$versions_file"
check_timestamps
fi
launch "$ip" "$gameport" "$sanitized_mods" "$appid"
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
@ -1384,7 +1762,7 @@ foreground(){
}
main(){
local params="$(printf '"%s", ' "$@")"
logger INFO "Received request from UI with params [${params::-2}]"
logger INFO "Received request from UI constructor with params [${params::-2}]"
func=${funcs["$1"]}
[[ -z $func ]] && return 1
if [[ -z $2 ]]; then

36
helpers/latlon.c Normal file
View File

@ -0,0 +1,36 @@
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#define R 6371
#define TO_RAD (3.1415926536 / 180)
double dist(double th1, double ph1, double th2, double ph2)
{
double dx, dy, dz;
ph1 -= ph2;
ph1 *= TO_RAD, th1 *= TO_RAD, th2 *= TO_RAD;
dz = sin(th1) - sin(th2);
dx = cos(ph1) * cos(th1) - cos(th2);
dy = sin(ph1) * cos(th1);
return asin(sqrt(dx * dx + dy * dy + dz * dz) / 2) * 2 * R;
}
int main(int argc, const char * argv[])
{
if(argc < 5 || argc > 5){
return 1;
}
float coords[4];
for(int i=1;i<5;i++){
if(atof(argv[i]) == 0){
return 1;
}
coords[i] = atof(argv[i]);
}
double d = dist(coords[1], coords[2], coords[3], coords[4]);
printf("%.1f\n", d);
return 0;
}

View File

@ -1,492 +0,0 @@
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:
raise 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

@ -1,472 +0,0 @@
import json
import math
import os
import re
import socket
import subprocess
import sys
import typing # noqa
from dataclasses import dataclass
from urllib import request, parse
from urllib.error import HTTPError
from typing import Union
sys.path.append("a2s")
import a2s # noqa
from a2s import dayzquery # noqa
params = [
r"\nor\1\map\chernarusplus\nor\1\map\sakhal\nor\1\map\enoch\empty\1\nor\1\map\namalsk", # noqa
r"\map\namalsk\empty\1",
r"\map\namalsk\noplayers\1",
r"\map\chernarusplus\empty\1",
r"\map\chernarusplus\noplayers\1",
r"\map\sakhal\empty\1",
r"\map\sakhal\noplayers\1",
r"\map\enoch\empty\1",
r"\map\enoch\noplayers\1",
]
class BmAPIError(Exception):
pass
class BmIdError(Exception):
pass
class InvalidIpError(Exception):
pass
def get_netmask() -> str:
hostname = os.uname()[1]
i = socket.gethostbyname(hostname)
netmask = i.rsplit(".", 1)[0]
return netmask
def test_ip(suffix: int, port: int) -> dict | None:
netmask = get_netmask()
hostname = f"{netmask}.{str(suffix)}"
ping = ["ping", "-c1", "-i", "0.1", "-w", "1"]
output = subprocess.run(ping + [hostname], capture_output=True)
if output.returncode == 0:
return query_direct(hostname, port)
return None
def sanitize(name: str) -> str:
name = re.sub(r"\r", r"", name)
name = re.sub(r"\n", r"", name)
name = re.sub(r"\x01", r"", name)
name = re.sub(r"\ufeff", r"", name)
name = re.sub(r"(^!\s*)", r"-", name)
name = re.sub(r"(^-\s*)", r"", name)
name = re.sub(r"(^-)", r"", name)
name = re.sub(r"(^\s*)", r"", name)
name = re.sub(r"\t", "", name)
return name
def parse_json(json: list) -> list:
"""
Server metadata is underspecified and server operators
tend to insert random garbage in the headers. In case
sanitization failed, discard malformed rows rather than
aborting outright.
"""
rows = []
for row in json:
try:
name = sanitize(row["name"])
if name == "":
continue
except KeyError:
continue
malformed = False
for key in [
"map",
"gametype",
"players",
"max_players",
"addr",
"gameport",
]:
try:
row[key]
except KeyError:
malformed = True
break
if malformed is True:
continue
try:
r = row["gametype"].split(",")
except KeyError:
continue
if "no3rd" in r:
view = "1PP"
else:
view = "3PP"
if "external" in r:
provider = "Unoffic."
else:
provider = "Official"
if "mod" in r:
modded = True
else:
modded = False
try:
r = row["gametype"].split("lqs")
queue = r[1].split(",")[0]
except IndexError:
queue = 0
try:
test_time = re.search(r"[0-9]{2}:[0-9]{2}", row["gametype"])
if test_time:
time = test_time.group(0)
else:
time = "Unknown"
except AttributeError:
time = "Unknown"
try:
ip = row["addr"].split(":")[0] + ":" + str(row["gameport"])
qport = row["addr"].split(":")[1]
except IndexError:
continue
try:
ping = row["ping"]
except KeyError:
ping = 9999
mapname = row["map"].lower()
players = row["players"]
max_players = row["max_players"]
raw = [
name,
mapname,
view,
time,
int(players),
int(max_players),
int(queue),
ip,
int(qport),
ping,
provider,
modded,
]
rows.append(raw)
return rows
def query_direct(ip: str, qport: int, TIMEOUT: float=3.0) -> dict | None:
try:
info = a2s.info((ip, qport), TIMEOUT)
name = info.server_name
mapname = info.map_name
address = ip + ":" + str(qport)
gameport = str(info.port)
players = info.player_count
max_players = info.max_players
keywords = info.keywords
try:
ping = info.ping
ping = math.floor(info.ping * 1000)
except AttributeError:
ping = 9999
res = {}
res["name"] = name
res["map"] = mapname
res["gametype"] = keywords
res["players"] = players
res["max_players"] = max_players
res["addr"] = address
res["gameport"] = gameport
res["ping"] = ping
return res
except TimeoutError:
return None
except KeyError:
return None
@dataclass(slots=True, frozen=True)
class Res:
status: int
parsed: bool
json: Union[str, None]
@dataclass(slots=True, frozen=True)
class Ping:
addr: str
iteration: int
ping: int
@dataclass(slots=True, frozen=True)
class Details:
data: Union[list, None]
description: str
success: bool
@dataclass(slots=True, frozen=True)
class Prereqs:
password: bool
gameport: int
appid: Union[int, None]
version: Union[str, None]
@dataclass(slots=True)
class Record:
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str
gameport: int
qport: int
def get_prereqs(ip: str, qport: int) -> Prereqs:
try:
info = a2s.info((ip, qport))
except TimeoutError:
return Prereqs(False, 0, None, None)
gameport = getattr(info, "port", 0)
is_password = getattr(info, "password_protected", False)
appid = getattr(info, "game_id", None)
version = getattr(info, "version", None)
return Prereqs(is_password, gameport, appid, version)
def details(ip: str, qport: int) -> Details:
default_str = "None provided"
try:
info = a2s.info((ip, qport))
except TimeoutError:
return Details(None, default_str, False)
try:
rules = dayzquery.dayz_rules((ip, int(qport)))
except TimeoutError:
return Details(None, default_str, False)
try:
keywords = info.keywords.split(",")
except AttributeError:
return Details(None, default_str, False)
battleye = "Disabled"
if "battleye" in keywords:
battleye = "Enabled"
day_accel = 0.0
night_accel = 0.0
for keyword in keywords:
if "etm" in keyword:
day_accel = float(keyword.lstrip("etm"))
day_accel = float(f"{day_accel:g}")
if "entm" in keywords:
night_accel = float(keyword.lstrip("entm"))
night_accel = float(f"{night_accel:g}")
try:
password = info.password_protected
if password is False:
password = "Disabled"
else:
password = "Enabled"
except AttributeError:
password = "-"
try:
vac = info.vac_enabled
if vac is False:
vac = "Disabled"
else:
vac = "Enabled"
except AttributeError:
vac = "-"
try:
version = info.version
except AttributeError:
version = "-"
try:
dlc = rules.dlc_flags
if dlc == 0:
dlc = "None"
if dlc == 2:
dlc = "Frostline"
except AttributeError:
dlc = "not specified"
try:
platform = rules.platform
if platform == "win":
platform = "Windows"
if platform == "?":
platform = "Linux"
except AttributeError:
platform = "not specified"
try:
description = rules.description.strip()
if description == "":
description = default_str
except AttributeError:
description = default_str
rows = [
["Battleye", battleye],
["Daytime acceleration", f"{day_accel}x"],
["DLC", dlc],
["Night-time acceleration", f"{night_accel}x"],
["Password", password],
["Platform", platform],
["Valve Anti-Cheat", vac],
["Version", version],
]
return Details(rows, description, True)
def ping(iteration: int, row: list) -> Ping:
addr = row[7]
qport = row[8]
res = None
if row[9] != 9999:
return Ping(addr, iteration, row[9])
try:
ip = addr.split(":")[0]
except IndexError:
ping = 9999
try:
res = query_direct(ip, qport, 0.5)
except Exception:
pass
if res is None:
ping = 9999
else:
ping = res["ping"]
return Ping(addr, iteration, ping)
def query_api(key: str, appid: int, param: str) -> Res:
LIMIT = 10000
url = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?"
payload: dict[str, Union[int, str]] = {
"filter": r"\appid" + fr"\{appid}" + param,
"limit": LIMIT,
"key": key,
}
par = parse.urlencode(payload)
url = f"{url}{par}"
status = 200
parsed = True
data = None
try:
with request.urlopen(url) as response:
if response.status != 200:
status = response.status
try:
parsed = True
data = json.load(response)
except json.decoder.JSONDecodeError:
parsed = False
data = None
except HTTPError:
status = 403
parsed = False
data = None
finally:
return Res(status, parsed, data)
def query_bm_api(api_key: str, bm_id: str) -> Record:
if bm_id.isnumeric() is False:
raise BmIdError("ID must be numeric only")
payload: dict[str, Union[int, str]] = {
"sort": "-players",
"filter[game]": "dayz",
"filter[ids][whitelist]": bm_id,
}
url = "https://api.battlemetrics.com/servers?"
par = parse.urlencode(payload)
url = f"{url}{par}"
hdr = {"Authorization": "Bearer " + api_key}
r = request.Request(url, headers=hdr)
try:
with request.urlopen(r) as response:
try:
j = json.load(response)
except json.decoder.JSONDecodeError:
raise BmAPIError("Malformed response from Battlemetrics")
if len(j["data"]) < 1:
raise BmAPIError("Not a valid Battlemetrics ID")
j = j["data"][0]["attributes"]
return Record(j["ip"], j["port"], j["portQuery"])
except HTTPError:
raise BmAPIError("Failed to query Battlemetrics")
def validate_ip(addr: str) -> Record:
fields = addr.split(":")
if len(fields) != 2:
raise InvalidIpError("Address must be formatted as IP:Queryport")
ip = fields[0]
port = fields[1]
try:
int(port)
except ValueError:
raise InvalidIpError(f"'{port}' is not a valid port")
if int(port) > 65535 or int(port) < 0:
raise InvalidIpError(f"'{port}' is not a valid port")
try:
socket.inet_aton(ip)
except OSError:
raise InvalidIpError(f"'{ip}' is not a valid IP")
ip = addr.split(":")[0]
qport = int(addr.split(":")[1])
record = Record(ip, 0, qport)
return record

View File

@ -1 +0,0 @@
latlon

View File

@ -1,24 +0,0 @@
bin := latlon
CC = gcc
CFLAGS += -Wall
CFLAGS += -Wextra
CFLAGS += -Wpedantic
CFLAGS += -std=c23
CFLAGS += -O3
LDFLAGS += -lm
PREFIX ?= $(HOME)/.local/share/dzgui
all: $(bin)
install: $(bin)
install -Dm 755 $(bin) $(PREFIX)
$(bin): latlon.c
$(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS)
clean:
rm -f $(bin)

View File

@ -1,77 +0,0 @@
// This small program computes the distance between two points on the Earths
// surface. The algorithm used for computing is called haversine formula.
// Source for the algorithm can be found here:
// https://en.wikipedia.org/wiki/Haversine_formula
#include <math.h>
#include <stdlib.h>
#include <stdio.h>
// Earth's radius in meters.
#define RADIUS ((12756L / 2.0L) * 1000L)
// PI natural constant
#define PI 3.1415926536L
// coordinates struct for storing points
struct Coordinate {
double latitude;
double longitude;
};
/**
* @brief Calculates a radian value from an angle
*/
double radian_from(const double angle) {
return (angle * PI) / 180;
}
/**
* @brief Calculate the haversine function from an angle in radian
*/
double haversine(const double angle) { return ((1.0L - cos(angle)) / 2); }
/**
* @brief Calculate the distance between two coordinates
*/
double great_circle_distance(const struct Coordinate a,
const struct Coordinate b) {
// calculate longitude and latitude differences
struct Coordinate diff_coord = {
.longitude = a.longitude - b.longitude,
.latitude = a.latitude - b.latitude,
};
// calculate haversine(theta) value, where theta is the angle between the
// two coordinates
double hav_theta = haversine(diff_coord.latitude)
+ cos(a.latitude) * cos(b.latitude) * haversine(diff_coord.longitude);
// calculate distance from radius, and haversine(theta) values
double distance = 2 * RADIUS * asin(sqrt(hav_theta));
return distance;
}
int main(int argc, const char* argv[]) {
if (argc < 5 || argc > 5) {
return 1;
}
const struct Coordinate a = {
.latitude = radian_from(atof(argv[1])),
.longitude = radian_from(atof(argv[2])),
};
const struct Coordinate b = {
.latitude = radian_from(atof(argv[3])),
.longitude = radian_from(atof(argv[4])),
};
// Returns the distance in meters.
// To pretty-print the result in a consumer of this module, it is
// recommended to divide the result by 1,000 and round it.
printf("%.1f\n", great_circle_distance(a, b));
return 0;
}

File diff suppressed because it is too large Load Diff

BIN
images/tutorial/01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
images/tutorial/02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
images/tutorial/03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
images/tutorial/04.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

BIN
images/tutorial/05.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

BIN
images/tutorial/06.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

BIN
images/tutorial/07.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

BIN
images/tutorial/08.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
images/tutorial/09.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB