diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb55ba..8f3a913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [5.3.3] 2024-08-27 +### Added +- Scan local area network for DayZ servers +- Freedesktop application icons for system taskbar, tray, and other dialogs +- Emit CPU model name when exporting system debug log +### Fixed +- Detect Steam Deck OLED APU variant during initial setup +- Errors being printed to the console when Exit button was explicitly clicked +- Test if DayZ library location was moved internally on Steam by user +- Encapsulate player names correctly to support whitespace +- Report WM_CLASS name to the window manager + ## [5.3.2] 2024-07-02 ### Fixed - Server list would sometimes be missing some expected servers due to truncation being caused when a server erroneously set an incomplete gametype diff --git a/dzgui.sh b/dzgui.sh index 5e59ad9..5625570 100755 --- a/dzgui.sh +++ b/dzgui.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -o pipefail -version=5.3.2 +version=5.3.3 #CONSTANTS aid=221100 @@ -13,7 +13,7 @@ sd_res="--width=1280 --height=800" steamsafe_zenity="/usr/bin/zenity" zenity_flags=("--width=500" "--title=DZGUI") declare -A deps -deps=([awk]="5.1.1" [curl]="7.80.0" [jq]="1.6" [tr]="9.0" [$steamsafe_zenity]="3.42.1") +deps=([awk]="5.1.1" [curl]="7.80.0" [jq]="1.6" [tr]="9.0" [$steamsafe_zenity]="3.44.1") #CONFIG config_path="$HOME/.config/dztui" @@ -65,6 +65,7 @@ km_helper_url="$releases_url/latlon" geo_file_url="$releases_url/ips.csv.gz" set_im_module(){ + #TODO: drop pending SteamOS changes pgrep -a gamescope | grep -q "generate-drm-mode" if [[ $? -eq 0 ]]; then GTK_IM_MODULE="" @@ -377,18 +378,20 @@ test_display_mode(){ fi } check_architecture(){ - local cpu=$(< /proc/cpuinfo grep "AMD Custom APU 0405") - if [[ -n "$cpu" ]]; then - if [[ $(test_display_mode) == "gm" ]]; then - is_steam_deck=2 - else - is_steam_deck=1 - fi - logger INFO "Setting architecture to 'Steam Deck'" - else + 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 is_steam_deck=0 logger INFO "Setting architecture to 'desktop'" + return fi + + if [[ $(test_display_mode) == "gm" ]]; then + is_steam_deck=2 + else + is_steam_deck=1 + fi + logger INFO "Setting architecture to 'Steam Deck'" } check_map_count(){ [[ $is_steam_deck -gt 0 ]] && return 0 @@ -531,14 +534,39 @@ fetch_dzq(){ curl -Ls "$url" > "$file" logger INFO "Updated DZQ to sha '$sha'" } +fetch_icons(){ + res=( + "16" + "24" + "32" + "48" + "64" + "96" + "128" + "256" + ) + url="$stable_url/images/icons" + for i in "${res[@]}"; do + size="${i}x${i}" + dir="$HOME/.local/share/icons/hicolor/$size/apps" + icon="$dir/$app_name.png" + [[ -f $icon ]] && return + if [[ ! -d $dir ]]; then + mkdir -p "$dir" + fi + logger INFO "Updating $size Freedesktop icon" + curl -Ls "${url}/${i}.png" > "$icon" + done +} fetch_helpers_by_sum(){ [[ -f "$config_file" ]] && source "$config_file" declare -A sums sums=( - ["ui.py"]="f14772424461ec438579dec567db0634" - ["query_v2.py"]="1822bd1769ce7d7cb0d686a60f9fa197" + ["ui.py"]="13bb5456aa9d95efdf2335e437f4c6fb" + ["query_v2.py"]="55d339ba02512ac69de288eb3be41067" ["vdf2json.py"]="2f49f6f5d3af919bebaab2e9c220f397" - ["funcs"]="718c3060b88041f5b99a1ce52b12642f" + ["funcs"]="44eca80b207057423c2d298cc7cf1e29" + ["lan"]="c62e84ddd1457b71a85ad21da662b9af" ) local author="aclist" local repo="dztui" @@ -576,6 +604,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 } @@ -600,6 +629,7 @@ fetch_helpers(){ fetch_geo_file fetch_helpers_by_sum [[ ! -f $share_path/icon.png ]] && freedesktop_dirs + fetch_icons } raise_error_and_quit(){ local msg="$1" @@ -778,7 +808,7 @@ varcheck(){ source "$config_file" local workshop_dir="$steam_path/steamapps/workshop/content/$aid" local game_dir="$steam_path/steamapps/common/DayZ" - if [[ ! -d $steam_path ]] || [[ ! -d $game_dir ]]; then + if [[ ! -d $steam_path ]] || [[ ! -d $game_dir ]] || [[ ! $(find $game_dir -type f) ]]; then logger WARN "DayZ path resolved to '$game_dir'" logger WARN "Workshop path resolved to '$workshop_dir'" qdialog "$msg2" "Yes" "Exit" @@ -894,7 +924,7 @@ uninstall(){ } main(){ local zenv=$(zenity --version 2>/dev/null) - [[ -z $zenv ]] && { echo "Requires zenity <= 3.44.1"; exit 1; } + [[ -z $zenv ]] && { echo "Requires zenity >= ${deps[$steamsafe_zenity]}"; exit 1; } if [[ $1 == "--uninstall" ]] || [[ $1 == "-u" ]]; then uninstall && exit 0 diff --git a/helpers/funcs b/helpers/funcs index b36a30b..7d19b29 100755 --- a/helpers/funcs +++ b/helpers/funcs @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -o pipefail -version=5.2.3 +version=5.3.3 #CONSTANTS aid=221100 @@ -44,6 +44,7 @@ _cache_launch="$cache_dir/$prefix.launch_mods" _cache_address="$cache_dir/$prefix.launch_address" _cache_coords="$cache_path/$prefix.coords" _cache_cooldown="$cache_path/$prefix.cooldown" +_cache_lan="$cache_path/$prefix.lan" #XDG freedesktop_path="$HOME/.local/share/applications" @@ -55,6 +56,7 @@ km_helper="$helpers_path/latlon" sums_path="$helpers_path/sums.md5" query_helper="$helpers_path/query_v2.py" func_helper="$helpers_path/funcs" +lan_helper="$helpers_path/lan" #STEAM PATHS workshop_path="$steam_path/steamapps/workshop" @@ -125,8 +127,19 @@ declare -A funcs=( ["force_update"]="force_update" ["Handshake"]="final_handshake" ["get_player_count"]="get_player_count" +["lan_scan"]="lan_scan" ) +lan_scan(){ + local port="$1" + local res + res=$("$lan_helper" "$port") + if [[ -z $res ]]; then + printf "\n" + else + printf "%s\n" "$res" + fi +} get_player_count(){ shift local res @@ -589,6 +602,16 @@ dump_servers(){ _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'" @@ -955,6 +978,7 @@ generate_log(){ cat <<-DOC > $system_log Distro: $(< /etc/os-release grep -w NAME | awk -F\" '{print $2}') Kernel: $(uname -mrs) + CPU: $(< /proc/cpuinfo awk -F": " '/model name/ {print $2; exit}') Version: $version Branch: $branch Mode: $(if [[ -z $debug ]]; then echo normal; else echo debug; fi) @@ -988,8 +1012,6 @@ query_defunct(){ -d "$(payload)" 'https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json' } local result=$(post | jq -r '.[].publishedfiledetails[] | select(.result==1) | "\(.file_size) \(.publishedfileid)"') - local result2=$(post | jq -r '') - echo "$result2" > $HOME/json <<< "$result" awk '{print $2}' } encode(){ @@ -1287,7 +1309,7 @@ launch(){ update_symlinks 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 $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 @@ -1307,11 +1329,19 @@ final_handshake(){ return 1 fi logger INFO "Kicking off Steam launch" - $steam_cmd -applaunch $aid -connect=$saved_address -nolauncher -nosplash -skipintro -name=$name \"-mod=$saved_mods\" & + local params=() + params+=("-connect=$saved_address") + params+=("-nolauncher") + params+=("-nosplash") + params+=("-skipintro") + params+=("-name=$name") + params+=("-mod=$saved_mods") + $steam_cmd -applaunch $aid "${params[@]}" & until [[ $(is_dayz_running) -eq 1 ]]; do sleep 0.1s done logger INFO "Caught DayZ process" + printf "\n" return 6 } manual_mod_install(){ diff --git a/helpers/lan b/helpers/lan new file mode 100755 index 0000000..cf16934 --- /dev/null +++ b/helpers/lan @@ -0,0 +1,46 @@ +#!/usr/bin/env bash + +query_name(){ + local ip="$1" + local port="$2" + local api="$HOME/.local/share/dzgui/helpers/query_v2.py" + python3 "$api" "$ip" "$port" test +} + +scan(){ + local ip="$1" + local port="$2" + local res=$(query_name "$ip" "$port") + [[ -z $res ]] && return 1 + printf "%s\n" "${ip}:XXX:${port}" +} + +get_netmask(){ + ip r \ + | awk '/default/ {print $3}' \ + | uniq \ + | awk -F. 'OFS="."{print $1,$2,$3}' +} + +iter(){ + _testping(){ + ping -c1 -i 0.1 -w 1 "$1" 2>/dev/null 1>&2 + [[ $? -eq 0 ]] && echo "$1" + } + export -f _testping + local mask=$(get_netmask) + # GNU parallel is not available OOTB on Steam Deck + for i in $(seq 1 255); do + echo "${mask}.${i}" + done | xargs -I {} -P 200 bash -c '_testping "{}"' +} + +export -f scan +export -f query_name + +DZG_LAN_PORT="$1" + +readarray -t ips < <(iter) +for i in "${ips[@]}"; do + scan "$i" $DZG_LAN_PORT +done diff --git a/helpers/query_v2.py b/helpers/query_v2.py index 4ed62e9..1033d5b 100644 --- a/helpers/query_v2.py +++ b/helpers/query_v2.py @@ -5,6 +5,14 @@ import json from a2s import dayzquery sys.path.append('a2s') +def test_local(ip, qport): + try: + info = a2s.info((ip, int(qport)), 0.5) + name = info.server_name + print(name) + except: + sys.exit(1) + def get_info(ip, qport): try: info = a2s.info((ip, int(qport))) @@ -73,3 +81,5 @@ match mode: get_rules(ip, qport) case "names": get_names(ip, qport) + case "test": + test_local(ip, qport) diff --git a/helpers/ui.py b/helpers/ui.py index b830ce8..f4ca328 100644 --- a/helpers/ui.py +++ b/helpers/ui.py @@ -17,8 +17,9 @@ import time locale.setlocale(locale.LC_ALL, '') gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, Gdk, GObject, Pango +from enum import Enum -# 5.2.3 +# 5.3.3 app_name = "DZGUI" start_time = 0 @@ -91,7 +92,8 @@ connect = [ ("Quick-connect to favorite server",), ("Recent servers",), ("Connect by IP",), - ("Connect by ID",) + ("Connect by ID",), + ("Scan LAN servers",) ] manage = [ ("Add server by IP",), @@ -143,6 +145,7 @@ status_tooltip = { "Recent servers": "Shows the last 10 servers you connected to (includes attempts)", "Connect by IP": "Connect to a server by IP", "Connect by ID": "Connect to a server by Battlemetrics ID", + "Scan LAN servers": "Search for servers on your local network", "Add server by IP": "Add a server by IP", "Add server by ID": "Add a server by Battlemetrics ID", "Change favorite server": "Update your quick-connect server", @@ -570,10 +573,16 @@ class ButtonBox(Gtk.Box): renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn(column_title, renderer, text=i) treeview.append_column(column) + self._populate(context) treeview.set_model(row_store) treeview.grab_focus() - def _populate(self, array_context): + def _populate(self, context): + match context: + case 'Manage': array_context = manage + case 'Main menu': array_context = connect + case 'Options': array_context = options + case 'Help': array_context = help row_store.clear() status = array_context[0][0] treeview = self.get_treeview() @@ -593,6 +602,7 @@ class ButtonBox(Gtk.Box): if context == "Exit": logger.info("Normal user exit") Gtk.main_quit() + return cols = treeview.get_columns() if len(cols) > 1: @@ -606,11 +616,7 @@ class ButtonBox(Gtk.Box): for col in cols: col.set_title(context) - match context: - case 'Manage': self._populate(manage) - case 'Main menu': self._populate(connect) - case 'Options': self._populate(options) - case 'Help': self._populate(help) + self._populate(context) toggle_signal(treeview, treeview.selected_row, '_on_tree_selection_changed', True) @@ -1069,6 +1075,17 @@ class TreeView(Gtk.TreeView): toggle_signal(self.get_outer_grid().right_panel.filters_vbox, check, '_on_check_toggle', True) toggle_signal(self, self, '_on_keypress', True) + if mode == "Scan LAN servers": + lan_dialog = LanButtonDialog(self.get_outer_window()) + port = lan_dialog.get_selected_port() + if port is None: + grid = self.get_outer_grid() + right_panel = grid.right_panel + vbox = right_panel.button_vbox + vbox._update_single_column("Main menu") + return + mode = mode + ":" + port + wait_dialog = GenericDialog(transient_parent, "Fetching server metadata", "WAIT") wait_dialog.show_all() thread = threading.Thread(target=self._background, args=(wait_dialog, mode)) @@ -1170,7 +1187,7 @@ class TreeView(Gtk.TreeView): right_panel = outer.grid.right_panel filters_vbox = right_panel.filters_vbox - valid_contexts = ["Server browser", "My saved servers", "Recent servers"] + valid_contexts = ["Server browser", "My saved servers", "Recent servers", "Scan LAN servers"] if chosen_row in valid_contexts: # server contexts share the same model type for check in checks: @@ -1304,6 +1321,10 @@ class AppHeaderBar(Gtk.HeaderBar): self.set_decoration_layout("menu:minimize,maximize,close") self.set_show_close_button(True) +class Port(Enum): + # Contains enums for LanButtonDialog ports + DEFAULT = 1 + CUSTOM = 2 class GenericDialog(Gtk.MessageDialog): def __init__(self, parent, text, mode): @@ -1361,6 +1382,140 @@ class GenericDialog(Gtk.MessageDialog): self.format_secondary_text(text) +class LanButtonDialog(Gtk.Window): + def __init__(self, parent): + super().__init__() + + self.buttonBox = Gtk.Box() + + header_label = "Scan LAN servers" + buttons = [ + ( "Use default query port (27016)", Port.DEFAULT ), + ( "Enter custom query port", Port.CUSTOM ), + ] + + self.buttonBox.set_orientation(Gtk.Orientation.VERTICAL) + self.buttonBox.active_button = None + + for i in enumerate(buttons): + + string = i[1][0] + enum = i[1][1] + + button = Gtk.RadioButton(label=string) + button.port = enum + button.connect("toggled", self._on_button_toggled) + + if i[0] == 0: + self.buttonBox.active_button = button + else: + button.join_group(self.buttonBox.active_button) + + self.buttonBox.add(button) + + self.entry = Gtk.Entry() + self.buttonBox.add(self.entry) + self.entry.set_no_show_all(True) + + self.label = Gtk.Label() + self.label.set_text("Invalid port") + self.label.set_no_show_all(True) + self.buttonBox.add(self.label) + + self.dialog = LanDialog(parent, header_label, self.buttonBox, self.entry, self.label) + self.dialog.run() + self.dialog.destroy() + + def get_selected_port(self): + return self.dialog.p + + def _on_button_toggled(self, button): + if button.get_active(): + self.buttonBox.active_button = button + + match button.port: + case Port.DEFAULT: + self.entry.set_visible(False) + case Port.CUSTOM: + self.entry.set_visible(True) + self.entry.grab_focus() + + def get_active_button(): + return self.buttonBox.active_button + +class LanDialog(Gtk.MessageDialog): + # Custom dialog class that performs integer validation and blocks input if invalid port + # Returns None if user cancels the dialog + def __init__(self, parent, text, child, entry, label): + super().__init__(transient_for=parent, + flags=0, + message_type=Gtk.MessageType.INFO, + buttons=Gtk.ButtonsType.OK_CANCEL, + text=text, + secondary_text="Select the query port", + title=app_name, + modal=True, + ) + + self.outer = self.get_content_area() + self.outer.pack_start(child, False, False, 0) + self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) + self.set_size_request(500, 0) + self.outer.set_margin_start(30) + self.outer.set_margin_end(30) + self.outer.show_all() + + self.connect("response", self._on_dialog_response, child, entry) + self.connect("key-press-event", self._on_keypress, entry) + self.connect("key-release-event", self._on_key_release, entry, label) + + self.child = child + + self.p = None + + def _on_key_release(self, dialog, event, entry, label): + label.set_visible(False) + if entry.is_visible() == False or entry.get_text() == "": + return + if self._is_invalid(entry.get_text()): + label.set_visible(True) + else: + label.set_visible(False) + + def _on_keypress(self, a, event, entry): + if event.keyval == Gdk.KEY_Return: + self.response(Gtk.ResponseType.OK) + if event.keyval == Gdk.KEY_Up: + entry.set_text("") + self.child.get_children()[0].grab_focus() + + def _on_dialog_response(self, dialog, resp, child, entry): + match resp: + case Gtk.ResponseType.CANCEL: + return + case Gtk.ResponseType.DELETE_EVENT: + return + + string = entry.get_text() + port = child.active_button.port + + match port: + case Port.DEFAULT: + self.p = "27016" + case Port.CUSTOM: + if self._is_invalid(string): + self.stop_emission_by_name("response") + else: + self.p = string + + def _is_invalid(self, string): + if string.isdigit() == False \ + or int(string) == 0 \ + or int(string[0]) == 0 \ + or int(string) > 65535: + return True + return False + def ChangelogDialog(parent, text, mode): dialog = GenericDialog(parent, text, mode) @@ -1657,7 +1812,9 @@ class App(Gtk.Application): is_steam_deck = False is_game_mode = False + GLib.set_prgname(app_name) self.win = OuterWindow(is_steam_deck, is_game_mode) + self.win.set_icon_name("dzgui") accel = Gtk.AccelGroup() accel.connect(Gdk.KEY_q, Gdk.ModifierType.CONTROL_MASK, Gtk.AccelFlags.VISIBLE, self._halt_window_subprocess) diff --git a/images/icon.png b/images/icon.png index 80b925a..3aa4bac 100644 Binary files a/images/icon.png and b/images/icon.png differ diff --git a/images/icons/128.png b/images/icons/128.png new file mode 100644 index 0000000..b6aed5c Binary files /dev/null and b/images/icons/128.png differ diff --git a/images/icons/16.png b/images/icons/16.png new file mode 100644 index 0000000..b4500d3 Binary files /dev/null and b/images/icons/16.png differ diff --git a/images/icons/24.png b/images/icons/24.png new file mode 100644 index 0000000..403aba1 Binary files /dev/null and b/images/icons/24.png differ diff --git a/images/icons/256.png b/images/icons/256.png new file mode 100644 index 0000000..204ca9d Binary files /dev/null and b/images/icons/256.png differ diff --git a/images/icons/32.png b/images/icons/32.png new file mode 100644 index 0000000..6736f85 Binary files /dev/null and b/images/icons/32.png differ diff --git a/images/icons/48.png b/images/icons/48.png new file mode 100644 index 0000000..9e636c3 Binary files /dev/null and b/images/icons/48.png differ diff --git a/images/icons/64.png b/images/icons/64.png new file mode 100644 index 0000000..37ba624 Binary files /dev/null and b/images/icons/64.png differ diff --git a/images/icons/96.png b/images/icons/96.png new file mode 100644 index 0000000..7a34cfb Binary files /dev/null and b/images/icons/96.png differ