Compare commits

..
1 Commits
Author SHA1 Message Date
aclist d4a61bc7f4 Merge 8edb38f175 into 58a2bf0bfa 2026-06-04 19:37:56 +09:00
114 changed files with 745 additions and 5106 deletions

No files matched your search

+7 -794
View File
@@ -1,7 +1,4 @@
# Changelog
## [7.0.0] Unreleased
### Added
## Added
- Setup wizard
- Changelog text wrapping and formatting
- Changelog ships with source
@@ -40,12 +37,9 @@
- Save filters per server context between sessions
- Preboot progress dialog
- Choose to jump into splash screen instead of server
- Collapsible connection panel
- Play offline (load mods directly)
- Generate Steam shortcuts and cover art
- Warn user if background downloads are disabled
### Changed
## Changed
- Conform to PEP 440 versioning for beta versions
- Reduce padding on keys button
- Boldface breadcrumbs
@@ -60,806 +54,25 @@
- Disable overlay scrollbars on server tables
- Reduce size of geolocation DB on disk (~100MB)
- Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client
### Dropped
## Dropped
- Debug mode
- Branch switching
- Manual mod install mode (describe rationale)
- Force update mods
### Fixed
## Fixed
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
- Center server title text on server dialogs
- Rare segfaults when changing maps (threading)
- Moved dialogs out of threads
### Unreleased
## Unreleased
- Load offline mods
- Local documentation
- Raw debug command in context menu
### Developer-facing
## Developer-facing
- Add pyproject.toml file
- Show deprecation warnings
- Options -> Dev page
## [6.0.5] 2026-05-17
### Changed
- Update symlink hash logic to align with DZGUI 7 beta
- Update A2S helper to latest commit
## [6.0.4] 2026-04-13
### Fixed
- Update A2S module checksums to support servers with missing description field
## [6.0.3] 2026-04-12
### Fixed
- Use GLibUnix namespace where available when setting up SIGINT callback
## [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
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
## [5.8.0] 2025-07-06
### Added
- Filter servers by official/unofficial status
- Automatically fetch geolocation records
### Changed
- Updated internal versioning of helper files
### Fixed
- Corrected erroneous 2024 date in prior changelog entries
## [5.7.0] 2025-04-17
### Added
- Save application dimensions when quitting and restore on subsequent boot
### Fixed
- Issues with the window exceeding the bounds of the screen when displaying some table contexts
### Changed
- More direct server ping query method that should return marginally more accurate times
## [5.6.7] 2025-04-04
### Dropped
- Removed extraneous pre-boot API checks that could cause error messages to be printed if the user had not set up an API key yet
## [5.6.6] 2025-03-16
### Changed
- Update IP database records for 2025-03
## [5.6.5] 2025-03-04
### Fixed
- Livonia server results being dropped from batch queries
## [5.6.4] 2025-02-10
### Changed
- Drop launch flag and check for invocation through Steam automatically
## [5.6.3] 2025-02-09
### Changed
- Update IP database records for 2025-02
## [5.6.2] 2025-01-22
### Fixed
- Resolve regression introduced with IP resolution feature in 5.6.0 (restores functionality of right-click action: Add to My Servers)
## [5.6.1] 2025-01-10
### Fixed
- Add fallback support for jq 1.6
## [5.6.0] 2025-01-06
### Added
- Application header bar and controls
- Menu context subtitle in header bar
- Bulk delete mods (via 'List installed mods' list). Not compatible with Manual Mod install mode
- Highlight stale mods in mods list
- Added --steam launch parameter
- Open Steam workshop subscriptions dialog when switching from Manual to Auto mode
- Hover tooltips to most buttons
- Add in-app documentation link to Codeberg mirror
- Additional logging
### Fixed
- Avoid sudo escalation if system map count is sufficient (jiriks74)
- Duplicate dialog title on Steam Deck
- Resolve remote IP when saving records for game servers with multiple hosts
- More performant symlink traversal when checking for legacy links
- Only iterate on missing symlinks at boot (400% performance uplift)
- Fix for server list truncation causing some servers to not appear in results
- Empty dialog popups if user manually deletes local mods while application is running
- Update statusbar when removing servers from list/repopulating
- Abort DayZ path discovery if Steam files are not synched
- Race condition when checking for installed mods
- Favorite server message not updating correctly
- Improved handling for cases where there are no locally installed mods
- Set up mod symlinks at boot, rather than only on server connect
- Prevent context menus from opening when table is empty
- When reloading table in-place, prevent duplicate panel elements from being added if already present
- Clean up local mod signatures from versions file when deleting mods
- Focus first row when opening mods list
### Changed
- Redact usernames in log files
- Prevent ArrowUp/ArrowDown input when inside keyword field
- Admonish user to restart Steam in error dialog if DayZ path could not be found
- Refactor control flow for more robust contextual parsing
- Stop sending modal dialog hints to outer window
- Clean up signal emission
- Clarify some error messages and normalize text formatting
- Update forum URL
- Update README.md
- Update IP database to 2025-01
- Reduce IP database size by 50%
- Update documentation to 5.6.x standard
### Dropped
- Hall of Fame section from button links, to be moved inside documentation
- Unused imports
## [5.5.3] 2024-12-13
### Fixed
- Add remote resource health checks before downloading updates
### Added
- Add fallback repository
## [5.5.1] 2024-12-03
### Fixed
- Support localized decimal separators when parsing installed mod sizes
## [5.5.0] 2024-11-10
### Added
- Support servers running DLC content (fixes Frostline servers)
- Expose a toggle setting for whether to launch the application in fullscreen
- Text autocompletion in maps search field (partial search)
- Add disk space warning to popup dialog when downloading mods
### Fixed
- Servers in saved servers list would populate context menu with same option when right-clicking in server browser
- Enable adding/removing servers to/from My Saved Servers when in Recent Servers context
- Prevent maps combobox from duplicating contents
- Restore keyboard input to keyword entry field
### Changed
- Abort fallback query method if DLC is required
## [5.4.2] 2024-10-05
### Fixed
- Sanitize third-party API IDs to remove UGC collisions
## [5.4.1] 2024-09-25
### Added
- Pre-boot validation check for users with self-compiled version of jq
### Fixed
- Use fallback logic for modlist queries when user traverses networks
- Fix signal handling control flow for checkbox toggles
- When reloading the server browser, the map combobox selection would revert to the last selected map instead of All Maps
- Server filter toggle signals were accessible from the main menu when switching between menu contexts
- Global cooldown dialog could sometimes block filter toggles after cooldown reset
- Normalized minor version number due to a previous clerical error
## [5.4.0] 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
- Errors being printed to the console when Exit button was explicitly clicked
- Detect Steam Deck OLED APU variant during initial setup
- Encapsulate player names correctly so that names with whitespace in them are supported
- Test if DayZ directory is empty at startup, implying that the game was moved to a new library collection
- 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
## [5.3.1] 2024-06-15
### Added
- Uninstall routine: invoke via dzgui.sh -u or dzgui.sh --uninstall and choose from full or partial uninstall
## [5.3.0] 2024-06-15
### Added
- Server browser tables now display the number of players in queue (for full servers)
- Warning dialog if the user's API key returned no servers, and a global API cooldown dialog (60 seconds)
### Fixed
- Removing servers from My Servers via the right-click context menu was not actually removing the server and drawing an empty dialog
- Resolved an issue where, when Steam was not installed in a standard location and the user was prompted to let DZGUI auto-discover the correct path, this information was not retained correctly
## [5.2.4] 2024-05-29
### Fixed
- Resolved an issue that could cause an old path to a prior DayZ installation to be reinserted in the config file if the Steam client did not synch the new path internally. DZGUI would try to ask the user to re-run first-time setup and update the path, but the old path would still be used.
## [5.2.3] 2024-04-19
### Added
- Added Debug Mode button to main menu
### Changed
- Reworded debug mode notification dialog
- Updated geolocation records
### Dropped
- Dropped seen_news key from configs
## [5.2.2] 2024-04-18
### Added
- Cover artwork/icons for Steam "Recent Games" and tree view
### Changed
- Updated documentation to the v5.0.0 spec
## [5.2.1] 2024-04-01
### Fixed
- Fixed a regression where where the first-time setup dialog would not trigger auto-path discovery
### Changed
- Reworded some menus and dialogs for clarity
## [5.2.0] 2024-03-21
### Added
- Refresh player count for active row: invoke via right-click context meu or directly with the Ctrl-r hotkey. This feature has a 30 second global cooldown to prevent throttling.
### Fixed
- Improve case-insensitive keyword search to be portable across awk versions (previous version required gawk)
- Fixed a dialog string from being shown twice when adding a server to favorites via context menus
### Changed
- Use a more robust method for downloading mods when auto mod install is enabled
## [5.1.1] 2024-03-18
### Fixed
- Hotfix for remote helper files not being fetched correctly
## [5.1.0] 2024-03-18
### Added
- Make columns in the server browser user-resizable (affects Server Browser, My Servers, and Recent Servers)
- Save dragged position of user-resized columns
- Display ping to server in statusbar: by popular request, added the ability to visualize both distance to server and round-trip latency (ping), at the cost of a small calculation delay. Please leave feedback regarding whether this feature feels fast/responsive enough.
### Fixed
- Fixed a rare scenario in Auto Mod Install Mode where defunct mods (mods no longer available on Steam) would try to be downloaded if the user had previously downloaded the mod
## [5.0.0] 2024-01-31
### Added
- Context switching: navigate to different pages using side buttons
- Dynamic statusbar: updates metadata and server distance when selecting rows
- Show server-side modlist and allow jumping to Steam Workshop pages to browse, and list whether mod is currently installed
- Print debug logs in-app (Help > Show debug log)
- Functionality to change API keys in-app (used when revoking old API keys)
- Print atomic mod sizes when listing installed mods
- Dialogs show direct links to API key management URLs when changing API keys
- Toggle dry-run mode directly from server browser
- Extensive keybindings for fully controlling the application without the mouse
- Extensive pre-boot sanity checks
- Keybinding help dialog in main menu
- Right-click context menus in server browsers/mod list: add/remove from favorites, show server-side mods, delete mod, copy server IP to clipboard
### Changed
- Utilize GTK bindings and MVC paradigm for UI creation and data flow
- Filter servers dynamically from within server browser
- Performance and security improvements to DZGUI helper files
### Fixed
- First-time setup dialogs respawning repeatedly in certain scenarios
- Issues with Steam client switching to the wrong page when using auto-mod install
- Set text input module correctly when launching on Steam Deck
- Separate current/total player count and use proper integer sort method in table
## [4.1.1] 2023-12-18
### Fixed
- News marquee not showing
## [4.1.0] 2023-12-17
This update adds support for DayZ servers running on a local area network. To connect or add to your server list, supply the server IP and query port in the format IP:PORT.
Support for Steam Deck Game Mode has also been restored, with a new dialog format that allows for virtual keyboard input. This should allow you to enter text in form fields by activating the Steam button + X. In addition, you can unlock the mouse and keyboard input when launching DZGUI through Steam by using an internal binding provided by Steam Deck: long-press the three dots button on the right of the device for three seconds to toggle the input state. This allows you to use mouse and keyboard-style bindings on Game Mode and vice versa.
### Added
- Virtual keyboard support for text entry fields on Steam Deck Game Mode
- Validate and connect to LAN server IPs
## [4.0.4] 2023-12-23
### Fixed
- Enforce version check for Python versions before 3.10
## [4.0.3] 2023-11-22
### Fixed
- Query helper: backwards compatibility for pre-2021 versions of Python 3
## [4.0.2] 2023-11-22
### Fixed
- Query helper not loading: fixed a remote link pointing to the wrong destination and added a checksum verification to ensure file is present
## [4.0.1] 2023-11-22
### Fixed
- Emergency hotfix to remove build artifacts leaking into main script: if you updated DZGUI from 3.3.18 to 4.0.0 between 2023-11-22 15:00:02 and 2023-11-22 15:03:37 GMT, there is a small chance it will be unable to launch correctly. If so, please follow the instructions on the manual to reinstall.
## [4.0.0] 2023-11-22
Hello players, this is a major version update which overhauls many of DZGUI's underlying systems to improve responsiveness of the application and make menus more intuitive to interact with. It should be considerably more difficult, if not impossible, to inadvertently crash a dialog, and nested dialogs should behave in a more expected fashion, such as when going back and forth between menus or changing options dynamically within a given menu.
In addition, with this update we are querying servers directly. The net benefit of this is that results will returned faster, bootup is faster, and it also paves the way for future features like connecting to servers on a LAN. The Battlemetrics API is now entirely optional and can be skipped during the setup process. However, if you have already set up a BM API key, you can continue using it if you want to query or add servers by ID instead of IP.
As part of this change, version 4.0.0 introduces the ability to connect to or add servers to your list based on either ID or IP. Previously, you could only connect by IP and add by IP, but now you can connect by IP/ID or add by IP/ID. If you choose the ID method, this will be translated into an IP seamlessly in the background. Similarly, favorite servers are now stored using the full IP rather than the ID. Due to the variety of systems and methods for connecting to servers, the application was carrying around and converting server IPs, IDs, and other formats back and forth, creating unnecessary complexity. By normalizing everything to an IP basis, maintainability should be more consistent. When upgrading to this version, your old favorites lists will be updated automatically to the new IP method. Note that as a result of this change, we must purge old history lists ("Recent Servers"), but everything else should carry over as before.
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
from source or waiting until the latest version is merged into Fedora's package manager.
### Added
- Change in game name: dynamically change your profile name via the Advanced Options menu
- Connect by ID: supply a Battlemetrics ID to connect to a server; this can be used in lieu of the IP
- Add by IP: supply a standard IP to add a server to your list; this can be used as a more direct way of saving servers
- Filter by 1PP and 3PP in the server browser
- Save connected server to favorites: prior to connecting, asks the user if they want to save this server for future use
- Generate additional output when generating system logs
### Fixed
- Rare cases where the keyword filter would not filter server results correctly
- Handling of dialog exit signals: made it much more difficult to crash the application in rare cases when spamming input or returning from menus
- Update menus in place: when toggling options in the Advanced Options menu, displays the current state/mode of the option for better readability into what option is currently enabled
- More intuitive menu navigation: dialog setup and teardown is more responsive and follows expected flows when inside of nested menus
- Message formatting: fold long messages inside of popup dialogs for proper word wrapping regardless of screen type
- Fixed a rare case where dialogs would spawn twice during first-time setup
- Properly remain inside of menus when looping where it would make sense to do so : e.g., Delete Servers list, Advanced Options
### Changed
- Query servers directly to reduce API hops: initial bootup and subsequent server queries should be considerably faster
- 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.
The My Servers list thus shows online and accessible servers
## [3.3.0] 2023-05-16
### Added
- Fetch more inclusive global "players in-game" count
- List mod directory on installed mods list
- Detect default Flatpak Steam path
- Dark mode/light mode theme to help file
- Alpha-sort My Servers list
- Add description of how to enable hidden folders on GTK2/3
- Initial logging framework
### Changed
- Test for wmctrl when enabling full auto mod installation
- Steam Deck: block toggling full auto mod installation due to extra dependencies needed
- 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
- 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
- First-time setup: break out of automatic path discovery when user specifies a path manually
- More portable interpreter invocation
- Properly size down window resolution when returning from server browser
## [3.2.10] 2023-05-11
### Fixed
- Return from lockfile function if first-time setup has not been run
- Sanitize inputs when using file picker
- Require both wmctrl and xdotool
## [3.2.9] 2023-05-10
### Changed
- Reword button to "Choose path manually" instead of "Retry"
## [3.2.7] 2023-05-10
### Changed
- Better sudo escalation within zenity dialogs if vm map count is too small
## [3.2.6] 2023-05-10
### Fixed
- Don't parse Flatpak symlinks when setting up default Steam path
## [3.2.5] 2023-05-07
### Fixed
- Require sudo when checking vm map count
## [3.2.4] 2023-03-01
### Fixed
- BM API returning stale query port and preventing fetching modlist
## [3.2.3] 2023-02-17
### Fixed
- sysctl map count value not being loaded immediately after setting
- Application terminating when user declines to update map count value
- Erroneous stderror output when flatpak is not installed
## [3.2.0] 2023-01-19
### Added
- Support Flatpak version of Steam
## [3.1.8] 2023-01-18
### Fixed
- Progress window blocking rest of window stack
- Bug when updating old mods if automod set to ON
## [3.1.7] 2023-01-06
### Fixed
- Hotfix for xdotool repeating input
## [3.1.6] 2023-01-01
### Changed
- Tick low pop servers by default
## [3.1.5] 2023-01-01
### Fixed
- Validate BM key on initial setup
- Fix history menu not parsing query ports correctly
### Changed
- More permissive Steam client discovery for tiling WMs
## [3.1.4] 2022-12-10
### Fixed
- Issue #43: Hotfix for workspace-driven WMs
## [3.1.3] 2022-12-06
### Fixed
- Explicitly require Python 3
## [3.1.1-2] 2022-12-03
### Fixed
- Fix lockfile path
## [3.1.0] 2022-12-03
### Added
- Recent connect history
- Simple, OS-agnostic automod installation
- Track local mod versions
- Force update local mods option
- Added python to dependencies
- File-picker driven path discovery on initial setup
### Dropped
- Headless mod installation
- Drop server ID field requirement on initial setup
### Changed
- Clean up main menu options
- Enforce Steam API key on initial setup
- More accurate path discovery on initial setup
- Add thousands separator to player counts in server browser
### Fixed
- Initial setup dialog causing early crash
- Improved error handling on initial setup to avoid malformed config files
- Delete server menu not clearing when returning to main menu
- Handle whitelist deletion when only one entry present
- Include path to drives under /run in path discovery
- Use Steam-safe local zenity version
## [3.0.7] 2022-11-25
### Fixed
- Hotfix for server reporting multiple versions of same mod
## [3.0.6] 2022-11-09
### Changed
- More verbose logs
## [3.0.5] 2022-10-27
### Fixed
- Properly create .desktop file on desktop PCs
## [3.0.3-4] 2022-10-16
### Fixed
- Steam Deck path discovery on first-time setup
## [3.0.2] 2022-10-12
### Fixed
- Size of certain popups on Steam Deck
## [3.0.1] 2022-10-12
### Fixed
- Initial popup size on Steam Deck
## [3.0.0] 2022-10-12
### Added
- Foreground progress of manual mod subscriptions
- Automatic mod helper through steamcmd
- Forum link
- Enforce Steam API
### Dropped
- Stop retrieving extra metadata from BM
### Changed
- Reorder main menu
- More verbose error messages
- Better abstraction of URLs
### Fixed
- Width and text of some popups on Steam Deck
## [2.7.2] 2022-10-07
### Fixed
- Fix internal URL
## [2.7.1] 2022-10-05
### Fixed
- Game launch not kicking off after symlink creation
## [2.7.0] 2022-10-04
### Added
- Server browser and geolocation algorithm
- More verbose error codes
- Additional progress bar setup and destruction throughout the application
- Additional API response validation
### Fixed
- Encapsulate title strings to prevent leaky arguments in title bars
- Remove erroneous slow boot process if first-time setup was already complete
- Delete server list not emptying when returning to main menu
### Changed
- Group main menu entries in advance of future functionality
- Better abstraction of paths
- Clarification of certain options and errors
## [2.6.3] 2022-10-02
### Fixed
- Hotfix for connect-to-fav not getting modlist
## [2.6.2] 2022-10-02
### Fixed
- Hotfix for BM API returning malformed publishedfileids
## [2.6.1] 2022-09-25
### Fixed
- Freedesktop shortcut errors
## [2.6.0] 2022-09-05
### Added
- Connect by IP method
## [2.5.1] 2022-08-17
### Fixed
- Hotfix for malformed paths during first-time setup
## [2.5.0] 2022-08-16
### Added
- Lockfile: prevent concurrent instances of DZGUI from being opened
### Changed
- Faster path discovery on first-time setup
### Fixed
- Symlink collision on servers with many mods
- API response pagination for large server lists
## [2.4.1] 2022-08-09
### Fixed
- Hotfix for progress bar breaking table when >9 servers in list
## [2.4.0] 2022-08-07
### Added
- Delete server command added to main menu
- Write log to file for bug reports
- Prompt to permanently increase map count size
- Dependency check for Steam
- Cover artwork
- News backend for OTA updates
- Clean up stale symlinks when checking mods
### Fixed
- Minify long mod launch params for servers with launch params breaking the upper limit
- Port DZTUI method of handling legacy symlinks
- Prevent user from entering invalid data on first-time setup
- Prompt to re-run first-time setup if config is malformed
- Better handling of field output from table
- Better Steam Deck detection and handling
- Prevent garbage in error messages
- Miscellaneous backend improvements
### Changed
- Enforce download when switching branches
## [2.3.2] 2022-08-04
### Fixed
Set branch flag to 'stable' if no config file present
## [2.3.1] 2022-08-04
### Fixed
- Improved error handling of first-time setup fields
- Interpolate config file values for debug, branch when writing file
### Changed
- Prompt user to restart first-time setup if broken config is found
## [2.3.0] 2022-07-18
### Added
- Numbered mod links in browser
- Admonition to upgrade versions for bug fixes
- Toggle branch between stable/testing
### Fixed
- Hotfix for fav server select on main menu
## [2.2.1] 2022-07-17
### Fixed
- Hotfix for upstream API returning malformed modlists
- Handle servers with no mods
- Fix dialog window depending on browser exit
- Remove stray newlines in config file
## [2.2.0] 2022-06-22
### Added
- Toggle debug mode in-app
## [2.1.0] 2022-06-19
### Changed
- Updated link to new documentation
## [2.0.3] 2022-06-17
### Fixed
- Safer expansion of originating script path
## [2.0.2] 2022-06-16
### Fixed
- Remove enforced runtime check of workshop path
## [2.0.2] 2022-06-16
### Fixed
- Expansion of Steam path prefix when default path was found
## [2.0.1] 2022-06-16
### Fixed
- Regenerate mod links file in browser when clicking dialog
- Reset whitelist when canceling one-shot (fav) mode
## [2.0.0] 2022-06-15
### Added
- Attempt to find DayZ path and write to config on first launch
- Merge existing config values into new config format when upgrading version
- Dynamically detect Steam Deck and set launch parameters
- Add "gametime" column to server list
- One-shot mode to open mod links in browser if using desktop
- Add favorite server from main menu
- Update favorite server changes in real time
- Add link to help pages from main menu
- Add link to changelog from main menu
### Changed
- Don't require duplication of fav server in whitelist
- More permissive truncation of long server names (50 char limit)
- Render mod list as a scrollable menu
- Reword errors for greater verbosity
- Initial support of granular error handling for API response codes
- Move extended path variables out of user config
### Fixed
- Check if mod dir is sane before listing mods
- Prevent favorite server launch if none set
- Reset server list to entire whitelist if canceling out of fav connect
- Do not load table on empty API response and warn user
- Suppress stderr cruft in logs and use logger instead
## [1.2.1] 2022-06-12
### Fixed
- Print the entire changelog
- Add confirmation dialog before run
## [1.2.0] 2022-06-12
### Changed
- Mod validation process now uses faster, single-pass API query
- Improved logger output when setting fav server
### Added
- In-app changelog
## [1.1.2] 2022-06-08
### Fixed
- Fix array used for mod concatenation; fetch post-sanitized list of mods
## [1.1.1] 2022-06-05
### Fixed
- Stricter regex to parse upstream version number
## [1.1.0] 2022-06-05
### Added
- Main menu: fav server label on header
- Main menu: add servers by ID directly into config file
- Main menu: link to report a bug
- Main menu: quick connect to fav server
- Connect: mod compatibility check
- Connect: mod download prompt
- Connect: mod auto symlinks
- New version download prompt
- Additional visualization of progress/menus
### Fixed
- Menu recursion when navigating backwards
### Changed
- Reduced ping timeout interval
-69
View File
@@ -1,69 +0,0 @@
# Introduction
Thank you for your interest in DZGUI!
This guide goes over development conventions and best practices for contributors.
If you are a developer, you can skip to the end.
# Requesting help
If you encounter a problem with DZGUI, you can submit tickets on the GitHub
[issue tracker](https://github.com/aclist/dztui/issues) under the
`troubleshooting` tag.
# How can I help the project?
There are several ways to help this project.
1. Report bugs that you find
2. Request features that you would like
3. Contribute features and fixes to the codebase
4. Contribute documentation to the project
Before making any contribution, please read the `CODE_OF_CONDUCT.md` and act
accordingly.
## Submitting a ticket
Navigate to the GitHub [issue tracker](https://github.com/aclist/dztui/issues).
From there, follow the onscreen prompts. You will be asked questions such as:
- What version are you using?
- What distribution are you using?
- What is the issue that you found?
- How can we reproduce the issue?
You can also attach screenshots, logs, or other data that can help us.
## Requesting a feature
You can also request features via the same issue tracker. It is good practice to
first search for your idea to see if a similar one has already been posted.
If not, open a ticket where you describe your feature and its possible benefits.
Please note that this is a community project, so it takes time for us to develop
features. Putting in a feature request does not mean that it will be implemented,
but we will do our best to support as many cool ideas as possible.
## Contributing code or documentation
If you would like to assist with an issue on the issue tracker or
contribute a new change, please follow the guidelines below.
Fork this repository and check out the code up to the `dzgui7` (development) branch.
The following naming conventions apply for PRs:
- fix/<your-fix> - patch/hotfix branches
- feat/<your-feature> - feature branches
- doc/<your-doc-branch> - documentation branches
- infra/<your-infra-branch> - infrastructure branches
Implement your changes and test them locally. If they work, you may
open a merge request, and then we review your changes. If everything is OK, it
will be merged to `dzgui7`. After sufficient changes are consolidated into a new release,
this branch will be tagged to a certain point in time and a binary release published.
It is recommended to follow
[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), as this
integrates well with tooling and helps the project review your code.
Please see DEVELOPERS.md for further details.
+9 -21
View File
@@ -1,30 +1,18 @@
# TODO: link to here from sphinx docs
## Installing from source
# Installing dev dependencies
uv pip install -e .[dev]
### Fetch submodules
git submodule update --recursive --init
### Install with dev dependencies
uv pip install -e '.[dev]'
This includes all dependencies for building, linting, testing, and checking in code.
If you optionally want to install only a subset of dev tools, replace 'dev' with the following flag:
build: package release deps
docs: documentation tools
lint: code linting, fixing, and type checking
test: unit and integration tests
## Miscellaneous
### Setting up precommit hooks
# Setting up precommit hooks
pre-commit install
### Building documentation
# Fetching submodules
git submodule update --recursive --init
# Building documentation
sphinx-build -M html source build -a
### Building standalone release binaries
# Building standalone DZGUI release binaries
1. Set up the build target: `rustup target add x86_64-unknown-linux-musl`
2. Run `python3 scripts/release.py`
3. Generate a release and tag against the resulting tarfile generated in `dist`
3. Generate a release and tag against the bundled `dzgui` executable
+7 -2
View File
@@ -24,8 +24,13 @@ Please refer to the documentation for installation and setup instructions:
Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https://creativecommons.org/licenses/by/4.0/)
Executable versions of DZGUI published as release binaries ship with various runtime dependencies and the Python interpreter built in.
Users wishing to review the licenses to these components can inspect the `LICENSE` file located in the release tarball.
This tool uses [python-a2s](https://github.com/Yepoleb/python-a2s) and [dayzquery](https://github.com/Yepoleb/dayzquery) as submodules; licenses for these submodules can be found in the LICENSES file
of the project root.
Both the geolocation records and submodules listed above are not shipped with the source code, but are retrieved and assembled at runtime.
Finally, executable versions of DZGUI shipped as release binaries are thin wrappers around the Python interpreter, and also retrieve and assemble the above dependencies at runtime on the end-user's
machine, rather than using pre-compiled source code. Users wishing to review these dependencies can inspect the 'pyproject.toml' manifest in the project root.
## Disclaimer
File renamed without changes.
File renamed without changes.
-19
View File
@@ -1,19 +0,0 @@
Copyright (c) 2015 Rossen Georgiev <rossen@rgp.io>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File renamed without changes.
File renamed without changes.
+1 -1
View File
@@ -26,7 +26,7 @@ You can install it via the following set of commands:
.. code:: console
sudo apt update
sudo apt install libgirepository-2.0-0
sudo apt install libgirepository-2.0
Next steps
---------------
-119
View File
@@ -1,119 +0,0 @@
import logging
import re
from collections.abc import Iterator
from pathlib import Path
from typing import Any
from warnings import deprecated
from dzgui.const.constants import APP_NAME
logger = logging.getLogger(APP_NAME)
class ACF:
def __init__(self, file: Path) -> None:
super().__init__()
self.dict: dict[str, Any]
self.load(file)
def as_dict(self) -> dict[str, Any]:
return self.dict
def load(self, file: Path) -> None:
delimiter = r"\t\t"
lines = []
# TODO: illegal file handling
with open(file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
els = re.split(delimiter, line, maxsplit=1)
lines.append(els)
self.dict = self.parse(iter(lines))
@classmethod
def enquote(cls, s: str) -> str:
return f'"{s}"'
@classmethod
def dequote(cls, s: str) -> str:
return s.rstrip('"').lstrip('"')
def parse(self, lines: Iterator[list[str]]) -> dict[str, str]:
acf: dict[str, Any] = {}
try:
while True:
line = next(lines)
if len(line) == 1:
key = line[0]
if key == "{":
continue
return acf
elif key == "}":
return acf
else:
key = self.dequote(key)
acf[key] = self.parse(lines)
elif len(line) == 2:
k, v = line
k = self.dequote(k)
v = self.dequote(v)
try:
n = list(acf.keys())[-1]
acf[n][k] = v
except Exception:
acf[k] = v
except StopIteration:
return acf
def get_allows_downloads(self) -> int | None:
try:
# TODO: consider coercing strs to ints on initial import
flag = int(self.dict["AppState"]["AllowOtherDownloadsWhileRunning"])
return flag
except Exception as e:
logger.critical(e)
return None
@deprecated(
"Serializing workshop files is no longer supported, use dzgui.api.steam.unsubscribe"
)
class WorkshopACF(ACF):
def __init__(self, file: Path) -> None:
super().__init__(file)
def unpack(self, d: dict, lines: list[Any] = []) -> str:
t1 = "AppWorkshop"
t2 = ("WorkshopItemsInstalled", "WorkshopItemDetails")
for k, v in d.items():
if type(v) is dict:
if k in t1:
self.indent = 0
elif k in t2:
self.indent = 1
else:
self.indent = 2
pref = "\t" * self.indent
lines.append(pref + self.enquote(k))
lines.append(pref + "{")
self.unpack(v, lines)
lines.append(pref + "}")
else:
pref = "\t" * (self.indent + 1)
lines.append(pref + self.enquote(k) + "\t\t" + self.enquote(v))
s = ""
for line in lines:
s += line + "\n"
return s
def to_file(self, file: Path) -> None:
s = self.unpack(self.dict)
file.write_text(s)
def delete(self, modid: int) -> None:
del self.dict["AppWorkshop"]["WorkshopItemsInstalled"][modid]
del self.dict["AppWorkshop"]["WorkshopItemDetails"][modid]
+47 -63
View File
@@ -1,18 +1,18 @@
import hashlib
import logging
import re
import shlex
from concurrent.futures import wait
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from pathlib import Path
from dzgui.api.steam import get_app_path
import dzgui.api.pefile as PeFile
from dzgui.api.servers import get_rules, fqip_to_record
from dzgui.const.constants import (
APP_NAME,
APPID_DAYZ,
DAYZ_COMMUNITY_ROOT,
LIBRARYFOLDERS_PATH,
WORKSHOP_PATH,
)
@@ -39,7 +39,7 @@ def get_local_mod_ids(steam_path: Path) -> list[int]:
def get_local_mod_path(steam_path: Path) -> Path:
p = get_app_path(steam_path, APPID_DAYZ)
p = PeFile.get_app_path(steam_path / Path(LIBRARYFOLDERS_PATH), APPID_DAYZ)
workshop_path = p / WORKSHOP_PATH
return workshop_path
@@ -49,33 +49,34 @@ def get_local_mods(workshop_path: Path) -> list[Path]:
return mods
def is_mission(path: Path) -> bool:
# TODO: parse integrity of other files
parent = path.parent.name
if parent != DAYZ_COMMUNITY_ROOT:
return False
file = path / "init.c"
return file.exists()
def tokenize(mod: Path) -> dict[str, Any] | None:
file = mod.joinpath("meta.cpp")
delimiter = r"\s*=\s*"
modmeta = {}
try:
with open(file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip().rstrip(";")
if not line:
continue
els = re.split(delimiter, line, maxsplit=1)
if len(els) == 2:
key, value = els
modmeta[key.strip()] = value.strip('"')
return modmeta
except Exception as e:
logger.critical(e)
def parse_meta(file: Path) -> ModMeta | None:
mod = file / "meta.cpp"
if mod.exists() is False:
return None
with open(file / "meta.cpp", "r") as f:
st = f.read()
lex = shlex.shlex(st)
lex.whitespace += "=;"
v = []
while True:
tok = lex.get_token()
if not tok:
break
if tok == "protocol" or tok == "publishedid":
ntok = lex.get_token()
elif tok == "timestamp":
# NOTE: some malformed .NET tick conversions result in numbers < 0
ntok = lex.get_token()
if ntok == "-":
ntok += str(lex.get_token())
elif tok == "name":
ntok = lex.get_token()
if ntok is not None:
ntok = ntok.split('"')[1]
if ntok is not None:
v.append(ntok)
meta = ModMeta(*v)
return meta
def get_mod_size(path: Path) -> float:
@@ -86,66 +87,49 @@ def get_mod_size(path: Path) -> float:
return size
def get_custom_mods(path: Path) -> list[Any]:
# TODO: rename this function for clarity ("get local vs. get custom")
mods = get_local_mods(path)
# TODO: error handling
return parse_mods(mods, use_custom=True)
def parse_mods(mods: list[Path], use_custom: bool = False) -> list[Any]:
def get_delimited_mods(steam_path: Path) -> list[Any]:
workshop_path = get_local_mod_path(steam_path)
mods = get_local_mods(workshop_path)
clean = []
for mod in mods:
mod_dir = mod.name
symlink = _hash(mod_dir, use_custom)
meta = tokenize(mod)
symlink = _hash(mod_dir)
# FIXME: malformed .cpp files could break this
# mention that mods may be downloading
meta = parse_meta(mod)
if meta is None:
continue
size = get_mod_size(mod)
# NOTE: final col is cell renderer highlight toggle
clean.append([meta["name"], symlink, mod_dir, size, False])
clean.append([meta.name, symlink, mod_dir, size, False])
clean.sort(key=lambda row: str(row[0]).casefold())
return clean
def get_delimited_mods(steam_path: Path) -> list[Any]:
workshop_path = get_local_mod_path(steam_path)
mods = get_local_mods(workshop_path)
return parse_mods(mods)
def get_missing_mods(local: list, remote: list) -> list:
return [mod for mod in remote if mod not in local]
def _hash(uid: str, use_custom: bool = False) -> str:
def _hash(uid: str) -> str:
md5 = hashlib.md5()
md5.update(uid.encode("ascii"))
prefix = "@C" if use_custom else "@"
return prefix + md5.hexdigest()[:8]
return "@" + md5.hexdigest()[:8]
def remove_stale_signatures(
config: Path, versions: Path, ids: list[int] | None = None
) -> None:
def remove_stale_signatures(config: Path, versions: Path) -> None:
if versions.is_file() is False:
logger.warning("Creating new version signatures file")
versions.touch()
return
path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path)
if ids is None:
local_ids = get_local_mod_ids(steam_path)
ids = get_local_mod_ids(steam_path)
with open(versions, "r") as f:
lines = f.readlines()
if ids is None:
lines = [line for line in lines if int(line.split(",")[0]) in local_ids]
else:
lines = [line for line in lines if int(line.split(",")[0]) not in ids]
for line in lines:
uid = int(line.split(",")[0])
if uid not in ids:
lines.remove(line)
with open(versions, "w") as f:
for line in lines:
f.write(line)
+49 -2
View File
@@ -1,3 +1,4 @@
import json
import struct
from dataclasses import dataclass
@@ -11,8 +12,9 @@ from dzgui.const.constants import (
APPNAME_DAYZ,
APPNAME_DAYZ_EXP,
DAYZ_BINARY,
LIBRARYFOLDERS_PATH,
)
from dzgui.api.steam import get_app_path
from dzgui.api.steam import vdf2json
# https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
endian = "<"
@@ -255,6 +257,25 @@ class PeFileError(Exception):
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) -> FileVersion:
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
@@ -398,11 +419,37 @@ def get_pefile_path(steam_path: Path, appid: int) -> Path:
name = identifier[appid]
binary = DAYZ_BINARY
app_path = get_app_path(steam_path, appid)
app_path = get_app_path(steam_path / LIBRARYFOLDERS_PATH, appid)
pe_path = app_path / f"steamapps/common/{name}/{binary}"
return pe_path
def get_app_path(folders_path: Path, appid: int) -> Path:
app_path = None
try:
j = json.loads(vdf2json(folders_path))
except Exception:
raise VDFLoadError("Failed to parse libraryfolders")
for obj in j["libraryfolders"]:
if str(appid) in j["libraryfolders"][obj]["apps"]:
app_path = j["libraryfolders"][obj]["path"]
if Path(app_path).exists():
break
if app_path is None:
raise AppNotInstalledError(
f"Failed to find a libraryfolder for the appid {appid}"
)
if Path(app_path).exists() is False:
raise AppMovedError(
f"The location '{app_path}' pointed to by '{appid}' no longer exists and may have been changed on the disk."
)
return Path(app_path)
def get_pretty_version(steam_path: Path, appid: int) -> str | None:
try:
pe_file_path = get_pefile_path(steam_path, appid)
+3 -6
View File
@@ -14,7 +14,6 @@ from typing import Any, Optional, TYPE_CHECKING, Union
from dzgui.api.bm import map_id_to_record
from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT
from dzgui.const.endpoints import STEAM_SERVERS
from dzgui.util.format import format_exception
from dzgui.util import strings
import a2s
@@ -273,9 +272,8 @@ def source_info_to_dict(ip: str, qport: int, info: "SourceInfo") -> dict[str, An
res["ping"] = ping
return res
except Exception as e:
msg = format_exception(e)
msg += f" ({ip}:{qport})"
logger.critical(msg)
# TODO: generalized function
logger.critical(f"{type(e).__name__}: {e} ({ip}:{qport})")
raise e
@@ -289,14 +287,13 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] |
def get_details(record: Record) -> Details:
# TODO: consolidate try/except statements
ip = record.ip
qport = record.qport
default_str = strings.none_provided
try:
info = a2s.info((ip, qport))
except (TimeoutError, a2s.exceptions.BrokenMessageError):
except TimeoutError:
return Details(None, default_str, False)
try:
rules = dayzquery.dayz_rules((ip, qport))
-189
View File
@@ -1,189 +0,0 @@
import binascii
import ctypes
import logging
import shutil
import vdf # type: ignore
from dataclasses import dataclass
from importlib import resources
from pathlib import Path
from typing import Any
from dzgui.api.steam import find_user_id_32
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH
from dzgui.util.dirs import copy_dzgui_to_xdg_data
from dzgui.util.strings import unknown
logger = logging.getLogger(APP_NAME)
@dataclass(slots=True, frozen=True)
class ShortcutMetadata:
appname: str
appid: int
exe_path: str
start_dir: str
icon: str
class Shortcuts:
def __init__(self, steam_path: Path) -> None:
self.user_config_path: Path
self.shortcuts_path = self.find_shortcuts_path(steam_path)
self._load_shortcuts(self.shortcuts_path)
@classmethod
def gen_bpid(cls, uid: str) -> int:
u = uid.encode()
encoded = binascii.crc32(u)
return encoded | 0x80000000
@classmethod
def gen_signed_appid(cls, appid: int) -> int:
return ctypes.c_int(appid).value
@classmethod
def gen_exe_uid(cls, appname: str, exe: Path) -> str:
wrapped_exe = f'"{exe}"'
return appname + wrapped_exe
def find_appname_by_unsigned_id(self, appid: int) -> str:
# NOTE: bitmask signed int back to 32-bit CRC
# varying client versions treat case sensitivity differently
try:
for key in self.shortcuts["shortcuts"].keys():
if self.shortcuts["shortcuts"][key]["appid"] & 0xFFFFFFFF == appid:
try:
return str(self.shortcuts["shortcuts"][key]["appname"])
except Exception as e:
logger.debug(e)
try:
return str(self.shortcuts["shortcuts"][key]["AppName"])
except Exception as e:
logger.debug(e)
return unknown
except Exception as e:
logger.debug(e)
return unknown
def get_shortcuts(self) -> Any:
return self.shortcuts
def _load_shortcuts(self, path: Path) -> None:
try:
with open(path, "rb") as f:
shortcuts = vdf.binary_load(f)
self.shortcuts = shortcuts
logger.debug("Loaded shortcuts file")
except Exception as e:
logger.critical(e)
raise e
def find_shortcuts_path(self, steam_path: Path) -> Path:
uid = find_user_id_32(steam_path)
self.user_config_path = steam_path.joinpath(f"userdata/{uid}/config")
return self.user_config_path.joinpath("shortcuts.vdf")
def add_shortcut(
self, appname: str, start_dir: Path, exe_path: Path, icon: Path
) -> None:
uid = self.gen_exe_uid(appname, exe_path)
self.bpid = self.gen_bpid(uid)
self.signed_appid = self.gen_signed_appid(self.bpid)
meta = ShortcutMetadata(
appname, self.signed_appid, str(exe_path), str(start_dir), str(icon)
)
entry = self._create_entry(meta)
self._insert_at_last_index(entry)
def add_grid_images(self, images: Path) -> None:
"""
{BPID}_hero.png: hero (splash)
{BPID}p.ping: portrait boxart in library
{BPID}_logo.png: logo overlay on top of hero
{BPID}.png: legacy Big Picture/landscape image
"""
grid_path = self.user_config_path.joinpath("grid")
for img in ("_hero", "p", "_logo"):
filename = str(self.bpid) + img
dest = grid_path.joinpath(filename).with_suffix(".png")
b = images.joinpath(img).with_suffix(".png").read_bytes()
dest.write_bytes(b)
# NOTE: legacy Big Picture header
b = images.joinpath("_hero.png").read_bytes()
dest = grid_path.joinpath(str(self.bpid)).with_suffix(".png")
dest.write_bytes(b)
def _insert_at_last_index(self, entry: dict[str, Any]) -> None:
try:
last = list(self.shortcuts["shortcuts"].keys())[-1]
n = int(last) + 1
except Exception:
n = 0
self.shortcuts["shortcuts"][str(n)] = entry
@classmethod
def _create_entry(cls, meta: ShortcutMetadata) -> dict[str, Any]:
"""
https://developer.valvesoftware.com/wiki/Add_Non-Steam_Game
https://developer.valvesoftware.com/wiki/Steam_Library_Shortcuts
Key case is not internally consistent and varies between client versions
Most keys use Pascal case, but some do not
appid: signed int CRC
Exe: absolute path to the executable, must be wrapped in literal quotes
StartDir: directory the executable starts in, generally the parent
"""
NEW_ENTRY: dict[str, Any] = {}
NEW_ENTRY["appid"] = meta.appid
NEW_ENTRY["AppName"] = meta.appname
NEW_ENTRY["Exe"] = f'"{meta.exe_path}"'
NEW_ENTRY["StartDir"] = f"{meta.start_dir}"
NEW_ENTRY["icon"] = meta.icon
NEW_ENTRY["ShortcutPath"] = ""
NEW_ENTRY["LaunchOptions"] = ""
NEW_ENTRY["IsHidden"] = 0
NEW_ENTRY["AllowDesktopConfig"] = 1
NEW_ENTRY["AllowOverlay"] = 1
NEW_ENTRY["OpenVR"] = 0
NEW_ENTRY["Devkit"] = 0
NEW_ENTRY["DevkitGameID"] = ""
NEW_ENTRY["DevkitOverrideAppID"] = 0
NEW_ENTRY["LastPlayTime"] = 0
NEW_ENTRY["FlatpakAppID"] = ""
NEW_ENTRY["sortas"] = ""
NEW_ENTRY["tags"] = {}
return NEW_ENTRY
def save_shortcuts(self) -> None:
try:
backup = self.shortcuts_path.with_suffix(".vdf.bak")
shutil.copy(self.shortcuts_path, backup)
with open(self.shortcuts_path, "wb") as f:
vdf.binary_dump(self.shortcuts, f)
except Exception as e:
logger.critical(e)
def add_steam_shortcut(steam_path: Path, exe_path: Path) -> None:
try:
start_dir = exe_path.parent
traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH)
images = Path(str(traversable))
icon = images.joinpath("icon.png")
shortcuts = Shortcuts(Path(steam_path))
shortcuts.add_shortcut(APP_NAME, start_dir, exe_path, icon)
shortcuts.save_shortcuts()
shortcuts.add_grid_images(images)
copy_dzgui_to_xdg_data(exe_path)
logger.debug("Finished creating Steam shortcut")
except Exception as e:
logger.critical(e)
+81 -277
View File
@@ -1,59 +1,29 @@
import json
import logging
import os
import psutil
import requests
import subprocess
import vdf # type: ignore
from shlex import shlex
from pathlib import Path
from typing import Any, Union
from warnings import deprecated
from dzgui.api.acf import ACF
from dzgui.init.prereqs import has_steam_client
from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
APP_NAME,
DAYZ_BINARY,
DEBIAN_STEAM_PATH,
DEFAULT_STEAM_PATH,
FLATPAK_STEAM_PATH,
LIBRARYFOLDERS_PATH,
UBUNTU_STEAM_PATH,
REQUEST_TIMEOUT,
VDF_PATH,
)
from dzgui.const.endpoints import (
APP_DETAILS,
SUB_ENDPOINT,
STEAM_PUBLISHED_FILES,
UNSUB_ENDPOINT,
)
from dzgui.const.endpoints import STEAM_PUBLISHED_FILES
from dzgui.strings import wizard
from dzgui.util.bash import concat_bash_args
logger = logging.getLogger(APP_NAME)
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 get_steam_paths() -> list[tuple[Path, str]]:
paths = []
if has_steam_client():
@@ -83,8 +53,7 @@ def get_steam_paths() -> list[tuple[Path, str]]:
def concat_mods(mods: list[str]) -> str:
# TODO: circular import workaround
from dzgui.api.mods import _hash
from dzgui.util.symlink import _hash
hashes = []
for mod in mods:
@@ -99,21 +68,31 @@ def get_local_signatures(version_file: Path) -> dict[str, int]:
for line in lines:
data = line.split(",")
_id = data[0]
mod_hash = int(data[1])
hashes[_id] = mod_hash
_hash = int(data[1])
hashes[_id] = _hash
return hashes
def enqueue_mod(mod: str, appid: int) -> None:
args = [
"steam",
f"steam://url/CommunityFilePage/{mod}+workshop_download_item",
str(appid),
mod,
]
subprocess.Popen(["/usr/bin/env", "bash", *args])
def get_needs_update(
version_file: Path, remote_hashes: list[tuple[str, str, int, int]]
) -> list[tuple[str, str, int, int]]:
local_stamps = get_local_signatures(version_file)
local_hashes = get_local_signatures(version_file)
needs_update: list[tuple[str, str, int, int]] = []
for title, _id, stamp, size in remote_hashes:
if _id not in local_stamps:
needs_update.append((title, _id, stamp, size))
elif stamp != local_stamps[_id]:
needs_update.append((title, _id, stamp, size))
for title, _id, _hash, size in remote_hashes:
if _id not in local_hashes:
needs_update.append((title, _id, _hash, size))
elif _hash != local_hashes[_id]:
needs_update.append((title, _id, _hash, size))
else:
continue
return needs_update
@@ -167,7 +146,7 @@ def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> i
return proc.returncode
def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int:
def load_to_menu(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int:
"""Loads to the menu screen with the selected mods; used for AFK/pre-joining"""
concat = concat_mods(mods)
client_args = concat_bash_args(client)
@@ -184,244 +163,69 @@ def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int:
return proc.returncode
def launch_offline(
client: str, appid: int, name: str, mods: list[str], mission: str
) -> int:
"""Launch offline with specific mods/missions"""
symlinks = ";".join(mods)
client_args = concat_bash_args(client)
params = [
"-applaunch",
str(appid),
"-nolauncher",
"-nosplash",
"-skipintro",
f"-name={name}",
f"-mod={symlinks}",
]
if len(mission) > 0:
arg = f"-mission={mission}"
params.append(arg)
proc = subprocess.run([*client_args, *params])
return proc.returncode
def find_loginusers(path: Path) -> Path:
return path / "config" / "loginusers.vdf"
def find_user_id(path: Path) -> str | None:
resolved_path = find_loginusers(path)
resolved_path = path / "config" / "loginusers.vdf"
try:
with open(resolved_path, "r") as f:
v = vdf.load(f)
# NOTE: beta client
# /package/beta
if len(v["users"]) == 1:
return str(list(v["users"].keys())[0])
for user in v["users"]:
if v["users"][user]["MostRecent"] == "1":
return str(user)
return None
vdf = vdf2json(resolved_path)
j = json.loads(vdf)
for user in j["users"]:
if j["users"][user]["MostRecent"] == "1":
return str(user)
return None
except Exception as e:
logger.debug(e)
logger.warn(e)
return None
def vdf2json(path: Path) -> str:
def _istr(indent: int, string: str) -> str:
return (indent * " ") + string
def find_user_id_32(path: Path) -> int:
uid = find_user_id(path)
if uid is None:
raise ValueError("Failed to parse a valid Steam32 ID")
return int(uid) & 0xFFFFFFFF
jbuf = "{\n"
indent = 1
with open(path, "r") as f:
st = f.read()
lex = shlex(st)
while True:
tok = lex.get_token()
if not tok:
return jbuf + "}\n"
if tok == "}":
indent -= 1
jbuf += _istr(indent, "}")
ntok = lex.get_token()
if ntok is not None:
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:
if ntok is not None:
jbuf += _istr(indent, tok + ": " + ntok)
ntok = lex.get_token()
if ntok is not None:
lex.push_token(ntok)
if ntok != "}":
jbuf += ","
jbuf += "\n"
def update_workshop(key: str, mod: int, endpoint: str) -> None:
payload: dict[str, Union[int, str]] = {
"publishedfileid": mod,
"appid": APPID_DAYZ,
"key": key,
"list_type": 1,
"notify_client": 1,
}
try:
res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT)
res.raise_for_status()
except Exception as e:
logger.critical(e)
def subscribe(key: str, mod: int) -> None:
update_workshop(key, mod, SUB_ENDPOINT)
def unsubscribe(key: str, mod: int) -> None:
update_workshop(key, mod, UNSUB_ENDPOINT)
@deprecated("Use subscribe()")
def enqueue_mod(client: str, mod: str, appid: int) -> None:
client_args = concat_bash_args(client)
subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod])
@deprecated("Cf. https://github.com/ValveSoftware/steam-for-linux/issues/9672")
def get_registry() -> Any | None:
home = os.getenv("HOME")
try:
with open(f"{home}/.steam/registry.vdf") as f:
registry = vdf.load(f)
return registry
except Exception as e:
logger.critical(e)
return None
def _is_dayz_running() -> bool:
registry = get_registry()
if registry is None:
return False
apps = registry["Registry"]["HKCU"]["Software"]["Valve"]["Steam"]["apps"].items()
for app in apps:
k, v = app
if k in (APPID_DAYZ, APPID_DAYZ_EXP):
try:
state = v["Running"]
# NOTE: 0 denotes False
return bool(int(state))
except Exception as e:
logger.critical(e)
return False
return False
def _get_running_app() -> int | None:
registry = get_registry()
if registry is None:
return None
try:
return int(
registry["Registry"]["HKCU"]["Software"]["Valve"]["Steam"]["RunningAppID"]
)
except Exception:
return None
def get_running_app() -> int | None:
PROC_NAME = "steam"
SUBPROC_NAME = "reaper"
FLAG = "AppId"
# NOTE: may cause conflicts if multiple apps are running
try:
for proc in psutil.process_iter():
if proc.name() == PROC_NAME:
subprocs = proc.children()
filtered = (proc for proc in subprocs if proc.name() == SUBPROC_NAME)
try:
proc = next(filtered)
except StopIteration:
return None
args = proc.cmdline()
appid = (row for row in args if FLAG in row)
try:
return int(next(appid).split("=")[1])
except StopIteration:
return None
except Exception as e:
logger.debug(e)
return None
def get_app_allows_downloads(path: Path, appid: int) -> bool:
try:
root_path = get_app_path(path, appid)
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
flag = ACF(acf).get_allows_downloads()
except AppNotInstalledError:
flag = 0
match flag:
# NOTE: adheres to global client setting
case 0:
return get_client_allows_downloads(path)
# NOTE: always allow
case 1:
return True
# NOTE: never allow
case 2:
return False
# NOTE: no other known values at this time, fallback (assume permits)
case _:
return True
def get_config(path: Path) -> Path:
return path.joinpath("config/config.vdf")
def get_client_allows_downloads(path: Path) -> bool:
config = get_config(path)
try:
with open(config) as f:
settings = vdf.load(f)
# NOTE: "1" denotes "allow"
allow = bool(
int(
settings["InstallConfigStore"]["Software"]["Valve"]["Steam"][
"AllowDownloadsDuringGameplay"
]
)
)
return allow
except Exception as e:
logger.critical(e)
return True
def is_dayz_running() -> bool:
"""Subprocesses spawned from Steam will not show up in regular process tree"""
procs = []
substring = DAYZ_BINARY
for proc in psutil.process_iter():
try:
procs.append(proc.cmdline())
except Exception as e:
logger.warning(e)
continue
return any(substring in item for sublist in procs for item in sublist)
def get_app_path(folders_path: Path, appid: int) -> Path:
app_path = None
try:
with open(folders_path / LIBRARYFOLDERS_PATH) as f:
folders = vdf.load(f)
except Exception:
raise VDFLoadError("Failed to parse libraryfolders")
for obj in folders["libraryfolders"]:
if str(appid) in folders["libraryfolders"][obj]["apps"]:
app_path = folders["libraryfolders"][obj]["path"]
if Path(app_path).exists():
break
if app_path is None:
raise AppNotInstalledError(
f"Failed to find a libraryfolder for the appid {appid}"
)
if Path(app_path).exists() is False:
raise AppMovedError(
f"The location '{app_path}' pointed to by '{appid}' no longer exists and may have been changed on the disk."
)
return Path(app_path)
def get_app_name(appid: int) -> str | None:
payload = {"appids": [appid]}
res = requests.get(APP_DETAILS, params=payload)
if res.status_code != 200:
return None
try:
return str(res.json()[str(appid)]["data"]["name"])
except Exception as e:
logger.debug(e)
return None
def gen_shortcut() -> None:
# TODO:
"""
during setup, prompt user to select matching user id from loginusers
show user account name, select outer steam id
steam/userdata/<steamid_32>/config/shortcuts.vdf
"""
# STEAMID_MAGIC = 76561197960265728
# STEAMID_64 - STEAMID_MAGIC = STEAMID32
# or get right-most 32 bits
# STEAMID_64 & 0xFFFFFFFF
pass
-156
View File
@@ -1,156 +0,0 @@
import logging
import os
import shutil
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
from dzgui.config.userprefs import UserPrefs
from dzgui.config.xdg import get_xdg_paths, parse_filepaths
from dzgui.init.migrate import (
has_new_config,
copy_state_files,
migrate_cols_file,
)
from dzgui.init.flock import lock_acquire
from dzgui.init.prereqs import has_steam_client
from dzgui.strings import boot
# from dzgui.util.map_count import get_map_count
from dzgui.util.deck import is_steam_deck, is_game_mode
from dzgui.util.dirs import make_parents
from dzgui.util.localize import set_locale
from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS
from dzgui.util.strings import init
from dzgui.views.base import App
from dzgui.views.dialogs.boot import BootWindow
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
from dzgui.views.dialogs.wizard import SetupWizard
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(APP_NAME)
# TODO: profile load time
def setup_logger(log_path: "Path") -> None:
# TODO: put in consts?
_format = (
"%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s"
)
fh = logging.FileHandler(log_path)
formatter = logging.Formatter(_format)
fh.setFormatter(formatter)
fh.setLevel(logging.DEBUG)
_filter = RedactionFilter(patterns=REDACTION_PATTERNS)
fh.addFilter(_filter)
logger.setLevel(logging.DEBUG)
logger.addHandler(fh)
def copy_bare_configs(config: "Path", resolution: "Path") -> tuple[bool, bool]:
# NOTE: temporary workaround for #375
conf = "config.json"
state = [
"dzg.history",
"dzg.versions",
"dzg.res.json",
"dzg.filters.json",
"dzg.columns.json",
"dzg.notes.json",
"ips.csv",
".month",
]
config_changed = False
state_changed = False
if config.parent.exists() is False:
new_file = config
old_file = config.parent.parent / conf
if old_file.is_file():
try:
make_parents(new_file)
shutil.copy(old_file, new_file)
config_changed = True
except Exception as e:
logger.critical(e)
if resolution.parent.exists() is False:
state_path = resolution.parent
for state_file in state:
old_file = state_path.parent / state_file
if old_file.is_file():
try:
new_file = state_path / state_file
make_parents(new_file)
shutil.copy(old_file, new_file)
state_changed = True
except Exception as e:
logger.critical(e)
return (config_changed, state_changed)
def load_gui(version: str, is_debug: bool) -> None:
lock = lock_acquire() # noqa
set_locale()
# NOTE: consider aborting this check if steam deck
xdg_paths = get_xdg_paths()
XDG = parse_filepaths(xdg_paths)
copy_bare_configs(XDG.config, XDG.resolution)
if XDG.resolution.parent.is_dir() is False:
make_parents(XDG.resolution)
if XDG.debug.is_file() is False:
make_parents(XDG.debug)
_is_steam_deck = is_steam_deck()
_is_game_mode = is_game_mode() if _is_steam_deck else False
if _is_game_mode:
# NOTE: this may no longer be necessary on newer versions of SteamOS
del os.environ["GTK_IM_MODULE"]
if has_new_config(XDG.config) is False:
migrate_cols_file(XDG.columns)
copy_state_files(xdg_paths["XDG_STATE_HOME"])
# TODO: add logging inside wizard
wizard = SetupWizard(_is_steam_deck, XDG)
if not wizard.is_setup_complete():
return
setup_logger(XDG.debug)
with open(XDG.debug, "w") as f:
f.truncate(0)
if _is_steam_deck is False:
# TODO: sudo escalation dialog
# count = get_map_count()
# TODO: move into module
if has_steam_client() is False:
EarlyAlertDialog(init.requires_steam)
bootwin = BootWindow(XDG, version)
local_coords, latest_release = bootwin.get_results()
use_miles = lookup(XDG.config, Preferences.DIST)
prefs = UserPrefs(
is_steam_deck=_is_steam_deck,
is_game_mode=_is_game_mode,
is_debug=is_debug,
coords=local_coords,
version=version,
paths=XDG,
latest_release=latest_release,
use_miles=use_miles,
)
print(boot.all_ok)
App(prefs)
-6
View File
@@ -3,8 +3,6 @@ import shlex
from pathlib import Path
from typing import Any
from dzgui.const.boilerplate import config_boilerplate
"""
Convert legacy dztuirc to config.json
@@ -91,8 +89,4 @@ def rc2json(file: Path) -> str:
keys["ip_list"] = ips
keys["use_miles"] = False
keys["start_tab"] = 0
# NOTE: workaround for DZGUI 6 logic (see #375)
for key in config_boilerplate.keys():
if key not in keys:
keys[key] = config_boilerplate[key]
return json.dumps(keys, indent=2)
+5 -30
View File
@@ -1,43 +1,18 @@
import stat
import textwrap
from pathlib import Path
from dzgui.const.constants import APP_NAME
from dzgui.util.dirs import copy_dzgui_to_xdg_data, find_icon_resource, make_parents
def get_share_path(exe_path: Path) -> Path:
return exe_path.parent.parent
def write_desktop_file(exe_path: Path) -> Path:
icon = find_icon_resource()
def write_desktop_file(share_path: Path) -> None:
template = f"""\
[Desktop Entry]
Version=1.0
Type=Application
Terminal=false
Exec={exe_path}
Exec={share_path}/launch.sh
Name=DZGUI
Comment=dzgui
Icon={icon}
Icon={share_path}/dzgui
Categories=Game"""
copy_dzgui_to_xdg_data(exe_path)
share_path = get_share_path(exe_path)
desktop_file = share_path.joinpath("applications/dzgui.desktop")
make_parents(desktop_file)
desktop_file.write_text(textwrap.dedent(template))
desktop_file.chmod(desktop_file.stat().st_mode | stat.S_IEXEC)
return desktop_file
def write_desktop_shortcut(desktop_file: Path) -> None:
# NOTE: necessarily depends on the above (UI blocks creation without XDG entry first)
link = Path.home().joinpath(f"Desktop/{APP_NAME}.desktop")
if link.exists():
link.unlink()
link.symlink_to(desktop_file)
file = share_path / "dzgui.desktop"
file.write_text(textwrap.dedent(template))
+6 -12
View File
@@ -18,7 +18,6 @@ class Xdg:
debug: Path
ips: Path
filters: Path
shortcut: Path
def is_writeable(path_str: str) -> bool:
@@ -30,9 +29,9 @@ def is_writeable(path_str: str) -> bool:
if not path.exists():
try:
path.mkdir(parents=True)
path.unlink()
except OSError:
return False
path.unlink()
return True
try:
@@ -56,19 +55,17 @@ def get_xdg_paths() -> dict:
resolved_paths = {}
for path in xdg_paths:
real_path = os.environ.get(path)
if real_path is not None and is_writeable(real_path):
new_path = Path(real_path)
rp = os.environ.get(path)
if rp is not None and is_writeable(rp):
resolved_paths[path] = Path(rp)
else:
new_path = xdg_paths[path]
resolved_paths[path] = new_path / APP_NAME_LOWER
resolved_paths[path] = xdg_paths[path] / APP_NAME_LOWER
return resolved_paths
def parse_filepaths(xdg: dict) -> Xdg:
config = xdg["XDG_CONFIG_HOME"]
state = xdg["XDG_STATE_HOME"]
share = xdg["XDG_DATA_HOME"]
config = config / "config.json"
@@ -83,8 +80,6 @@ def parse_filepaths(xdg: dict) -> Xdg:
ips = state / "ips.csv"
filters = state / "dzg.filters.json"
shortcut = share / APP_NAME_LOWER
return Xdg(
config,
columns,
@@ -95,6 +90,5 @@ def parse_filepaths(xdg: dict) -> Xdg:
system,
debug,
ips,
filters,
shortcut,
filters
)
+3 -14
View File
@@ -4,8 +4,6 @@ UDP_PORT = 27016
VM_FILE = "/proc/sys/vm/max_map_count"
MIN_COUNT = 1048576
API_RATE_LIMIT = 1
CLIENT_RATE_LIMIT = 3
REQUEST_TIMEOUT = 10
APPNAME_DAYZ = "DayZ"
@@ -34,13 +32,7 @@ HEX_GREEN = "#32CD32"
HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C"
# FIXME: normalize icon names
CARET_DOWN = "go-down-symbolic"
CARET_UP = "go-up-symbolic"
CLIPBOARD = "edit-copy-symbolic"
FOLDER = "folder-symbolic"
EDIT_DELETE = "edit-delete-symbolic"
ERROR = "dialog-error-symbolic"
HELP_BUBBLE = "help-about-symbolic"
INPUT_KEYBOARD = "input-keyboard-symbolic"
LIST_ADD = "list-add-symbolic"
@@ -49,6 +41,7 @@ SEARCH_ICON = "system-search-symbolic"
STEAM_ICON = "steam_tray_mono"
VIEW_CONCEAL = "view-conceal-symbolic"
VIEW_REVEAL = "view-reveal-symbolic"
ERROR = "dialog-error-symbolic"
WARNING = "dialog-warning-symbolic"
WEB_BROWSER = "web-browser-symbolic"
@@ -72,9 +65,7 @@ LEGACY_IPS_PATH = ".local/share/dzgui/helpers/ips.csv"
DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG"
SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG"
CHANGELOG_PATH = "data/CHANGELOG.md"
IMAGES_PATH = "data/images"
HERO_PATH = "data/images/_hero.png"
HERO_PATH = "data/images/hero.png"
CSS_PATH = "data/app.css"
VDF_PATH = "steamapps/libraryfolders.vdf"
DEFAULT_STEAM_PATH = ".local/share/Steam"
@@ -86,6 +77,4 @@ LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG")
TMP_PATH = "/tmp"
TMP_TARBALL = "/tmp/dzgui.tar.gz"
TMP_EXE = "/tmp/dzgui/dzgui"
DAYZ_COMMUNITY_ROOT = "Missions"
TMP_EXE = "/tmp/dzgui"
+2 -8
View File
@@ -1,12 +1,6 @@
# internal
STEAM_PUBLISHED_FILES = (
"https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1"
)
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1"
SUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Subscribe/v1"
UNSUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Unsubscribe/v1"
APP_DETAILS = "https://store.steampowered.com/api/appdetails?"
STEAM_PUBLISHED_FILES = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json"
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?"
BM_SERVERS = "https://api.battlemetrics.com/servers?"
GITHUB = "https://github.com/aclist"
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
+5 -7
View File
@@ -103,7 +103,6 @@ class Preferences(EnumWithAttrs):
class NotebookPage(EnumWithAttrs):
OFFLINE = {"crumbs": strings.crumbs.offline, "statusbar": True}
CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": True}
DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False}
HELP = {"crumbs": strings.crumbs._help, "statusbar": True}
@@ -168,7 +167,7 @@ class ContextMenu(EnumWithAttrs):
COPY_LOG_CLIPBOARD = {"label": strings.copy_log}
COPY_SERVER_IP = {"label": strings.copy_ip}
COPY_SERVER_NAME = {"label": strings.copy_name}
UNSUB_MOD = {"label": strings.unsub_mod}
DELETE_MOD = {"label": strings.delete_mod}
OPEN_WORKSHOP = {"label": strings.open_workshop}
REFRESH_PLAYERS = {"label": strings.refresh_players}
REMOVE_HISTORY = {"label": strings.remove_history}
@@ -184,8 +183,7 @@ class ContextMenuGroup(Enum):
"""
SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,)
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.UNSUB_MOD)
MOD_OFFLINE = (None,)
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD)
LOG = (ContextMenu.COPY_LOG_CLIPBOARD,)
SERVER_BROWSER = (
ContextMenu.CONNECT,
@@ -250,9 +248,9 @@ class ModButton(EnumWithAttrs):
"label": strings.mod_panel.unhighlight_stale,
"tooltip": strings.mod_panel.unhighlight_stale_tooltip,
}
UNSUB_SELECTED = {
"label": strings.mod_panel.unsub_selected,
"tooltip": strings.mod_panel.unsub_selected_tooltip,
DELETE_SELECTED = {
"label": strings.mod_panel.delete_selected,
"tooltip": strings.mod_panel.delete_selected_tooltip,
}
SELECT_STALE = {
"label": strings.mod_panel.select_stale,
+2 -24
View File
@@ -8,8 +8,6 @@ from gi.repository import Gdk, GObject # noqa E402
if TYPE_CHECKING:
from dzgui.const.enum import NotebookPage, ServerTab
from dzgui.model.model_factory import FastInsertListStore
from dzgui.views.pages.offline import FolderHBox
# TODO: if servers fail to load, may leave dangling widgets waiting for a signal
@@ -81,8 +79,8 @@ class Emitter(GObject.GObject):
def distcalc_started(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool, int))
def mod_page_toggled(self, state: bool, mods: int) -> None:
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
def mod_page_toggled(self, state: bool) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
@@ -156,23 +154,3 @@ class Emitter(GObject.GObject):
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def lan_page_initialized(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str))
def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def custom_mods_unloaded(self, widget: "FolderHBox") -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, bool))
def custom_mission_loaded(self, folder: str, is_valid: bool) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def invalid_custom_mods(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def all_mods_synched(self) -> None:
pass
+15 -43
View File
@@ -20,10 +20,11 @@ from dzgui.controllers.emitter import Emitter
from dzgui.managers.config import ConfigManager
from dzgui.managers.connection import ConnectionManager
from dzgui.managers.contextmenu import ContextMenuManager
from dzgui.managers.mods import ModManager
from dzgui.managers.notes import NoteManager
from dzgui.model.servers import ServerModelManager
from dzgui.util.diag import write_diagnostic
from dzgui.util.format import format_exception, format_player_count
from dzgui.util.format import format_player_count
from dzgui.util.open_links import open_user_workshop, open_workshop_page
from dzgui.views.dialogs.filepicker import FilePicker
from dzgui.views.dialogs.generic import ExceptionDialog
@@ -46,13 +47,12 @@ if TYPE_CHECKING:
from dzgui.views.components.filter_panel import FilterPanel
from dzgui.views.components.right_panel import RightPanel
from dzgui.views.components.statusbar import Statusbar
from dzgui.views.pages.offline import OfflineLoader
from dzgui.views.pages.options import Options
from dzgui.views.pages.preconnect import PreConnectionAssistant
from dzgui.views.pages.servers import ServerNotebook
from dzgui.views.trees.tree_log import LogTreeView
from dzgui.views.trees.tree_menu import MenuTreeView
from dzgui.views.trees.tree_mods import ModTreeView, OfflineModTreeView
from dzgui.views.trees.tree_mods import ModTreeView
from dzgui.views.trees.tree_servers import ServerTreeView
@@ -72,7 +72,6 @@ class AppNavigation:
filters: "FilterPanel"
preconnect: "PreConnectionAssistant"
options: "Options"
offline_loader: "OfflineLoader"
class Controller(GObject.GObject):
@@ -203,9 +202,7 @@ class Controller(GObject.GObject):
try:
# TODO: where to put config file check
self.mediator.grid.notebook.settings.populate_settings()
except Exception as e:
msg = format_exception(e)
logger.critical(msg)
except Exception:
return
case ButtonType.MODS:
self.load_mods()
@@ -221,29 +218,19 @@ class Controller(GObject.GObject):
open_user_workshop(uid, client)
def load_mods(self) -> None:
self.mediator.modtreeview.load_mods()
self.mod_man = ModManager(self)
def uncolorize_mods(self) -> None:
mod_man = self.mediator.modtreeview.get_mod_man()
mod_man.uncolorize_mods()
self.mod_man.uncolorize_mods()
def highlight_stale(self) -> None:
mod_man = self.mediator.modtreeview.get_mod_man()
mod_man.highlight_stale()
self.mod_man.highlight_stale()
def toggle_mod_selection(self, state: bool) -> None:
mod_man = self.mediator.modtreeview.get_mod_man()
mod_man.toggle_mod_selection(state)
self.mod_man.toggle_mod_selection(state)
def unsub_mods(
self, treeview: Union["ModTreeView", "OfflineModTreeView", None] = None
) -> None:
if treeview is None:
view = self.mediator.modtreeview
else:
view = treeview
mod_man = view.get_mod_man()
mod_man.unsub_mods()
def delete_mods(self) -> None:
self.mod_man.delete_mods()
def get_mod_store(self) -> Gtk.TreeModel | None:
return self.mediator.modtreeview.get_model()
@@ -270,8 +257,7 @@ class Controller(GObject.GObject):
dialog.run()
def select_colorized(self) -> None:
mod_man = self.mediator.modtreeview.get_mod_man()
mod_man.select_colorized()
self.mod_man.select_colorized()
def get_all_tree_filters(self) -> dict[str, dict[str, bool]]:
servers = self.get_servers()
@@ -525,11 +511,11 @@ class Controller(GObject.GObject):
ind = self.config_man.get_start_tab()
self.get_servers().notebook.set_current_page(ind)
def update_and_load_to_menu(self) -> None:
self.connection_man.update_and_connect(menu_only=True)
def update_and_load_to_menu(self, raise_window: bool) -> None:
self.connection_man.update_and_connect(raise_window, menu_only=True)
def update_and_connect(self) -> None:
self.connection_man.update_and_connect()
def update_and_connect(self, raise_window: bool) -> None:
self.connection_man.update_and_connect(raise_window)
def update_status(self) -> None:
self.mediator.preconnect.mark_finished()
@@ -539,17 +525,3 @@ class Controller(GObject.GObject):
def present_window(self) -> None:
self.mediator.window.present_with_time(Gdk.CURRENT_TIME)
def is_cancel_pending(self) -> bool:
if self.get_exit_event().is_set():
return True
if self.get_cancel_event().is_set():
self.clear_cancel_event()
return True
return False
def open_offline(self) -> None:
self.open_page(NotebookPage.OFFLINE)
mod_man = self.mediator.modtreeview.get_mod_man()
store = mod_man.store
self.mediator.offline_loader.populate(store)
+7 -794
View File
@@ -1,7 +1,4 @@
# Changelog
## [7.0.0] Unreleased
### Added
## Added
- Setup wizard
- Changelog text wrapping and formatting
- Changelog ships with source
@@ -40,12 +37,9 @@
- Save filters per server context between sessions
- Preboot progress dialog
- Choose to jump into splash screen instead of server
- Collapsible connection panel
- Play offline (load mods directly)
- Generate Steam shortcuts and cover art
- Warn user if background downloads are disabled
### Changed
## Changed
- Conform to PEP 440 versioning for beta versions
- Reduce padding on keys button
- Boldface breadcrumbs
@@ -60,806 +54,25 @@
- Disable overlay scrollbars on server tables
- Reduce size of geolocation DB on disk (~100MB)
- Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client
### Dropped
## Dropped
- Debug mode
- Branch switching
- Manual mod install mode (describe rationale)
- Force update mods
### Fixed
## Fixed
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
- Center server title text on server dialogs
- Rare segfaults when changing maps (threading)
- Moved dialogs out of threads
### Unreleased
## Unreleased
- Load offline mods
- Local documentation
- Raw debug command in context menu
### Developer-facing
## Developer-facing
- Add pyproject.toml file
- Show deprecation warnings
- Options -> Dev page
## [6.0.5] 2026-05-17
### Changed
- Update symlink hash logic to align with DZGUI 7 beta
- Update A2S helper to latest commit
## [6.0.4] 2026-04-13
### Fixed
- Update A2S module checksums to support servers with missing description field
## [6.0.3] 2026-04-12
### Fixed
- Use GLibUnix namespace where available when setting up SIGINT callback
## [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
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
## [5.8.0] 2025-07-06
### Added
- Filter servers by official/unofficial status
- Automatically fetch geolocation records
### Changed
- Updated internal versioning of helper files
### Fixed
- Corrected erroneous 2024 date in prior changelog entries
## [5.7.0] 2025-04-17
### Added
- Save application dimensions when quitting and restore on subsequent boot
### Fixed
- Issues with the window exceeding the bounds of the screen when displaying some table contexts
### Changed
- More direct server ping query method that should return marginally more accurate times
## [5.6.7] 2025-04-04
### Dropped
- Removed extraneous pre-boot API checks that could cause error messages to be printed if the user had not set up an API key yet
## [5.6.6] 2025-03-16
### Changed
- Update IP database records for 2025-03
## [5.6.5] 2025-03-04
### Fixed
- Livonia server results being dropped from batch queries
## [5.6.4] 2025-02-10
### Changed
- Drop launch flag and check for invocation through Steam automatically
## [5.6.3] 2025-02-09
### Changed
- Update IP database records for 2025-02
## [5.6.2] 2025-01-22
### Fixed
- Resolve regression introduced with IP resolution feature in 5.6.0 (restores functionality of right-click action: Add to My Servers)
## [5.6.1] 2025-01-10
### Fixed
- Add fallback support for jq 1.6
## [5.6.0] 2025-01-06
### Added
- Application header bar and controls
- Menu context subtitle in header bar
- Bulk delete mods (via 'List installed mods' list). Not compatible with Manual Mod install mode
- Highlight stale mods in mods list
- Added --steam launch parameter
- Open Steam workshop subscriptions dialog when switching from Manual to Auto mode
- Hover tooltips to most buttons
- Add in-app documentation link to Codeberg mirror
- Additional logging
### Fixed
- Avoid sudo escalation if system map count is sufficient (jiriks74)
- Duplicate dialog title on Steam Deck
- Resolve remote IP when saving records for game servers with multiple hosts
- More performant symlink traversal when checking for legacy links
- Only iterate on missing symlinks at boot (400% performance uplift)
- Fix for server list truncation causing some servers to not appear in results
- Empty dialog popups if user manually deletes local mods while application is running
- Update statusbar when removing servers from list/repopulating
- Abort DayZ path discovery if Steam files are not synched
- Race condition when checking for installed mods
- Favorite server message not updating correctly
- Improved handling for cases where there are no locally installed mods
- Set up mod symlinks at boot, rather than only on server connect
- Prevent context menus from opening when table is empty
- When reloading table in-place, prevent duplicate panel elements from being added if already present
- Clean up local mod signatures from versions file when deleting mods
- Focus first row when opening mods list
### Changed
- Redact usernames in log files
- Prevent ArrowUp/ArrowDown input when inside keyword field
- Admonish user to restart Steam in error dialog if DayZ path could not be found
- Refactor control flow for more robust contextual parsing
- Stop sending modal dialog hints to outer window
- Clean up signal emission
- Clarify some error messages and normalize text formatting
- Update forum URL
- Update README.md
- Update IP database to 2025-01
- Reduce IP database size by 50%
- Update documentation to 5.6.x standard
### Dropped
- Hall of Fame section from button links, to be moved inside documentation
- Unused imports
## [5.5.3] 2024-12-13
### Fixed
- Add remote resource health checks before downloading updates
### Added
- Add fallback repository
## [5.5.1] 2024-12-03
### Fixed
- Support localized decimal separators when parsing installed mod sizes
## [5.5.0] 2024-11-10
### Added
- Support servers running DLC content (fixes Frostline servers)
- Expose a toggle setting for whether to launch the application in fullscreen
- Text autocompletion in maps search field (partial search)
- Add disk space warning to popup dialog when downloading mods
### Fixed
- Servers in saved servers list would populate context menu with same option when right-clicking in server browser
- Enable adding/removing servers to/from My Saved Servers when in Recent Servers context
- Prevent maps combobox from duplicating contents
- Restore keyboard input to keyword entry field
### Changed
- Abort fallback query method if DLC is required
## [5.4.2] 2024-10-05
### Fixed
- Sanitize third-party API IDs to remove UGC collisions
## [5.4.1] 2024-09-25
### Added
- Pre-boot validation check for users with self-compiled version of jq
### Fixed
- Use fallback logic for modlist queries when user traverses networks
- Fix signal handling control flow for checkbox toggles
- When reloading the server browser, the map combobox selection would revert to the last selected map instead of All Maps
- Server filter toggle signals were accessible from the main menu when switching between menu contexts
- Global cooldown dialog could sometimes block filter toggles after cooldown reset
- Normalized minor version number due to a previous clerical error
## [5.4.0] 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
- Errors being printed to the console when Exit button was explicitly clicked
- Detect Steam Deck OLED APU variant during initial setup
- Encapsulate player names correctly so that names with whitespace in them are supported
- Test if DayZ directory is empty at startup, implying that the game was moved to a new library collection
- 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
## [5.3.1] 2024-06-15
### Added
- Uninstall routine: invoke via dzgui.sh -u or dzgui.sh --uninstall and choose from full or partial uninstall
## [5.3.0] 2024-06-15
### Added
- Server browser tables now display the number of players in queue (for full servers)
- Warning dialog if the user's API key returned no servers, and a global API cooldown dialog (60 seconds)
### Fixed
- Removing servers from My Servers via the right-click context menu was not actually removing the server and drawing an empty dialog
- Resolved an issue where, when Steam was not installed in a standard location and the user was prompted to let DZGUI auto-discover the correct path, this information was not retained correctly
## [5.2.4] 2024-05-29
### Fixed
- Resolved an issue that could cause an old path to a prior DayZ installation to be reinserted in the config file if the Steam client did not synch the new path internally. DZGUI would try to ask the user to re-run first-time setup and update the path, but the old path would still be used.
## [5.2.3] 2024-04-19
### Added
- Added Debug Mode button to main menu
### Changed
- Reworded debug mode notification dialog
- Updated geolocation records
### Dropped
- Dropped seen_news key from configs
## [5.2.2] 2024-04-18
### Added
- Cover artwork/icons for Steam "Recent Games" and tree view
### Changed
- Updated documentation to the v5.0.0 spec
## [5.2.1] 2024-04-01
### Fixed
- Fixed a regression where where the first-time setup dialog would not trigger auto-path discovery
### Changed
- Reworded some menus and dialogs for clarity
## [5.2.0] 2024-03-21
### Added
- Refresh player count for active row: invoke via right-click context meu or directly with the Ctrl-r hotkey. This feature has a 30 second global cooldown to prevent throttling.
### Fixed
- Improve case-insensitive keyword search to be portable across awk versions (previous version required gawk)
- Fixed a dialog string from being shown twice when adding a server to favorites via context menus
### Changed
- Use a more robust method for downloading mods when auto mod install is enabled
## [5.1.1] 2024-03-18
### Fixed
- Hotfix for remote helper files not being fetched correctly
## [5.1.0] 2024-03-18
### Added
- Make columns in the server browser user-resizable (affects Server Browser, My Servers, and Recent Servers)
- Save dragged position of user-resized columns
- Display ping to server in statusbar: by popular request, added the ability to visualize both distance to server and round-trip latency (ping), at the cost of a small calculation delay. Please leave feedback regarding whether this feature feels fast/responsive enough.
### Fixed
- Fixed a rare scenario in Auto Mod Install Mode where defunct mods (mods no longer available on Steam) would try to be downloaded if the user had previously downloaded the mod
## [5.0.0] 2024-01-31
### Added
- Context switching: navigate to different pages using side buttons
- Dynamic statusbar: updates metadata and server distance when selecting rows
- Show server-side modlist and allow jumping to Steam Workshop pages to browse, and list whether mod is currently installed
- Print debug logs in-app (Help > Show debug log)
- Functionality to change API keys in-app (used when revoking old API keys)
- Print atomic mod sizes when listing installed mods
- Dialogs show direct links to API key management URLs when changing API keys
- Toggle dry-run mode directly from server browser
- Extensive keybindings for fully controlling the application without the mouse
- Extensive pre-boot sanity checks
- Keybinding help dialog in main menu
- Right-click context menus in server browsers/mod list: add/remove from favorites, show server-side mods, delete mod, copy server IP to clipboard
### Changed
- Utilize GTK bindings and MVC paradigm for UI creation and data flow
- Filter servers dynamically from within server browser
- Performance and security improvements to DZGUI helper files
### Fixed
- First-time setup dialogs respawning repeatedly in certain scenarios
- Issues with Steam client switching to the wrong page when using auto-mod install
- Set text input module correctly when launching on Steam Deck
- Separate current/total player count and use proper integer sort method in table
## [4.1.1] 2023-12-18
### Fixed
- News marquee not showing
## [4.1.0] 2023-12-17
This update adds support for DayZ servers running on a local area network. To connect or add to your server list, supply the server IP and query port in the format IP:PORT.
Support for Steam Deck Game Mode has also been restored, with a new dialog format that allows for virtual keyboard input. This should allow you to enter text in form fields by activating the Steam button + X. In addition, you can unlock the mouse and keyboard input when launching DZGUI through Steam by using an internal binding provided by Steam Deck: long-press the three dots button on the right of the device for three seconds to toggle the input state. This allows you to use mouse and keyboard-style bindings on Game Mode and vice versa.
### Added
- Virtual keyboard support for text entry fields on Steam Deck Game Mode
- Validate and connect to LAN server IPs
## [4.0.4] 2023-12-23
### Fixed
- Enforce version check for Python versions before 3.10
## [4.0.3] 2023-11-22
### Fixed
- Query helper: backwards compatibility for pre-2021 versions of Python 3
## [4.0.2] 2023-11-22
### Fixed
- Query helper not loading: fixed a remote link pointing to the wrong destination and added a checksum verification to ensure file is present
## [4.0.1] 2023-11-22
### Fixed
- Emergency hotfix to remove build artifacts leaking into main script: if you updated DZGUI from 3.3.18 to 4.0.0 between 2023-11-22 15:00:02 and 2023-11-22 15:03:37 GMT, there is a small chance it will be unable to launch correctly. If so, please follow the instructions on the manual to reinstall.
## [4.0.0] 2023-11-22
Hello players, this is a major version update which overhauls many of DZGUI's underlying systems to improve responsiveness of the application and make menus more intuitive to interact with. It should be considerably more difficult, if not impossible, to inadvertently crash a dialog, and nested dialogs should behave in a more expected fashion, such as when going back and forth between menus or changing options dynamically within a given menu.
In addition, with this update we are querying servers directly. The net benefit of this is that results will returned faster, bootup is faster, and it also paves the way for future features like connecting to servers on a LAN. The Battlemetrics API is now entirely optional and can be skipped during the setup process. However, if you have already set up a BM API key, you can continue using it if you want to query or add servers by ID instead of IP.
As part of this change, version 4.0.0 introduces the ability to connect to or add servers to your list based on either ID or IP. Previously, you could only connect by IP and add by IP, but now you can connect by IP/ID or add by IP/ID. If you choose the ID method, this will be translated into an IP seamlessly in the background. Similarly, favorite servers are now stored using the full IP rather than the ID. Due to the variety of systems and methods for connecting to servers, the application was carrying around and converting server IPs, IDs, and other formats back and forth, creating unnecessary complexity. By normalizing everything to an IP basis, maintainability should be more consistent. When upgrading to this version, your old favorites lists will be updated automatically to the new IP method. Note that as a result of this change, we must purge old history lists ("Recent Servers"), but everything else should carry over as before.
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
from source or waiting until the latest version is merged into Fedora's package manager.
### Added
- Change in game name: dynamically change your profile name via the Advanced Options menu
- Connect by ID: supply a Battlemetrics ID to connect to a server; this can be used in lieu of the IP
- Add by IP: supply a standard IP to add a server to your list; this can be used as a more direct way of saving servers
- Filter by 1PP and 3PP in the server browser
- Save connected server to favorites: prior to connecting, asks the user if they want to save this server for future use
- Generate additional output when generating system logs
### Fixed
- Rare cases where the keyword filter would not filter server results correctly
- Handling of dialog exit signals: made it much more difficult to crash the application in rare cases when spamming input or returning from menus
- Update menus in place: when toggling options in the Advanced Options menu, displays the current state/mode of the option for better readability into what option is currently enabled
- More intuitive menu navigation: dialog setup and teardown is more responsive and follows expected flows when inside of nested menus
- Message formatting: fold long messages inside of popup dialogs for proper word wrapping regardless of screen type
- Fixed a rare case where dialogs would spawn twice during first-time setup
- Properly remain inside of menus when looping where it would make sense to do so : e.g., Delete Servers list, Advanced Options
### Changed
- Query servers directly to reduce API hops: initial bootup and subsequent server queries should be considerably faster
- 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.
The My Servers list thus shows online and accessible servers
## [3.3.0] 2023-05-16
### Added
- Fetch more inclusive global "players in-game" count
- List mod directory on installed mods list
- Detect default Flatpak Steam path
- Dark mode/light mode theme to help file
- Alpha-sort My Servers list
- Add description of how to enable hidden folders on GTK2/3
- Initial logging framework
### Changed
- Test for wmctrl when enabling full auto mod installation
- Steam Deck: block toggling full auto mod installation due to extra dependencies needed
- 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
- 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
- First-time setup: break out of automatic path discovery when user specifies a path manually
- More portable interpreter invocation
- Properly size down window resolution when returning from server browser
## [3.2.10] 2023-05-11
### Fixed
- Return from lockfile function if first-time setup has not been run
- Sanitize inputs when using file picker
- Require both wmctrl and xdotool
## [3.2.9] 2023-05-10
### Changed
- Reword button to "Choose path manually" instead of "Retry"
## [3.2.7] 2023-05-10
### Changed
- Better sudo escalation within zenity dialogs if vm map count is too small
## [3.2.6] 2023-05-10
### Fixed
- Don't parse Flatpak symlinks when setting up default Steam path
## [3.2.5] 2023-05-07
### Fixed
- Require sudo when checking vm map count
## [3.2.4] 2023-03-01
### Fixed
- BM API returning stale query port and preventing fetching modlist
## [3.2.3] 2023-02-17
### Fixed
- sysctl map count value not being loaded immediately after setting
- Application terminating when user declines to update map count value
- Erroneous stderror output when flatpak is not installed
## [3.2.0] 2023-01-19
### Added
- Support Flatpak version of Steam
## [3.1.8] 2023-01-18
### Fixed
- Progress window blocking rest of window stack
- Bug when updating old mods if automod set to ON
## [3.1.7] 2023-01-06
### Fixed
- Hotfix for xdotool repeating input
## [3.1.6] 2023-01-01
### Changed
- Tick low pop servers by default
## [3.1.5] 2023-01-01
### Fixed
- Validate BM key on initial setup
- Fix history menu not parsing query ports correctly
### Changed
- More permissive Steam client discovery for tiling WMs
## [3.1.4] 2022-12-10
### Fixed
- Issue #43: Hotfix for workspace-driven WMs
## [3.1.3] 2022-12-06
### Fixed
- Explicitly require Python 3
## [3.1.1-2] 2022-12-03
### Fixed
- Fix lockfile path
## [3.1.0] 2022-12-03
### Added
- Recent connect history
- Simple, OS-agnostic automod installation
- Track local mod versions
- Force update local mods option
- Added python to dependencies
- File-picker driven path discovery on initial setup
### Dropped
- Headless mod installation
- Drop server ID field requirement on initial setup
### Changed
- Clean up main menu options
- Enforce Steam API key on initial setup
- More accurate path discovery on initial setup
- Add thousands separator to player counts in server browser
### Fixed
- Initial setup dialog causing early crash
- Improved error handling on initial setup to avoid malformed config files
- Delete server menu not clearing when returning to main menu
- Handle whitelist deletion when only one entry present
- Include path to drives under /run in path discovery
- Use Steam-safe local zenity version
## [3.0.7] 2022-11-25
### Fixed
- Hotfix for server reporting multiple versions of same mod
## [3.0.6] 2022-11-09
### Changed
- More verbose logs
## [3.0.5] 2022-10-27
### Fixed
- Properly create .desktop file on desktop PCs
## [3.0.3-4] 2022-10-16
### Fixed
- Steam Deck path discovery on first-time setup
## [3.0.2] 2022-10-12
### Fixed
- Size of certain popups on Steam Deck
## [3.0.1] 2022-10-12
### Fixed
- Initial popup size on Steam Deck
## [3.0.0] 2022-10-12
### Added
- Foreground progress of manual mod subscriptions
- Automatic mod helper through steamcmd
- Forum link
- Enforce Steam API
### Dropped
- Stop retrieving extra metadata from BM
### Changed
- Reorder main menu
- More verbose error messages
- Better abstraction of URLs
### Fixed
- Width and text of some popups on Steam Deck
## [2.7.2] 2022-10-07
### Fixed
- Fix internal URL
## [2.7.1] 2022-10-05
### Fixed
- Game launch not kicking off after symlink creation
## [2.7.0] 2022-10-04
### Added
- Server browser and geolocation algorithm
- More verbose error codes
- Additional progress bar setup and destruction throughout the application
- Additional API response validation
### Fixed
- Encapsulate title strings to prevent leaky arguments in title bars
- Remove erroneous slow boot process if first-time setup was already complete
- Delete server list not emptying when returning to main menu
### Changed
- Group main menu entries in advance of future functionality
- Better abstraction of paths
- Clarification of certain options and errors
## [2.6.3] 2022-10-02
### Fixed
- Hotfix for connect-to-fav not getting modlist
## [2.6.2] 2022-10-02
### Fixed
- Hotfix for BM API returning malformed publishedfileids
## [2.6.1] 2022-09-25
### Fixed
- Freedesktop shortcut errors
## [2.6.0] 2022-09-05
### Added
- Connect by IP method
## [2.5.1] 2022-08-17
### Fixed
- Hotfix for malformed paths during first-time setup
## [2.5.0] 2022-08-16
### Added
- Lockfile: prevent concurrent instances of DZGUI from being opened
### Changed
- Faster path discovery on first-time setup
### Fixed
- Symlink collision on servers with many mods
- API response pagination for large server lists
## [2.4.1] 2022-08-09
### Fixed
- Hotfix for progress bar breaking table when >9 servers in list
## [2.4.0] 2022-08-07
### Added
- Delete server command added to main menu
- Write log to file for bug reports
- Prompt to permanently increase map count size
- Dependency check for Steam
- Cover artwork
- News backend for OTA updates
- Clean up stale symlinks when checking mods
### Fixed
- Minify long mod launch params for servers with launch params breaking the upper limit
- Port DZTUI method of handling legacy symlinks
- Prevent user from entering invalid data on first-time setup
- Prompt to re-run first-time setup if config is malformed
- Better handling of field output from table
- Better Steam Deck detection and handling
- Prevent garbage in error messages
- Miscellaneous backend improvements
### Changed
- Enforce download when switching branches
## [2.3.2] 2022-08-04
### Fixed
Set branch flag to 'stable' if no config file present
## [2.3.1] 2022-08-04
### Fixed
- Improved error handling of first-time setup fields
- Interpolate config file values for debug, branch when writing file
### Changed
- Prompt user to restart first-time setup if broken config is found
## [2.3.0] 2022-07-18
### Added
- Numbered mod links in browser
- Admonition to upgrade versions for bug fixes
- Toggle branch between stable/testing
### Fixed
- Hotfix for fav server select on main menu
## [2.2.1] 2022-07-17
### Fixed
- Hotfix for upstream API returning malformed modlists
- Handle servers with no mods
- Fix dialog window depending on browser exit
- Remove stray newlines in config file
## [2.2.0] 2022-06-22
### Added
- Toggle debug mode in-app
## [2.1.0] 2022-06-19
### Changed
- Updated link to new documentation
## [2.0.3] 2022-06-17
### Fixed
- Safer expansion of originating script path
## [2.0.2] 2022-06-16
### Fixed
- Remove enforced runtime check of workshop path
## [2.0.2] 2022-06-16
### Fixed
- Expansion of Steam path prefix when default path was found
## [2.0.1] 2022-06-16
### Fixed
- Regenerate mod links file in browser when clicking dialog
- Reset whitelist when canceling one-shot (fav) mode
## [2.0.0] 2022-06-15
### Added
- Attempt to find DayZ path and write to config on first launch
- Merge existing config values into new config format when upgrading version
- Dynamically detect Steam Deck and set launch parameters
- Add "gametime" column to server list
- One-shot mode to open mod links in browser if using desktop
- Add favorite server from main menu
- Update favorite server changes in real time
- Add link to help pages from main menu
- Add link to changelog from main menu
### Changed
- Don't require duplication of fav server in whitelist
- More permissive truncation of long server names (50 char limit)
- Render mod list as a scrollable menu
- Reword errors for greater verbosity
- Initial support of granular error handling for API response codes
- Move extended path variables out of user config
### Fixed
- Check if mod dir is sane before listing mods
- Prevent favorite server launch if none set
- Reset server list to entire whitelist if canceling out of fav connect
- Do not load table on empty API response and warn user
- Suppress stderr cruft in logs and use logger instead
## [1.2.1] 2022-06-12
### Fixed
- Print the entire changelog
- Add confirmation dialog before run
## [1.2.0] 2022-06-12
### Changed
- Mod validation process now uses faster, single-pass API query
- Improved logger output when setting fav server
### Added
- In-app changelog
## [1.1.2] 2022-06-08
### Fixed
- Fix array used for mod concatenation; fetch post-sanitized list of mods
## [1.1.1] 2022-06-05
### Fixed
- Stricter regex to parse upstream version number
## [1.1.0] 2022-06-05
### Added
- Main menu: fav server label on header
- Main menu: add servers by ID directly into config file
- Main menu: link to report a bug
- Main menu: quick connect to fav server
- Connect: mod compatibility check
- Connect: mod download prompt
- Connect: mod auto symlinks
- New version download prompt
- Additional visualization of progress/menus
### Fixed
- Menu recursion when navigating backwards
### Changed
- Reduced ping timeout interval
File renamed without changes.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 81 B

After

Width:  |  Height:  |  Size: 81 B

+7 -4
View File
@@ -1,12 +1,14 @@
import logging
import sys
from pathlib import Path
from dzgui.api.steam import get_app_path
from dzgui.config.query import lookup
from dzgui.const.constants import APPID_DAYZ, APP_NAME
from dzgui.const.constants import APPID_DAYZ, APP_NAME, LIBRARYFOLDERS_PATH
from dzgui.const.enum import Preferences
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
import dzgui.api.pefile as PeFile
logger = logging.getLogger(APP_NAME)
@@ -14,7 +16,8 @@ logger = logging.getLogger(APP_NAME)
def is_dayz_installed(config: Path) -> None:
try:
path = lookup(config, Preferences.DEFAULT)
get_app_path(Path(path), APPID_DAYZ)
PeFile.get_app_path(Path(path) / LIBRARYFOLDERS_PATH, APPID_DAYZ)
except Exception as e:
logger.critical(e)
raise e
EarlyAlertDialog(str(e))
sys.exit(1)
-50
View File
@@ -1,50 +0,0 @@
import platform
import sys
from ctypes.util import find_library
from warnings import deprecated
LIB = "libgirepository-2.0"
@deprecated("currently unused")
def is_debian() -> bool:
"""
cf.
NAME="Debian GNU/Linux"
ID=debian
NAME="Ubuntu"
ID=ubuntu
ID_LIKE=debian
NAME="Linux Mint"
ID=linuxmint
ID_LIKE="ubuntu debian"
NAME="Pop!_OS"
ID=pop
ID_LIKE="ubuntu debian"
"""
release = platform.freedesktop_os_release()
strings = ["ubuntu debian", "debian"]
try:
self_id = release["ID"]
if self_id == "debian":
return True
id_like = release["ID_LIKE"]
return id_like in strings
except Exception:
return False
def has_libgi() -> bool:
return bool(find_library("girepository-2.0"))
def test_libgi_missing() -> None:
msg = f"System is missing the required library '{LIB}'. Install it via your system package manager."
if has_libgi() is False:
print(msg)
sys.exit(1)
+37 -3
View File
@@ -1,10 +1,11 @@
import psutil
import subprocess
import shutil
import logging
from warnings import deprecated
from dzgui.const.constants import (
APP_NAME,
DAYZ_BINARY,
STEAM_CMD,
FLATPAK_APPID,
FLATPAK_CMD,
@@ -12,7 +13,18 @@ from dzgui.const.constants import (
FLATPAK_SANDBOX,
)
logger = logging.getLogger(APP_NAME)
# TODO: move to util.proc
def is_dayz_running() -> bool:
"""Subprocesses spawned from Steam will not show up in regular process tree"""
procs = []
substring = DAYZ_BINARY
for proc in psutil.process_iter():
try:
procs.append(proc.cmdline())
except Exception:
continue
return any(substring in item for sublist in procs for item in sublist)
def is_steam_running(cmd: str) -> bool:
@@ -49,3 +61,25 @@ def has_cmd(cmd: str) -> bool:
if shutil.which(cmd) is not None:
return True
return False
@deprecated("dropped in favor of Gtk native methods")
def foreground(cmd: str, pid: int) -> None:
if cmd == "wmctrl":
proc = subprocess.run(["wmctrl", "-ilp"], capture_output=True, text=True)
lines = proc.stdout.splitlines()
for line in lines:
els = line.split(" ")
if str(pid) in els:
wid = els[0]
break
subprocess.run(["wmctrl", "-ia", wid])
elif cmd == "xdotool":
args = [cmd, "search", "--pid", str(pid)]
proc = subprocess.run([*args], capture_output=True, text=True)
lines = proc.stdout.splitlines()
## NOTE: some forked subprocesses may fail, so skip over them
for line in lines:
subprocess.run(
["xdotool", "windowactivate", line], stderr=subprocess.DEVNULL
)
+113 -6
View File
@@ -1,11 +1,41 @@
import argparse
import logging
import os
import sys
import warnings
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME
from dzgui.init.libgi import test_libgi_missing
from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
from dzgui.config.userprefs import UserPrefs
from dzgui.config.xdg import get_xdg_paths, parse_filepaths
from dzgui.init.dayz import is_dayz_installed
from dzgui.init.flock import lock_acquire
from dzgui.init.migrate import (
has_new_config,
copy_state_files,
migrate_cols_file,
)
from dzgui.init.prefix import get_version
from dzgui.util.strings import flags
from dzgui.init.prereqs import has_steam_client
from dzgui.strings import boot
# from dzgui.util.map_count import get_map_count
from dzgui.util.deck import is_steam_deck, is_game_mode
from dzgui.util.localize import set_locale
from dzgui.util.strings import init, flags
from dzgui.views.base import App
from dzgui.views.dialogs.boot import BootWindow
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
from dzgui.views.dialogs.wizard import SetupWizard
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(APP_NAME)
parser = argparse.ArgumentParser(description=flags.description)
parser.add_argument("-v", "--version", action="store_true", help=flags.version)
@@ -14,6 +44,11 @@ parser.add_argument("-d", "--debug", action="store_true", help=flags.debug)
args = parser.parse_args()
# TODO: profile load time
def make_parents(path: "Path") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def uninstall() -> None:
# TODO: uninstall data files (-u)
# -u removes state, log, freedesktop
@@ -23,7 +58,21 @@ def uninstall() -> None:
pass
def setup_logger(log_path: "Path") -> None:
_format = (
"%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s"
)
fh = logging.FileHandler(log_path)
formatter = logging.Formatter(_format)
fh.setFormatter(formatter)
fh.setLevel(logging.DEBUG)
logger.setLevel(logging.DEBUG)
logger.addHandler(fh)
def main() -> None:
lock = lock_acquire() # noqa
if args.version is True:
print(get_version())
sys.exit(0)
@@ -34,11 +83,69 @@ def main() -> None:
warnings.filterwarnings("default", category=DeprecationWarning)
version = get_version()
set_locale()
print(f"{APP_NAME} {version}")
test_libgi_missing()
# NOTE: only import ligbirepository modules after the above check
from dzgui.app_init import load_gui
# NOTE: consider aborting this check if steam deck
xdg_paths = get_xdg_paths()
XDG = parse_filepaths(xdg_paths)
load_gui(version, args.debug)
if XDG.resolution.parent.is_dir() is False:
make_parents(XDG.resolution)
if XDG.debug.is_file() is False:
make_parents(XDG.debug)
_is_steam_deck = is_steam_deck()
_is_game_mode = is_game_mode() if _is_steam_deck else False
if _is_game_mode:
# NOTE: this may no longer be necessary on newer versions of SteamOS
del os.environ["GTK_IM_MODULE"]
if has_new_config(XDG.config) is False:
migrate_cols_file(XDG.columns)
copy_state_files(xdg_paths["XDG_STATE_HOME"])
# TODO: add logging inside wizard
SetupWizard(_is_steam_deck, XDG.config)
# NOTE: implies that setup wizard failed or was closed
if has_new_config(XDG.config) is False:
return
setup_logger(XDG.debug)
with open(XDG.debug, "w") as f:
f.truncate(0)
if _is_steam_deck is False:
# TODO: sudo escalation dialog
# count = get_map_count()
# TODO: move into module
if has_steam_client() is False:
EarlyAlertDialog(init.requires_steam)
is_dayz_installed(XDG.config)
bootwin = BootWindow(XDG, version)
local_coords, latest_release = bootwin.get_results()
# rebuild_symlinks(XDG.config)
# remove_stale_signatures(XDG.config, XDG.version)
## TODO: handle IP DB failure and use coords fallback
# local_coords = get_local_coords(XDG.ips)
# latest_release = check_updates(version)
use_miles = lookup(XDG.config, Preferences.DIST)
prefs = UserPrefs(
is_steam_deck=_is_steam_deck,
is_game_mode=_is_game_mode,
is_debug=args.debug,
coords=local_coords,
version=version,
paths=XDG,
latest_release=latest_release,
use_miles=use_miles,
)
print(boot.all_ok)
App(prefs)
+4 -7
View File
@@ -16,7 +16,6 @@ from dzgui.api.probe import test_steam_api, test_bm_api
from dzgui.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.views.dialogs.generic import ExceptionDialog
from dzgui.strings import dialogs
from dzgui.util._json import read_json, write_json
import gi
@@ -55,11 +54,8 @@ class ConfigManager:
self.update_config(Preferences.IP_LIST, ips)
def update_history_file(self, fqip: str) -> None:
try:
with open(self.prefs.paths.history, "r") as f:
ips = [line.rstrip() for line in f.readlines()]
except FileNotFoundError:
ips = []
with open(self.prefs.paths.history, "r") as f:
ips = [line.rstrip() for line in f.readlines()]
seen = set()
ips.append(fqip)
@@ -98,7 +94,8 @@ class ConfigManager:
logger.critical(e)
raise e
@call_on_thread(dialogs.checking_api)
# TODO: strings
@call_on_thread("Checking api")
def update_api_key(self, key: Preferences, text: str) -> None:
if key is Preferences.STEAM:
res = test_steam_api(text)
+45 -75
View File
@@ -9,17 +9,13 @@ from typing import TYPE_CHECKING
import dzgui.api.pefile as PeFile
import dzgui.api.servers as Servers
from dzgui.api.shortcuts import Shortcuts
from dzgui.api.steam import (
connect,
get_app_allows_downloads,
get_app_name,
get_needs_update,
enqueue_mod,
get_remote_signatures,
get_running_app,
get_needs_update,
load_to_menu,
subscribe,
)
from dzgui.api.mods import (
@@ -29,22 +25,16 @@ from dzgui.api.mods import (
update_signatures,
)
from dzgui.const.constants import (
API_RATE_LIMIT,
APP_NAME,
APPID_DAYZ,
APPID_DAYZ_EXP,
APPNAME_DAYZ,
APPNAME_DAYZ_EXP_HUMAN,
)
from dzgui.api.steam import is_dayz_running
from dzgui.const.enum import NotebookPage, Preferences
from dzgui.init.proc import is_steam_running
from dzgui.init.proc import is_dayz_running, is_steam_running
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings.dialogs import (
waiting_for_launch,
waiting_for_mods,
waiting_for_directories,
)
from dzgui.strings.dialogs import waiting_for_launch, waiting_for_mods
from dzgui.strings.server_mods import checkmark, resync
from dzgui.util.format import format_mib
from dzgui.util.strings import dialog, server_timeout
@@ -86,7 +76,6 @@ class Prerequisites:
mods: list[list[str]]
game_mode: bool
is_last_server: bool
allows_downloads: tuple[bool, str]
class ConnectionManager:
@@ -99,7 +88,6 @@ class ConnectionManager:
self.record: Record
self.workshop: Path
self.client: str
self.remote_mod_ids: list[str] = []
self.missing_mods: list[tuple[str, str, int, int]] = []
@@ -154,14 +142,21 @@ class ConnectionManager:
remote_mods: list[list[str]] = []
if res.is_modded():
try:
remote_mods, missing_mods = self._query_modlist(record)
self.missing_mods = missing_mods
remote_mods = self._query_modlist(record)
self.remote_mod_ids = [mod[1] for mod in remote_mods]
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
return
hashes = get_remote_signatures(self.remote_mod_ids)
version_file = prefs.paths.version
self.missing_mods = get_needs_update(version_file, hashes)
for mod in remote_mods:
if any(mod[1] in tuple for tuple in self.missing_mods):
mod[2] = resync
if local_version is not None:
pefile_path = PeFile.get_pefile_path(steam_path, info.game_id)
total, used, free = shutil.disk_usage(pefile_path)
@@ -172,26 +167,10 @@ class ConnectionManager:
dayz_running = is_dayz_running()
running_app = get_running_app()
if running_app is not None:
allows_dl = get_app_allows_downloads(steam_path, running_app)
running_app_name = get_app_name(running_app)
# NOTE: for out-of-range appid, assume NSG.
# dzgui.api.steam.get_app_name() will return None
if running_app_name is None:
s = Shortcuts(steam_path)
# NOTE: returns "Unknown" if unparseable
running_app_name = s.find_appname_by_unsigned_id(running_app)
allows_downloads = (allows_dl, running_app_name)
else:
allows_downloads = (True, "")
client_name = self.controller.get_steam_client_name()
client = self.controller.query_config(Preferences.CLIENT)
running = is_steam_running(client)
steam_proc = SteamProcess(client_name, running)
self.client = client
game_mode = prefs.is_game_mode
@@ -212,7 +191,6 @@ class ConnectionManager:
mods=remote_mods,
game_mode=game_mode,
is_last_server=is_last,
allows_downloads=allows_downloads,
)
func = StoredFunc(self.controller.open_connection_assistant, prereqs)
@@ -241,13 +219,10 @@ class ConnectionManager:
StoredFunc(self._present_details_dialog, details), destroy_first=True
)
def _query_modlist(
self, record: Servers.Record
) -> tuple[list[list[str]], list[tuple[str, str, int, int]]]:
def _query_modlist(self, record: Servers.Record) -> list[list[str]]:
mods = Servers.get_rules(record)
steam_path = self.controller.query_config(Preferences.DEFAULT)
local = get_local_mod_ids(Path(steam_path))
local = get_local_mod_ids(steam_path)
alpha_mods: list[list[str]] = [
[
mod.name,
@@ -257,24 +232,14 @@ class ConnectionManager:
for mod in mods
]
alpha_mods.sort(key=lambda x: x[0])
prefs = self.controller.get_prefs()
version_file = prefs.paths.version
remote_mod_ids = [mod[1] for mod in alpha_mods]
hashes = get_remote_signatures(remote_mod_ids)
missing_mods = get_needs_update(version_file, hashes)
for mod in alpha_mods:
if any(mod[1] in tuple for tuple in missing_mods):
mod[2] = resync
return alpha_mods, missing_mods
return alpha_mods
@call_on_thread(dialog.querying)
def query_modlist_and_present(self, record: Servers.Record) -> None:
try:
mods, missing_mods = self._query_modlist(record)
except Exception:
mods = self._query_modlist(record)
except Exception as e:
print(f"{type(e).__name__}: {e}")
self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True
)
@@ -299,20 +264,22 @@ class ConnectionManager:
def _connect_steam(self, menu_only: bool) -> None:
addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME)
client = self.controller.query_config(Preferences.CLIENT)
if menu_only:
rc = load_to_menu(self.client, self.appid, playername, self.remote_mod_ids)
rc = load_to_menu(client, addr, self.appid, playername, self.remote_mod_ids)
else:
rc = connect(self.client, addr, self.appid, playername, self.remote_mod_ids)
rc = connect(client, addr, self.appid, playername, self.remote_mod_ids)
if rc != 0:
# TODO: log/pop the error
func = StoredFunc(self.controller.update_status)
self.thread_man.set_cleanup_func(func)
return
self.thread_man.show_cancel(False)
self.thread_man.update_dialog(waiting_for_launch)
while True:
# FIXME: cancel should not be visible here per setting above
if self.controller.is_cancel_pending():
# TODO: check cancel event
if self.controller.get_exit_event().is_set():
# TODO: some facility to also close spawned steam process
return
if is_dayz_running():
@@ -326,28 +293,35 @@ class ConnectionManager:
self.controller.add_to_history(self.history, self.record)
self.controller.open_page(NotebookPage.SERVERS)
def _update_mods(self, menu_only: bool = False) -> None:
def _update_mods(self, raise_window: bool, menu_only: bool = False) -> None:
# NOTE: fast enqueue all mods in auto mode
prefs = self.controller.get_prefs()
config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.STEAM)
for title, mod, stamp, size in self.missing_mods:
if self.controller.is_cancel_pending():
# TODO: check cancel and exit events
if self.controller.get_exit_event().is_set():
return
subscribe(key, int(mod))
time.sleep(API_RATE_LIMIT)
if self.controller.get_cancel_event().is_set():
self.controller.clear_cancel_event()
return
enqueue_mod(mod, self.appid)
time.sleep(3)
if raise_window is True:
logger.info("Bringing window to foreground")
GLib.idle_add(self.controller.present_window)
self.thread_man.update_dialog(waiting_for_directories)
for title, mod, stamp, size in self.missing_mods:
mod_path = self.workshop / mod
# NOTE: Steam updates mod chunks in parallel, will finish at the same time
while mod_path.is_dir() is False:
if self.controller.is_cancel_pending():
return
time.sleep(1)
while True:
if self.controller.is_cancel_pending():
if self.controller.get_exit_event().is_set():
return
if self.controller.get_cancel_event().is_set():
self.controller.clear_cancel_event()
return
cur_size = get_mod_dir_size(mod_path)
if cur_size == size:
@@ -355,17 +329,13 @@ class ConnectionManager:
time.sleep(1)
update_signatures(self.missing_mods, prefs.paths.version)
rebuild_symlinks(prefs.paths.config)
# NOTE: update table status in main loop
self.thread_man.update_emitter("all_mods_synched")
# TODO: just push steam path directly
rebuild_symlinks(prefs.paths.config)
self._connect_steam(menu_only)
@call_on_thread(waiting_for_mods, show_cancel=True)
def update_and_connect(self, menu_only: bool = False) -> None:
def update_and_connect(self, raise_window: bool, menu_only: bool = False) -> None:
if len(self.missing_mods) > 0:
self._update_mods(menu_only)
self._update_mods(raise_window, menu_only)
else:
self._connect_steam(menu_only)
+5 -11
View File
@@ -8,7 +8,7 @@ from dzgui.util.clip import copy_clipboard
from dzgui.util.open_links import open_workshop_page
from dzgui.views.dialogs.note import NoteDialog
from dzgui.views.trees.tree_mods import ModTreeView, OfflineModTreeView
from dzgui.views.trees.tree_mods import ModTreeView
from dzgui.views.trees.tree_log import LogTreeView
from dzgui.views.trees.tree_servers import ServerTreeView
from dzgui.views.trees.tree_server_mods import ServerModTreeView
@@ -27,13 +27,7 @@ if TYPE_CHECKING:
class ContextMenuManager:
def __init__(
self,
treeview: (
LogTreeView
| ModTreeView
| ServerTreeView
| ServerModTreeView
| OfflineModTreeView
),
treeview: LogTreeView | ModTreeView | ServerTreeView | ServerModTreeView,
controller: "Controller",
) -> None:
self.controller = controller
@@ -78,10 +72,10 @@ class ContextMenuManager:
case ContextMenu.SHOW_MODS:
self.controller.get_modlist(record)
if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)):
if isinstance(self.treeview, ModTreeView):
match action:
case ContextMenu.UNSUB_MOD:
self.controller.unsub_mods(self.treeview)
case ContextMenu.DELETE_MOD:
self.controller.delete_mods()
case ContextMenu.OPEN_WORKSHOP:
self.open_mod_page()
+1 -3
View File
@@ -80,7 +80,6 @@ class FilterManager:
if maps is None:
return
if len(maps) < 1:
self.reinit_map_store()
return
self.reinit_map_store()
@@ -90,9 +89,8 @@ class FilterManager:
continue
self.append_map([m])
# TODO: currently unused
def get_unique_maps(self) -> list[str]:
return [row[0] for row in self.map_store if row[0] != "All maps"]
return [row[0] for row in self.map_store if row != "All maps"]
def get_all_filters(self) -> tuple:
map_name = self.get_active_map_name()
+38 -63
View File
@@ -1,29 +1,22 @@
import logging
import time
import shutil
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any, TYPE_CHECKING
from dzgui.api.steam import unsubscribe
from dzgui.api.mods import (
get_delimited_mods,
get_local_mod_path,
find_stale_mods,
_hash,
remove_stale_signatures,
)
from dzgui.const.constants import (
API_RATE_LIMIT,
APP_NAME,
APPID_DAYZ,
APPID_DAYZ_EXP,
)
from dzgui.const.constants import APP_NAME, APPID_DAYZ, APPID_DAYZ_EXP
from dzgui.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import FastInsertListStore, ModelFactory
from dzgui.strings import dialogs
from dzgui.util.format import format_mods
from dzgui.util.strings import server_timeout
from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.dialogs.generic import ExceptionDialog
@@ -46,48 +39,40 @@ class ModManager:
this manager is instantiated each time the Mods page is opened
"""
def __init__(self, tree: Gtk.TreeView, controller: "Controller") -> None:
self.treeview = tree
def __init__(self, controller: "Controller") -> None:
self.controller = controller
self.emitter = controller.get_emitter()
self.prefs = controller.get_prefs()
self.path = controller.query_config(Preferences.DEFAULT)
self.treeview = self.controller.get_modtreeview()
self.store: FastInsertListStore | None = None
self.store: FastInsertListStore
self.thread_man = ThreadingManager(controller)
self._get_mods()
@call_on_thread(dialogs.fetching_mods)
def load_mods(self) -> None:
mods = get_delimited_mods(Path(self.path))
# TODO: dialog strings
@call_on_thread("getting mods")
def _get_mods(self) -> None:
mods = get_delimited_mods(self.path)
if len(mods) < 1:
msg = self.format_mod_statusbar()
func = StoredFunc(lambda: self.emitter.emit("mods_updated", msg, 0))
self.thread_man.set_cleanup_func(func)
return
prefs = self.controller.get_prefs()
rebuild_symlinks(prefs.paths.config)
self.store = ModelFactory().make_mod_store()
self.store.extend(mods)
func = StoredFunc(self._on_mods_loaded)
func = StoredFunc(self._on_mods_loaded, mods)
self.thread_man.set_cleanup_func(func)
def set_store(self, store: "FastInsertListStore") -> None:
self.store = store
def _on_mods_loaded(self) -> None:
def _on_mods_loaded(self, mods: list[list[Any]]) -> None:
self.store = ModelFactory().make_mod_store()
self.store.extend(mods)
self.treeview.set_model(self.store)
if self.store is None:
return
msg = self.format_mod_statusbar()
total_mods = len(self.store)
self.emitter.emit("mods_updated", msg, total_mods)
def unsub_mods(self) -> None:
def delete_mods(self) -> None:
sel = self.treeview.get_selection()
model, pathlist = sel.get_selected_rows()
# NOTE: reverse when multiple selection
@@ -98,8 +83,7 @@ class ModManager:
continue
mod, _iter = res
mods.append((mod, _iter))
self.thread_man.set_job_count(len(mods))
self.unsub_all_mods(mods)
self.delete_mods_on_system(mods)
def get_mod_from_tree_path(
self, tree_path: Gtk.TreePath
@@ -111,55 +95,45 @@ class ModManager:
mod = model.get_value(tree_iter, 2)
return mod, tree_iter
@call_on_thread(dialogs.deleting_mods)
def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
# TODO: strings
@call_on_thread("deleting mods")
def delete_mods_on_system(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
for mod, _iter in mods:
self.unsub_atomic_mod(mod)
self.thread_man.increment_dialog()
time.sleep(API_RATE_LIMIT)
self.delete_single_mod(mod)
func = StoredFunc(self._on_mods_unsubbed, mods)
iters = [_iter for mod, _iter in mods]
func = StoredFunc(self._on_mods_deleted, iters)
self.thread_man.set_cleanup_func(func)
def unsub_atomic_mod(self, mod: str) -> None:
config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.STEAM)
unsubscribe(key, int(mod))
def delete_single_mod(self, mod: str) -> None:
steam_path = Path(self.path)
mods_path = get_local_mod_path(steam_path)
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
try:
md5 = _hash(mod)
symlink = app_path / md5
symlink.unlink()
except Exception as e:
logger.warning(e)
md5 = _hash(mod)
symlink = app_path / md5
symlink.unlink()
shutil.rmtree(mods_path / mod)
# NOTE: second pass to unlink DAYZ_EXP mods
# TODO: test this with working APPID_DAYZ_EXP installation
try:
app_path_exp = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP)
symlink = app_path_exp / md5
symlink.unlink()
except Exception:
except PeFile.AppNotInstalledError:
pass
time.sleep(API_RATE_LIMIT)
def _on_mods_unsubbed(self, mod_iter: list[tuple[str, Gtk.TreeIter]]) -> None:
mods = [int(mod) for mod, _iter in mod_iter]
iters = [_iter for mod, _iter in mod_iter]
if self.store is None:
return
def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None:
for _iter in iters:
self.store.remove(_iter)
# TODO: process config path in called function
remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version, mods)
remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version)
model = self.treeview.get_model()
if model is None:
return
total_mods = len(model)
mods = len(model)
msg = self.format_mod_statusbar()
self.emitter.emit("mods_updated", msg, total_mods)
self.emitter.emit("mods_updated", msg, mods)
def uncolorize_mods(self) -> None:
model = self.treeview.get_model()
@@ -207,7 +181,8 @@ class ModManager:
model[path][4] = True
self.emitter.emit("mods_highlighted")
@call_on_thread(dialogs.scanning_mods)
# TODO: dialog strings
@call_on_thread("working")
def highlight_stale(self) -> None:
stale = find_stale_mods(self.prefs.paths.config)
func = StoredFunc(self._on_stale_mods_found, stale)
-133
View File
@@ -1,133 +0,0 @@
import logging
import time
from pathlib import Path
from typing import Callable, TYPE_CHECKING, Union
import dzgui.api.pefile as PeFile
from dzgui.api.mods import is_mission, get_custom_mods
from dzgui.api.steam import is_dayz_running, launch_offline
from dzgui.const.constants import APP_NAME, APPID_DAYZ_EXP
from dzgui.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import ModelFactory
from dzgui.strings import dialogs
from dzgui.util.symlink import create_custom_symlinks, rebuild_symlinks, symlink_mission
from dzgui.views.dialogs.filepicker import FolderPicker
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.model.model_factory import FastInsertListStore
logger = logging.getLogger(APP_NAME)
class OfflineManager:
def __init__(
self,
controller: "Controller",
) -> None:
super().__init__()
self.controller = controller
self.emitter = controller.get_emitter()
self.thread_man = ThreadingManager(controller)
self.appid: int
self.local_mods: list[str] | None
self.custom_mods: list[str] | None
# TODO: more robust test
def is_custom_folder_valid(self, folder: str) -> bool:
return Path(folder).exists()
# TODO: set properties as members of offline manager on demand and check them here
# rather than passing strings again
def is_mission_valid(self, folder: str) -> bool:
return is_mission(Path(folder))
def get_mission(self) -> None:
folder = self.open_folderpicker(dialogs.mission_dialog)
if folder is None:
return
is_valid = is_mission(folder)
self.emitter.emit("custom_mission_loaded", str(folder), is_valid)
def find_custom_mods(self, callback: Callable) -> None:
folder = self.open_folderpicker(dialogs.custom_mod_dialog)
window = self.controller.get_window()
window.set_sensitive(False)
if folder is None:
window.set_sensitive(True)
return
# NOTE: starts spinner in calling UI
callback()
self.parse_custom_mods(folder)
@call_on_thread(dialogs.parsing_mods, show_dialog=False)
def parse_custom_mods(self, folder: str) -> None:
mods = get_custom_mods(Path(folder))
store = ModelFactory().make_mod_store()
store.extend(mods)
func = StoredFunc(self.post_mod_loading, store, folder)
self.thread_man.set_cleanup_func(func)
def post_mod_loading(self, store: "FastInsertListStore", folder: str) -> None:
window = self.controller.get_window()
window.set_sensitive(True)
self.emitter.emit("custom_mods_loaded", store, folder)
@call_on_thread(dialogs.waiting_for_launch, show_cancel=True)
def launch(
self,
appid: int,
mission: str,
local_mods: list[str],
custom_folder: str,
custom_mods: list[str],
) -> None:
# NOTE: local_mods and custom_mods are lists of symlinks
client = self.controller.query_config(Preferences.CLIENT)
name = self.controller.query_config(Preferences.NAME)
steam_path = self.controller.query_config(Preferences.DEFAULT)
combined_mods: list[str] = []
if len(local_mods) > 0:
combined_mods.extend(local_mods)
config = self.controller.get_prefs().paths.config
rebuild_symlinks(config)
if len(custom_mods) > 0:
new_symlinks = create_custom_symlinks(
Path(steam_path), Path(custom_folder), custom_mods
)
combined_mods.extend(new_symlinks)
relative_link = ""
if len(mission) > 0:
relative_link = symlink_mission(Path(steam_path), mission)
launch_offline(client, appid, name, combined_mods, relative_link)
while True:
if is_dayz_running():
break
time.sleep(1)
def open_folderpicker(self, title: str) -> Union["Path", None]:
picker = FolderPicker(self.controller.get_window(), title)
folder = picker.pick_folder()
picker.destroy()
return folder
def has_dayz_exp(self) -> bool:
try:
default_steam_path = self.controller.query_config(Preferences.DEFAULT)
steam_path = Path(default_steam_path)
dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP)
if dayz_exp is None:
return False
return True
except Exception as e:
logger.warning(e)
return False
-5
View File
@@ -81,11 +81,6 @@ class ThreadingManager:
def set_job_count(self, jobs: int) -> None:
self.jobs = jobs
def update_emitter(self, signal: str) -> None:
if self.controller:
emitter = self.controller.get_emitter()
GLib.idle_add(lambda: emitter.emit(signal))
def update_dialog(self, msg: str) -> None:
GLib.idle_add(lambda: self.wait_dialog.update_text(msg))
+2 -2
View File
@@ -42,7 +42,7 @@ class UpdateManager:
with tarfile.open(TMP_TARBALL) as tar:
tar.extractall(TMP_PATH)
shutil.copy(TMP_EXE, exe_path)
shutil.move(TMP_EXE, exe_path)
proc = subprocess.run([exe_path, "self", "restore"])
if proc.returncode == 0:
@@ -53,7 +53,7 @@ class UpdateManager:
func = StoredFunc(self._on_update_failure, msg)
self.thread_man.set_cleanup_func(func, destroy_first=True)
except Exception as e:
func = StoredFunc(self._on_update_failure, str(e))
func = StoredFunc(self._on_update_failure, e)
self.thread_man.set_cleanup_func(func, destroy_first=True)
logger.warning(e)
+4 -4
View File
@@ -2,6 +2,7 @@ from dataclasses import dataclass
from typing import Any, Sequence, TYPE_CHECKING
from dzgui.const.enum import HELP_MENU_ROWS
from dzgui.util.redact import redact_log
from dzgui.util.strings import delimiter
import gi
@@ -133,7 +134,9 @@ class ModelFactory:
with open(path, "r") as f:
lines = [line.split(delimiter) for line in f.read().splitlines()]
for record in lines:
store.append(record)
# NOTE: strips PII and API keys
clean = redact_log(record)
store.append(clean)
return store
def new_model_from_class(self, cls: type) -> FastInsertListStore:
@@ -162,6 +165,3 @@ class ModelFactory:
def make_server_store(self) -> FastInsertListStore:
return self.new_model_from_class(ServerCols)
# def convert_model_to_list(self, model: "FastInsertListStore") -> list:
# return [[el for el in row] for row in model]
+7 -5
View File
@@ -138,9 +138,13 @@ class ServerModelManager:
futures = [executor.submit(Servers.test_ip, i, port, event) for i in ports]
for future in as_completed(futures):
try:
if self.controller.is_cancel_pending():
if self.controller.get_exit_event().is_set():
event.set()
return
if self.controller.get_cancel_event().is_set():
event.set()
self.controller.clear_cancel_event()
return
res = future.result(timeout=LAN_TIMEOUT)
if res is None:
continue
@@ -374,7 +378,7 @@ class ServerModelManager:
proxy = self._get_proxy_man().get_proxy_model()
self.tv.set_model(proxy)
if self.controller.get_active_treeview().get_enum() == ServerTab.SAVED:
if self.controller.get_active_treeview().get_enum == ServerTab.SAVED:
self.emitter.emit("servers_loaded", self.enum)
filter_man = self.tv.get_filter_man()
@@ -395,10 +399,8 @@ class ServerModelManager:
self.first_iteration = False
def _cleanup_on_success(self) -> None:
proxy = self.proxy_man.get_proxy_model()
proxy = self._get_proxy_man().get_proxy_model()
self.tv.set_model(proxy)
if self.preserve_on_fail is True:
self.proxy_man.wipe_cache()
self.emitter.emit("servers_loaded", self.enum)
if self.first_iteration:
+1 -15
View File
@@ -1,22 +1,8 @@
waiting_for_launch = "Waiting for DayZ to launch"
waiting_for_mods = "Queueing up mods (step 1/2)"
waiting_for_directories = "Steam is staging mods (step 2/2)"
waiting_for_mods = "Waiting for Steam to update mods"
fetching_update = "Fetching update"
failed_to_update = "Failed to update DZGUI executable"
update_success = "Updated successfully. Please exit and relaunch."
load_error_lan = "Failed to find any servers on your network.\nCheck the server query port or your firewall settings."
fetching_mods = "Fetching mod metadata"
deleting_mods = "Unsubscribing mods"
scanning_mods = "Scanning mods"
parsing_mods = "Parsing mods"
checking_api = "Checking API key"
running = "Running"
failed = "FAILED"
ok = "OK"
mission_dialog = "Set a mission folder location"
custom_mod_dialog = "Set location to a custom mods folder"
-1
View File
@@ -3,4 +3,3 @@ api_validation_error = (
)
api_popover = "API key validation failed."
dayz_not_found = "Could not find a valid DayZ installation."
-1
View File
@@ -1 +0,0 @@
selectable_tree = "Ctrl-click to select multiple; Shift-click to select a range."
-22
View File
@@ -1,22 +0,0 @@
heading = "Play offline"
mission_button = "Select mission folder"
mission_frame = "Mission"
version = "DayZ Version"
custom_frame = "Custom mods"
custom_button = "Set custom mod folder"
local_frame = "Installed mods"
no_local_mods = "No local mods found"
no_mods = "No valid mods found"
no_mission = "Not a valid mission"
folder_changed = "Folder missing or changed on disk"
custom_eventbox = "Set the root folder. Mods must be in atomic folders and have a meta.cpp file at a minimum."
mission_eventbox = "Select a single mission folder containing an init.c file."
folder_prefix = "<b>Current folder: </b>"
unset_button = "Unset folder"
-1
View File
@@ -3,4 +3,3 @@ signatures = "Updating mod signatures"
geo = "Checking geolocation records"
coords = "Checking local coordinates"
updates = "Checking for updates"
dayz = "Looking for DayZ"
-1
View File
@@ -10,7 +10,6 @@ warnings = "Warnings"
errors = "Errors"
mods = "Mods"
total_mods = "Total mods: "
up_to_date = "All mods are up to date."
all_updated = "All mods updated."
"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
-19
View File
@@ -64,25 +64,6 @@ radio_mi = "mi (miles)"
label_dist = "Distance display"
label_client = "Steam client"
### Shortcuts page
heading_shortcuts = "Create shortcuts"
blurb_shortcuts = (
"You can optionally create shortcuts to facilitate launching DZGUI faster."
)
button_shortcuts = "Create shortcuts"
checkbox_steam_shortcut = (
"Add DZGUI to Steam library",
(
"This creates a standalone copy of DZGUI and adds it to Steam. "
"You <b>must restart Steam</b> for the shortcut to appear. This will not replace or update existing shortcuts. "
"If you update DZGUI when it is launched via Steam, updates will be applied to the Steam version."
),
)
checkbox_start_menu = (
"Add a start menu shortcut",
"Depends on local window manager settings. Will work on Steam Deck.",
)
checkbox_desktop_shortcut = ("Add a desktop shortcut", "Depends on the option above.")
### Completion page
heading_completion = "Setup complete"
+7 -14
View File
@@ -1,4 +1,3 @@
import logging
import os
import platform
@@ -10,9 +9,7 @@ from dzgui.const.constants import APP_NAME
from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
from dzgui.init.prefix import get_version
from dzgui.util.redact import redact_home
logger = logging.getLogger(APP_NAME)
from dzgui.util.redact import redact
def get_cpu_model() -> str:
@@ -41,19 +38,15 @@ def print_mods(mods: list[int]) -> str:
def write_diagnostic(config: Path, outfile: Path) -> None:
# TODO: test availability on other distros
date = datetime.now().isoformat()
try:
# FIXME: ID_LIKE not available on CachyOS
distro = platform.freedesktop_os_release()["ID_LIKE"]
except Exception as e:
logger.warn(e)
distro = "Unknown"
distro = platform.freedesktop_os_release()["ID_LIKE"]
kernel = os.uname().release
cpu = get_cpu_model()
version = get_version()
branch = lookup(config, Preferences.BRANCH)
debug = lookup(config, Preferences.DEBUG)
install = lookup(config, Preferences.INSTALL)
default = lookup(config, Preferences.DEFAULT)
@@ -61,8 +54,8 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
steam_path = Path(default)
workshop_path = get_local_mod_path(steam_path)
steam_redacted = redact_home(default)
workshop_redacted = redact_home(str(workshop_path))
steam_redacted = redact(default)
workshop_redacted = redact(str(workshop_path))
mods = get_local_mod_ids(steam_path)
mods_pretty = print_mods(mods)
@@ -72,7 +65,7 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
# FIXME: extraneous newlines in lists of mods
template = f"""\
{APP_NAME} version {version}
{APP_NAME} version {version} ({branch})
Date: {date}
===============================
Distribution: {distro}
-32
View File
@@ -1,32 +0,0 @@
import logging
import os
import shutil
from importlib import resources
from pathlib import Path
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH
logger = logging.getLogger(APP_NAME)
def make_parents(path: "Path") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def copy_dzgui_to_xdg_data(exe_path: Path) -> None:
dzgui = os.getenv("PYAPP")
if dzgui is None:
return
try:
make_parents(exe_path)
shutil.copy(dzgui, exe_path)
except Exception as e:
logger.critical(e)
def find_icon_resource() -> "Path":
traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH)
images = Path(str(traversable))
icon = images.joinpath("icon.png")
return icon
+2 -6
View File
@@ -1,7 +1,6 @@
import re
from dzgui.util.localize import number
from dzgui.strings.generic import selectable_tree
from dzgui.util.strings import no_mods, no_servers, workshop
import gi
@@ -47,7 +46,8 @@ def format_mods(size: int, mods: int) -> str:
return no_mods
l_size = number(size)
plural = pluralize("mods", mods)
suffix = selectable_tree
# TODO: strings
suffix = "Ctrl-click to select multiple; Shift-click to select a range."
return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}"
@@ -83,7 +83,3 @@ def format_player_count(model: Gtk.TreeModel | None, control: list) -> str:
def embolden(text: str) -> str:
return f"<b>{text}</b>"
def format_exception(e: Exception) -> str:
return f"{type(e).__name__}: {e}"
+16 -25
View File
@@ -1,29 +1,20 @@
import logging
import re
from typing import Literal
api_filter = r"(.*&key=)([^&]*)(.*)"
home_filter = r"(/home/)([^\s'\/]*)(.*)"
REDACTED = r"\1REDACTED\3"
REDACTION_PATTERNS = [api_filter, home_filter]
def redact_home(text: str) -> str:
pat = re.compile(home_filter)
cleaned = pat.sub(REDACTED, text)
def redact(text: str) -> str:
r = r"(/home/)([^/])*"
cleaned = re.sub(r, r"/home/REDACTED", text)
return cleaned
class RedactionFilter(logging.Filter):
def __init__(self, patterns: list[str] | None = None) -> None:
super().__init__()
self._patterns = [re.compile(pat) for pat in (patterns or [])]
def filter(self, record: logging.LogRecord) -> Literal[True]:
for pattern in self._patterns:
try:
record.msg = pattern.sub(REDACTED, record.msg)
except TypeError:
exception_text = f"{type(record.msg).__name__}: {record.msg}"
record.msg = pattern.sub(REDACTED, exception_text)
return True
def redact_log(record: list) -> list[str]:
"""
requests library includes Steam API key in URL params
"""
clean = []
for item in record:
if "&key=" in item:
pat = r"(.*&key=)(\S+)(.*)"
scrubbed = re.sub(pat, r"\1REDACTED\3", item)
clean.append(scrubbed)
else:
clean.append(item)
return clean
+10 -12
View File
@@ -52,7 +52,7 @@ show_mods = "Show server-side mods"
show_details = "Show server details"
refresh_players = "Refresh player count"
open_workshop = "Open in Steam Workshop"
unsub_mod = "Unsubscribe mod"
delete_mod = "Delete mod"
copy_name = "Copy name to clipboard"
copy_ip = "Copy IP to clipboard"
copy_log = "Copy record(s) to clipboard"
@@ -64,7 +64,7 @@ connect = "Connect"
# Columns
server_mod_cols = ["Mod", "ID", "Installed"]
mod_cols = ["Mod", "Symlink", "ID", "Size (MiB)", "Color"]
mod_cols = ["Mod", "Symlink", "Dir", "Size (MiB)", "Color"]
log_cols = ["Timestamp", "Flag", "Traceback", "Message"]
browser_cols = [
"Name",
@@ -130,6 +130,7 @@ servers = {
"Enter/space/double-click": "connect to server",
"Right-click/Ctrl-l": "additional context menus",
"Ctrl-r": "refresh players",
"Ctrl-p": "refresh ping",
"Ctrl-f": "jump to keyword search field",
"Ctrl-m": "jump to maps field",
"Ctrl-d": "select default LAN port",
@@ -147,8 +148,8 @@ servers = {
vim = {
"j": "Move down a row/scroll up",
"k": "Move up a row/scroll down",
"h": "Jump to main area from sidebar",
"l": "Jump to sidebar from main area",
"l": "Jump to main area from sidebar",
"h": "Jump to sidebar from main area",
"g": "Jump to first row/top of page",
"G": "Jump to last row/bottom of page",
}
@@ -180,7 +181,7 @@ filter_lowpop = "Low pop"
filter_nonascii = "Non-ASCII"
filter_duplicate = "Duplicate"
filter_official = "Official"
filter_unofficial = "Unofficial"
filter_unofficial = "Unoffic."
filter_modded = "Modded"
# maps
@@ -214,8 +215,8 @@ class ModPanelStrings:
unhighlight_stale_tooltip: str
highlight_stale: str
highlight_stale_tooltip: str
unsub_selected: str
unsub_selected_tooltip: str
delete_selected: str
delete_selected_tooltip: str
unselect_all: str
unselect_all_tooltip: str
select_all: str
@@ -272,7 +273,6 @@ class Crumbs:
thanks: str
developers: str
default: str
offline: str
@dataclass(slots=True, frozen=True)
@@ -336,8 +336,8 @@ mod_panel = ModPanelStrings(
"Shows locally-installed mods which are not used by any server "
"in your Saved Servers"
),
unsub_selected="Unsubscribe selected",
unsub_selected_tooltip="Unsubscribes from selected mods",
delete_selected="Delete selected",
delete_selected_tooltip="Deletes selected mods from the system",
unselect_all="Unselect all",
unselect_all_tooltip="Bulk unselects all mods",
select_all="Select all",
@@ -366,7 +366,6 @@ thanks = Thanks(
"Johnofwrong",
"MatheusLasserr",
"nolan-perez",
"OnniSaarni",
"scandalouss",
"SnackSBR",
"StevelDusa",
@@ -463,7 +462,6 @@ crumbs = Crumbs(
thanks="Help > Special thanks",
developers="Options > Developers",
default="Servers > ",
offline="Mods > Play offline",
)
no_mods = "No local mods found."
+4 -44
View File
@@ -2,7 +2,7 @@ import logging
from pathlib import Path
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME
from dzgui.const.enum import Preferences
from dzgui.config.query import lookup
@@ -11,23 +11,14 @@ import dzgui.api.pefile as PeFile
logger = logging.getLogger(APP_NAME)
def expunge_link(file: Path) -> None:
if not file.is_symlink():
return
# NOTE: unlink stale symlinks
if file.exists() is False:
file.unlink()
if str(file.stem)[:2] == "@C":
file.unlink()
def rebuild_symlinks(config: Path) -> None:
# TODO: pass direct path as argument
path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path)
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
# NOTE: unlink stale symlinks
for file in dayz_path.iterdir():
expunge_link(file)
if file.is_symlink() and file.exists() is False:
file.unlink()
workshop = get_local_mod_path(steam_path)
# NOTE: create symlinks for missing mods
for mod_id in get_local_mod_ids(steam_path):
@@ -39,37 +30,6 @@ def rebuild_symlinks(config: Path) -> None:
clone_symlinks(steam_path)
def create_custom_symlinks(
steam_path: Path, custom_dir: Path, uids: list[str]
) -> list[str]:
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
links = []
for uid in uids:
md5sum = _hash(uid, use_custom=True)
source = dayz_path.joinpath(md5sum)
target = custom_dir.joinpath(uid)
links.append(md5sum)
if source.exists() is False:
source.symlink_to(target)
clone_symlinks(steam_path)
return links
def symlink_mission(steam_path: Path, target: str) -> str:
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
for file in dayz_path.iterdir():
expunge_link(file)
path = Path(target)
stem = path.name
parent = path.parent
source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT)
try:
source.symlink_to(parent)
except Exception:
return ""
return f"{DAYZ_COMMUNITY_ROOT}/{stem}"
def clone_symlinks(steam_path: Path) -> None:
"""
Shares symlinks between builds. Used after any symlink operation
+1 -5
View File
@@ -23,8 +23,6 @@ from dzgui.views.pages.help import Help
from dzgui.views.pages.keys import Keybindings
from dzgui.views.pages.log import Log
from dzgui.views.pages.mods import Mods
from dzgui.views.pages.offline import OfflineLoader
from dzgui.views.pages.options import Options
from dzgui.views.pages.preconnect import PreConnectionAssistant
from dzgui.views.pages.servers import ServerNotebook
@@ -70,6 +68,7 @@ class OuterWindow(Gtk.Window):
MainController.set_start_tab()
self.grid.hide_widgets_on_init()
# TODO: POC, trigger page change here
MainController.loaded = True
MainController.populate_model(MainController.get_active_treeview())
@@ -111,11 +110,8 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore
self.connection = PreConnectionAssistant(MainController)
self.offline = OfflineLoader(MainController)
self.pages = {
self.help: NotebookPage.HELP,
self.offline: NotebookPage.OFFLINE,
self.clog: NotebookPage.CHANGELOG,
self.keys: NotebookPage.KEYS,
self.settings: NotebookPage.OPTIONS,
-25
View File
@@ -1,25 +0,0 @@
from typing import Sequence
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa
class GenericBox(Gtk.Box):
def __init__(self, orientation: Gtk.Orientation, spacing: int = 0) -> None:
super().__init__(orientation=orientation, spacing=spacing)
def extend(self, els: Sequence[Gtk.Widget]) -> None:
for el in els:
self.add(el)
class HBox(GenericBox):
def __init__(self, spacing: int = 0) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=spacing)
class VBox(GenericBox):
def __init__(self, spacing: int = 0) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing)
+11 -36
View File
@@ -31,61 +31,40 @@ if TYPE_CHECKING:
class Icon(Gtk.Image):
def __init__(self, name: str, margin_start: int = 0, margin_end: int = 0) -> None:
def __init__(self, name: str, l_margin: int = 0) -> None:
super().__init__(
icon_name=name,
icon_size=Gtk.IconSize.BUTTON,
margin_start=margin_start,
margin_end=margin_end,
margin_start=l_margin,
ypad=2,
)
class LargeIcon(Gtk.Image):
def __init__(self, name: str, margin_start: int = 5, margin_end: int = 0) -> None:
def __init__(self, name: str, l_margin: int = 5) -> None:
super().__init__(
icon_name=name,
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
margin_start=margin_start,
margin_end=margin_end,
icon_name=name, icon_size=Gtk.IconSize.LARGE_TOOLBAR, margin_start=l_margin
)
class IconButton(Gtk.Button):
def __init__(
self,
icon: str,
position: Gtk.PositionType = Gtk.PositionType.RIGHT,
margin_start: int = 0,
margin_end: int = 0,
) -> None:
def __init__(self, icon: str, margin: int = 0) -> None:
super().__init__()
self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end)
self.icon = Icon(icon, l_margin=margin)
self.set_image(self.icon)
self.set_image_position(position)
# self.set_image_position(Gtk.PositionType.RIGHT)
self.set_image_position(Gtk.PositionType.RIGHT)
self.set_focus_on_click(False)
class IconTextButton(IconButton):
def __init__(
self, icon: str, label: str, position: Gtk.PositionType = Gtk.PositionType.RIGHT
) -> None:
match position:
case Gtk.PositionType.RIGHT:
super().__init__(icon, position, margin_start=5)
case Gtk.PositionType.LEFT:
super().__init__(icon, position, margin_end=5)
case _:
raise ValueError("Unsupported position type")
def __init__(self, icon: str, label: str) -> None:
super().__init__(icon, margin=5)
self.set_label(label)
class LargeIconTextButton(IconButton):
def __init__(
self, icon: str, label: str, position: Gtk.PositionType = Gtk.PositionType.RIGHT
) -> None:
super().__init__(icon, position)
def __init__(self, icon: str, label: str) -> None:
super().__init__(icon)
self.set_label(label)
self.set_image(LargeIcon(icon))
@@ -141,8 +120,6 @@ class RefreshButton(IconTextButton):
def _on_refresh_clicked(self, button: Self) -> None:
"""Spawned in a thread"""
if self.controller.get_active_treeview().is_loaded() is False:
return
self.is_clicked = True
self.controller.refresh_tree()
# TODO: get server tab enum
@@ -210,8 +187,6 @@ class SteamWorkshopButton(SteamTextButton):
def __init__(self) -> None:
super().__init__(label=buttons.workshop)
self.set_tooltip_text(buttons.workshop_tooltip)
self.set_margin_top(10)
self.set_margin_bottom(10)
class AddButton(IconTextButton):
-68
View File
@@ -1,68 +0,0 @@
from dzgui.strings import preconnect
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk # noqa E402
class ConnectBox(Gtk.Box):
def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
self.back = Gtk.Button(
label=preconnect.back, halign=Gtk.Align.END, hexpand=True
)
# TODO: dynamically update this
self.ok = Gtk.Button(label="Launch", halign=Gtk.Align.END)
for button in self.back, self.ok:
self.add(button)
def get_back_button(self) -> Gtk.Button:
return self.back
def get_ok_button(self) -> Gtk.Button:
return self.ok
class PreconnectBox(Gtk.Box):
def __init__(self) -> None:
super().__init__(
orientation=Gtk.Orientation.VERTICAL,
valign=Gtk.Align.END,
vexpand=True,
spacing=5,
)
self.button_hbox = ConnectBox()
self.ok = self.button_hbox.get_ok_button()
self.ok.set_label(preconnect.update_mods)
self.connect_last = Gtk.Button(
label=preconnect.connect_last,
halign=Gtk.Align.END,
tooltip_text=preconnect.connect_last_tooltip,
)
self.button_hbox.add(self.connect_last)
self.raise_window = Gtk.CheckButton(
# TODO: strings
label="Foreground DZGUI while downloading",
halign=Gtk.Align.END,
hexpand=True,
valign=Gtk.Align.END,
visible=False,
has_tooltip=True,
sensitive=False,
tooltip_text="Foreground the DZGUI window after mod downloads are queued",
active=True,
)
self.add(self.button_hbox)
self.add(self.raise_window)
def get_back_button(self) -> Gtk.Button:
return self.button_hbox.get_back_button()
def get_ok_button(self) -> Gtk.Button:
return self.ok
+4 -44
View File
@@ -1,14 +1,12 @@
from typing import Self, TYPE_CHECKING
from typing import TYPE_CHECKING
from dzgui.const.constants import CARET_DOWN, CARET_UP, UDP_PORT
from dzgui.const.constants import UDP_PORT
from dzgui.model.servers import ServerModelManager
from dzgui.strings import connect_panel
from dzgui.util.keys import is_ctrl_mask
from dzgui.views.components.buttons import (
AddButton,
CopyIpButton,
Icon,
IconButton,
SteamConnectButton,
)
from dzgui.views.components.entry import IpEntry, PortEntry
@@ -20,45 +18,12 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
from dzgui.controllers.mc import Controller, Emitter
COLS = 1
ROWS = 1
class DockButton(IconButton):
def __init__(self, child: Gtk.Widget) -> None:
super().__init__(CARET_DOWN)
self.expanded = True
self.child = child
self.connect("clicked", self._on_click)
self.collapse_text = "Collapse the connection panel"
self.expand_text = "Expand the connection panel"
self.set_margin_top(10)
self.set_tooltip_text(self.collapse_text)
def collapse(self) -> None:
self.set_image(Icon(CARET_UP))
self.child.hide()
self.set_tooltip_text(self.expand_text)
def expand(self) -> None:
self.set_image(Icon(CARET_DOWN))
self.child.show()
self.set_tooltip_text(self.collapse_text)
def _on_click(self, button: Self) -> None:
if self.expanded:
self.collapse()
else:
self.expand()
self.expanded = not self.expanded
class LanPanel(Gtk.Frame):
def __init__(self, controller: "Controller") -> None:
super().__init__(margin_top=10, margin_bottom=5)
@@ -307,17 +272,12 @@ class ConnectPanel(Gtk.Box):
emitter = self.controller.get_emitter()
emitter.connect("lan_tab_toggled", self._on_lan_tab_toggled)
self.panel_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.lan = LanPanel(controller)
self.fav = FavPanel(controller)
self.add_panel = AddPanel(controller)
for el in self.lan, self.fav, self.add_panel:
self.panel_box.add(el)
self.add(DockButton(self.panel_box))
self.add(self.panel_box)
self.add(el)
self.lan.set_visible(False)
def _on_lan_tab_toggled(self, emitter: "Emitter", state: bool) -> None:
+3 -8
View File
@@ -11,7 +11,6 @@ from dzgui.const.constants import (
)
from dzgui.const.enum import FilterMode
from dzgui.model.servers import ServerModelManager
from dzgui.util.format import format_exception
from dzgui.util.strings import all_maps
from dzgui.views.components.maps_combo import MapsCombo
from dzgui.views.components.labels import BoldLabel
@@ -334,10 +333,6 @@ class FilterPanel(Gtk.Box):
self.emitter.emit("map_selection_changed", name)
def set_filters(self, filters: dict[str, bool]) -> None:
try:
for check in self.button_grid.checks:
label = check.get_label()
check.set_active(filters[label])
except Exception as e:
msg = format_exception(e)
logger.warning(msg)
for check in self.button_grid.checks:
label = check.get_label()
check.set_active(filters[label])
+8 -16
View File
@@ -1,4 +1,3 @@
from typing import Self
from dzgui.util import css
import gi
@@ -7,23 +6,16 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa
# TODO: subclass Gtk.Frame directly
class HeadingFrame(Gtk.Box):
def __init__(self, heading: str = "") -> None:
def __init__(self, widget: Gtk.Widget, heading: str) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.label = Gtk.Label(label=heading)
self.label.set_halign(Gtk.Align.START)
css.add_class(self.label, "settings-subheading")
label = Gtk.Label(label=heading)
label.set_halign(Gtk.Align.START)
css.add_class(label, "settings-subheading")
self.frame = Gtk.Frame(hexpand=True)
self.frame.set_label_widget(self.label)
frame = Gtk.Frame(hexpand=True)
frame.add(widget)
frame.set_label_widget(label)
self.add(self.frame)
@classmethod
def new_with_widget_and_label(cls, widget: Gtk.Widget, label: str) -> Self:
n = cls()
n.frame.add(widget)
n.label.set_label(label)
return n
self.add(frame)
+9 -21
View File
@@ -11,8 +11,7 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
from dzgui.controllers.mc import Controller, Emitter
class EnumeratedModButton(Gtk.Button):
@@ -23,12 +22,10 @@ class EnumeratedModButton(Gtk.Button):
self.set_label(enum.dict["label"])
self.set_tooltip_text(enum.dict["tooltip"])
class ModPanelButton(EnumeratedModButton):
def __init__(self, enum: ModButton) -> None:
super().__init__(enum=enum)
self.enum = enum
if enum is not ModButton.HIGHLIGHT_STALE:
self.set_sensitive(False)
@@ -49,7 +46,7 @@ class ModSelectionPanel(Gtk.Box):
buttons = (
ModButton.SELECT_ALL,
ModButton.UNSELECT_ALL,
ModButton.UNSUB_SELECTED,
ModButton.DELETE_SELECTED,
)
for button in buttons:
b = EnumeratedModButton(button)
@@ -61,13 +58,8 @@ class ModSelectionPanel(Gtk.Box):
self.unhighlight_stale_button = ModPanelButton(ModButton.UNHIGHLIGHT_STALE)
self.select_stale_button = ModPanelButton(ModButton.SELECT_STALE)
for b in (
self.highlight_stale_button,
self.unhighlight_stale_button,
self.select_stale_button,
):
for b in (self.highlight_stale_button, self.unhighlight_stale_button, self.select_stale_button):
self.stale_panel.pack_start(b, NO_EXPAND, FILL, NO_PADDING)
b.connect("clicked", self._on_button_clicked)
for el in header, self.main_panel, self.stale_panel:
self.pack_start(el, NO_EXPAND, FILL, NO_PADDING)
@@ -80,18 +72,14 @@ class ModSelectionPanel(Gtk.Box):
self.highlight_stale_button.set_sensitive(True)
def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None:
self._toggle_panel_sensitivity(mods)
def _toggle_panel_sensitivity(self, mods: int) -> None:
state = bool(mods)
for el in self.main_panel, self.stale_panel:
el.set_sensitive(state)
if mods < 1:
self.main_panel.set_sensitive(False)
self.stale_panel.set_sensitive(False)
def _on_mods_highlighted(self, emitter: "Emitter") -> None:
self.swap_sensitive(True)
def _on_mod_page_toggled(self, emitter: "Emitter", state: bool, mods: int) -> None:
self._toggle_panel_sensitivity(mods)
def _on_mod_page_toggled(self, emitter: "Emitter", state: bool) -> None:
self.set_visible(state)
def swap_sensitive(self, state: bool) -> None:
@@ -105,8 +93,8 @@ class ModSelectionPanel(Gtk.Box):
self.controller.toggle_mod_selection(True)
case ModButton.UNSELECT_ALL:
self.controller.toggle_mod_selection(False)
case ModButton.UNSUB_SELECTED:
self.controller.unsub_mods()
case ModButton.DELETE_SELECTED:
self.controller.delete_mods()
case ModButton.HIGHLIGHT_STALE:
self.controller.highlight_stale()
case ModButton.UNHIGHLIGHT_STALE:
+23 -14
View File
@@ -1,4 +1,5 @@
from typing import Self, Union, TYPE_CHECKING
from warnings import deprecated
from dzgui.const.enum import NotebookPage, ServerTab
from dzgui.util.strings import esc_to_return, question_to_return
@@ -7,7 +8,7 @@ from dzgui.views.components.buttons import LoggerAlertsButton
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
from gi.repository import Gtk, GObject # noqa E402
if TYPE_CHECKING:
from dzgui.const.enum import ServerTab
@@ -25,8 +26,6 @@ class Statusbar(Gtk.Grid):
self.controller.register_widget("statusbar", self)
self.emitter = controller.get_emitter()
self.prior_enum: NotebookPage
self.prior_status: str
self.playercount = ""
self.statusbar = Gtk.Statusbar()
@@ -35,6 +34,14 @@ class Statusbar(Gtk.Grid):
self.attach(self.statusbar, 0, 0, 3, 1)
self.attach_next_to(self.spinner, self.statusbar, Gtk.PositionType.RIGHT, 3, 1)
# TODO: pack version event box in right panel into hbox with update button
# TODO: spawns a modal or just jumps right to install page
# from dzgui.views.components.buttons import IconTextButton
# b = IconTextButton("dialog-information-symbolic", label="Updates available")
# b.set_halign(Gtk.Align.END)
# b.set_hexpand(True)
# self.attach_next_to(b, self.spinner, Gtk.PositionType.RIGHT, 3, 1)
self.players = ""
controller.get_menu().connect("generic_treesel_changed", self._help_row_changed)
@@ -71,9 +78,7 @@ class Statusbar(Gtk.Grid):
NotebookPage.CHANGELOG,
NotebookPage.LOG,
NotebookPage.CONNECTION,
NotebookPage.OFFLINE,
):
self.prior_enum = enum
self.set_by_context(enum, esc_to_return)
return
@@ -83,11 +88,16 @@ class Statusbar(Gtk.Grid):
case NotebookPage.KEYS:
bar = question_to_return
case _:
bar = self.prior_status
return
self.prior_enum = enum
self.set_by_context(enum, bar)
# TODO: unused
def _on_notebook_page_returned(
self, statusbar: Self, prior_context: NotebookPage
) -> None:
self.pop(prior_context)
def _on_server_row_changed(self, statusbar: Self) -> None:
self.spinner.start()
@@ -101,13 +111,8 @@ class Statusbar(Gtk.Grid):
context: Union["ServerTab", NotebookPage],
) -> None:
self.spinner.stop()
if type(context) is NotebookPage:
self.prior_enum = context
self.prior_status = ""
# FIXME: CalcDist is being called when table is not loaded
if dist is None:
self.set_by_context(context, "")
return
else:
pretty = self.append_distance(dist)
@@ -127,11 +132,15 @@ class Statusbar(Gtk.Grid):
cid = self.statusbar.get_context_id(str(context))
self.statusbar.pop(cid)
@deprecated("currently unused")
# def get_text(self) -> str:
# area = self.statusbar.get_message_area()
# label = area.get_children()[0]
# return str(label.get_text())
def set_by_context(
self, context: Union[NotebookPage, "ServerTab"], string: str
) -> None:
if context != NotebookPage.KEYS:
self.prior_status = string
meta = self.statusbar.get_context_id(str(context))
self.statusbar.push(meta, string)
self.set_cache(string)
+10 -19
View File
@@ -1,20 +1,19 @@
import sys
from typing import Any, Literal, Self, TYPE_CHECKING
from typing import Literal, TYPE_CHECKING
# import time
from enum import Enum
from typing import Any, Self
# TODO: import dialog titles
from dzgui.api.mods import remove_stale_signatures as remove_stale
from dzgui.config.ipdb import get_ipdb
from dzgui.const.constants import HEX_GREEN, HEX_RED
from dzgui.init.coords import get_local_coords
from dzgui.init.dayz import is_dayz_installed
from dzgui.init.update import check_updates
from dzgui.const.constants import EXPAND, FILL
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import preboot, dialogs
from dzgui.util.format import format_exception
from dzgui.strings import preboot
from dzgui.util.strings import dialog_header
from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.components.buttons import ClipboardButton
@@ -58,9 +57,6 @@ class BootDialog(Gtk.Dialog):
self.view = Gtk.TreeView(enable_search=False, headers_visible=False)
self.view.set_model(self.store)
# TODO: capture sigint in early dialogs
self.connect("key-press-event", self._on_keypress)
for i, column_title in enumerate(["Task", "State"]):
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(column_title, renderer, text=i)
@@ -122,7 +118,6 @@ class BootDialog(Gtk.Dialog):
self.error_box.hide()
steps = [
(StoredFunc(is_dayz_installed, self.xdg.config), preboot.dayz, False),
(StoredFunc(rebuild_symlinks, self.xdg.config), preboot.symlinks, False),
(
StoredFunc(remove_stale, self.xdg.config, self.xdg.version),
@@ -130,7 +125,6 @@ class BootDialog(Gtk.Dialog):
False,
),
(StoredFunc(get_ipdb, self.xdg.ips), preboot.geo, False),
# TODO: handle IP DB failure and use coords fallback
(StoredFunc(get_local_coords, self.xdg.ips), preboot.coords, True),
(StoredFunc(check_updates, self.version), preboot.updates, True),
]
@@ -140,10 +134,6 @@ class BootDialog(Gtk.Dialog):
GLib.timeout_add(100, self.pulse_spinner)
def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None:
if event.keyval == Gdk.KEY_Escape:
self.exit_button.emit("clicked")
def pulse_spinner(self) -> Literal[True]:
for row in self.store:
if row[2]:
@@ -185,15 +175,16 @@ class BootDialog(Gtk.Dialog):
self.thread_man.set_cleanup_func(callback)
except Exception as e:
self.failed = True
self.exception = format_exception(e)
# TODO: generic exception wrapper
self.exception = f"{type(e).__name__}: {e}"
callback = StoredFunc(self.update_status, Success.FAIL)
self.thread_man.set_cleanup_func(callback)
def update_task(self, task: str) -> None:
self.store.append((task, dialogs.running, True, 0))
self.store.append((task, "Running", True, 0))
def update_status(self, state: Success) -> None:
d = {Success.OK: dialogs.ok, Success.FAIL: dialogs.failed}
d = {Success.OK: "OK", Success.FAIL: "FAILED"}
msg = d[state]
self.store[len(self.store) - 1][1] = msg
self.iter_step()
@@ -206,7 +197,7 @@ class BootDialog(Gtk.Dialog):
it: Gtk.TreeIter,
data: Any,
) -> None:
if model[it][1] == dialogs.running:
if model[it][1] == "Running":
cell.set_property("visible", True)
else:
cell.set_property("visible", False)
@@ -223,9 +214,9 @@ class BootDialog(Gtk.Dialog):
state = model[it][1]
if column.get_sort_column_id() != 1:
return
if state == dialogs.ok:
if state == "OK":
cell.set_property(prop, HEX_GREEN)
elif state == dialogs.failed:
elif state == "FAILED":
cell.set_property(prop, HEX_RED)
else:
cell.set_property(prop, None)
-33
View File
@@ -33,36 +33,3 @@ class FilePicker(Gtk.FileChooserDialog):
self.destroy()
return Path(file)
return None
class FolderPicker(Gtk.FileChooserDialog):
def __init__(self, parent: Gtk.Window, title: str) -> None:
super().__init__(
title=title,
action=Gtk.FileChooserAction.SELECT_FOLDER,
parent=parent,
resizable=True,
)
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL)
self.add_buttons("_OK", Gtk.ResponseType.OK)
self.set_default_response(Gtk.ResponseType.OK)
def pick_folder(self) -> Path | None:
res = self.run()
if res in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
self.destroy()
return None
if res == Gtk.ResponseType.OK:
folder = self.get_current_folder()
if folder is None:
try:
uri = self.get_uri()
if uri is None:
return None
return Path.from_uri(uri)
except Exception:
# FIXME: edge cases
return Path("/")
self.destroy()
return Path(folder)
return None
+15 -136
View File
@@ -1,13 +1,11 @@
import os
import textwrap
from enum import Enum
from importlib import resources
from pathlib import Path
from typing import Any, Callable, Self, TYPE_CHECKING
from typing import Any, Callable, Self
from dzgui.api.probe import test_steam_api, test_bm_api
from dzgui.api.shortcuts import add_steam_shortcut
from dzgui.api.steam import get_steam_paths
from dzgui.const.constants import (
APP_NAME,
@@ -17,9 +15,6 @@ from dzgui.const.constants import (
)
from dzgui.const.boilerplate import config_boilerplate
from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP
from dzgui.const.enum import Preferences
from dzgui.config import freedesktop
from dzgui.config.query import lookup
from dzgui.init.migrate import migrate_legacy_conf
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import wizard
@@ -35,9 +30,6 @@ import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402
if TYPE_CHECKING:
from dzgui.config.xdg import Xdg
class PageNum(Enum):
INTRO = 1
@@ -46,8 +38,7 @@ class PageNum(Enum):
STEAM_API = 4
BM_API = 5
USER_PREFS = 6
SHORTCUTS = 7
FINAL = 8
FINAL = 7
class DescriptionArea(Gtk.Box):
@@ -67,7 +58,7 @@ class Progress(Gtk.ProgressBar):
class ScrolledWizardPage(Gtk.ScrolledWindow):
def __init__(self, enum: PageNum, heading: str, description: str):
super().__init__(overlay_scrolling=False)
super().__init__()
self.enum = enum
self.page_type: Gtk.AssistantPageType
@@ -150,10 +141,6 @@ class NotificationFrame(Gtk.Frame):
if error:
add_class(self, "error-frame")
def set_text(self, text: str) -> None:
wrapped = textwrap.fill(text, width=80)
self.label.set_markup(wrapped)
class APIValidationPage(ScrolledWizardPage):
def __init__(
@@ -352,7 +339,6 @@ class ConfigMigrationPage(ScrolledWizardPage):
def _on_import_clicked(self, button: Gtk.Button) -> None:
self.grid.set_sensitive(False)
try:
# TODO: this could be deferred to the final page (prevents accidental destruction of dialog via ESC)
migrate_legacy_conf(self.config)
self.migrated = True
self.success_box.set_visible(True)
@@ -437,28 +423,24 @@ class CompletionPage(ScrolledWizardPage):
class Assistant(Gtk.Assistant):
def __init__(self, is_deck: bool, XDG: "Xdg"):
def __init__(self, is_deck: bool, config: Path):
super().__init__()
if is_deck:
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
self.set_default_size(1085, 670)
self.fullscreen()
else:
self.set_default_size(1500, 900)
self.config_path = XDG.config
self.config_path = config
self.config_values: dict[str, Any] = config_boilerplate
self.setup_complete = False
self.page1 = IntroductionPage()
self.page2 = ConfigMigrationPage(XDG.config)
self.page2 = ConfigMigrationPage(config)
self.page3 = SteamPathPage()
self.page4 = SteamValidationPage()
self.page5 = BMValidationPage()
self.page6 = PreferencesPage()
self.page7 = ShortcutCreationPage(XDG.shortcut)
self.page8 = CompletionPage()
self.page7 = CompletionPage()
self.set_forward_page_func(self._advance_page)
@@ -476,16 +458,9 @@ class Assistant(Gtk.Assistant):
self.page5,
self.page6,
self.page7,
self.page8,
):
# NOTE: skip config migration page if no legacy config file
if (
isinstance(page, ConfigMigrationPage)
and self.has_legacy_config is False
):
continue
# NOTE: disabled for now on system-provided packages
if isinstance(page, ShortcutCreationPage) and os.getenv("PYAPP") is None:
if page == self.page2 and self.has_legacy_config is False:
continue
self._add_page(page, page.get_page_type())
@@ -500,15 +475,13 @@ class Assistant(Gtk.Assistant):
def _advance_page(self, index: int) -> int:
page = self.get_nth_page(index)
# TODO: use enums/isinstance
# TODO: use enums
match page:
case self.page1:
pass
case self.page2:
if self.page2.is_migrated():
steam_path = lookup(self.config_path, Preferences.DEFAULT)
self.page7.set_steam_path(steam_path)
return self.get_n_pages() - 2
return self.get_n_pages() - 1
case self.page3:
self.config_values["default_steam_path"] = page.get_path_from_radio()
case self.page4:
@@ -522,17 +495,10 @@ class Assistant(Gtk.Assistant):
self.config_values["use_miles"] = use_miles
self.config_values["client"] = client
self.write_config()
self.page7.set_steam_path(self.config_values["default_steam_path"])
case self.page7:
self.page7.create_shortcuts()
self.setup_complete = True
case _:
raise AttributeError("Trying to advance a non-canonical page")
return index + 1
def is_setup_complete(self) -> bool:
return self.setup_complete
def destroy_and_quit(self, widget: Self) -> None:
self.destroy()
Gtk.main_quit()
@@ -577,90 +543,6 @@ class Assistant(Gtk.Assistant):
EMITTER.emit("step_pending")
class CheckboxWithLabel(Gtk.Box):
def __init__(self, text: str, blurb_text: str) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5)
self.button = Gtk.CheckButton(label=text)
self.button.set_active(True)
label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=20)
wrapped = textwrap.fill(blurb_text, width=100)
label.set_markup(f"- {wrapped}")
for el in self.button, label:
self.add(el)
def get_checkbox(self) -> Gtk.CheckButton:
return self.button
def get_active(self) -> bool:
return self.button.get_active()
def set_active(self, state: bool) -> None:
self.button.set_active(state)
class ShortcutCreationPage(ScrolledWizardPage):
def __init__(self, shortcut: Path) -> None:
super().__init__(
enum=PageNum.SHORTCUTS,
heading=wizard.heading_shortcuts,
description=wizard.blurb_shortcuts,
)
self.steam_path: Path
self.shortcut_path = shortcut
self.checks_area = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=20
)
self.page_type = Gtk.AssistantPageType.INTRO
label, blurb = wizard.checkbox_steam_shortcut
self.steam_checkbox = CheckboxWithLabel(label, blurb)
label, blurb = wizard.checkbox_start_menu
self.start_menu_checkbox = CheckboxWithLabel(label, blurb)
cb = self.start_menu_checkbox.get_checkbox()
cb.connect("toggled", self._on_start_menu_toggled)
label, blurb = wizard.checkbox_desktop_shortcut
self.desktop_checkbox = CheckboxWithLabel(label, blurb)
for el in (
self.steam_checkbox,
self.start_menu_checkbox,
self.desktop_checkbox,
):
self.checks_area.add(el)
self.add_start(self.checks_area)
self.show_all()
self.connect("map", self._on_map)
def _on_start_menu_toggled(self, button: Gtk.CheckButton) -> None:
state = button.get_active()
if not state:
self.desktop_checkbox.set_active(state)
self.desktop_checkbox.set_sensitive(state)
def _on_map(self, page: "ScrolledWizardPage") -> None:
EMITTER.emit("step_complete")
def set_steam_path(self, path: Path) -> None:
self.steam_path = path
def create_shortcuts(self) -> None:
# NOTE: best-effort, permissive even on failure (page is already marked as complete)
if self.steam_checkbox.get_active():
add_steam_shortcut(self.steam_path, self.shortcut_path)
if self.start_menu_checkbox.get_active():
desktop_file = freedesktop.write_desktop_file(self.shortcut_path)
if self.desktop_checkbox.get_active():
freedesktop.write_desktop_shortcut(desktop_file)
class SteamPathPage(ScrolledWizardPage):
def __init__(self) -> None:
super().__init__(
@@ -717,20 +599,17 @@ class SteamPathPage(ScrolledWizardPage):
class SetupWizard(Gtk.Application):
def __init__(self, is_deck: bool, XDG: "Xdg") -> None:
def __init__(self, is_deck: bool, config: Path) -> None:
super().__init__()
GLib.set_prgname(APP_NAME)
self.win = Window(is_deck, XDG)
Window(is_deck, config)
Gtk.main()
def is_setup_complete(self) -> int:
return self.win.assistant.is_setup_complete()
class Window(Gtk.Window):
def __init__(self, is_deck: bool, XDG: "Xdg") -> None:
def __init__(self, is_deck: bool, config: Path) -> None:
super().__init__(title=APP_NAME, icon_name=APP_NAME)
self.assistant = Assistant(is_deck, XDG)
Assistant(is_deck, config)
class Emitter(GObject.GObject):
-2
View File
@@ -44,8 +44,6 @@ class ContextMixin(TreeView):
self.context_menu.connect("key-press-event", self._on_key)
for row in group.value:
if row is None:
return False
item = self._process_dynamic_row(row)
self.context_menu.append(item)
+5 -54
View File
@@ -1,5 +1,4 @@
import logging
import re
from typing import TYPE_CHECKING
from importlib import resources
@@ -33,63 +32,15 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
# TODO: should long text be wrapped?
self.controller = controller
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10)
formatted = format_pango(changelog)
self.changelog_label = Gtk.Label(valign=Gtk.Align.START, margin=15)
self.changelog_label.set_markup(formatted)
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.box.add(self.changelog_label)
self.add(self.box)
self.connect("key-press-event", self._on_keypress)
self.connect("key-press-event", self._on_esc_keypress)
changes = self._parse(changelog)
self._generate_nodes(changes)
self.show_all()
def grab_content_area(self) -> None:
self.grab_focus()
def _parse_version(self, version: str) -> tuple[str, str]:
pattern = r"(^##\s\[)(.+)(\] )(.+$)"
match = re.match(pattern, version)
if match:
version = match.group(2)
date = match.group(4)
return version, date
raise ValueError("Changelog parse error")
def _generate_nodes(self, releases: list[tuple[str, list[str]]]) -> None:
for header, changes in releases:
for ind in (0, -1):
if changes[ind] == "":
del changes[ind]
text = "\n".join(changes)
formatted = format_pango(text)
label = Gtk.Label(valign=Gtk.Align.START, margin=15, halign=Gtk.Align.START)
label.set_markup(formatted)
version, date = self._parse_version(header)
button_text = f"{version} ({date})"
button = Gtk.Button(label=button_text, margin_start=5)
expander = Gtk.Expander()
expander.set_label_widget(button)
expander.add(label)
self.box.add(expander)
def _parse(self, changelog: str) -> list[tuple[str, list[str]]]:
release = ""
releases: list[tuple[str, list[str]]] = []
release_notes: list[str] = []
for line in changelog.splitlines():
if line.startswith("# "):
continue
if line.startswith("## "):
if release != "":
releases.append((release, release_notes))
release_notes = []
release = ""
release = line
continue
release_notes.append(line.rstrip())
return releases
+8 -57
View File
@@ -1,10 +1,4 @@
from pathlib import Path
from typing import Self, TYPE_CHECKING
from dzgui.api.steam import find_user_id
from dzgui.const.enum import NotebookPage, Preferences
from dzgui.views.components.box import HBox
from dzgui.views.components.buttons import SteamWorkshopButton
from dzgui.views.components.scrollable import NoOverlayScrolledWindow
from dzgui.views.trees.tree_mods import ModTreeView
@@ -17,69 +11,26 @@ if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
class Mods(Gtk.Box):
class Mods(NoOverlayScrolledWindow):
def __init__(self, controller: "Controller") -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
super().__init__()
self.box = NoOverlayScrolledWindow()
self.tree = ModTreeView(controller)
self.tree.set_vexpand(True)
self.box.add(self.tree)
self.statusbar_cache = ""
self.controller = controller
self.controller.register_widget("modtreeview", self.tree)
self.emitter = controller.get_emitter()
self.tree = ModTreeView(controller)
# TODO: move
default_steam_path = self.controller.query_config(Preferences.DEFAULT)
steam_path = Path(default_steam_path)
uid = find_user_id(steam_path)
pretty_uid = "" if uid is None else uid
hbox = HBox(spacing=10)
workshop_button = SteamWorkshopButton()
workshop_button.connect(
"clicked", lambda _: self.controller.open_user_workshop(pretty_uid)
)
self.offline_button = Gtk.Button(
label="Play offline",
halign=Gtk.Align.START,
margin_top=10,
margin_bottom=10,
)
self.offline_button.connect("clicked", self._on_offline_clicked)
hbox.add(workshop_button)
hbox.add(self.offline_button)
self.add(hbox)
self.add(self.box)
self.add(self.tree)
self.controller.register_widget("modtreeview", self.tree)
self.connect("map", self._on_map)
self.connect("unmap", self._on_unmap)
def _on_offline_clicked(self, button: Gtk.Button) -> None:
self.statusbar_cache = self.controller.get_statusbar().get_cache()
self.controller.open_offline()
def _on_unmap(self, widget: Self) -> None:
self.emitter.emit("mod_page_toggled", False, 0)
self.emitter.emit("mod_page_toggled", False)
def _on_map(self, widget: Self) -> None:
model = self.tree.get_model()
if model is None:
mods = 0
else:
mods = len(model)
self.emitter.emit("mod_page_toggled", True, mods)
# TODO: delegation
# NOTE: handles going back from offline mods page
# more generic cache restoration method
if len(self.statusbar_cache) > 0:
self.controller.get_statusbar().set_by_context(
NotebookPage.MODS, self.statusbar_cache
)
self.emitter.emit("mod_page_toggled", True)
def grab_content_area(self) -> None:
self.tree.grab_focus()
-513
View File
@@ -1,513 +0,0 @@
from __future__ import annotations
from enum import Enum
from typing import Self, TYPE_CHECKING, Union
from dzgui.util import css
from dzgui.const.constants import (
APPID_DAYZ,
APPID_DAYZ_EXP,
APPNAME_DAYZ,
APPNAME_DAYZ_EXP_HUMAN,
EDIT_DELETE,
ERROR,
FOLDER,
WARNING,
)
from dzgui.const.enum import ContextMenuGroup, NotebookPage
from dzgui.managers.offline import OfflineManager
from dzgui.strings import generic, offline
from dzgui.views.components.box import HBox, VBox
from dzgui.views.components.buttons import Icon, IconTextButton
from dzgui.views.components.eventbox import InfoEventBox
from dzgui.views.components.frame import HeadingFrame
from dzgui.views.components.scrollable import NoOverlayScrolledWindow
from dzgui.views.trees.tree_mods import OfflineModTreeView
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GObject # noqa
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
from dzgui.model.model_factory import FastInsertListStore
class FolderError(Enum):
NO_VALID_MODS = 1
NO_VALID_MISSION = 2
FOLDER_CHANGED = 3
class PageHeading(Gtk.Label):
def __init__(self, label: str) -> None:
super().__init__(label=label, halign=Gtk.Align.CENTER)
css.add_class(self, "page-heading")
class ErrorPopover(Gtk.Popover):
def __init__(self) -> None:
super().__init__(position=Gtk.PositionType.RIGHT)
self.hbox = HBox()
self.label = Gtk.Label(label="", margin_start=10, margin_end=10)
error_icon = Icon(ERROR, margin_start=10)
self.hbox.extend([error_icon, self.label])
self.add(self.hbox)
self.show_all()
self.popdown()
def set_label(self, error: FolderError, msg: str) -> None:
match error:
case FolderError.NO_VALID_MODS:
prefix = offline.no_mods
case FolderError.NO_VALID_MISSION:
prefix = offline.no_mission
case FolderError.FOLDER_CHANGED:
prefix = offline.folder_changed
self.label.set_label(prefix)
return
self.label.set_label(f"{prefix}: '{msg}'")
class FolderHBox(HBox):
def __init__(self, controller: "Controller", btn_label: str, eb_text: str) -> None:
super().__init__(spacing=10)
self.set_margin_start(5)
self.set_margin_end(10)
self.set_margin_bottom(5)
self.folder = ""
self.controller = controller
self.emitter = controller.get_emitter()
self.eb = InfoEventBox(eb_text, controller)
self.button = IconTextButton(FOLDER, btn_label, Gtk.PositionType.LEFT)
self.button.set_halign(Gtk.Align.START)
self.button.set_image_position(Gtk.PositionType.LEFT)
self.set_can_focus(True)
self.scrolled_label = Gtk.ScrolledWindow(
propagate_natural_width=True, halign=Gtk.Align.START
)
self.label = Gtk.Label()
self.scrolled_label.add(self.label)
self.unset_button = IconTextButton(EDIT_DELETE, offline.unset_button)
self.unset_button.connect("clicked", self._on_unset_clicked)
self.spinner = Gtk.Spinner()
self.extend(
[self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button]
)
self.pop = ErrorPopover()
self.pop.set_relative_to(self.button)
self.pop.connect("unmap", lambda _: self.grab_focus())
self.sidepop = ErrorPopover()
self.sidepop.set_position(Gtk.PositionType.BOTTOM)
self.sidepop.set_relative_to(self.scrolled_label)
self.sidepop.connect("unmap", lambda _: self.grab_focus())
self.connect("map", self._on_map)
self.connect("unmap", self._on_unmap)
def start_spinner(self) -> None:
self.spinner.show()
self.spinner.start()
def stop_spinner(self) -> None:
self.spinner.hide()
self.spinner.stop()
def _on_unmap(self, widget: Self) -> None:
self.unset_button.hide()
self.label.set_label("")
self.scrolled_label.hide()
def _on_map(self, widget: Self) -> None:
self.spinner.hide()
self.scrolled_label.show()
self.unset_button.hide()
def _on_unset_clicked(self, button: Gtk.Button) -> None:
self.folder = ""
self.label.set_label("")
self.unset_button.hide()
# FIXME: rename signal
self.emitter.emit("custom_mods_unloaded", self)
def get_button(self) -> Gtk.Button:
return self.button
def hide_label(self) -> None:
self.label.hide()
def get_folder(self) -> str:
return self.folder
def set_folder(self, folder: str) -> None:
prefix = offline.folder_prefix
self.folder = folder
self.label.set_markup(prefix + folder)
self.label.show()
self.unset_button.show()
def present_error(self, error: FolderError, msg: str) -> None:
if error == FolderError.FOLDER_CHANGED:
self.sidepop.set_label(error, msg)
self.sidepop.popup()
return
self.folder = ""
self.label.set_text("")
self.unset_button.hide()
self.pop.set_label(error, msg)
self.pop.popup()
class ModFrame(HeadingFrame):
def __init__(
self, parent: OfflineLoader, controller: "Controller", label: str
) -> None:
super().__init__(heading=label)
self.parent = parent
self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.tree = OfflineModTreeView(controller)
self.tree.set_model(None)
self.tree.set_valign(Gtk.Align.FILL)
self.scrolled = NoOverlayScrolledWindow()
self.scrolled.set_margin_end(5)
self.scrolled.set_vexpand(True)
self.scrolled.add(self.tree)
self.status = Gtk.Label(
halign=Gtk.Align.START, margin_start=5, margin_top=3, margin_bottom=3
)
self.tree_vbox = VBox()
self.tree_vbox.extend([self.scrolled, self.status])
self.no_mods = HBox(spacing=10)
no_mods_icon = Icon(WARNING, margin_start=10)
no_mods_label = Gtk.Label(label=offline.no_local_mods, halign=Gtk.Align.START)
self.no_mods.extend([no_mods_icon, no_mods_label])
self.vbox.pack_end(self.no_mods, expand=True, fill=True, padding=3)
self.vbox.pack_end(self.tree_vbox, expand=True, fill=True, padding=3)
self.frame.add(self.vbox)
sel = self.tree.get_selection()
sel.connect("changed", self._on_selection_changed)
self.connect("map", self._on_map)
self.connect("unmap", self._on_unmap)
def start_empty(self) -> None:
self.tree_vbox.hide()
self.no_mods.show()
def _on_map(self, widget: Self) -> None:
self.no_mods.hide()
self.tree.set_model(None)
self.tree_vbox.hide()
def _on_unmap(self, widget: Self) -> None:
self.tree.set_model(None)
self.tree_vbox.hide()
self.no_mods.hide()
def start(self, store: "FastInsertListStore") -> None:
# TODO: this manipulates mod man store out of band
self.tree.set_model(store)
self.tree.mod_man.set_store(store)
self.show_tree()
def get_mods(self) -> list[str]:
model, treeiters = self.tree.get_selection().get_selected_rows()
if model is None:
return []
if type(self) is CustomModFrame:
# NOTE: custom mod dirs to be hashed
return [model[_iter][2] for _iter in treeiters]
else:
# NOTE: pre-existing, canonical symlinks to published mods
return [model[_iter][1] for _iter in treeiters]
def show_tree(self) -> None:
self.tree_vbox.show()
def collapse_tree(self) -> None:
self.tree_vbox.hide()
def get_tree(self) -> OfflineModTreeView:
return self.tree
def pack(self, widget: Gtk.Widget) -> None:
self.vbox.pack_start(widget, expand=False, fill=False, padding=5)
def set_model(self, model: Union["FastInsertListStore", None]) -> None:
self.tree.set_model(model)
self.tree.mod_man.store = model
self.set_cursor()
def _on_selection_changed(self, sel: Gtk.TreeSelection) -> None:
model, rows = sel.get_selected_rows()
if len(rows) == 0:
status = generic.selectable_tree
else:
status = f"Mods selected: {len(rows)}"
self.status.set_label(status)
# TODO: cleaner delegation
# simply send int value or use emitter
self.parent.check_button()
def set_cursor(self) -> None:
path = Gtk.TreePath.new_from_indices([0])
self.tree.set_cursor(path)
self.tree.get_selection().unselect_all()
class CustomModFrame(ModFrame):
def __init__(
self, parent: OfflineLoader, controller: "Controller", heading: str
) -> None:
super().__init__(parent, controller, heading)
self.tree.set_menu(ContextMenuGroup.MOD_OFFLINE)
self.parent = parent
self.controller = controller
self.emitter = controller.get_emitter()
self.custom_hbox = FolderHBox(
controller, offline.custom_button, offline.custom_eventbox
)
self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked)
self.pack(self.custom_hbox)
self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded)
self.emitter.connect("custom_mods_unloaded", self._on_custom_mods_unloaded)
self.connect("map", self._on_map)
def _on_map(self, widget: Self) -> None:
self.hide_tree()
def _on_custom_mods_unloaded(self, emitter: "Emitter", widget: FolderHBox) -> None:
# TODO: kludgy workaround for generic button emitting global signal
if widget == self.custom_hbox:
self.hide_tree()
def hide_tree(self) -> None:
self.custom_hbox.hide_label()
self.tree.set_model(None)
self.tree_vbox.hide()
self.no_mods.hide()
def present_folder_changed(self, folder: str) -> None:
self.custom_hbox.present_error(FolderError.FOLDER_CHANGED, folder)
def present_error(self, folder: str) -> None:
self.hide_tree()
self.custom_hbox.present_error(FolderError.NO_VALID_MODS, folder)
def present_tree(self, store: "FastInsertListStore", folder: str) -> None:
self.custom_hbox.set_folder(folder)
self.tree.set_model(store)
self.tree_vbox.show()
def _on_custom_mods_loaded(
self,
emitter: "Emitter",
store: "FastInsertListStore",
folder: str,
) -> None:
self.custom_hbox.stop_spinner()
if len(store) == 0:
self.present_error(folder)
else:
self.present_tree(store, folder)
def _on_custom_button_clicked(self, button: Gtk.Button) -> None:
callback = self.custom_hbox.start_spinner
# TODO: cleaner delegation
self.parent.offline_man.find_custom_mods(callback)
def get_folder(self) -> str:
return self.custom_hbox.get_folder()
class MissionFrame(HeadingFrame):
def __init__(self, parent: OfflineLoader, controller: "Controller") -> None:
super().__init__(heading=offline.mission_frame)
self.parent = parent
self.controller = controller
self.emitter = controller.get_emitter()
self.mission_hbox = FolderHBox(
controller, offline.mission_button, offline.mission_eventbox
)
self.mission_button = self.mission_hbox.get_button()
self.mission_button.connect("clicked", self._on_mission_button_clicked)
self.frame.add(self.mission_hbox)
self.emitter.connect("custom_mission_loaded", self._on_mission_loaded)
def _on_mission_loaded(
self, emitter: "Emitter", folder: str, is_valid: bool
) -> None:
if is_valid:
self.mission_hbox.set_folder(folder)
else:
self.mission_hbox.present_error(FolderError.NO_VALID_MISSION, folder)
self.parent.check_button()
def _on_mission_button_clicked(self, button: Gtk.Button) -> None:
self.parent.offline_man.get_mission()
def get_mission(self) -> str:
return self.mission_hbox.get_folder()
def present_folder_changed(self, folder: str) -> None:
self.mission_hbox.present_error(FolderError.FOLDER_CHANGED, folder)
class RadioFrame(HeadingFrame):
def __init__(self, parent: OfflineLoader, controller: "Controller") -> None:
super().__init__(heading=offline.version)
self.controller = controller
self.id_map = {APPNAME_DAYZ: APPID_DAYZ, APPNAME_DAYZ_EXP_HUMAN: APPID_DAYZ_EXP}
self.appid = APPID_DAYZ
self.dayz = Gtk.RadioButton.new_with_label(None, APPNAME_DAYZ)
self.dayz_exp = Gtk.RadioButton.new_with_label_from_widget(
self.dayz, APPNAME_DAYZ_EXP_HUMAN
)
self.radio_box = HBox()
self.radio_box.extend([self.dayz, self.dayz_exp])
self.radio_box.set_margin_start(10)
self.radio_box.set_margin_bottom(5)
self.frame.add(self.radio_box)
# TODO: cleaner delegation
has_dayz_exp = parent.offline_man.has_dayz_exp()
self.dayz.connect("toggled", self._on_radio_toggled)
if has_dayz_exp is False:
self.dayz_exp.set_sensitive(False)
def _on_radio_toggled(self, radio: Gtk.RadioButton) -> None:
for el in self.dayz, self.dayz_exp:
if el.get_active():
label = el.get_label()
self.appid = self.id_map[label]
def get_appid(self) -> int:
return self.appid
class OfflineLoader(Gtk.Box):
def __init__(self, controller: "Controller"):
super().__init__(
orientation=Gtk.Orientation.VERTICAL,
margin_start=10,
margin_end=10,
)
self.controller = controller
self.controller.register_widget("offline_loader", self)
self.emitter = controller.get_emitter()
self.offline_man = OfflineManager(controller)
self.add(PageHeading(offline.heading))
self.local_frame = ModFrame(self, controller, offline.local_frame)
self.custom_frame = CustomModFrame(self, controller, offline.custom_frame)
self.custom_tree = OfflineModTreeView(controller)
self.mission_frame = MissionFrame(self, controller)
self.radio_frame = RadioFrame(self, controller)
self.scrollable = Gtk.ScrolledWindow(
vexpand=True, propagate_natural_height=True
)
self.content_box = VBox(spacing=5)
self.content_box.extend(
[
self.local_frame,
self.custom_frame,
self.mission_frame,
self.radio_frame,
]
)
# TODO: share ConnectBox class with preconnect dialog?
self.button_box = HBox(spacing=5)
self.button_box.set_halign(Gtk.Align.END)
self.button_box.set_margin_top(15)
self.back = Gtk.Button(label="Back")
self.ok = Gtk.Button(label="Launch", sensitive=False)
self.back.connect("clicked", self._on_back_clicked)
self.ok.connect("clicked", self._on_ok_clicked)
self.connect("key-press-event", self._on_keypress)
self.emitter.connect("custom_mods_unloaded", lambda _, __: self.check_button())
self.button_box.extend([self.back, self.ok])
self.scrollable.add(self.content_box)
self.add(self.scrollable)
self.add(self.button_box)
def check_button(self) -> None:
local_mods = self.local_frame.get_mods()
custom_mods = self.custom_frame.get_mods()
mission = self.mission_frame.get_mission()
if len(local_mods) == 0 and len(custom_mods) == 0 and len(mission) == 0:
self.ok.set_sensitive(False)
else:
self.ok.set_sensitive(True)
def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None:
if event.keyval == Gdk.KEY_Escape:
self.back.emit("clicked")
def populate(self, store: Union["FastInsertListStore", None]) -> None:
if store is None:
self.local_frame.start_empty()
return
self.local_frame.start(store)
def _on_back_clicked(self, button: Gtk.Button) -> None:
self.controller.open_page(NotebookPage.MODS)
def _on_ok_clicked(self, button: Gtk.Button) -> None:
appid = self.radio_frame.get_appid()
mission = self.mission_frame.get_mission()
local_mods = self.local_frame.get_mods()
custom_folder = self.custom_frame.get_folder()
custom_mods = self.custom_frame.get_mods()
if custom_folder and not self.offline_man.is_custom_folder_valid(custom_folder):
self.custom_frame.present_folder_changed(mission)
return
if mission and not self.offline_man.is_mission_valid(mission):
self.mission_frame.present_folder_changed(mission)
return
self.offline_man.launch(appid, mission, local_mods, custom_folder, custom_mods)
+23 -10
View File
@@ -2,7 +2,7 @@ from pathlib import Path
from typing import TYPE_CHECKING
from dzgui.api import pefile as PeFile
from dzgui.api.steam import find_user_id
from dzgui.config import query
from dzgui.const.constants import (
APPID_DAYZ,
@@ -19,8 +19,9 @@ from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP
from dzgui.const.enum import Preferences, ServerTab
from dzgui.strings import errors, options
from dzgui.util import strings, css, open_links
from dzgui.views.components.buttons import SteamWorkshopButton
from dzgui.views.components.labels import LeftLabel
from dzgui.views.components.eventbox import InfoEventBox
from dzgui.views.components.buttons import WebButton
from dzgui.views.components.frame import HeadingFrame
from dzgui.views.components.misc import ClientCombo
@@ -133,6 +134,16 @@ class Options(Gtk.Box):
[LeftLabel(strings.options.name), self.player_box],
]
eb = InfoEventBox(options.workshop_eventbox, controller)
workshop_button = SteamWorkshopButton()
workshop_button.connect(
"clicked", lambda _: self.controller.open_user_workshop(self.uid)
)
mod_rows = [
[LeftLabel(options.workshop_label), workshop_button, eb],
]
self.dayz_version_label = Gtk.Label(label=strings.null)
self.dayz_exp_version_label = Gtk.Label(label=strings.null)
@@ -158,6 +169,7 @@ class Options(Gtk.Box):
api_box.add(api_links_box)
prefs_grid = self._make_grid(pref_rows)
mods_grid = self._make_grid(mod_rows)
version_grid = self._make_grid(version_rows)
col = 1
@@ -175,13 +187,12 @@ class Options(Gtk.Box):
if prefs.is_debug:
grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT)
for pair in [
(api_box, strings.options.api_keys),
(prefs_grid, strings.options.prefs),
(version_grid, strings.options.version),
for frame in [
HeadingFrame(api_box, strings.options.api_keys),
HeadingFrame(prefs_grid, strings.options.prefs),
HeadingFrame(mods_grid, strings.options.mods),
HeadingFrame(version_grid, strings.options.version),
]:
frame = HeadingFrame.new_with_widget_and_label(*pair)
grid.attach(frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT)
row += 1
@@ -288,7 +299,6 @@ class Options(Gtk.Box):
def _on_api_change_failed(self, emitter: "Emitter") -> None:
self.old_entry.set_text(self.old_text)
# TODO: use popover
dialog = ExceptionDialog(self.controller, errors.api_validation_error)
dialog.run()
@@ -393,7 +403,7 @@ class Options(Gtk.Box):
if prefs.paths.config.is_file() is False:
dialog = ExceptionDialog(self.controller, strings.config_not_found)
dialog.run()
raise OSError(f"Config file '{prefs.paths.config}' not found")
raise Exception
config = query.get_config(prefs.paths.config)
@@ -404,6 +414,9 @@ class Options(Gtk.Box):
bm = self.controller.query_config(Preferences.BM)
steam_path = Path(default_steam_path)
# NOTE: this is a best effort guess at the most recent user
uid = find_user_id(steam_path)
self.uid = "" if uid is None else uid
self.old_steam = steam
self.old_bm = bm
+41 -46
View File
@@ -23,7 +23,6 @@ from gi.repository import Gdk, Gtk # noqa E402
if TYPE_CHECKING:
from dzgui.managers.connection import Prerequisites
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
@dataclass
@@ -86,9 +85,9 @@ class MaskedTree(Gtk.TreeView):
self.store.append([self.icon, item])
class PreConnectionAssistant(Gtk.Box):
class PreConnectionAssistant(Gtk.ScrolledWindow):
def __init__(self, controller: "Controller") -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL)
super().__init__()
self.controller = controller
@@ -108,11 +107,21 @@ class PreConnectionAssistant(Gtk.Box):
)
self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END)
self.connect_last = Gtk.Button(
label=preconnect.connect_last,
halign=Gtk.Align.END,
tooltip_text=preconnect.connect_last_tooltip,
label=preconnect.connect_last, halign=Gtk.Align.END, tooltip_text=preconnect.connect_last_tooltip
)
# TODO: abstract
self.raise_window = Gtk.CheckButton(
label="Foreground DZGUI while downloading",
halign=Gtk.Align.END,
hexpand=True,
valign=Gtk.Align.END,
visible=False,
has_tooltip=True,
sensitive=False,
tooltip_text="Foreground the DZGUI window after mod downloads are queued",
active=True,
)
self.button_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
valign=Gtk.Align.END,
@@ -122,7 +131,8 @@ class PreConnectionAssistant(Gtk.Box):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
for button in self.back, self.ok, self.connect_last:
box.add(button)
self.button_box.add(box)
for el in self.raise_window, box:
self.button_box.add(el)
self.back.connect("clicked", self._on_back_clicked)
self.ok.connect("clicked", self._on_ok_clicked)
@@ -142,7 +152,7 @@ class PreConnectionAssistant(Gtk.Box):
self.progress_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15)
self.progress_box.add(self.mod_count)
self.scrolled = Gtk.ScrolledWindow(margin_end=10)
self.scrolled = Gtk.ScrolledWindow()
self.scrolled.add(self.tree)
self.scrolled.set_size_request(600, 400)
@@ -154,52 +164,37 @@ class PreConnectionAssistant(Gtk.Box):
self.mods_placeholder = Placeholder("This server has no mods.")
self.tree_box.add(self.mods_placeholder)
self.tree_frame = HeadingFrame.new_with_widget_and_label(
self.tree_box, preconnect.mods
)
self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods)
# TODO: abstract into components
# TODO: strings
self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.warning_tree = MaskedTree(WARNING)
# TODO: strings
self.warning_placeholder = Placeholder("No warnings.")
self.warning_box.add(self.warning_tree)
self.warning_box.add(self.warning_placeholder)
self.warning_frame = HeadingFrame.new_with_widget_and_label(
self.warning_box, preconnect.warnings
)
self.warning_frame = HeadingFrame(self.warning_box, preconnect.warnings)
# TODO: strings
self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.error_tree = MaskedTree(ERROR)
# TODO: strings
self.error_placeholder = Placeholder("No errors.")
self.error_box.add(self.error_tree)
self.error_box.add(self.error_placeholder)
self.error_frame = HeadingFrame.new_with_widget_and_label(
self.error_box, preconnect.errors
)
self.error_frame = HeadingFrame(self.error_box, preconnect.errors)
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
self.box.add(self.title)
self.box.add(self.tree_frame)
self.box.add(self.warning_frame)
self.box.add(self.error_frame)
self.box.add(self.button_box)
self.scrolled_box = Gtk.ScrolledWindow(
vexpand=True, propagate_natural_height=True
)
self.scrolled_box.add(self.box)
self.add(self.scrolled_box)
self.add(self.button_box)
self.add(self.box)
emitter = self.controller.get_emitter()
emitter.connect("all_mods_synched", self._on_mods_synched)
self.connect("key-press-event", self._on_keypress)
self.connect("map", self._on_map)
def _on_mods_synched(self, emitter: "Emitter") -> None:
self.tree.mark_mods_synched()
def _on_map(self, widget: Self) -> None:
widgets = (
self.tree_frame,
@@ -210,6 +205,8 @@ class PreConnectionAssistant(Gtk.Box):
for child in widgets:
child.set_visible(True)
self.raise_window.set_visible(False)
self.raise_window.set_sensitive(False)
self.ok.set_sensitive(True)
self.ok.set_label(preconnect.update_mods)
@@ -221,10 +218,10 @@ class PreConnectionAssistant(Gtk.Box):
self.ok.emit("clicked")
def _on_connect_last_clicked(self, button: Gtk.Button) -> None:
self.controller.update_and_load_to_menu()
self.controller.update_and_load_to_menu(self.raise_window.get_active())
def _on_ok_clicked(self, button: Gtk.Button) -> None:
self.controller.update_and_connect()
self.controller.update_and_connect(self.raise_window.get_active())
def _on_back_clicked(self, button: Gtk.Button) -> None:
self.controller.open_page(NotebookPage.SERVERS)
@@ -272,15 +269,6 @@ class PreConnectionAssistant(Gtk.Box):
"It looks like DayZ is already running in the background. Exit DayZ before connecting."
)
allows_dl, running_app = prereqs.allows_downloads
if len(prereqs.mods) > 0 and allows_dl is False:
msg = (
f"The game '{running_app}' is currently running in Steam, but background downloads are not enabled.\n"
"Either stop the game first, or update your global Steam settings or the game's local settings.\n"
"Otherwise, mods may be queued for download but never update."
)
warnings.append(msg)
self.add_warnings(warnings)
self.add_errors(errors)
@@ -312,16 +300,20 @@ class PreConnectionAssistant(Gtk.Box):
self._hide_mod_area()
else:
self._show_mod_area()
msg = preconnect.up_to_date
msg = "All mods are up to date."
self.mod_count.set_text(msg)
if prereqs.required_space != 0:
if prereqs.required_space == 0:
self.ok.set_label(preconnect.connect)
self.raise_window.set_visible(False)
else:
self.raise_window.set_visible(True)
self.raise_window.set_sensitive(True)
pretty = number(prereqs.required_space)
suffix = f" Need to download {pretty} MiB of mod updates."
prefix = preconnect.total_mods
self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}")
else:
self.ok.set_label(preconnect.connect)
if prereqs.is_last_server:
self.connect_last.show()
@@ -329,7 +321,10 @@ class PreConnectionAssistant(Gtk.Box):
self.connect_last.hide()
self._process_warnings(prereqs)
self.tree.grab_focus()
if self.tree.is_visible():
self.tree.grab_focus()
else:
self.grab_focus()
def mark_finished(self) -> None:
self.mod_count.set_label(preconnect.all_updated)
+1 -1
View File
@@ -16,7 +16,7 @@ logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
from dzgui.controllers.mc import Emitter
class ScrollableTree(Gtk.ScrolledWindow):
+4 -11
View File
@@ -4,7 +4,7 @@ from typing import Any, Optional, TYPE_CHECKING
from warnings import deprecated
from dzgui.const.constants import APP_NAME, SEPARATOR
from dzgui.util.keys import is_ctrl_mask, is_navkey
from dzgui.util.keys import is_navkey
from dzgui.views.mixins.cursor_mixin import CursorMixin
import gi
@@ -85,17 +85,10 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
model, rows = sel.get_selected_rows()
return [model[row] for row in rows]
def _on_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> bool:
from dzgui.views.trees.tree_servers import ServerTreeView
# NOTE: suppress popup search and delegate binding to server view (cf. request_keyword_focus)
if not isinstance(treeview, ServerTreeView):
if is_ctrl_mask(event) and event.keyval == Gdk.KEY_f:
return True
def _on_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None:
if is_navkey(event.keyval):
if self.get_model() is None:
return True
return
if self.sel_blocked is False:
self.sel_blocked = True
self.controller.suppress_signal(
@@ -105,7 +98,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
True,
)
self._vim_nav(event)
return False
return
def _on_key_release(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None:
"""
+4 -46
View File
@@ -3,7 +3,6 @@ from typing import Any, TYPE_CHECKING
from dzgui.const.constants import APP_NAME, HEX_RED
from dzgui.const.enum import ContextMenuGroup
from dzgui.managers.mods import ModManager
from dzgui.util import strings, localize
from dzgui.views.mixins.context_mixin import ContextMixin
from dzgui.views.mixins.mods_mixin import ModsMixin
@@ -23,19 +22,16 @@ logger = logging.getLogger(APP_NAME)
class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore
def __init__(
self, controller: "Controller", menu: ContextMenuGroup = ContextMenuGroup.MOD
) -> None:
super().__init__(controller, menu=menu)
def __init__(self, controller: "Controller") -> None:
super().__init__(controller, menu=ContextMenuGroup.MOD)
self.controller = controller
self.mod_man = ModManager(self, controller)
emitter = self.controller.get_emitter()
emitter.connect("mods_updated", self._on_mods_updated)
emitter.connect("mods_highlighted", self._on_mods_highlighted)
self.set_fixed_height_mode(True)
self.set_headers_visible(True)
self.set_model(None)
for i, column_title in enumerate(strings.mod_cols):
@@ -62,40 +58,12 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore
self.connect("button-press-event", self.present_menu)
self.connect("key-press-event", self.present_menu)
self.connect("button-press-event", self.on_row_click)
def on_row_click(self, widget: Gtk.TreeView, event: Gdk.EventButton) -> bool:
if event.state is Gdk.ModifierType.CONTROL_MASK and event.button == 1:
path_info = widget.get_path_at_pos(int(event.x), int(event.y))
if path_info is None:
return False
path, column, cellx, celly = path_info
model = self.get_model()
if model is None:
return False
if path is None:
return False
_iter = model.get_iter(path)
if self.get_selection().iter_is_selected(_iter):
self.get_selection().unselect_iter(_iter)
else:
self.get_selection().select_iter(_iter)
return True
return False
def get_mod_man(self) -> ModManager:
return self.mod_man
def load_mods(self) -> None:
self.mod_man.load_mods()
def _on_mods_highlighted(self, emitter: "Emitter") -> None:
self.get_selection().unselect_all()
def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None:
if mods < 1:
return
self.set_headers_visible(True)
path = Gtk.TreePath.new_from_indices([0])
self.set_cursor(path)
@@ -138,13 +106,3 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore
val = model[it][3]
formatted = localize.number(val)
cell.set_property("text", formatted)
# NOTE: reapply color property after float conversion
self._format_color(column, cell, model, it, data)
def set_menu(self, context: ContextMenuGroup) -> None:
self.menu = context
class OfflineModTreeView(ModTreeView):
def __init__(self, controller: "Controller") -> None:
super().__init__(controller)
-4
View File
@@ -48,10 +48,6 @@ class ServerModTreeView(ContextMixin, TreeView): # type: ignore
case _:
pass
def mark_mods_synched(self) -> None:
for row in self.mod_store:
row[2] = server_mods.checkmark
# TODO: could be problematic if user downloads mods out of band
def _on_row_activated(
self,
+2 -5
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [
{name = "aclist"}
]
version = "7.0.0b19"
version = "7.0.0b8"
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
readme = "README.md"
@@ -27,7 +27,6 @@ dependencies = [
"psutil==7.1.3",
"python-a2s",
"requests==2.32.5",
"vdf==3.4"
]
[project.scripts]
@@ -76,7 +75,7 @@ build-backend = "setuptools.build_meta"
line-length = 88
indent-width = 4
extend-exclude = ["*lib/a2s"]
lint.unfixable = ["F401", "I001"]
lint.unfixable = ["F401"]
[tool.ruff.format]
quote-style = "double"
@@ -108,10 +107,8 @@ config-settings-package = { pygobject-stubs = { config = "Gtk3,Gdk3,Soup2" } }
[tool.pytest.ini_options]
markers = [
"config: config file keys/values",
"mods: tests mod metadata/link creation",
"pefile: validate PE files",
"post_install: requires a completed installation",
"redact: log redaction mechanisms",
"slow: long-running tests",
"webtest: checks remote endpoints"
]
-1
View File
@@ -12,7 +12,6 @@ This file includes the combined licenses to the following components:
- psutil
- PyGObject
- python-a2s
- vdf
When shipped as a binary installer, these components are collectively bundled
as a turnkey application and used to run the DZGUI utility.\n\n
+3 -1
View File
@@ -62,6 +62,8 @@ build_params = [
"cargo",
"build",
"--release",
"--target-dir",
str(dist_dir),
"--target",
platform,
]
@@ -78,7 +80,7 @@ if proc.returncode != 0:
subfolder = dist_dir.joinpath(build_name)
subfolder.mkdir(parents=True)
output_exe = pyapp_dir.joinpath(f"target/{platform}/release/pyapp")
output_exe = dist_dir.joinpath("pyapp")
release_exe = subfolder.joinpath(appname)
output_exe.rename(release_exe)
-13
View File
@@ -1,13 +0,0 @@
"InstallConfigStore"
{
"Software"
{
"Valve"
{
"Steam"
{
"AllowDownloadsDuringGameplay" "1"
}
}
}
}
-13
View File
@@ -1,13 +0,0 @@
"InstallConfigStore"
{
"Software"
{
"Valve"
{
"Steam"
{
"AllowDownloadsDuringGameplay" "0"
}
}
}
}
-13
View File
@@ -1,13 +0,0 @@
"users"
{
"0"
{
"AccountName" "STEAMUSER"
"PersonaName" "STEAMUSER"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AutoLogin" "1"
"Timestamp" "0"
}
}
-14
View File
@@ -1,14 +0,0 @@
"users"
{
"0"
{
"AccountName" "STEAMUSER"
"PersonaName" "STEAMUSER"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "1"
"Timestamp" "0"
}
}
@@ -1,25 +0,0 @@
"users"
{
"0"
{
"AccountName" "STEAMUSER"
"PersonaName" "STEAMUSER"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "0"
"Timestamp" "0"
}
"1"
{
"AccountName" "STEAMUSER2"
"PersonaName" "STEAMUSER2"
"RememberPassword" "0"
"WantsOfflineMode" "0"
"SkipOfflineModeWarning" "0"
"AllowAutoLogin" "1"
"MostRecent" "1"
"Timestamp" "0"
}
}
Binary file not shown.
-53
View File
@@ -1,53 +0,0 @@
"AppState"
{
"appid" "111"
"Universe" "1"
"name" "TEST APP"
"StateFlags" "4"
"installdir" "TESTAPP"
"LastUpdated" "0"
"LastPlayed" "0"
"SizeOnDisk" "0"
"StagingSize" "0"
"buildid" "0"
"LastOwner" "0"
"DownloadType" "2"
"UpdateResult" "0"
"BytesToDownload" "0"
"BytesDownloaded" "0"
"BytesToStage" "0"
"BytesStaged" "0"
"TargetBuildID" "0"
"AutoUpdateBehavior" "0"
"AllowOtherDownloadsWhileRunning" "1"
"ScheduledAutoUpdate" "0"
"FullValidateAfterNextUpdate" "1"
"InstalledDepots"
{
"1110"
{
"manifest" "0"
"size" "0"
}
}
"SharedDepots"
{
"228983" "228980"
"228985" "228980"
"228988" "228980"
"228990" "228980"
"229004" "228980"
}
"UserConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
"MountedConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
}
-53
View File
@@ -1,53 +0,0 @@
"AppState"
{
"appid" "222"
"Universe" "1"
"name" "TEST APP"
"StateFlags" "4"
"installdir" "TESTAPP"
"LastUpdated" "0"
"LastPlayed" "0"
"SizeOnDisk" "0"
"StagingSize" "0"
"buildid" "0"
"LastOwner" "0"
"DownloadType" "2"
"UpdateResult" "0"
"BytesToDownload" "0"
"BytesDownloaded" "0"
"BytesToStage" "0"
"BytesStaged" "0"
"TargetBuildID" "0"
"AutoUpdateBehavior" "0"
"AllowOtherDownloadsWhileRunning" "2"
"ScheduledAutoUpdate" "0"
"FullValidateAfterNextUpdate" "1"
"InstalledDepots"
{
"2220"
{
"manifest" "0"
"size" "0"
}
}
"SharedDepots"
{
"228983" "228980"
"228985" "228980"
"228988" "228980"
"228990" "228980"
"229004" "228980"
}
"UserConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
"MountedConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
}
-53
View File
@@ -1,53 +0,0 @@
"AppState"
{
"appid" "333"
"Universe" "1"
"name" "TEST APP"
"StateFlags" "4"
"installdir" "TESTAPP"
"LastUpdated" "0"
"LastPlayed" "0"
"SizeOnDisk" "0"
"StagingSize" "0"
"buildid" "0"
"LastOwner" "0"
"DownloadType" "2"
"UpdateResult" "0"
"BytesToDownload" "0"
"BytesDownloaded" "0"
"BytesToStage" "0"
"BytesStaged" "0"
"TargetBuildID" "0"
"AutoUpdateBehavior" "0"
"AllowOtherDownloadsWhileRunning" "0"
"ScheduledAutoUpdate" "0"
"FullValidateAfterNextUpdate" "1"
"InstalledDepots"
{
"3330"
{
"manifest" "0"
"size" "0"
}
}
"SharedDepots"
{
"228983" "228980"
"228985" "228980"
"228988" "228980"
"228990" "228980"
"229004" "228980"
}
"UserConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
"MountedConfig"
{
"language" "english"
"platform_override_dest" "linux"
"platform_override_source" "windows"
}
}
-5
View File
@@ -1,5 +0,0 @@
protocol = 1;
publishedid = 1234567890;
name = "ModName";
timestamp = 638278452000000000;
hash = "a1b2c3d4e5f6g7h8i9j0";
-5
View File
@@ -1,5 +0,0 @@
protocol = 1
publishedid = 1234567890
name = "ModName"
timestamp = 638278452000000000
hash = "a1b2c3d4e5f6g7h8i9j0"
-5
View File
@@ -1,5 +0,0 @@
protocol=1
publishedid=1234567890
name="ModName"
timestamp=638278452000000000
hash="a1b2c3d4e5f6g7h8i9j0"
-5
View File
@@ -1,5 +0,0 @@
protocol=1
publishedid=1234567890
name=ModName
timestamp=638278452000000000
hash=a1b2c3d4e5f6g7h8i9j0
-5
View File
@@ -1,5 +0,0 @@
protocol=1;
publishedid=1234567890;
name=ModName;
timestamp=638278452000000000;
hash=a1b2c3d4e5f6g7h8i9j0;
-5
View File
@@ -1,5 +0,0 @@
protocol=1
publishedid=1234567890;
name="ModName";
timestamp=638278452000000000;
hash= a1b2c3d4e5f6g7h8i9j0;
-8
View File
@@ -1,8 +0,0 @@
1559212036,1771519119
1564026768,1770917948
2545327648,1780174455
2276010135,1756744083
1654462998,1780501511
3410710885,1752864732
2918418331,1780413260
3739934289,1780855136
-42
View File
@@ -1,42 +0,0 @@
#Path to DayZ installation
steam_path="/tmp"
#Battlemetrics API key
api_key=""
#Favorited server IP:PORT array
ip_list=(
)
#Favorite server to fast-connect to (limit one)
fav_server=""
#Favorite server label (human readable)
fav_label=""
#Custom player name (optional, required by some servers)
name=""
#Set to 1 to perform dry-run and print launch options
debug="1"
#Toggle stable/testing branch
branch="testing"
#Steam API key
steam_api=""
#Auto-install mods
auto_install="2"
#Automod staging directory
staging_dir="/tmp"
#Path to default Steam client
default_steam_path="/tmp"
#Preferred Steam launch command (for Flatpak support)
preferred_client="steam"
#DZGUI source path
src_path=""
File renamed without changes.
File renamed without changes.
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more