Compare commits

..
Author SHA1 Message Date
aclist c2cf996f92 Merge fa8792ba69 into f839001097 2026-06-20 10:45:45 +00:00
160 changed files with 1639 additions and 5176 deletions

No files matched your search

+29 -51
View File
@@ -1,10 +1,7 @@
# Changelog # Changelog
## [7.0.0] Unreleased ## Added
### Added
- Setup wizard - Setup wizard
- Uninstall wizard
- Descriptive details panel to certain error dialogs
- Changelog text wrapping and formatting - Changelog text wrapping and formatting
- Changelog ships with source - Changelog ships with source
- Documentation ships with source - Documentation ships with source
@@ -26,7 +23,6 @@
- Set favorite server from tables - Set favorite server from tables
- Detailed/copyable trace in critical error dialogs - Detailed/copyable trace in critical error dialogs
- Visual icons - Visual icons
- Unset favorite server button
- Integrated server notebook - Integrated server notebook
- Propagate width changes to all tables - Propagate width changes to all tables
- Remember tree position in menus - Remember tree position in menus
@@ -45,22 +41,11 @@
- Choose to jump into splash screen instead of server - Choose to jump into splash screen instead of server
- Collapsible connection panel - Collapsible connection panel
- Play offline (load mods directly) - Play offline (load mods directly)
- Generate Steam shortcuts and cover art
- Warn user if background downloads are disabled
- Dynamic copy button
- Update map count (-u flag)
- Filter button tooltips
- Expand/collapse changelog versions
- Support local light/dark mode changes on system theme
- Server time calculator
### Changed ## Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
- Enforce fullscreen mode on Steam Deck
- Reduce padding on keys button - Reduce padding on keys button
- Boldface breadcrumbs - Boldface breadcrumbs
- Changed some dialog error messages from popups to popovers
- Changed how filter buttons are packed in sidebar
- Bold labels inside frames - Bold labels inside frames
- Sidebar buttons do not steal focus - Sidebar buttons do not steal focus
- Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method
@@ -73,66 +58,59 @@
- Reduce size of geolocation DB on disk (~100MB) - Reduce size of geolocation DB on disk (~100MB)
- Enable LAN page Empty/Full filters on first run of app - Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client - Propagate subscribed mods to Steam client
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
### Dropped ## Dropped
- Battlemetrics API support - Debug mode
- Unused metadata from system log files
- Branch switching - Branch switching
- Manual mod install mode (describe rationale) - Manual mod install mode (describe rationale)
- Force update mods - Force update mods
### Fixed ## Fixed
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
- In-game time field missing certain ranges of time at night
- Wrap long entries in changelog
- Certain server descriptions with special characters not rendering correctly
- Center server title text on server dialogs - Center server title text on server dialogs
- Rare segfaults when changing maps (threading) - Rare segfaults when changing maps (threading)
- Moved dialogs out of threads - Moved dialogs out of threads
- History table not being in lock-step with state file
- Current keyword filter not being applied when refreshing table
### Unreleased ## Unreleased
- Load offline mods - Load offline mods
- Local documentation - Local documentation
- Raw debug command in context menu - Raw debug command in context menu
### Developer-facing ## Developer-facing
- Add pyproject.toml file - Add pyproject.toml file
- Show deprecation warnings - Show deprecation warnings
- Options -> Dev page - Options -> Dev page
## [6.0.5] 2026-05-17 ## [6.0.5] 2026-05-17
### Changed ## Changed
- Update symlink hash logic to align with DZGUI 7 beta - Update symlink hash logic to align with DZGUI 7 beta
- Update A2S helper to latest commit - Update A2S helper to latest commit
## [6.0.4] 2026-04-13 ## [6.0.4] 2026-04-13
### Fixed ## Fixed
- Update A2S module checksums to support servers with missing description field - Update A2S module checksums to support servers with missing description field
## [6.0.3] 2026-04-12 ## [6.0.3] 2026-04-12
### Fixed ## Fixed
- Use GLibUnix namespace where available when setting up SIGINT callback - Use GLibUnix namespace where available when setting up SIGINT callback
## [6.0.2] 2026-01-26 ## [6.0.2] 2026-01-26
### Fixed ## Fixed
- Explicitly use Python 3.13 when calling subprocesses - Explicitly use Python 3.13 when calling subprocesses
- Raise error correctly from module - Raise error correctly from module
- Do not spawn dialogs from inside of thread when fetching prereqs - Do not spawn dialogs from inside of thread when fetching prereqs
### Changed ## Changed
- Optimize time complexity of startup checks - Optimize time complexity of startup checks
- Optimize coordinate calculation by using local records first - Optimize coordinate calculation by using local records first
- Rename some internal functions - Rename some internal functions
- Wrap entire startup process in dialog - Wrap entire startup process in dialog
## [6.0.1] 2026-01-16 ## [6.0.1] 2026-01-16
### Fixed ## Fixed
- Explicitly use Python 3.13 when calling subprocesses - Explicitly use Python 3.13 when calling subprocesses
## [6.0.0] 2026-01-14 ## [6.0.0] 2026-01-14
### Added ## Added
- Support DayZ Experimental - Support DayZ Experimental
- Ping display on server tables - Ping display on server tables
- Save descriptive text notes on a per-server basis - Save descriptive text notes on a per-server basis
@@ -158,7 +136,7 @@
- Commandline usage help text (GaryBlackbourne) - Commandline usage help text (GaryBlackbourne)
- More descriptive help dialogs when initial dependencies are missing - More descriptive help dialogs when initial dependencies are missing
### Fixed ## Fixed
- Script failing to start when remote endpoints are unavailable (GaryBlackbourne) - Script failing to start when remote endpoints are unavailable (GaryBlackbourne)
- Key stickiness when quickly navigating through entries in tables - Key stickiness when quickly navigating through entries in tables
- Servers returning malformed A2S_INFO blocking server browser from loading - Servers returning malformed A2S_INFO blocking server browser from loading
@@ -197,7 +175,7 @@
- Path discovery during first-time setup when parsing filepaths with whitespaces - 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 - First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
### Changed ## Changed
- Require Python 3.13 - Require Python 3.13
- Reduced global API cooldown from 60s to 30s - Reduced global API cooldown from 60s to 30s
- Clarify dialog messages when DayZ path could not be found - Clarify dialog messages when DayZ path could not be found
@@ -209,50 +187,50 @@
- Rewrote distance calculation module (GaryBlackbourne) - Rewrote distance calculation module (GaryBlackbourne)
- Changed preferred client setting from radio toggle to combobox - Changed preferred client setting from radio toggle to combobox
### Dropped ## Dropped
- Ping readout in statusbar - Ping readout in statusbar
- Extraneous information from right statusbar - Extraneous information from right statusbar
## [5.8.3] 2026-01-04 ## [5.8.3] 2026-01-04
### Fixed ## Fixed
- Normalize checksum numbers and dates - Normalize checksum numbers and dates
## [5.8.2] 2026-01-04 ## [5.8.2] 2026-01-04
### Fixed ## Fixed
- Fix regression in symlink routine introduced by previous hotfix - Fix regression in symlink routine introduced by previous hotfix
## [5.8.1] 2025-12-27 ## [5.8.1] 2025-12-27
### Fixed ## Fixed
- Path discovery during first-time setup when parsing filepaths with whitespaces - 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 - First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
## [5.8.0] 2025-07-06 ## [5.8.0] 2025-07-06
### Added ## Added
- Filter servers by official/unofficial status - Filter servers by official/unofficial status
- Automatically fetch geolocation records - Automatically fetch geolocation records
### Changed ## Changed
- Updated internal versioning of helper files - Updated internal versioning of helper files
### Fixed ## Fixed
- Corrected erroneous 2024 date in prior changelog entries - Corrected erroneous 2024 date in prior changelog entries
## [5.7.0] 2025-04-17 ## [5.7.0] 2025-04-17
### Added ## Added
- Save application dimensions when quitting and restore on subsequent boot - Save application dimensions when quitting and restore on subsequent boot
### Fixed ## Fixed
- Issues with the window exceeding the bounds of the screen when displaying some table contexts - Issues with the window exceeding the bounds of the screen when displaying some table contexts
### Changed ## Changed
- More direct server ping query method that should return marginally more accurate times - More direct server ping query method that should return marginally more accurate times
## [5.6.7] 2025-04-04 ## [5.6.7] 2025-04-04
### Dropped ## 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 - 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 ## [5.6.6] 2025-03-16
### Changed ## Changed
- Update IP database records for 2025-03 - Update IP database records for 2025-03
## [5.6.5] 2025-03-04 ## [5.6.5] 2025-03-04
### Fixed ## Fixed
- Livonia server results being dropped from batch queries - Livonia server results being dropped from batch queries
## [5.6.4] 2025-02-10 ## [5.6.4] 2025-02-10
-73
View File
@@ -1,73 +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.
## License applicability to contributions
Code contributed to this repository becomes subject to the terms of the GPL license defined in the LICENSE file at the project root.
+12 -15
View File
@@ -1,23 +1,15 @@
## What this is ## What this is
DZGUI is a turnkey server browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI). DZGUI is a turnkey browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI).
This overcomes certain limitations in the Linux client and helps prepare the game to launch by providing features like: This overcomes certain limitations in the Linux client and helps prepare the game to launch by providing features like:
- Search for and display global and LAN servers via an interactive table - Search for and display global and LAN servers via an interactive table
- Supports official, third-party, and modded DayZ servers - Supports official, third-party, and modded DayZ servers
- Automatically find and prepare mods being requested by servers - Automatically find and prepare mods being requested by servers
- Add/delete/manage favorite servers by IP or ID
- Quick-connect to favorite/recent servers - Quick-connect to favorite/recent servers
- Manage and delete local mods - Manage local mods and symlinks
- And more - Prepare launch options to pass to Steam
## Design philosophy
DZGUI follows a specific set of guidelines, enumerated below:
- Provide a free and open-source tool under GPL
- Do not use undocumented, third-party servers to aggregate data; no central "DZGUI" server
- All connections go to Steam and DayZ servers directly; the tool should function in perpetuity without intermediary endpoints
- Target Linux desktop and Steam deck specifically; not a cross-platform tool
## Setup and usage ## Setup and usage
@@ -26,14 +18,19 @@ Please refer to the documentation for installation and setup instructions:
- [GitHub](https://aclist.github.io/dzgui/index.html) - [GitHub](https://aclist.github.io/dzgui/index.html)
- [Codeberg (Mirror)](https://aclist.codeberg.page) - [Codeberg (Mirror)](https://aclist.codeberg.page)
![A screenshot of DZGUI](/dzgui/data/images/example.png) ![A screenshot of DZGUI](/images/example.png)
## Attribution ## Attribution
Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https://creativecommons.org/licenses/by/4.0/) 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. 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
Users wishing to review the licenses to these components can inspect the `LICENSE` file located in the release tarball. 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 ## 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.
+2 -2
View File
@@ -8,7 +8,7 @@ runtime environment are built-in.
Turnkey installer Turnkey installer
------------------------ ------------------------
- Visit the project's `Releases page <https://github.com/aclist/dztui/releases/latest>`_. - Visit the project's `Releases page <https://github.com/aclist/dztui/releases>`_.
- Extract the DZGUI tarball from the top of the **Assets** list. - Extract the DZGUI tarball from the top of the **Assets** list.
- From a terminal, run the command ``./dzgui`` or double click on the extracted file in - From a terminal, run the command ``./dzgui`` or double click on the extracted file in
a file explorer. a file explorer.
@@ -26,7 +26,7 @@ You can install it via the following set of commands:
.. code:: console .. code:: console
sudo apt update sudo apt update
sudo apt install libgirepository-2.0-0 sudo apt install libgirepository-2.0
Next steps Next steps
--------------- ---------------
+7 -37
View File
@@ -3,22 +3,17 @@ Knowledge Base
.. _DZG-001: .. _DZG-001:
DZG-001: Timeouts occur while trying to query a specific server DZG-001: Periodically getting dropped from servers, or servers time out in DZGUI
------------------------------------------------------------------------------------ ------------------------------------------------------------------------------------
The leading cause of specific servers periodically timing out is local network configuration. DayZ opens a large number of connections while querying/connected to servers.
Many third-party DayZ servers use a server rental/hosting provider with DDoS protection. In addition, packets sent from server responses are expected to be a standard size (see warning below).
This can cause responses from servers to originate from a server other than the one originally queried.
Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation). If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers,
By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue. unresponsiveness, or a timeout.
If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for
the server's query port.
In addition, a uniform packet size is expected. If your router's MTU setting differs from the default of 1,500,
it could lead to incoming responses being discarded (see warning below).
If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers
tend to have less headroom than their wired counterparts.
.. important:: .. important::
Ensure that MTU (maximum tranmission unit) on your network is set to the standard size of 1,500 bytes. Ensure that MTU (maximum tranmission unit) on your network is set to the standard size of 1,500 bytes.
@@ -85,28 +80,3 @@ There is some misconception that a Steam Web API key could be used to gain infor
A Steam Web API key is the most strict way of getting authentic, reliable, and consistent server information in a zero-trust model. A Steam Web API key is the most strict way of getting authentic, reliable, and consistent server information in a zero-trust model.
You are responsible for the creation, storage, management, and revocation of your Web API key. You are responsible for the creation, storage, management, and revocation of your Web API key.
DZG-008: Periodically getting dropped from servers while connected
-------------------------------------------------------------------
In some cases, DayZ opens a large number of connections while connected to servers.
If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers,
unresponsiveness, or a timeout.
If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers
tend to have less headroom for simultaneous connections than their wired counterparts.
DZG-009: Floating dialogs appear maximized on tiling window managers
-------------------------------------------------------------------
The main DZGUI window and its child dialogs are expected to be rendered as floating by your window manager.
DZGUI sends window manager hints to this effect, but tiling window managers designed to bisect the screen into quadrants (e.g., i3 window manager)
may try to always launch applications in fullscreen.
To resolve this, set specific exclusions or window hints in your WM's configuration file.
For example, for i3, add the following to your `XDG_CONFIG_HOME/i3/config` file (defaults to `$HOME/.config/i3/config`):
.. code:: console
for_window [instance="DZGUI"] floating enable, move position center
for_window [instance="DZGUI - Dialog"] floating enable, move position center
+13 -3
View File
@@ -33,10 +33,20 @@ Once configured, you can insert this key in the app when launching it for the fi
Battlemetrics API key Battlemetrics API key
^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^
.. warning:: **This step is optional, but recommended**. Using this key lets you also connect to and query servers by their shorthand ID instead of by IP.
This feature is no longer supported in DZGUI. Battlemetrics has switched to a paid service. Register for an API key at `BattleMetrics <https://www.battlemetrics.com/account/register?after=%2Fdevelopers>`_ (free).
Battlemetrics queries are expected to fail in DZGUI 6. In DZGUI 7, this feature has been removed entirely.
From the ``Personal Access Tokens area``, select ``New Token``.
Give the token any name in the field at the top.
Leave **all options unchecked** and scroll to the bottom. Select ``Create Token``.
Once configured, you can insert this key in the app when launching it for the first time (optional), or later on when using the connect/query by ID methods in the app.
.. tip::
Each server has a unique ID. This is the string of numbers at the end of the URL. For example, in the URL ``https://www.battlemetrics.com/servers/dayz/8039514``, the ID is ``8039514``.
Next steps Next steps
-------------- --------------
-4
View File
@@ -1,10 +1,6 @@
Steam integration Steam integration
==================== ====================
.. note::
In :doc:`DZGUI 7 <dzgui7>`, Steam shortcut creation and cover art setup are handled automatically during setup. You can skip this step unless you are using DZGUI 6.
DZGUI can be added to Steam as a "non-Steam game" in order to facilitate integration with Steam Deck or desktop environments. DZGUI can be added to Steam as a "non-Steam game" in order to facilitate integration with Steam Deck or desktop environments.
First, launch Steam in the **Large** (default) view. First, launch Steam in the **Large** (default) view.
+15 -38
View File
@@ -1,18 +1,12 @@
import logging
import re import re
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path
from typing import Any from typing import Any
from warnings import deprecated from warnings import deprecated
from dzgui.const.constants import APP_NAME
logger = logging.getLogger(APP_NAME) @deprecated("Use dzgui.api.steam.unsubscribe()")
class WorkshopACF:
def __init__(self, file: str) -> None:
class ACF:
def __init__(self, file: Path) -> None:
super().__init__() super().__init__()
self.dict: dict[str, Any] self.dict: dict[str, Any]
@@ -21,10 +15,9 @@ class ACF:
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
return self.dict return self.dict
def load(self, file: Path) -> None: def load(self, file: str) -> None:
delimiter = r"\t\t" delimiter = r"\t\t"
lines = [] lines = []
# TODO: illegal file handling
with open(file, "r", encoding="utf-8") as f: with open(file, "r", encoding="utf-8") as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
@@ -34,14 +27,6 @@ class ACF:
lines.append(els) lines.append(els)
self.dict = self.parse(iter(lines)) 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]: def parse(self, lines: Iterator[list[str]]) -> dict[str, str]:
acf: dict[str, Any] = {} acf: dict[str, Any] = {}
try: try:
@@ -69,23 +54,6 @@ class ACF:
except StopIteration: except StopIteration:
return acf 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: def unpack(self, d: dict, lines: list[Any] = []) -> str:
t1 = "AppWorkshop" t1 = "AppWorkshop"
t2 = ("WorkshopItemsInstalled", "WorkshopItemDetails") t2 = ("WorkshopItemsInstalled", "WorkshopItemDetails")
@@ -110,9 +78,18 @@ class WorkshopACF(ACF):
s += line + "\n" s += line + "\n"
return s return s
def to_file(self, file: Path) -> None: def to_file(self, file: str) -> None:
s = self.unpack(self.dict) s = self.unpack(self.dict)
file.write_text(s) with open(file, "w") as f:
f.write(s)
@classmethod
def enquote(cls, s: str) -> str:
return f'"{s}"'
@classmethod
def dequote(cls, s: str) -> str:
return s.rstrip('"').lstrip('"')
def delete(self, modid: int) -> None: def delete(self, modid: int) -> None:
del self.dict["AppWorkshop"]["WorkshopItemsInstalled"][modid] del self.dict["AppWorkshop"]["WorkshopItemsInstalled"][modid]
+40
View File
@@ -0,0 +1,40 @@
import logging
import requests
from typing import Any, Optional, TYPE_CHECKING
from dzgui.const.constants import APP_NAME
from dzgui.const.endpoints import BM_SERVERS
logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from dzgui.api.servers import Record
def get_attributes(key: str, uid: int) -> Any:
# TODO: handle if key is not set
# TODO: tests for malformed IDs/values
hdr = {"Authorization": "Bearer " + key}
payload: dict[str, str] = {
"filter[game]": "dayz",
"sort": "-players",
"filter[ids][whitelist]": str(uid),
}
res = requests.get(BM_SERVERS, params=payload, headers=hdr)
res.raise_for_status()
j = res.json()["data"][0]["attributes"]
return j
def map_id_to_record(key: str, uid: int) -> Optional["Record"]:
from dzgui.api.servers import Record
try:
record = get_attributes(key, uid)
ip = record["ip"]
port = int(record["port"])
qport = int(record["portQuery"])
return Record(ip, port, qport)
except Exception:
return None
+9 -18
View File
@@ -7,12 +7,13 @@ from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path 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.api.servers import get_rules, fqip_to_record
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
APPID_DAYZ, APPID_DAYZ,
DAYZ_COMMUNITY_ROOT, DAYZ_COMMUNITY_ROOT,
LIBRARYFOLDERS_PATH,
WORKSHOP_PATH, WORKSHOP_PATH,
) )
@@ -39,7 +40,7 @@ def get_local_mod_ids(steam_path: Path) -> list[int]:
def get_local_mod_path(steam_path: Path) -> Path: 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 workshop_path = p / WORKSHOP_PATH
return workshop_path return workshop_path
@@ -48,7 +49,6 @@ def get_local_mods(workshop_path: Path) -> list[Path]:
mods = [file for file in workshop_path.iterdir() if file.is_dir()] mods = [file for file in workshop_path.iterdir() if file.is_dir()]
return mods return mods
def is_mission(path: Path) -> bool: def is_mission(path: Path) -> bool:
# TODO: parse integrity of other files # TODO: parse integrity of other files
parent = path.parent.name parent = path.parent.name
@@ -57,7 +57,6 @@ def is_mission(path: Path) -> bool:
file = path / "init.c" file = path / "init.c"
return file.exists() return file.exists()
def tokenize(mod: Path) -> dict[str, Any] | None: def tokenize(mod: Path) -> dict[str, Any] | None:
file = mod.joinpath("meta.cpp") file = mod.joinpath("meta.cpp")
delimiter=r"\s*=\s*" delimiter=r"\s*=\s*"
@@ -77,7 +76,6 @@ def tokenize(mod: Path) -> dict[str, Any] | None:
logger.critical(e) logger.critical(e)
return None return None
def get_mod_size(path: Path) -> float: def get_mod_size(path: Path) -> float:
s = 0 s = 0
for f in path.rglob("*"): for f in path.rglob("*"):
@@ -125,27 +123,20 @@ def _hash(uid: str, use_custom: bool = False) -> str:
return prefix + md5.hexdigest()[:8] return prefix + md5.hexdigest()[:8]
def remove_stale_signatures( def remove_stale_signatures(config: Path, versions: Path) -> None:
config: Path, versions: Path, ids: list[int] | None = None
) -> None:
if versions.is_file() is False: if versions.is_file() is False:
logger.warning("Creating new version signatures file") logger.warning("Creating new version signatures file")
versions.touch() versions.touch()
return return
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path) steam_path = Path(path)
if ids is None: ids = get_local_mod_ids(steam_path)
local_ids = get_local_mod_ids(steam_path)
with open(versions, "r") as f: with open(versions, "r") as f:
lines = f.readlines() lines = f.readlines()
for line in lines:
if ids is None: uid = int(line.split(",")[0])
lines = [line for line in lines if int(line.split(",")[0]) in local_ids] if uid not in ids:
else: lines.remove(line)
lines = [line for line in lines if int(line.split(",")[0]) not in ids]
with open(versions, "w") as f: with open(versions, "w") as f:
for line in lines: for line in lines:
f.write(line) f.write(line)
+56 -21
View File
@@ -1,3 +1,4 @@
import json
import struct import struct
from dataclasses import dataclass from dataclasses import dataclass
@@ -11,8 +12,9 @@ from dzgui.const.constants import (
APPNAME_DAYZ, APPNAME_DAYZ,
APPNAME_DAYZ_EXP, APPNAME_DAYZ_EXP,
DAYZ_BINARY, 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 # https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
endian = "<" endian = "<"
@@ -256,27 +258,34 @@ class PeFileError(Exception):
pass 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: def parse_version_number(data: BinaryIO) -> FileVersion:
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo # https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
""" minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
Note: structure differs from standard VS_FIXEDFILEINFO major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
Major: lower 16 bits of first DWORD build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
Minor: upper 16 bits of first DWORD # NOTE: 0-pad revision number
Build: lower 16 bits of third DWORD revision = str(struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF)
Revision: upper 16 bits of second DWORD, zero-padded offset = 3 - len(revision)
Lower 16 bits of revision DWORD appear unused. padded = "0" * offset + revision
""" return FileVersion(major, minor, build, padded)
major_minor = struct.unpack("<I", data.read(4))[0]
revision_pair = struct.unpack("<I", data.read(4))[0]
build_dword = struct.unpack("<I", data.read(4))[0]
major = major_minor & 0xFFFF
minor = (major_minor >> 16) & 0xFFFF
build = build_dword & 0xFFFF
revision = (revision_pair >> 16) & 0xFFFF
revision = f"{revision:03d}"
return FileVersion(major, minor, build, revision)
def seek_to_hex(address: str, data: BinaryIO) -> None: def seek_to_hex(address: str, data: BinaryIO) -> None:
@@ -410,11 +419,37 @@ def get_pefile_path(steam_path: Path, appid: int) -> Path:
name = identifier[appid] name = identifier[appid]
binary = DAYZ_BINARY 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}" pe_path = app_path / f"steamapps/common/{name}/{binary}"
return pe_path 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: def get_pretty_version(steam_path: Path, appid: int) -> str | None:
try: try:
pe_file_path = get_pefile_path(steam_path, appid) pe_file_path = get_pefile_path(steam_path, appid)
+15
View File
@@ -41,6 +41,21 @@ def test_ipdb() -> bool:
return False return False
def test_bm_api(key: str) -> bool:
payload: dict[str, str] = {
"filter[game]": "dayz",
}
hdr = {"Authorization": "Bearer " + key}
try:
res = requests.get(
endpoints.BM_SERVERS, params=payload, headers=hdr, timeout=REQUEST_TIMEOUT
)
return is_remote_up(res)
except Exception as e:
logger.critical(e)
return False
def is_remote_up(res: "Response") -> bool: def is_remote_up(res: "Response") -> bool:
if res.status_code == 200: if res.status_code == 200:
return True return True
+38 -28
View File
@@ -11,6 +11,7 @@ import threading
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Optional, TYPE_CHECKING, Union 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.constants import APP_NAME, REQUEST_TIMEOUT
from dzgui.const.endpoints import STEAM_SERVERS from dzgui.const.endpoints import STEAM_SERVERS
from dzgui.util.format import format_exception from dzgui.util.format import format_exception
@@ -54,16 +55,19 @@ class Res:
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class Details: class Details:
data: list[list[str]] data: Union[list, None]
name: str
description: str description: str
gametime: str success: bool
day_accel: float
night_accel: float
@dataclass(slots=True, frozen=True) @dataclass(slots=True)
class Record: class Record:
# TODO: investigate whether this enum is still being used;
# can this be frozen?
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str ip: str
gameport: int gameport: int
qport: int qport: int
@@ -280,41 +284,42 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] |
info = a2s.info((ip, qport), timeout) info = a2s.info((ip, qport), timeout)
return source_info_to_dict(ip, qport, info) return source_info_to_dict(ip, qport, info)
except Exception as e: except Exception as e:
logger.debug(e) logger.warning(e)
return None return None
def get_details(record: Record) -> Details | None: def get_details(record: Record) -> Details:
ip = record.ip ip = record.ip
qport = record.qport qport = record.qport
default_str = strings.none_provided default_str = strings.none_provided
try: try:
info = a2s.info((ip, qport)) info = a2s.info((ip, qport))
except TimeoutError:
return Details(None, default_str, False)
try:
rules = dayzquery.dayz_rules((ip, qport)) rules = dayzquery.dayz_rules((ip, qport))
except Exception:
return Details(None, default_str, False)
try:
keywords = info.keywords.split(",") keywords = info.keywords.split(",")
except Exception as e: except AttributeError:
logger.critical(e) return Details(None, default_str, False)
return None
battleye = strings.disabled battleye = strings.disabled
if "battleye" in keywords: if "battleye" in keywords:
battleye = strings.enabled battleye = strings.enabled
name = info.server_name day_accel = 0.0
day_accel = 1.0 night_accel = 0.0
night_accel = 1.0
gametime = "00:00"
for keyword in keywords: for keyword in keywords:
if "etm" in keyword: if "etm" in keyword:
day_accel = float(keyword.lstrip("etm")) day_accel = float(keyword.lstrip("etm"))
day_accel = float(f"{day_accel:g}") day_accel = float(f"{day_accel:g}")
if "entm" in keyword: if "entm" in keywords:
night_accel = float(keyword.lstrip("entm")) night_accel = float(keyword.lstrip("entm"))
night_accel = float(f"{night_accel:g}") night_accel = float(f"{night_accel:g}")
time_reg = r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]$)"
if re.match(time_reg, keyword):
gametime = keyword
try: try:
password = info.password_protected password = info.password_protected
@@ -341,15 +346,10 @@ def get_details(record: Record) -> Details | None:
try: try:
dlc = rules.dlc_flags dlc = rules.dlc_flags
match dlc: if dlc == 0:
case 0: dlc = strings.none
dlc = strings.dlc_none if dlc == 2:
case 2:
dlc = strings.dlc_frostline dlc = strings.dlc_frostline
case 4:
dlc = strings.dlc_badlands
case _:
dlc = strings.unspecified
except AttributeError: except AttributeError:
dlc = strings.unspecified dlc = strings.unspecified
@@ -380,7 +380,7 @@ def get_details(record: Record) -> Details | None:
["Version", version], ["Version", version],
] ]
return Details(rows, name, description, gametime, day_accel, night_accel) return Details(rows, description, True)
def ping(ip: str, qport: int) -> int: def ping(ip: str, qport: int) -> int:
@@ -477,6 +477,16 @@ def query_by_ip(addr: str) -> A2SInfo:
return query_by_record(record, update_gameport=True) return query_by_record(record, update_gameport=True)
def query_by_id(server_id: int, key: str) -> A2SInfo:
"""
Used with numeric Battlemetrics IDs
"""
record = map_id_to_record(key, server_id)
if record is None:
return A2SInfo(Record("0", 0, 0), None)
return query_by_record(record)
def query_by_record(record: Record, update_gameport: bool = False) -> A2SInfo: def query_by_record(record: Record, update_gameport: bool = False) -> A2SInfo:
try: try:
info = a2s.info((record.ip, record.qport), 3.0) info = a2s.info((record.ip, record.qport), 3.0)
-204
View File
@@ -1,204 +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, make_parents
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 FileNotFoundError:
self.shortcuts = {"shortcuts": {}}
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")
make_parents(dest)
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 delete_shortcut(self, start_path: Path) -> None:
shortcuts = self.shortcuts["shortcuts"]
for s in shortcuts:
if shortcuts[s]["StartDir"] == str(start_path):
del shortcuts[s]
break
self.save_shortcuts()
def save_shortcuts(self) -> None:
try:
backup = self.shortcuts_path.with_suffix(".vdf.bak")
shutil.copy(self.shortcuts_path, backup)
except Exception as e:
logger.critical(e)
try:
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)
+68 -297
View File
@@ -1,70 +1,37 @@
import json
import logging import logging
import os import os
import psutil
import re
import requests import requests
import subprocess import subprocess
import vdf # type: ignore from typing import Union
from pathlib import Path
from requests.exceptions import HTTPError
from typing import Any, Generator, Union
from warnings import deprecated from warnings import deprecated
from dzgui.api.acf import ACF from shlex import shlex
from dzgui.init.prereqs import has_any_client from pathlib import Path
from dzgui.init.proc import has_cmd
from dzgui.init.prereqs import has_steam_client
from dzgui.api.mods import _hash
from dzgui.const.constants import ( from dzgui.const.constants import (
APPID_DAYZ, APPID_DAYZ,
APPID_DAYZ_EXP,
APP_NAME, APP_NAME,
DAYZ_BINARY,
DAYZ_LAUNCHER,
DEBIAN_STEAM_PATH, DEBIAN_STEAM_PATH,
DEFAULT_STEAM_PATH, DEFAULT_STEAM_PATH,
DOWNLOADS_PATH,
FLATPAK_CMD,
FLATPAK_STEAM_PATH, FLATPAK_STEAM_PATH,
LIBRARYFOLDERS_PATH,
REQUEST_TIMEOUT,
STEAM_CMD,
UBUNTU_STEAM_PATH, UBUNTU_STEAM_PATH,
REQUEST_TIMEOUT,
VDF_PATH, VDF_PATH,
) )
from dzgui.const.endpoints import ( from dzgui.const.endpoints import SUB_ENDPOINT, STEAM_PUBLISHED_FILES, UNSUB_ENDPOINT
APP_DETAILS,
SUB_ENDPOINT,
STEAM_PUBLISHED_FILES,
UNSUB_ENDPOINT,
)
from dzgui.strings import wizard from dzgui.strings import wizard
from dzgui.strings.errors import api_validation_error, workshop_endpoint_error
from dzgui.util.bash import concat_bash_args from dzgui.util.bash import concat_bash_args
logger = logging.getLogger(APP_NAME) 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]]: def get_steam_paths() -> list[tuple[Path, str]]:
paths = [] paths = []
if has_any_client(): if has_steam_client():
HOME = Path.home() HOME = Path.home()
env = os.environ.get("XDG_DATA_HOME") env = os.environ.get("XDG_DATA_HOME")
if env is not None: if env is not None:
@@ -85,19 +52,12 @@ def get_steam_paths() -> list[tuple[Path, str]]:
} }
for path in DEFAULT_PATH, FLATPAK_PATH, UBUNTU_PATH, DEBIAN_PATH: for path in DEFAULT_PATH, FLATPAK_PATH, UBUNTU_PATH, DEBIAN_PATH:
if path is FLATPAK_PATH and has_cmd(FLATPAK_CMD) is False:
continue
if path is not FLATPAK_PATH and has_cmd(STEAM_CMD) is False:
continue
if path.joinpath(VDF_PATH).is_file(): if path.joinpath(VDF_PATH).is_file():
paths.append((path, human[path])) paths.append((path, human[path]))
return paths return paths
def concat_mods(mods: list[str]) -> str: def concat_mods(mods: list[str]) -> str:
# TODO: circular import workaround
from dzgui.api.mods import _hash
hashes = [] hashes = []
for mod in mods: for mod in mods:
md5sum = _hash(mod) md5sum = _hash(mod)
@@ -151,9 +111,6 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
j = r.json() j = r.json()
rows = j["response"]["publishedfiledetails"] rows = j["response"]["publishedfiledetails"]
for row in rows: for row in rows:
# NOTE: not a valid published file
if row["result"] == 9:
continue
title = str(row["title"]) title = str(row["title"])
_id = str(row["publishedfileid"]) _id = str(row["publishedfileid"])
time = int(row["time_updated"]) time = int(row["time_updated"])
@@ -162,25 +119,6 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
return hashes return hashes
def connect_debug(
client: str, addr: str, appid: int, name: str, mods: list[str]
) -> str:
concat = concat_mods(mods)
client_args = concat_bash_args(client)
params = [
"-applaunch",
str(appid),
f"-connect={addr}",
"-nolauncher",
"-nosplash",
"-skipintro",
f"-name={name}",
f"-mod={concat}",
]
client_args.extend(params)
return " ".join(client_args)
# TODO: set config to name=user, use official server and no mods, # TODO: set config to name=user, use official server and no mods,
# ensure that formatted string is identical to fixture with same hash # ensure that formatted string is identical to fixture with same hash
def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int: def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int:
@@ -205,7 +143,6 @@ def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int:
"""Loads to the menu screen with the selected mods; used for AFK/pre-joining""" """Loads to the menu screen with the selected mods; used for AFK/pre-joining"""
concat = concat_mods(mods) concat = concat_mods(mods)
client_args = concat_bash_args(client) client_args = concat_bash_args(client)
logger.debug(f"Setting client arguments to: {client_args}")
params = [ params = [
"-applaunch", "-applaunch",
str(appid), str(appid),
@@ -241,47 +178,58 @@ def launch_offline(
return proc.returncode return proc.returncode
def find_loginusers(path: Path) -> Path:
return path / "config" / "loginusers.vdf"
def find_user_id(path: Path) -> str | None: def find_user_id(path: Path) -> str | None:
resolved_path = find_loginusers(path) resolved_path = path / "config" / "loginusers.vdf"
try: try:
with open(resolved_path, "r") as f: vdf = vdf2json(resolved_path)
v = vdf.load(f) j = json.loads(vdf)
users = v["users"] for user in j["users"]:
# NOTE: beta client: /package/beta if j["users"][user]["MostRecent"] == "1":
last_user = str(next(iter(users))) return str(user)
if len(users) == 1: return None
return last_user
last_stamp = int(users[last_user]["timestamp"])
for user in users:
stamp = int(users[user]["timestamp"])
if stamp > last_stamp:
last_stamp = stamp
last_user = user
return last_user
except Exception as e: except Exception as e:
logger.debug(e) logger.warn(e)
return None return None
def find_user_id_32(path: Path) -> int: def vdf2json(path: Path) -> str:
uid = find_user_id(path) def _istr(indent: int, string: str) -> str:
if uid is None: return (indent * " ") + string
raise ValueError("Failed to parse a valid Steam32 ID")
return int(uid) & 0xFFFFFFFF
jbuf = "{\n"
indent = 1
class SteamAPIKeyError(Exception): with open(path, "r") as f:
def __init__(self, msg: str | None = None) -> None: st = f.read()
super().__init__(msg or api_validation_error) lex = shlex(st)
while True:
class SteamWorkshopEndpointError(Exception): tok = lex.get_token()
def __init__(self, msg: str | None = None) -> None: if not tok:
super().__init__(msg or workshop_endpoint_error) 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: def update_workshop(key: str, mod: int, endpoint: str) -> None:
@@ -295,19 +243,8 @@ def update_workshop(key: str, mod: int, endpoint: str) -> None:
try: try:
res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT) res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT)
res.raise_for_status() res.raise_for_status()
except HTTPError as e:
logger.critical(e)
match res.status_code:
case 403:
raise SteamAPIKeyError()
case 404:
raise SteamWorkshopEndpointError()
case _:
# TODO: apply redaction filter
raise e
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
raise e
def subscribe(key: str, mod: int) -> None: def subscribe(key: str, mod: int) -> None:
@@ -318,187 +255,21 @@ def unsubscribe(key: str, mod: int) -> None:
update_workshop(key, mod, UNSUB_ENDPOINT) update_workshop(key, mod, UNSUB_ENDPOINT)
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
@deprecated("Use subscribe()") @deprecated("Use subscribe()")
def enqueue_mod(client: str, mod: str, appid: int) -> None: def enqueue_mod(client: str, mod: str, appid: int) -> None:
client_args = concat_bash_args(client) client_args = concat_bash_args(client)
subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod]) 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, DAYZ_LAUNCHER]
for proc in psutil.process_iter():
try:
procs.append(proc.cmdline())
except Exception as e:
logger.warning(e)
continue
return any(s in item for sublist in procs for item in sublist for s in substring)
def get_pending_downloads(steam_path: Path, appid: int) -> Generator[Path, None, None]:
root_path = get_app_path(steam_path, appid)
downloads_dir = root_path / DOWNLOADS_PATH
files = downloads_dir.iterdir()
return files
def is_dayz_downloading(steam_path: Path, appid: int) -> bool:
files = get_pending_downloads(steam_path, appid)
reg = re.compile(rf"^state_(?:{APPID_DAYZ_EXP}|{APPID_DAYZ})_.*\.patch$")
pending = [f for f in files if reg.match(f.name)]
if len(pending) > 0:
return True
return False
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
-193
View File
@@ -1,193 +0,0 @@
import logging
import os
import shutil
from pathlib import Path
from typing import TYPE_CHECKING
from warnings import deprecated
from dzgui.const.constants import (
APP_NAME,
FLATPAK_ROOT_PATH,
STEAM_ICON,
STEAM_ICONS_PATH,
)
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_flatpak_client, has_steam_client
from dzgui.strings import boot
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.map_count import test_map_count
from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS
from dzgui.util.strings import init, steam_icon_missing
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)
@deprecated("Currently unused")
def load_steam_icon(config: Path) -> None:
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa
default = lookup(config, Preferences.DEFAULT)
icon_path = Path(default).joinpath(STEAM_ICONS_PATH)
try:
theme = Gtk.IconTheme.get_default()
paths = theme.get_search_path()
theme.set_search_path(paths + [str(icon_path)])
paths = theme.get_search_path()
icons = theme.list_icons(None)
if STEAM_ICON not in icons:
logger.warning(steam_icon_missing)
except Exception as e:
print(e)
logger.warning(e)
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:
test_map_count()
migrate_cols_file(XDG.columns)
copy_state_files(xdg_paths["XDG_STATE_HOME"])
# TODO: add logging inside wizard
wizard = SetupWizard(_is_steam_deck, XDG, xdg_paths)
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 FLATPAK_ROOT_PATH in lookup(XDG.config, Preferences.DEFAULT):
has_client = has_flatpak_client()
else:
has_client = has_steam_client()
if has_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 = App(prefs)
app.run(None)
+2 -7
View File
@@ -3,8 +3,6 @@ import shlex
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from dzgui.const.boilerplate import config_boilerplate
""" """
Convert legacy dztuirc to config.json Convert legacy dztuirc to config.json
@@ -42,7 +40,6 @@ def rc2json(file: Path) -> str:
toggles = ["fullscreen"] toggles = ["fullscreen"]
deprecated = [ deprecated = [
"api_key",
"staging_dir", "staging_dir",
"src_path", "src_path",
"steam_path", "steam_path",
@@ -67,6 +64,8 @@ def rc2json(file: Path) -> str:
value = str2bool(ntok) value = str2bool(ntok)
elif tok == "preferred_client": elif tok == "preferred_client":
tok = "client" tok = "client"
elif tok == "api_key":
tok = "bm_api"
elif tok == "ip_list": elif tok == "ip_list":
while True: while True:
ntok = lex.get_token() ntok = lex.get_token()
@@ -90,8 +89,4 @@ def rc2json(file: Path) -> str:
keys["ip_list"] = ips keys["ip_list"] = ips
keys["use_miles"] = False keys["use_miles"] = False
keys["start_tab"] = 0 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) return json.dumps(keys, indent=2)
+6 -31
View File
@@ -1,43 +1,18 @@
import stat
import textwrap import textwrap
from pathlib import Path from pathlib import Path
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"""\ template = f"""\
[Desktop Entry] [Desktop Entry]
Version=1.0 Version=1.0
Type=Application Type=Application
Terminal=false Terminal=false
Exec={exe_path} Exec={share_path}/launch.sh
Name=DZGUI Name=DZGUI
Comment=DayZ server browser and mod manager Comment=dzgui
Icon={icon} Icon={share_path}/dzgui
Categories=Game""" Categories=Game"""
copy_dzgui_to_xdg_data(exe_path) file = share_path / "dzgui.desktop"
file.write_text(textwrap.dedent(template))
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("Desktop/dzgui.desktop")
make_parents(link)
if link.exists():
link.unlink()
link.symlink_to(desktop_file)
+20
View File
@@ -4,6 +4,8 @@ from pathlib import Path
from dzgui.util._json import read_json from dzgui.util._json import read_json
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.const.constants import STEAM_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX
class ConfigFileError(Exception): class ConfigFileError(Exception):
pass pass
@@ -38,5 +40,23 @@ def get_favorites(path: Path) -> list[str]:
return list(conf["ip_list"]) return list(conf["ip_list"])
# def is_in_favs(record: str, path: Path) -> bool:
# favs = get_favorites(path)
# if record in favs:
# return True
# return False
def enum_to_key(enum: Preferences) -> str: def enum_to_key(enum: Preferences) -> str:
return str(enum.dict["key"]) return str(enum.dict["key"])
def get_client_index(client: str) -> int:
if client == STEAM_CMD:
return 0
elif client == FLATPAK_RUN_CMD:
return 1
elif client == FLATPAK_SANDBOX:
return 2
else:
return 0
+8 -22
View File
@@ -3,13 +3,7 @@ import os
from pathlib import Path from pathlib import Path
from dataclasses import dataclass from dataclasses import dataclass
from dzgui.const.constants import ( from dzgui.const.constants import APP_NAME_LOWER, DEBUG_LOG, SYSTEM_LOG
APP_NAME_LOWER,
DEBUG_LOG,
FLATPAK_ROOT_PATH,
SYSTEM_LOG,
)
from dzgui.init.proc import is_sandboxed
@dataclass @dataclass
@@ -24,7 +18,6 @@ class Xdg:
debug: Path debug: Path
ips: Path ips: Path
filters: Path filters: Path
shortcut: Path
def is_writeable(path_str: str) -> bool: def is_writeable(path_str: str) -> bool:
@@ -36,9 +29,9 @@ def is_writeable(path_str: str) -> bool:
if not path.exists(): if not path.exists():
try: try:
path.mkdir(parents=True) path.mkdir(parents=True)
path.unlink()
except OSError: except OSError:
return False return False
path.unlink()
return True return True
try: try:
@@ -59,24 +52,20 @@ def get_xdg_paths() -> dict:
"XDG_DATA_HOME": home / ".local" / "share", "XDG_DATA_HOME": home / ".local" / "share",
"XDG_CACHE_HOME": home / ".cache", "XDG_CACHE_HOME": home / ".cache",
} }
resolved_paths = {} resolved_paths = {}
for path in xdg_paths: for path in xdg_paths:
real_path = os.environ.get(path) rp = os.environ.get(path)
if real_path is not None and is_sandboxed(): if rp is not None and is_writeable(rp):
search_string = FLATPAK_ROOT_PATH + "/" resolved_paths[path] = Path(rp)
real_path = real_path.replace(search_string, "")
if real_path is not None and is_writeable(real_path):
new_path = Path(real_path)
else: else:
new_path = xdg_paths[path] resolved_paths[path] = xdg_paths[path] / APP_NAME_LOWER
resolved_paths[path] = new_path / APP_NAME_LOWER
return resolved_paths return resolved_paths
def parse_filepaths(xdg: dict) -> Xdg: def parse_filepaths(xdg: dict) -> Xdg:
config = xdg["XDG_CONFIG_HOME"] config = xdg["XDG_CONFIG_HOME"]
state = xdg["XDG_STATE_HOME"] state = xdg["XDG_STATE_HOME"]
share = xdg["XDG_DATA_HOME"]
config = config / "config.json" config = config / "config.json"
@@ -91,8 +80,6 @@ def parse_filepaths(xdg: dict) -> Xdg:
ips = state / "ips.csv" ips = state / "ips.csv"
filters = state / "dzg.filters.json" filters = state / "dzg.filters.json"
shortcut = share / APP_NAME_LOWER
return Xdg( return Xdg(
config, config,
columns, columns,
@@ -103,6 +90,5 @@ def parse_filepaths(xdg: dict) -> Xdg:
system, system,
debug, debug,
ips, ips,
filters, filters
shortcut,
) )
+1
View File
@@ -2,6 +2,7 @@
Generic defaults used when initializing a config file from scratch Generic defaults used when initializing a config file from scratch
""" """
config_boilerplate = { config_boilerplate = {
"bm_api": "",
"fav_server": "", "fav_server": "",
"fav_label": "", "fav_label": "",
"name": "", "name": "",
+3 -19
View File
@@ -12,10 +12,8 @@ APPNAME_DAYZ = "DayZ"
APPNAME_DAYZ_EXP = "DayZ Exp" APPNAME_DAYZ_EXP = "DayZ Exp"
APPNAME_DAYZ_EXP_HUMAN = "DayZ Experimental" APPNAME_DAYZ_EXP_HUMAN = "DayZ Experimental"
DAYZ_BINARY = "DayZ_x64.exe" DAYZ_BINARY = "DayZ_x64.exe"
DAYZ_LAUNCHER = "DayZLauncher.exe"
LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf" LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf"
DOWNLOADS_PATH = "steamapps/downloading"
WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ) WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ)
STEAM_CMD = "steam" STEAM_CMD = "steam"
@@ -31,18 +29,15 @@ BETA_REPO = "testing"
APP_NAME = "DZGUI" APP_NAME = "DZGUI"
APP_NAME_LOWER = "dzgui" APP_NAME_LOWER = "dzgui"
APP_NAME_ABBR = "dzg" APP_NAME_ABBR = "dzg"
GTK_APP_ID = "io.github.aclist.dzgui"
HEX_GREEN = "#32CD32" HEX_GREEN = "#32CD32"
HEX_RED = "#FF0000" HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C" HEX_ORANGE = "#FFAC1C"
# TODO: normalize icon names # FIXME: normalize icon names
CALCULATOR = "accessories-calculator-symbolic"
CARET_DOWN = "go-down-symbolic" CARET_DOWN = "go-down-symbolic"
CARET_UP = "go-up-symbolic" CARET_UP = "go-up-symbolic"
CLIPBOARD = "edit-copy-symbolic" CLIPBOARD = "edit-copy-symbolic"
CLOSE = "window-close-symbolic"
FOLDER = "folder-symbolic" FOLDER = "folder-symbolic"
EDIT_DELETE = "edit-delete-symbolic" EDIT_DELETE = "edit-delete-symbolic"
ERROR = "dialog-error-symbolic" ERROR = "dialog-error-symbolic"
@@ -56,8 +51,6 @@ VIEW_CONCEAL = "view-conceal-symbolic"
VIEW_REVEAL = "view-reveal-symbolic" VIEW_REVEAL = "view-reveal-symbolic"
WARNING = "dialog-warning-symbolic" WARNING = "dialog-warning-symbolic"
WEB_BROWSER = "web-browser-symbolic" WEB_BROWSER = "web-browser-symbolic"
DAY_ICON = "weather-clear-symbolic"
NIGHT_ICON = "weather-clear-night-symbolic"
SEPARATOR = "SEPARATOR" SEPARATOR = "SEPARATOR"
@@ -79,17 +72,13 @@ LEGACY_IPS_PATH = ".local/share/dzgui/helpers/ips.csv"
DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG" DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG"
SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG" SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG"
CHANGELOG_PATH = "data/CHANGELOG.md" 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" CSS_PATH = "data/app.css"
VDF_PATH = "steamapps/libraryfolders.vdf" VDF_PATH = "steamapps/libraryfolders.vdf"
DEFAULT_STEAM_PATH = ".local/share/Steam" DEFAULT_STEAM_PATH = ".local/share/Steam"
FLATPAK_ROOT_PATH = ".var/app/com.valvesoftware.Steam" FLATPAK_STEAM_PATH = ".var/app/com.valvesoftware.Steam/data/Steam"
FLATPAK_STEAM_PATH = FLATPAK_ROOT_PATH + "/data/Steam"
UBUNTU_STEAM_PATH = ".steam/steam" UBUNTU_STEAM_PATH = ".steam/steam"
DEBIAN_STEAM_PATH = ".steam/debian-installation" DEBIAN_STEAM_PATH = ".steam/debian-installation"
STEAM_ICONS_PATH = "public"
LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG") LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG")
@@ -98,8 +87,3 @@ TMP_TARBALL = "/tmp/dzgui.tar.gz"
TMP_EXE = "/tmp/dzgui/dzgui" TMP_EXE = "/tmp/dzgui/dzgui"
DAYZ_COMMUNITY_ROOT = "Missions" DAYZ_COMMUNITY_ROOT = "Missions"
GIO_SETTINGS_INTERFACE = "org.gnome.desktop.interface"
GIO_SETTINGS_PROP = "color-scheme"
GIO_SETTINGS_KEY = "gtk-application-prefer-dark-theme"
GIO_SETTINGS_VAL = "prefer-dark"
+3 -1
View File
@@ -5,8 +5,8 @@ STEAM_PUBLISHED_FILES = (
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1" STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1"
SUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Subscribe/v1" SUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Subscribe/v1"
UNSUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Unsubscribe/v1" UNSUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Unsubscribe/v1"
APP_DETAILS = "https://store.steampowered.com/api/appdetails?"
BM_SERVERS = "https://api.battlemetrics.com/servers?"
GITHUB = "https://github.com/aclist" GITHUB = "https://github.com/aclist"
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest" GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
CODEBERG_RELEASES = "https://codeberg.org/api/v1/repos/aclist/dzgui/releases/latest" CODEBERG_RELEASES = "https://codeberg.org/api/v1/repos/aclist/dzgui/releases/latest"
@@ -26,3 +26,5 @@ GITHUB_ISSUES = "https://github.com/aclist/dzgui/issues"
FORUM = "https://old.reddit.com/r/dzgui" FORUM = "https://old.reddit.com/r/dzgui"
SPONSORS = "https://github.com/sponsors/aclist" SPONSORS = "https://github.com/sponsors/aclist"
STEAM_API_SETUP = "https://steamcommunity.com/dev/apikey" STEAM_API_SETUP = "https://steamcommunity.com/dev/apikey"
BM_API_SETUP = "https://www.battlemetrics.com/developers"
BM_BROWSE = "https://www.battlemetrics.com/servers/dayz"
+12
View File
@@ -61,6 +61,9 @@ class Preferences(EnumWithAttrs):
STEAM = { STEAM = {
"key": "steam_api", "key": "steam_api",
} }
BM = {
"key": "bm_api",
}
CLIENT = { CLIENT = {
"key": "client", "key": "client",
} }
@@ -73,6 +76,12 @@ class Preferences(EnumWithAttrs):
NAME = { NAME = {
"key": "name", "key": "name",
} }
INSTALL = {
"key": "auto_install",
}
DEBUG = {
"key": "debug",
}
DEFAULT = { DEFAULT = {
"key": "default_steam_path", "key": "default_steam_path",
} }
@@ -85,6 +94,9 @@ class Preferences(EnumWithAttrs):
IP_LIST = { IP_LIST = {
"key": "ip_list", "key": "ip_list",
} }
BRANCH = {
"key": "branch",
}
START_TAB = { START_TAB = {
"key": "start_tab", "key": "start_tab",
} }
+2 -6
View File
@@ -81,8 +81,8 @@ class Emitter(GObject.GObject):
def distcalc_started(self) -> None: def distcalc_started(self) -> None:
pass pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool, int)) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
def mod_page_toggled(self, state: bool, mods: int) -> None: def mod_page_toggled(self, state: bool) -> None:
pass pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,)) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
@@ -145,10 +145,6 @@ class Emitter(GObject.GObject):
def api_change_failed(self) -> None: def api_change_failed(self) -> None:
pass pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def api_change_successful(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def already_saved_server(self) -> None: def already_saved_server(self) -> None:
pass pass
+20 -28
View File
@@ -7,12 +7,11 @@ import dzgui.util._json as JSON # noqa
from dzgui.const.enum import ( from dzgui.const.enum import (
FilterMode,
Preferences,
NotebookPage,
ButtonType, ButtonType,
ContextMenu, ContextMenu,
FilterMode,
NotebookPage,
Preferences,
ServerTab,
) )
from dzgui.config.userprefs import UserPrefs from dzgui.config.userprefs import UserPrefs
@@ -38,6 +37,7 @@ logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.const.enum import ServerTab
from dzgui.managers.connection import Prerequisites from dzgui.managers.connection import Prerequisites
from dzgui.managers.filter import FilterManager from dzgui.managers.filter import FilterManager
from dzgui.util.dist import Haversine from dzgui.util.dist import Haversine
@@ -88,6 +88,7 @@ class Controller(GObject.GObject):
# NOTE: suppress requests until entire UI is loaded # NOTE: suppress requests until entire UI is loaded
self.loaded = False self.loaded = False
self.pending_jobs = 1
self.exit_event = threading.Event() self.exit_event = threading.Event()
self.cancel_event = threading.Event() self.cancel_event = threading.Event()
@@ -114,6 +115,9 @@ class Controller(GObject.GObject):
def query_config(self, key: Preferences) -> Any: def query_config(self, key: Preferences) -> Any:
return self.config_man.lookup(key) return self.config_man.lookup(key)
def is_auto_install(self) -> bool:
return bool(self.query_config(Preferences.INSTALL))
def suppress_signal( def suppress_signal(
self, owner: Any, child: Any, func_name: str, state: bool self, owner: Any, child: Any, func_name: str, state: bool
) -> None: ) -> None:
@@ -201,6 +205,7 @@ class Controller(GObject.GObject):
self.mediator.grid.notebook.settings.populate_settings() self.mediator.grid.notebook.settings.populate_settings()
except Exception as e: except Exception as e:
msg = format_exception(e) msg = format_exception(e)
print(msg)
logger.critical(msg) logger.critical(msg)
return return
case ButtonType.MODS: case ButtonType.MODS:
@@ -263,7 +268,6 @@ class Controller(GObject.GObject):
self.open_page(NotebookPage.LOG) self.open_page(NotebookPage.LOG)
except Exception as e: except Exception as e:
dialog = ExceptionDialog(self, str(e)) dialog = ExceptionDialog(self, str(e))
dialog.show_all()
dialog.run() dialog.run()
def select_colorized(self) -> None: def select_colorized(self) -> None:
@@ -297,11 +301,10 @@ class Controller(GObject.GObject):
write_diagnostic(self.prefs.paths.config, file) write_diagnostic(self.prefs.paths.config, file)
except Exception as e: except Exception as e:
dialog = ExceptionDialog(self, str(e)) dialog = ExceptionDialog(self, str(e))
dialog.show_all()
dialog.run() dialog.run()
def update_steam_api_key(self, text: str) -> None: def update_api_key(self, key: Preferences, text: str) -> None:
self.config_man.update_steam_api_key(text) self.config_man.update_api_key(key, text)
def set_resolution(self, window: "OuterWindow") -> None: def set_resolution(self, window: "OuterWindow") -> None:
self.config_man.set_resolution(window) self.config_man.set_resolution(window)
@@ -400,9 +403,6 @@ class Controller(GObject.GObject):
def get_menu(self) -> "MenuTreeView": def get_menu(self) -> "MenuTreeView":
return self.mediator.menu return self.mediator.menu
def unset_fav(self) -> None:
self.config_man.unset_fav()
def has_favorites(self) -> bool: def has_favorites(self) -> bool:
favs = self.config_man.get_favorites() favs = self.config_man.get_favorites()
if len(favs) < 1: if len(favs) < 1:
@@ -414,7 +414,7 @@ class Controller(GObject.GObject):
def add_by_str(self, addr: str) -> None: def add_by_str(self, addr: str) -> None:
saved_tree = self.get_servers().get_saved() saved_tree = self.get_servers().get_saved()
ServerModelManager(self, saved_tree).add_by_ip(addr) ServerModelManager(self, saved_tree).add_by_str(addr)
def add_by_record(self, record: "Record") -> None: def add_by_record(self, record: "Record") -> None:
saved_tree = self.get_servers().get_saved() saved_tree = self.get_servers().get_saved()
@@ -438,6 +438,12 @@ class Controller(GObject.GObject):
ServerModelManager(self, tv).remove_from_history(record) ServerModelManager(self, tv).remove_from_history(record)
def connect_by_str(self, addr: str) -> None: def connect_by_str(self, addr: str) -> None:
if addr.isdigit():
config_man = self.get_config_man()
key = config_man.lookup(Preferences.BM)
self.connection_man = ConnectionManager(self)
self.connection_man.connect_by_id(int(addr), key)
else:
self.connection_man = ConnectionManager(self) self.connection_man = ConnectionManager(self)
self.connection_man.connect_by_ip(addr) self.connection_man.connect_by_ip(addr)
@@ -460,9 +466,6 @@ class Controller(GObject.GObject):
open_workshop_page(mod, cmd) open_workshop_page(mod, cmd)
def has_note(self) -> bool: def has_note(self) -> bool:
# TODO: get record string, store in memory
# get note by record, get is in favs, etc. store as a block
# this is only used by context mixin
note = self.get_note() note = self.get_note()
if len(note) > 0: if len(note) > 0:
return True return True
@@ -477,13 +480,11 @@ class Controller(GObject.GObject):
return self.notes_man.get_note(record) return self.notes_man.get_note(record)
def add_note(self, note: str) -> None: def add_note(self, note: str) -> None:
# TODO: record should be cached upon creation of dialog
tv = self.get_active_treeview() tv = self.get_active_treeview()
record = tv.get_record_string() record = tv.get_record_string()
self.notes_man.add_note(record, note) self.notes_man.add_note(record, note)
def delete_note(self) -> None: def delete_note(self) -> None:
# TODO: record should be cached upon creation of dialog
tv = self.get_active_treeview() tv = self.get_active_treeview()
record = tv.get_record_string() record = tv.get_record_string()
self.notes_man.delete_note(record) self.notes_man.delete_note(record)
@@ -498,9 +499,6 @@ class Controller(GObject.GObject):
def set_active_keyword(self, keyword: str) -> None: def set_active_keyword(self, keyword: str) -> None:
self.get_filter_man().set_active_keyword(keyword) self.get_filter_man().set_active_keyword(keyword)
ServerModelManager(self, self.get_active_treeview()).refilter(
FilterMode.TOGGLE_ON
)
def get_modtreeview(self) -> "ModTreeView": def get_modtreeview(self) -> "ModTreeView":
return self.mediator.modtreeview return self.mediator.modtreeview
@@ -526,12 +524,8 @@ class Controller(GObject.GObject):
def set_start_tab(self) -> None: def set_start_tab(self) -> None:
ind = self.config_man.get_start_tab() ind = self.config_man.get_start_tab()
logger.info(f"Setting start tab to index {ind}")
self.get_servers().notebook.set_current_page(ind) self.get_servers().notebook.set_current_page(ind)
def get_debug_args(self) -> str:
return self.connection_man.get_debug_args()
def update_and_load_to_menu(self) -> None: def update_and_load_to_menu(self) -> None:
self.connection_man.update_and_connect(menu_only=True) self.connection_man.update_and_connect(menu_only=True)
@@ -541,10 +535,8 @@ class Controller(GObject.GObject):
def update_status(self) -> None: def update_status(self) -> None:
self.mediator.preconnect.mark_finished() self.mediator.preconnect.mark_finished()
def get_client_name(self) -> str: def get_steam_client_name(self) -> str:
# NOTE: only possible values are "Steam" and "Flatpak" return self.mediator.options.get_client_name()
cmd = self.query_config(Preferences.CLIENT)
return str(cmd.split()[0].capitalize())
def present_window(self) -> None: def present_window(self) -> None:
self.mediator.window.present_with_time(Gdk.CURRENT_TIME) self.mediator.window.present_with_time(Gdk.CURRENT_TIME)
+29 -51
View File
@@ -1,10 +1,7 @@
# Changelog # Changelog
## [7.0.0] Unreleased ## Added
### Added
- Setup wizard - Setup wizard
- Uninstall wizard
- Descriptive details panel to certain error dialogs
- Changelog text wrapping and formatting - Changelog text wrapping and formatting
- Changelog ships with source - Changelog ships with source
- Documentation ships with source - Documentation ships with source
@@ -26,7 +23,6 @@
- Set favorite server from tables - Set favorite server from tables
- Detailed/copyable trace in critical error dialogs - Detailed/copyable trace in critical error dialogs
- Visual icons - Visual icons
- Unset favorite server button
- Integrated server notebook - Integrated server notebook
- Propagate width changes to all tables - Propagate width changes to all tables
- Remember tree position in menus - Remember tree position in menus
@@ -45,22 +41,11 @@
- Choose to jump into splash screen instead of server - Choose to jump into splash screen instead of server
- Collapsible connection panel - Collapsible connection panel
- Play offline (load mods directly) - Play offline (load mods directly)
- Generate Steam shortcuts and cover art
- Warn user if background downloads are disabled
- Dynamic copy button
- Update map count (-u flag)
- Filter button tooltips
- Expand/collapse changelog versions
- Support local light/dark mode changes on system theme
- Server time calculator
### Changed ## Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
- Enforce fullscreen mode on Steam Deck
- Reduce padding on keys button - Reduce padding on keys button
- Boldface breadcrumbs - Boldface breadcrumbs
- Changed some dialog error messages from popups to popovers
- Changed how filter buttons are packed in sidebar
- Bold labels inside frames - Bold labels inside frames
- Sidebar buttons do not steal focus - Sidebar buttons do not steal focus
- Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method - Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method
@@ -73,66 +58,59 @@
- Reduce size of geolocation DB on disk (~100MB) - Reduce size of geolocation DB on disk (~100MB)
- Enable LAN page Empty/Full filters on first run of app - Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client - Propagate subscribed mods to Steam client
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
### Dropped ## Dropped
- Battlemetrics API support - Debug mode
- Unused metadata from system log files
- Branch switching - Branch switching
- Manual mod install mode (describe rationale) - Manual mod install mode (describe rationale)
- Force update mods - Force update mods
### Fixed ## Fixed
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
- In-game time field missing certain ranges of time at night
- Wrap long entries in changelog
- Certain server descriptions with special characters not rendering correctly
- Center server title text on server dialogs - Center server title text on server dialogs
- Rare segfaults when changing maps (threading) - Rare segfaults when changing maps (threading)
- Moved dialogs out of threads - Moved dialogs out of threads
- History table not being in lock-step with state file
- Current keyword filter not being applied when refreshing table
### Unreleased ## Unreleased
- Load offline mods - Load offline mods
- Local documentation - Local documentation
- Raw debug command in context menu - Raw debug command in context menu
### Developer-facing ## Developer-facing
- Add pyproject.toml file - Add pyproject.toml file
- Show deprecation warnings - Show deprecation warnings
- Options -> Dev page - Options -> Dev page
## [6.0.5] 2026-05-17 ## [6.0.5] 2026-05-17
### Changed ## Changed
- Update symlink hash logic to align with DZGUI 7 beta - Update symlink hash logic to align with DZGUI 7 beta
- Update A2S helper to latest commit - Update A2S helper to latest commit
## [6.0.4] 2026-04-13 ## [6.0.4] 2026-04-13
### Fixed ## Fixed
- Update A2S module checksums to support servers with missing description field - Update A2S module checksums to support servers with missing description field
## [6.0.3] 2026-04-12 ## [6.0.3] 2026-04-12
### Fixed ## Fixed
- Use GLibUnix namespace where available when setting up SIGINT callback - Use GLibUnix namespace where available when setting up SIGINT callback
## [6.0.2] 2026-01-26 ## [6.0.2] 2026-01-26
### Fixed ## Fixed
- Explicitly use Python 3.13 when calling subprocesses - Explicitly use Python 3.13 when calling subprocesses
- Raise error correctly from module - Raise error correctly from module
- Do not spawn dialogs from inside of thread when fetching prereqs - Do not spawn dialogs from inside of thread when fetching prereqs
### Changed ## Changed
- Optimize time complexity of startup checks - Optimize time complexity of startup checks
- Optimize coordinate calculation by using local records first - Optimize coordinate calculation by using local records first
- Rename some internal functions - Rename some internal functions
- Wrap entire startup process in dialog - Wrap entire startup process in dialog
## [6.0.1] 2026-01-16 ## [6.0.1] 2026-01-16
### Fixed ## Fixed
- Explicitly use Python 3.13 when calling subprocesses - Explicitly use Python 3.13 when calling subprocesses
## [6.0.0] 2026-01-14 ## [6.0.0] 2026-01-14
### Added ## Added
- Support DayZ Experimental - Support DayZ Experimental
- Ping display on server tables - Ping display on server tables
- Save descriptive text notes on a per-server basis - Save descriptive text notes on a per-server basis
@@ -158,7 +136,7 @@
- Commandline usage help text (GaryBlackbourne) - Commandline usage help text (GaryBlackbourne)
- More descriptive help dialogs when initial dependencies are missing - More descriptive help dialogs when initial dependencies are missing
### Fixed ## Fixed
- Script failing to start when remote endpoints are unavailable (GaryBlackbourne) - Script failing to start when remote endpoints are unavailable (GaryBlackbourne)
- Key stickiness when quickly navigating through entries in tables - Key stickiness when quickly navigating through entries in tables
- Servers returning malformed A2S_INFO blocking server browser from loading - Servers returning malformed A2S_INFO blocking server browser from loading
@@ -197,7 +175,7 @@
- Path discovery during first-time setup when parsing filepaths with whitespaces - 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 - First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
### Changed ## Changed
- Require Python 3.13 - Require Python 3.13
- Reduced global API cooldown from 60s to 30s - Reduced global API cooldown from 60s to 30s
- Clarify dialog messages when DayZ path could not be found - Clarify dialog messages when DayZ path could not be found
@@ -209,50 +187,50 @@
- Rewrote distance calculation module (GaryBlackbourne) - Rewrote distance calculation module (GaryBlackbourne)
- Changed preferred client setting from radio toggle to combobox - Changed preferred client setting from radio toggle to combobox
### Dropped ## Dropped
- Ping readout in statusbar - Ping readout in statusbar
- Extraneous information from right statusbar - Extraneous information from right statusbar
## [5.8.3] 2026-01-04 ## [5.8.3] 2026-01-04
### Fixed ## Fixed
- Normalize checksum numbers and dates - Normalize checksum numbers and dates
## [5.8.2] 2026-01-04 ## [5.8.2] 2026-01-04
### Fixed ## Fixed
- Fix regression in symlink routine introduced by previous hotfix - Fix regression in symlink routine introduced by previous hotfix
## [5.8.1] 2025-12-27 ## [5.8.1] 2025-12-27
### Fixed ## Fixed
- Path discovery during first-time setup when parsing filepaths with whitespaces - 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 - First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
## [5.8.0] 2025-07-06 ## [5.8.0] 2025-07-06
### Added ## Added
- Filter servers by official/unofficial status - Filter servers by official/unofficial status
- Automatically fetch geolocation records - Automatically fetch geolocation records
### Changed ## Changed
- Updated internal versioning of helper files - Updated internal versioning of helper files
### Fixed ## Fixed
- Corrected erroneous 2024 date in prior changelog entries - Corrected erroneous 2024 date in prior changelog entries
## [5.7.0] 2025-04-17 ## [5.7.0] 2025-04-17
### Added ## Added
- Save application dimensions when quitting and restore on subsequent boot - Save application dimensions when quitting and restore on subsequent boot
### Fixed ## Fixed
- Issues with the window exceeding the bounds of the screen when displaying some table contexts - Issues with the window exceeding the bounds of the screen when displaying some table contexts
### Changed ## Changed
- More direct server ping query method that should return marginally more accurate times - More direct server ping query method that should return marginally more accurate times
## [5.6.7] 2025-04-04 ## [5.6.7] 2025-04-04
### Dropped ## 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 - 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 ## [5.6.6] 2025-03-16
### Changed ## Changed
- Update IP database records for 2025-03 - Update IP database records for 2025-03
## [5.6.5] 2025-03-04 ## [5.6.5] 2025-03-04
### Fixed ## Fixed
- Livonia server results being dropped from batch queries - Livonia server results being dropped from batch queries
## [5.6.4] 2025-02-10 ## [5.6.4] 2025-02-10
-4
View File
@@ -24,10 +24,6 @@
.left-label { .left-label {
font-size: 1.3rem; font-size: 1.3rem;
} }
.server-subheading {
font-size: 1.1rem;
font-weight: 700;
}
.details-heading { .details-heading {
font-size: 1.2rem font-size: 1.2rem
} }
File renamed without changes.
File renamed without changes.
File renamed without changes.
+4 -11
View File
@@ -2,26 +2,19 @@ import logging
from pathlib import Path from pathlib import Path
from dzgui.api.steam import get_app_path
from dzgui.config.query import lookup from dzgui.config.query import lookup
from dzgui.const.constants import APPID_DAYZ, APP_NAME, APPNAME_DAYZ from dzgui.const.constants import APPID_DAYZ, APP_NAME, LIBRARYFOLDERS_PATH
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.strings.preconnect import resync import dzgui.api.pefile as PeFile
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
class VDFSyncError(Exception):
pass
def is_dayz_installed(config: Path) -> None: def is_dayz_installed(config: Path) -> None:
try: try:
path = lookup(config, Preferences.DEFAULT) 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: except Exception as e:
logger.critical(e) logger.critical(e)
msg = resync.format(APPNAME_DAYZ) raise e
raise VDFSyncError(msg)
+5 -4
View File
@@ -1,12 +1,11 @@
import platform import platform
import sys import sys
from ctypes.util import find_library from pathlib import Path
from warnings import deprecated from warnings import deprecated
LIB = "libgirepository-2.0" LIB = "libgirepository-2.0"
@deprecated("currently unused") @deprecated("currently unused")
def is_debian() -> bool: def is_debian() -> bool:
""" """
@@ -40,10 +39,12 @@ def is_debian() -> bool:
def has_libgi() -> bool: def has_libgi() -> bool:
return bool(find_library("girepository-2.0")) parent_dir = Path("/usr/lib64")
exists = any(LIB in subdir.name for subdir in parent_dir.iterdir())
return exists
def test_libgi_missing() -> None: def test_missing_lib() -> bool:
msg = f"System is missing the required library '{LIB}'. Install it via your system package manager." msg = f"System is missing the required library '{LIB}'. Install it via your system package manager."
if has_libgi() is False: if has_libgi() is False:
print(msg) print(msg)
+3 -3
View File
@@ -1,4 +1,3 @@
import json
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -8,10 +7,11 @@ from dzgui.config.convert import rc2json
from dzgui.util._json import read_json, write_json from dzgui.util._json import read_json, write_json
def migrate_legacy_conf(config: Path) -> Any: def migrate_legacy_conf(config: Path) -> None:
old_conf = Path.home() / LEGACY_CONFIG_PATH old_conf = Path.home() / LEGACY_CONFIG_PATH
j = rc2json(old_conf) j = rc2json(old_conf)
return json.loads(j) config.parent.mkdir(parents=True, exist_ok=True)
config.write_text(j)
def has_new_config(config: Path) -> bool: def has_new_config(config: Path) -> bool:
+7 -20
View File
@@ -7,32 +7,19 @@ from dzgui.const.constants import APP_NAME, FLATPAK_APPID, FLATPAK_CMD, STEAM_CM
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
def has_flatpak_client() -> bool: def has_steam_client() -> bool:
if shutil.which(STEAM_CMD):
return True
if not shutil.which(FLATPAK_CMD):
return False
try: try:
res = subprocess.run( res = subprocess.run(
[FLATPAK_CMD, "list", "--columns=application"], [FLATPAK_CMD, "list"], capture_output=True, text=True, check=True
capture_output=True,
text=True,
check=True,
) )
if FLATPAK_APPID in res.stdout: if FLATPAK_APPID in res.stdout:
return True return True
else: else:
return False return False
except Exception as e: except subprocess.CalledProcessError as e:
logger.critical(e) logger.critical(e)
return False return False
def has_steam_client() -> bool:
if shutil.which(STEAM_CMD):
return True
return False
def has_any_client() -> bool:
if shutil.which(STEAM_CMD):
return True
if not shutil.which(FLATPAK_CMD):
return False
return has_flatpak_client()
+48 -40
View File
@@ -1,69 +1,60 @@
import logging
import os
import psutil import psutil
import shutil
import subprocess import subprocess
import shutil
from warnings import deprecated
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, DAYZ_BINARY,
STEAM_CMD, STEAM_CMD,
FLATPAK_APPID, FLATPAK_APPID,
FLATPAK_CMD, FLATPAK_CMD,
FLATPAK_RUN_CMD, FLATPAK_RUN_CMD,
FLATPAK_SANDBOX, FLATPAK_SANDBOX,
FLATPAK_ROOT_PATH,
FLATPAK_STEAM_PATH,
) )
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: def is_steam_running(cmd: str) -> bool:
if cmd in (FLATPAK_RUN_CMD, FLATPAK_SANDBOX): if cmd == STEAM_CMD:
if has_cmd(STEAM_CMD) is False:
return False
return is_running(STEAM_CMD)
elif cmd == FLATPAK_RUN_CMD or FLATPAK_SANDBOX:
return is_flatpak_steam_running() return is_flatpak_steam_running()
elif cmd == STEAM_CMD:
return is_native_steam_running()
else: else:
raise TypeError("Not a valid Steam client selection") raise TypeError("Not a valid Steam client selection")
def is_sandboxed() -> bool: # CHORE: test alternate clients
return bool(os.environ.get("FLATPAK_ID")) # TODO: has_flatpak_steam
def is_flatpak_steam_running() -> bool: def is_flatpak_steam_running() -> bool:
if is_sandboxed():
return True
if has_cmd(FLATPAK_CMD) is False: if has_cmd(FLATPAK_CMD) is False:
return False return False
proc = subprocess.check_output( proc = subprocess.check_output([FLATPAK_CMD, "ps"], text=True)
[FLATPAK_CMD, "ps", "--columns=application"], text=True
)
lines = proc.splitlines() lines = proc.splitlines()
if FLATPAK_APPID in lines: if FLATPAK_APPID in lines:
return True return True
return False return False
def get_steam_procs() -> list[list[str]]: def is_running(proc: str) -> bool:
return [ for process in psutil.process_iter(["pid", "name"]):
p.cmdline() if process.info["name"] == proc:
for p in psutil.process_iter(["pid", "name"]) return True
if STEAM_CMD in p.info["name"]
]
def is_native_steam_running() -> bool:
if has_cmd(STEAM_CMD) is False:
return False return False
substrings = [FLATPAK_ROOT_PATH, "steam-runtime-launcher-service"]
steam_procs = get_steam_procs()
native_steam_procs = [
proc
for proc in steam_procs
if not any(sub in item for item in proc for sub in substrings)
]
return bool(native_steam_procs)
def has_cmd(cmd: str) -> bool: def has_cmd(cmd: str) -> bool:
@@ -72,6 +63,23 @@ def has_cmd(cmd: str) -> bool:
return False return False
def path_to_steam_cmd(path: str) -> str: @deprecated("dropped in favor of Gtk native methods")
cmd = FLATPAK_RUN_CMD if FLATPAK_STEAM_PATH in path else STEAM_CMD def foreground(cmd: str, pid: int) -> None:
return cmd 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
)
+117 -23
View File
@@ -1,47 +1,141 @@
import argparse import argparse
import logging
import os
import sys import sys
import warnings import warnings
from dzgui.const.constants import APP_NAME from typing import TYPE_CHECKING
from dzgui.init.libgi import test_libgi_missing
from dzgui.init.prefix import get_version
from dzgui.util.deck import is_steam_deck
from dzgui.util.map_count import set_map_count
from dzgui.util.strings import flags
from dzgui.views.dialogs.uninstall import UninstallWizard from dzgui.const.constants import APP_NAME
from dzgui.config.xdg import get_xdg_paths 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.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.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 = argparse.ArgumentParser(description=flags.description)
group = parser.add_mutually_exclusive_group() parser.add_argument("-v", "--version", action="store_true", help=flags.version)
group.add_argument("-d", "--debug", action="store_true", help=flags.debug) parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall)
group.add_argument("-m", "--map", action="store_true", help=flags.map_count) parser.add_argument("-d", "--debug", action="store_true", help=flags.debug)
group.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall)
group.add_argument("-v", "--version", action="store_true", help=flags.version)
args = parser.parse_args() 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
# XDG_SHARE_HOME/dzgui
# XDG_STATE_HOME/dzgui
# XDG_DATA_HOME/dzgui
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: def main() -> None:
lock = lock_acquire() # noqa
if args.version is True: if args.version is True:
print(get_version()) print(get_version())
sys.exit(0) sys.exit(0)
if args.uninstall is True: if args.uninstall is True:
paths = get_xdg_paths() uninstall()
_is_steam_deck = is_steam_deck()
UninstallWizard(_is_steam_deck, paths)
sys.exit(0) sys.exit(0)
if args.debug is True: if args.debug is True:
warnings.filterwarnings("default", category=DeprecationWarning) warnings.filterwarnings("default", category=DeprecationWarning)
if args.map is True:
set_map_count()
sys.exit(0)
version = get_version() version = get_version()
set_locale()
print(f"{APP_NAME} {version}") print(f"{APP_NAME} {version}")
test_libgi_missing()
# NOTE: only import ligbirepository modules after the above check # NOTE: consider aborting this check if steam deck
from dzgui.app_init import load_gui 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)
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=args.debug,
coords=local_coords,
version=version,
paths=XDG,
latest_release=latest_release,
use_miles=use_miles,
)
print(boot.all_ok)
App(prefs)
+27 -25
View File
@@ -6,16 +6,24 @@ from typing import Any, TYPE_CHECKING
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
FLATPAK_RUN_CMD,
FLATPAK_SANDBOX,
STEAM_CMD,
WINDOW_DEFAULT_X, WINDOW_DEFAULT_X,
WINDOW_DEFAULT_Y, WINDOW_DEFAULT_Y,
) )
from dzgui.api.probe import test_steam_api from dzgui.api.probe import test_steam_api, test_bm_api
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.generic import ExceptionDialog
from dzgui.strings import dialogs from dzgui.strings import dialogs
from dzgui.util._json import read_json, write_json from dzgui.util._json import read_json, write_json
import gi
gi.require_version("Gtk", "3.0")
from gi.repository.Gtk import main_quit # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.config.userprefs import UserPrefs from dzgui.config.userprefs import UserPrefs
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -47,11 +55,8 @@ class ConfigManager:
self.update_config(Preferences.IP_LIST, ips) self.update_config(Preferences.IP_LIST, ips)
def update_history_file(self, fqip: str) -> None: def update_history_file(self, fqip: str) -> None:
try:
with open(self.prefs.paths.history, "r") as f: with open(self.prefs.paths.history, "r") as f:
ips = [line.rstrip() for line in f.readlines()] ips = [line.rstrip() for line in f.readlines()]
except FileNotFoundError:
ips = []
seen = set() seen = set()
ips.append(fqip) ips.append(fqip)
@@ -90,13 +95,14 @@ class ConfigManager:
logger.critical(e) logger.critical(e)
raise e raise e
@call_on_thread(dialogs.checking_api, show_dialog=False) @call_on_thread(dialogs.checking_api)
def update_steam_api_key(self, text: str) -> None: def update_api_key(self, key: Preferences, text: str) -> None:
if key is Preferences.STEAM:
res = test_steam_api(text) res = test_steam_api(text)
else:
res = test_bm_api(text)
if res is True: if res is True:
self.update_config(Preferences.STEAM, text) self.update_config(key, text)
func = StoredFunc(lambda: self.emitter.emit("api_change_successful"))
self.thread_man.set_cleanup_func(func)
else: else:
self.thread_man.set_cleanup_func( self.thread_man.set_cleanup_func(
StoredFunc(lambda: self.emitter.emit("api_change_failed")) StoredFunc(lambda: self.emitter.emit("api_change_failed"))
@@ -126,6 +132,15 @@ class ConfigManager:
def enum_to_key(self, enum: Preferences) -> str: def enum_to_key(self, enum: Preferences) -> str:
return str(enum.dict["key"]) return str(enum.dict["key"])
def get_client_index(self, client: str) -> int:
if client == STEAM_CMD:
return 0
if client == FLATPAK_RUN_CMD:
return 1
if client == FLATPAK_SANDBOX:
return 2
return 0
def toggle_config(self, key: Preferences) -> None: def toggle_config(self, key: Preferences) -> None:
try: try:
real_key = self.enum_to_key(key) real_key = self.enum_to_key(key)
@@ -144,13 +159,6 @@ class ConfigManager:
except Exception: except Exception:
return return
def unset_fav(self) -> None:
try:
self.write_config(Preferences.FAV_LBL, "")
self.write_config(Preferences.FAV_SRV, "")
except Exception:
return
def write_config(self, key: Preferences, value: str) -> None: def write_config(self, key: Preferences, value: str) -> None:
try: try:
real_key = self.enum_to_key(key) real_key = self.enum_to_key(key)
@@ -161,8 +169,8 @@ class ConfigManager:
logger.critical(e) logger.critical(e)
trace = traceback.format_exc() trace = traceback.format_exc()
dialog = ExceptionDialog(self.controller, trace) dialog = ExceptionDialog(self.controller, trace)
dialog.show_all()
dialog.run() dialog.run()
raise e
def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None: def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None:
columns = tv.get_columns() columns = tv.get_columns()
@@ -189,10 +197,7 @@ class ConfigManager:
logger.info("Normal user exit") logger.info("Normal user exit")
if window.props.is_maximized: if window.props.is_maximized:
app = window.get_application() main_quit()
if app is None:
return
app.quit()
return return
w, h = window.get_size() w, h = window.get_size()
@@ -204,10 +209,7 @@ class ConfigManager:
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
app = window.get_application() main_quit()
if app is None:
return
app.quit()
def set_resolution(self, window: "OuterWindow") -> None: def set_resolution(self, window: "OuterWindow") -> None:
if self.prefs.is_game_mode: if self.prefs.is_game_mode:
+24 -89
View File
@@ -9,17 +9,11 @@ from typing import TYPE_CHECKING
import dzgui.api.pefile as PeFile import dzgui.api.pefile as PeFile
import dzgui.api.servers as Servers import dzgui.api.servers as Servers
from dzgui.api.shortcuts import Shortcuts
from dzgui.api.steam import ( from dzgui.api.steam import (
connect, connect,
connect_debug,
is_dayz_downloading,
get_app_allows_downloads,
get_app_name,
get_needs_update,
get_remote_signatures, get_remote_signatures,
get_running_app, get_needs_update,
load_to_menu, load_to_menu,
subscribe, subscribe,
) )
@@ -38,18 +32,12 @@ from dzgui.const.constants import (
APPNAME_DAYZ, APPNAME_DAYZ,
APPNAME_DAYZ_EXP_HUMAN, APPNAME_DAYZ_EXP_HUMAN,
) )
from dzgui.api.steam import is_dayz_running
from dzgui.const.enum import NotebookPage, Preferences 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.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings.dialogs import ( from dzgui.strings.dialogs import waiting_for_launch, waiting_for_mods, waiting_for_directories
waiting_for_launch,
waiting_for_mods,
waiting_for_directories,
)
from dzgui.strings import kb
from dzgui.strings.server_mods import checkmark, resync from dzgui.strings.server_mods import checkmark, resync
from dzgui.util.format import format_exception, format_mib from dzgui.util.format import format_mib
from dzgui.util.strings import dialog, server_timeout from dzgui.util.strings import dialog, server_timeout
from dzgui.util.symlink import rebuild_symlinks from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.generic import ExceptionDialog
@@ -85,13 +73,10 @@ class Prerequisites:
available_space: float available_space: float
passworded: bool passworded: bool
dayz_running: bool dayz_running: bool
download_paused: bool
steam_proc: SteamProcess steam_proc: SteamProcess
mods: list[list[str]] mods: list[list[str]]
game_mode: bool game_mode: bool
is_last_server: bool is_last_server: bool
invalid_mods: list[tuple[str, str]]
allows_downloads: tuple[bool, str]
class ConnectionManager: class ConnectionManager:
@@ -108,6 +93,11 @@ class ConnectionManager:
self.remote_mod_ids: list[str] = [] self.remote_mod_ids: list[str] = []
self.missing_mods: list[tuple[str, str, int, int]] = [] self.missing_mods: list[tuple[str, str, int, int]] = []
@call_on_thread(dialog.querying)
def connect_by_id(self, _id: int, key: str) -> None:
res = Servers.query_by_id(_id, key)
self._prepare_connection(res)
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def connect_by_ip(self, addr: str) -> None: def connect_by_ip(self, addr: str) -> None:
res = Servers.query_by_ip(addr) res = Servers.query_by_ip(addr)
@@ -154,14 +144,21 @@ class ConnectionManager:
remote_mods: list[list[str]] = [] remote_mods: list[list[str]] = []
if res.is_modded(): if res.is_modded():
try: try:
remote_mods, missing_mods = self._query_modlist(record) remote_mods = self._query_modlist(record)
self.missing_mods = missing_mods
self.remote_mod_ids = [mod[1] for mod in remote_mods] self.remote_mod_ids = [mod[1] for mod in remote_mods]
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
self.thread_man.set_cleanup_func(failure_func, destroy_first=True) self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
return 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: if local_version is not None:
pefile_path = PeFile.get_pefile_path(steam_path, info.game_id) pefile_path = PeFile.get_pefile_path(steam_path, info.game_id)
total, used, free = shutil.disk_usage(pefile_path) total, used, free = shutil.disk_usage(pefile_path)
@@ -171,24 +168,9 @@ class ConnectionManager:
free_mib = format_mib(free) free_mib = format_mib(free)
dayz_running = is_dayz_running() dayz_running = is_dayz_running()
download_paused = is_dayz_downloading(steam_path, self.appid)
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) client = self.controller.query_config(Preferences.CLIENT)
client_name = self.controller.get_client_name()
running = is_steam_running(client) running = is_steam_running(client)
steam_proc = SteamProcess(client_name, running) steam_proc = SteamProcess(client_name, running)
self.client = client self.client = client
@@ -197,11 +179,6 @@ class ConnectionManager:
is_last = self.is_last_server() is_last = self.is_last_server()
# TODO: strings
invalid_mods = [
(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod"
]
prereqs = Prerequisites( prereqs = Prerequisites(
name=info.server_name, name=info.server_name,
appid=info.game_id, appid=info.game_id,
@@ -213,13 +190,10 @@ class ConnectionManager:
available_space=free_mib, available_space=free_mib,
passworded=info.password_protected, passworded=info.password_protected,
dayz_running=dayz_running, dayz_running=dayz_running,
download_paused=download_paused,
steam_proc=steam_proc, steam_proc=steam_proc,
mods=remote_mods, mods=remote_mods,
game_mode=game_mode, game_mode=game_mode,
is_last_server=is_last, is_last_server=is_last,
invalid_mods=invalid_mods,
allows_downloads=allows_downloads,
) )
func = StoredFunc(self.controller.open_connection_assistant, prereqs) func = StoredFunc(self.controller.open_connection_assistant, prereqs)
@@ -239,7 +213,7 @@ class ConnectionManager:
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def query_details(self, record: Servers.Record) -> None: def query_details(self, record: Servers.Record) -> None:
details = Servers.get_details(record) details = Servers.get_details(record)
if details is None: if details.success is False:
self.thread_man.set_cleanup_func( self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True StoredFunc(self._server_timeout), destroy_first=True
) )
@@ -248,13 +222,10 @@ class ConnectionManager:
StoredFunc(self._present_details_dialog, details), destroy_first=True StoredFunc(self._present_details_dialog, details), destroy_first=True
) )
def _query_modlist( def _query_modlist(self, record: Servers.Record) -> list[list[str]]:
self, record: Servers.Record
) -> tuple[list[list[str]], list[tuple[str, str, int, int]]]:
mods = Servers.get_rules(record) mods = Servers.get_rules(record)
steam_path = self.controller.query_config(Preferences.DEFAULT) 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]] = [ alpha_mods: list[list[str]] = [
[ [
mod.name, mod.name,
@@ -264,28 +235,12 @@ class ConnectionManager:
for mod in mods for mod in mods
] ]
alpha_mods.sort(key=lambda x: x[0]) alpha_mods.sort(key=lambda x: x[0])
return alpha_mods
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
for mod in alpha_mods:
# NOTE: if the mod is neither synched or out of date, it is a malformed mod
if mod[2] == "":
# TODO: strings
mod[2] = "Invalid mod"
return alpha_mods, missing_mods
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def query_modlist_and_present(self, record: Servers.Record) -> None: def query_modlist_and_present(self, record: Servers.Record) -> None:
try: try:
mods, missing_mods = self._query_modlist(record) mods = self._query_modlist(record)
except Exception: except Exception:
self.thread_man.set_cleanup_func( self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True StoredFunc(self._server_timeout), destroy_first=True
@@ -304,24 +259,10 @@ class ConnectionManager:
dialog = ServerDetailsDialog(self.controller, details) dialog = ServerDetailsDialog(self.controller, details)
dialog.run() dialog.run()
def _workshop_timeout(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.show_all()
dialog.run()
def _server_timeout(self) -> None: def _server_timeout(self) -> None:
dialog = ExceptionDialog(self.controller, server_timeout) dialog = ExceptionDialog(self.controller, server_timeout)
dialog.set_details_buffer(kb.DZG_006)
dialog.show_all()
dialog.run() dialog.run()
def get_debug_args(self) -> str:
addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME)
return connect_debug(
self.client, addr, self.appid, playername, self.remote_mod_ids
)
def _connect_steam(self, menu_only: bool) -> None: def _connect_steam(self, menu_only: bool) -> None:
addr = f"{self.record.ip}:{self.record.gameport}" addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME) playername = self.controller.query_config(Preferences.NAME)
@@ -360,14 +301,8 @@ class ConnectionManager:
for title, mod, stamp, size in self.missing_mods: for title, mod, stamp, size in self.missing_mods:
if self.controller.is_cancel_pending(): if self.controller.is_cancel_pending():
return return
try:
subscribe(key, int(mod)) subscribe(key, int(mod))
time.sleep(API_RATE_LIMIT) time.sleep(API_RATE_LIMIT)
except Exception as e:
msg = format_exception(e)
func = StoredFunc(self._workshop_timeout, msg)
self.thread_man.set_cleanup_func(func, destroy_first=True)
return
self.thread_man.update_dialog(waiting_for_directories) self.thread_man.update_dialog(waiting_for_directories)
for title, mod, stamp, size in self.missing_mods: for title, mod, stamp, size in self.missing_mods:
+5
View File
@@ -1,3 +1,4 @@
import gi
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -13,6 +14,10 @@ from dzgui.views.trees.tree_servers import ServerTreeView
from dzgui.views.trees.tree_server_mods import ServerModTreeView from dzgui.views.trees.tree_server_mods import ServerModTreeView
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING: if TYPE_CHECKING:
+4 -27
View File
@@ -21,30 +21,12 @@ class FilterManager:
strings.filter_3pp: True, strings.filter_3pp: True,
strings.filter_night: True, strings.filter_night: True,
strings.filter_full: show_full, strings.filter_full: show_full,
strings.filter_official: True,
strings.filter_modded: True,
strings.filter_lowpop: True, strings.filter_lowpop: True,
strings.filter_unofficial: True,
strings.filter_unmodded: True,
strings.filter_duplicate: False,
strings.filter_nonascii: False, strings.filter_nonascii: False,
} strings.filter_duplicate: False,
strings.filter_official: True,
# TODO: strings strings.filter_unofficial: True,
self.tooltips = { strings.filter_modded: True,
strings.filter_1pp: "First-person perspective",
strings.filter_day: "In-game time between 0600 and 1759",
strings.filter_empty: "Servers with no players",
strings.filter_3pp: "Third-person perspective",
strings.filter_night: "In-game time between 1800 and 0559",
strings.filter_full: "Servers with no open slots",
strings.filter_official: "Bohemia official servers",
strings.filter_nonascii: "Server names using non-standard, complex glyphs",
strings.filter_lowpop: "Current population is under 30% of total",
strings.filter_unofficial: "Third-party servers",
strings.filter_duplicate: "Duplicates of existing servers (usually spoofed)",
strings.filter_modded: "Servers with one or more mods",
strings.filter_unmodded: "Servers with no mods",
} }
self.active_keyword = "" self.active_keyword = ""
@@ -54,9 +36,6 @@ class FilterManager:
self.filters: list self.filters: list
self.enabled_filters = dict(self.default_filters) self.enabled_filters = dict(self.default_filters)
def get_tooltips(self) -> dict[str, str]:
return self.tooltips
def set_prior_map(self, name: str) -> None: def set_prior_map(self, name: str) -> None:
self.prior_map = name self.prior_map = name
@@ -101,13 +80,11 @@ class FilterManager:
if maps is None: if maps is None:
return return
if len(maps) < 1: if len(maps) < 1:
self.reinit_map_store()
return return
self.reinit_map_store() self.reinit_map_store()
maps.sort() maps.sort()
for m in maps: for m in maps:
# TODO: strings
if m == "All maps": if m == "All maps":
continue continue
self.append_map([m]) self.append_map([m])
+14 -21
View File
@@ -22,7 +22,8 @@ from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManage
from dzgui.model.model_factory import FastInsertListStore, ModelFactory from dzgui.model.model_factory import FastInsertListStore, ModelFactory
from dzgui.strings import dialogs from dzgui.strings import dialogs
from dzgui.util.format import format_mods from dzgui.util.format import format_mods
from dzgui.util.symlink import rebuild_symlinks from dzgui.util.strings import server_timeout
from dzgui.views.dialogs.generic import ExceptionDialog
import dzgui.api.pefile as PeFile import dzgui.api.pefile as PeFile
@@ -58,20 +59,16 @@ class ModManager:
@call_on_thread(dialogs.fetching_mods) @call_on_thread(dialogs.fetching_mods)
def load_mods(self) -> None: def load_mods(self) -> None:
mods = get_delimited_mods(Path(self.path)) mods = get_delimited_mods(self.path)
if len(mods) < 1: if len(mods) < 1:
msg = self.format_mod_statusbar() msg = self.format_mod_statusbar()
func = StoredFunc(lambda: self.emitter.emit("mods_updated", msg, 0)) func = StoredFunc(lambda: self.emitter.emit("mods_updated", msg, 0))
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
return return
prefs = self.controller.get_prefs()
rebuild_symlinks(prefs.paths.config)
self.store = ModelFactory().make_mod_store() self.store = ModelFactory().make_mod_store()
self.store.extend(mods) self.store.extend(mods)
func = StoredFunc(self._on_mods_loaded) func = StoredFunc(self._on_mods_loaded)
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
def set_store(self, store: "FastInsertListStore") -> None: def set_store(self, store: "FastInsertListStore") -> None:
@@ -96,7 +93,6 @@ class ModManager:
continue continue
mod, _iter = res mod, _iter = res
mods.append((mod, _iter)) mods.append((mod, _iter))
self.thread_man.set_job_count(len(mods))
self.unsub_all_mods(mods) self.unsub_all_mods(mods)
def get_mod_from_tree_path( def get_mod_from_tree_path(
@@ -113,20 +109,16 @@ class ModManager:
def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None: def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
for mod, _iter in mods: for mod, _iter in mods:
self.unsub_atomic_mod(mod) self.unsub_atomic_mod(mod)
self.thread_man.increment_dialog()
time.sleep(API_RATE_LIMIT) time.sleep(API_RATE_LIMIT)
func = StoredFunc(self._on_mods_unsubbed, mods) iters = [_iter for mod, _iter in mods]
func = StoredFunc(self._on_mods_unsubbed, iters)
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
def unsub_atomic_mod(self, mod: str) -> None: def unsub_atomic_mod(self, mod: str) -> None:
config_man = self.controller.get_config_man() config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.STEAM) key = config_man.lookup(Preferences.STEAM)
try:
unsubscribe(key, int(mod)) unsubscribe(key, int(mod))
except Exception as e:
logger.critical(e)
return
steam_path = Path(self.path) steam_path = Path(self.path)
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
@@ -143,25 +135,22 @@ class ModManager:
app_path_exp = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP) app_path_exp = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP)
symlink = app_path_exp / md5 symlink = app_path_exp / md5
symlink.unlink() symlink.unlink()
except Exception: except PeFile.AppNotInstalledError:
pass pass
time.sleep(API_RATE_LIMIT) time.sleep(API_RATE_LIMIT)
def _on_mods_unsubbed(self, mod_iter: list[tuple[str, Gtk.TreeIter]]) -> None: def _on_mods_unsubbed(self, iters: list[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: if self.store is None:
return return
for _iter in iters: for _iter in iters:
self.store.remove(_iter) self.store.remove(_iter)
# TODO: process config path in called function remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version)
remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version, mods)
model = self.treeview.get_model() model = self.treeview.get_model()
if model is None: if model is None:
return return
total_mods = len(model) mods = len(model)
msg = self.format_mod_statusbar() 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: def uncolorize_mods(self) -> None:
model = self.treeview.get_model() model = self.treeview.get_model()
@@ -215,6 +204,10 @@ class ModManager:
func = StoredFunc(self._on_stale_mods_found, stale) func = StoredFunc(self._on_stale_mods_found, stale)
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
def _server_timeout(self) -> None:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.run()
def select_colorized(self) -> None: def select_colorized(self) -> None:
model = self.treeview.get_model() model = self.treeview.get_model()
if model is None: if model is None:
+3 -2
View File
@@ -5,9 +5,10 @@ from typing import Callable, TYPE_CHECKING, Union
import dzgui.api.pefile as PeFile import dzgui.api.pefile as PeFile
from dzgui.api.mods import is_mission, get_custom_mods from dzgui.api.mods import is_mission, get_custom_mods
from dzgui.api.steam import is_dayz_running, launch_offline from dzgui.api.steam import launch_offline
from dzgui.const.constants import APP_NAME, APPID_DAYZ_EXP from dzgui.const.constants import APP_NAME, APPID_DAYZ_EXP
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.init.proc import is_dayz_running
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
from dzgui.strings import dialogs from dzgui.strings import dialogs
@@ -76,7 +77,7 @@ class OfflineManager:
window.set_sensitive(True) window.set_sensitive(True)
self.emitter.emit("custom_mods_loaded", store, folder) self.emitter.emit("custom_mods_loaded", store, folder)
@call_on_thread(dialogs.waiting_for_launch, show_cancel=True) @call_on_thread(dialogs.waiting_for_launch)
def launch( def launch(
self, self,
appid: int, appid: int,
-1
View File
@@ -64,5 +64,4 @@ class UpdateManager:
def _on_update_failure(self, msg: str) -> None: def _on_update_failure(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg) dialog = ExceptionDialog(self.controller, msg)
dialog.show_all()
dialog.run() dialog.run()
+4 -1
View File
@@ -2,6 +2,7 @@ from dataclasses import dataclass
from typing import Any, Sequence, TYPE_CHECKING from typing import Any, Sequence, TYPE_CHECKING
from dzgui.const.enum import HELP_MENU_ROWS from dzgui.const.enum import HELP_MENU_ROWS
from dzgui.util.redact import redact_log
from dzgui.util.strings import delimiter from dzgui.util.strings import delimiter
import gi import gi
@@ -133,7 +134,9 @@ class ModelFactory:
with open(path, "r") as f: with open(path, "r") as f:
lines = [line.split(delimiter) for line in f.read().splitlines()] lines = [line.split(delimiter) for line in f.read().splitlines()]
for record in lines: for record in lines:
store.append(record) # NOTE: strips PII and API keys
clean = redact_log(record)
store.append(clean)
return store return store
def new_model_from_class(self, cls: type) -> FastInsertListStore: def new_model_from_class(self, cls: type) -> FastInsertListStore:
+12 -12
View File
@@ -1,6 +1,7 @@
import re import re
from typing import Any, TYPE_CHECKING, Union from typing import Any, TYPE_CHECKING, Union
from warnings import deprecated
from dzgui.const.enum import FilterMode from dzgui.const.enum import FilterMode
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
@@ -12,9 +13,6 @@ if TYPE_CHECKING:
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.model.servers import NewPlayerCount from dzgui.model.servers import NewPlayerCount
DAY_REG = r"([0][6-9]|[1][0-7])"
NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])"
class ProxyModelManager: class ProxyModelManager:
""" """
@@ -88,8 +86,8 @@ class ProxyModelManager:
if found is False: if found is False:
self.control_model.append(history) self.control_model.append(history)
if len(self.control_model) == 11: # if len(self.control_model) == 11:
del self.control_model[0] # del self.control_model[0]
self.filter(FilterMode.INITIAL, skip_cache=True) self.filter(FilterMode.INITIAL, skip_cache=True)
@@ -234,17 +232,17 @@ class ProxyModelManager:
final.append(row) final.append(row)
rows = final rows = final
case strings.filter_day: case strings.filter_day:
rows = [row for row in rows if not re.match(DAY_REG, row[3])] reg = r"([0][0-9]|[1][0-6])"
rows = [row for row in rows if not re.match(reg, row[3])]
case strings.filter_night: case strings.filter_night:
rows = [row for row in rows if not re.match(NIGHT_REG, row[3])] reg = r"([0][0-4]|[1][8]|[2][0-3])"
rows = [row for row in rows if not re.match(reg, row[3])]
case strings.filter_nonascii: case strings.filter_nonascii:
rows = [row for row in rows if row[0].isascii()] rows = [row for row in rows if row[0].isascii()]
case strings.filter_lowpop: case strings.filter_lowpop:
rows = [row for row in rows if (row[4] / row[5] * 100) > 30] rows = [row for row in rows if (row[4] / row[5] * 100) > 30]
case strings.filter_modded: case strings.filter_modded:
rows = [row for row in rows if not row[11]] rows = [row for row in rows if not row[11]]
case strings.filter_unmodded:
rows = [row for row in rows if row[11]]
return rows return rows
def filter_toggle_on(self, filters: tuple[str]) -> list: def filter_toggle_on(self, filters: tuple[str]) -> list:
@@ -261,6 +259,10 @@ class ProxyModelManager:
def set_cache(self, filters: tuple[str], filtered_rows: list[tuple]) -> None: def set_cache(self, filters: tuple[str], filtered_rows: list[tuple]) -> None:
self.filter_cache[filters] = filtered_rows self.filter_cache[filters] = filtered_rows
@deprecated("Currently unused")
# def convert_model_to_list(self, model: "FastInsertListStore") -> list:
# return [[el for el in row] for row in model]
def set_filtered(self, rows: list | None) -> None: def set_filtered(self, rows: list | None) -> None:
if rows is None: if rows is None:
self.filtered = [] self.filtered = []
@@ -296,6 +298,4 @@ class ProxyModelManager:
def push(self, data: list[Any]) -> None: def push(self, data: list[Any]) -> None:
self.wipe_cache() self.wipe_cache()
self.set_control(data) self.set_control(data)
cur_keyword = self.filter_man.get_active_keyword() self.filter(FilterMode.INITIAL)
mode = FilterMode.TOGGLE_ON if len(cur_keyword) > 0 else FilterMode.INITIAL
self.filter(mode)
+20 -20
View File
@@ -14,14 +14,14 @@ from dzgui.const.constants import (
) )
from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.const.enum import FilterMode, Preferences, ServerTab
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import dialogs, kb from dzgui.strings import dialogs
from dzgui.util.strings import server_timeout, dialog from dzgui.util.strings import api_warn_msg, dialog
from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.generic import ExceptionDialog
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402 from gi.repository import Gtk # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record from dzgui.api.servers import A2SInfo, Record
@@ -175,18 +175,11 @@ class ServerModelManager:
# TODO: wrap except # TODO: wrap except
if self.controller.get_exit_event().is_set(): if self.controller.get_exit_event().is_set():
return return
self.thread_man.increment_dialog()
"""
Collects futures in linear order after completion
for lock-step representation of the state file
"""
for future in futures:
res = future.result(timeout=API_TIMEOUT) res = future.result(timeout=API_TIMEOUT)
self.thread_man.increment_dialog()
# NOTE: failing entries are culled # NOTE: failing entries are culled
if res is None: if res is None:
# TODO: log which servers failed # TODO: log which servers failed
# query_direct() currently does not return enough information
continue continue
servers.append(res) servers.append(res)
if len(servers) == 0: if len(servers) == 0:
@@ -198,6 +191,13 @@ class ServerModelManager:
parsed = Servers.parse_json(servers) parsed = Servers.parse_json(servers)
self._push_data(parsed) self._push_data(parsed)
@call_on_thread(dialog.querying)
def add_by_id(self, _id: str) -> None:
config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.BM)
res = Servers.query_by_id(int(_id), key)
self._parse_single_record(res)
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def add_by_ip(self, addr: str) -> None: def add_by_ip(self, addr: str) -> None:
res = Servers.query_by_ip(addr) res = Servers.query_by_ip(addr)
@@ -241,6 +241,7 @@ class ServerModelManager:
self.first_iteration = False self.first_iteration = False
self.emitter.emit("load_maps", store) self.emitter.emit("load_maps", store)
# self.emitter.emit("servers_loaded_init")
# TODO: dataclass for record rows; check for other dict annotations # TODO: dataclass for record rows; check for other dict annotations
def add_to_history(self, data: tuple[dict[str, Any], "Record"]) -> None: def add_to_history(self, data: tuple[dict[str, Any], "Record"]) -> None:
@@ -262,6 +263,12 @@ class ServerModelManager:
proxy_man.remove_row_from_control(record) proxy_man.remove_row_from_control(record)
self.update_history() self.update_history()
def add_by_str(self, addr: str) -> None:
if addr.isdigit():
self.add_by_id(addr)
else:
self.add_by_ip(addr)
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def update_playercount( def update_playercount(
self, treeiter: Gtk.TreeIter, record: Servers.Record self, treeiter: Gtk.TreeIter, record: Servers.Record
@@ -388,12 +395,8 @@ class ServerModelManager:
self.first_iteration = False self.first_iteration = False
def _cleanup_on_success(self) -> None: 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) self.tv.set_model(proxy)
if self.tv.enum == ServerTab.BROWSER:
GLib.idle_add(self.tv.wipe_cache)
if self.preserve_on_fail is True:
self.proxy_man.wipe_cache()
self.emitter.emit("servers_loaded", self.enum) self.emitter.emit("servers_loaded", self.enum)
if self.first_iteration: if self.first_iteration:
@@ -407,7 +410,6 @@ class ServerModelManager:
if show_dialog: if show_dialog:
dialog = ExceptionDialog(self.controller, dialogs.load_error_lan) dialog = ExceptionDialog(self.controller, dialogs.load_error_lan)
dialog.show_all()
dialog.run() dialog.run()
def _cleanup_on_failure(self, show_dialog: bool = True) -> None: def _cleanup_on_failure(self, show_dialog: bool = True) -> None:
@@ -422,9 +424,7 @@ class ServerModelManager:
# TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty" # TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty"
# customize statusbar and dialog accordingly # customize statusbar and dialog accordingly
if show_dialog: if show_dialog:
dialog = ExceptionDialog(self.controller, server_timeout) dialog = ExceptionDialog(self.controller, api_warn_msg)
dialog.set_details_buffer(kb.DZG_006)
dialog.show_all()
dialog.run() dialog.run()
def _push_data(self, data: list[Any]) -> None: def _push_data(self, data: list[Any]) -> None:
-7
View File
@@ -1,7 +0,0 @@
heading_local_time = "Local time"
heading_server_time = "In-game time"
heading_target_time = "Target time"
heading_remainder_time = "Remainder until target"
heading_adjusted_time = "Adjusted local time"
calculate = "Calculate"
disclaimer = "Times shown are approximations.\nServer time may differ based on time of year in game."
-2
View File
@@ -1,2 +0,0 @@
collapse_all = "Collapse all"
expand_all = "Expand all"
+7 -5
View File
@@ -1,22 +1,24 @@
from dzgui.const.constants import UDP_PORT
add_button="Add" add_button="Add"
add_con_heading="Add/connect" add_con_heading="Add/connect"
add_popover = "This address is already in your Saved Servers" add_popover = "This address is already in your Saved Servers"
add_tooltip="Add to Saved Servers" add_tooltip="Add to Saved Servers"
connect_button="Connect" connect_button="Connect"
connect_tooltip="Connect to this server" connect_tooltip="Connect to this server"
connect_entry_placeholder = f"Enter IP:Query port (e.g., 192.168.1.1:{UDP_PORT})" connect_entry_tooltip=(
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n"
"- Battlemetrics: numeric server ID\ne.g. 123456"
)
connect_entry_placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)"
fav_heading="Favorite server" fav_heading="Favorite server"
favs_empty="None set. Right click a server and select 'Set favorite' to set." favs_empty="None set. Right click a server and select 'Set favorite' to set."
lan_heading="LAN query port" lan_heading="LAN query port"
lan_default_button = f"Default port ({UDP_PORT})" lan_default_button="Default port (27016)"
lan_custom_button="Custom port" lan_custom_button="Custom port"
lan_scan_button="Scan" lan_scan_button="Scan"
lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster"
lan_checkbox = "Stop scanning on first hit" lan_checkbox = "Stop scanning on first hit"
lan_entry_placeholder = "Enter the query port (1-65535)" lan_placeholder="Enter the query port (1-65535)"
lan_entry_tooltip="Specify the port to search for DayZ servers on the local network" lan_entry_tooltip="Specify the port to search for DayZ servers on the local network"
lan_scan_tooltip="Scan for servers" lan_scan_tooltip="Scan for servers"
-8
View File
@@ -20,11 +20,3 @@ ok = "OK"
mission_dialog = "Set a mission folder location" mission_dialog = "Set a mission folder location"
custom_mod_dialog = "Set location to a custom mods folder" custom_mod_dialog = "Set location to a custom mods folder"
debug_heading = "Debug"
debug_secondary = "Launch arguments"
toggle_calc = "Toggle calculator"
server_details = "Server details"
server_message = "Server message"
-1
View File
@@ -4,4 +4,3 @@ api_validation_error = (
api_popover = "API key validation failed." api_popover = "API key validation failed."
dayz_not_found = "Could not find a valid DayZ installation." dayz_not_found = "Could not find a valid DayZ installation."
workshop_endpoint_error = "Steam Workshop service unavailable, try again later."
-13
View File
@@ -1,13 +0,0 @@
DZG_006 = """The leading cause of specific servers periodically timing out is local network configuration.
Many third-party DayZ servers use a server rental/hosting provider with DDoS protection.
This can cause responses from servers to originate from a server other than the one originally queried.
Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation).
By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue.
If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port.
In addition, a uniform packet size is expected. If your router's MTU (maximum transmission unit) setting differs from the default of 1500, it could lead to incoming responses being discarded."""
-16
View File
@@ -1,16 +0,0 @@
exit_msg = (
"System map count is not high enough to run DayZ. "
"Please exit and run 'dzgui -m' to update map count."
)
failed_to_parse = (
"Failed to parse system map count.\n"
"This usually indicates that systemd is not installed."
)
failed_to_update = (
"Failed to update system map count.\n"
"Please report the issue upstream and provide the following traceback.\n\n"
"{0}"
)
meets_minimum = "System map count of {0} already meets the minimum."
prompt = "Updated map count will be written to the file {0}."
user_exit = "User exit"
-3
View File
@@ -1,7 +1,4 @@
heading = "Play offline" heading = "Play offline"
heading_disclaimer = (
"Not all mod combinations will function offline. Use at your own discretion."
)
mission_button = "Select mission folder" mission_button = "Select mission folder"
mission_frame = "Mission" mission_frame = "Mission"
+1 -15
View File
@@ -1,26 +1,12 @@
developers = "Developers" developers = "Developers"
start_tab = "Start tab" start_tab = "Start tab"
workshop_label = "Subscribed mods" workshop_label = "Subscribed mods"
workshop_eventbox = "If you manually subscribed to mods on Steam prior to using DZGUI, you can unsubscribe via this link."
steam_combo = "Steam" steam_combo = "Steam"
flatpak_combo = "Flatpak" flatpak_combo = "Flatpak"
flatpak_container_combo = "Flatpak (container)" flatpak_container_combo = "Flatpak (container)"
steam_placeholder = "Enter your Steam API key"
name_placeholder = "Identifies you to other players"
server_combo = "Server browser" server_combo = "Server browser"
saved_combo = "Saved servers" saved_combo = "Saved servers"
recent_combo = "Recent" recent_combo = "Recent"
lan_combo = "LAN" lan_combo = "LAN"
fullscreen_eventbox = "This option is not available on Steam Deck."
save_button = "Save"
api_failed = "API validation failed"
flatpak_label = "Flatpak client"
flatpak_standard = "Standard"
flatpak_container = "Containerized (flatpak-spawn --host)"
flatpak_info = (
"Containerized mode is used on certain distributions. May have side effects."
)
+4 -41
View File
@@ -1,8 +1,9 @@
update_mods = "Update mods and connect" update_mods = "Update mods and connect"
connect = "Connect" connect = "Connect"
connect_last = "Load to menu screen" connect_last = "Load to menu screen"
connect_last_tooltip = "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting." connect_last_tooltip = (
debug = "Debug" "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting."
)
back = "Back" back = "Back"
cancel = "Cancel" cancel = "Cancel"
warnings = "Warnings" warnings = "Warnings"
@@ -11,43 +12,5 @@ mods = "Mods"
total_mods = "Total mods: " total_mods = "Total mods: "
up_to_date = "All mods are up to date." up_to_date = "All mods are up to date."
all_updated = "All mods updated." all_updated = "All mods updated."
required_space = " Need to download {0} MiB of mod updates." "If you recently installed {build} or moved it to a different drive, "
placeholder_no_mods = "This server has no mods."
placeholder_no_warnings = "No warnings."
# Errors
invalid_mods = (
"Server has invalid mods that are not recognized by Steam.\n"
"Contact the server owner and include these mod IDs in your report:\n"
"{0}"
)
not_enough_space = (
"Need to update {0} MiB of mods, but installation path only has {1} MiB."
)
resync = (
"If you recently installed {0} or moved it to a different drive,\n"
"restart Steam to allow these changes to synchronize, then try again." "restart Steam to allow these changes to synchronize, then try again."
)
steam_not_running = "'{0}' is set as the default Steam client, but it is either not installed or not running."
use_desktop_mode = "Use Desktop Mode to download mods on Steam Deck."
version_mismatch = "Local client version '{0}' does not match remote version '{1}'."
version_missing = "Remote server is running the build '{0}', but it is not installed."
no_errors = "No errors."
# Warnings
protected_server = (
"Protected: you will be prompted for a password when connecting to this server."
)
dayz_running = "It looks like DayZ is already running in the background. Exit DayZ before connecting."
running_app = (
"The app '{0}' 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."
)
downloads_paused = (
"It looks like DayZ may have a pending update on Steam that was paused.\n"
"To enqueue mods for download, unpause any pending DayZ update and allow it to complete."
)
-39
View File
@@ -1,39 +0,0 @@
pg1_title = "Choose uninstall targets"
pg1_heading = "Choose uninstall targets"
pg1_blurb = "Here you can choose which DZGUI configuration/state files to remove. "
pg2_title = "Uninstall complete"
pg2_heading = "Uninstall complete"
pg2_blurb = "Finished removing the selected files. See the console for any errors."
config_label = "Configuration files"
config_details = (
"User preferences and API key. "
"Keep this file if you want to import it again later."
)
desktop_label = "Desktop shortcut"
desktop_details = "Shortcut set on the desktop, if applicable."
start_menu_label = "Start menu shortcut"
start_menu_details = (
"Shortcut set under the Games category of the start menu, if applicable."
)
state_label = "Long-term state files"
state_details = (
"UI size, filter preferences, column size, server notes, and mod signatures."
)
steam_shortcut = "Steam shortcut"
steam_details = "Shortcut to DZGUI installed within Steam, if applicable. Requires Steam to be restarted to reflect the change."
path_remove_prefix = "The following path will be removed:"
not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall."
standalone_uninstall = (
"It looks like you are running the standalone version of DZGUI.\n"
"To finalize the uninstall process, invoke './dzgui self remove',\n"
"then remove the 'dzgui' file."
)
system_uninstall = (
"It looks like DZGUI was installed via your system package manager or directly from source code.\n"
"To finalize the uninstall process, follow the standard removal procedure for that context."
)
+6 -34
View File
@@ -3,8 +3,6 @@ title_intro = "Welcome"
blurb_intro = """ blurb_intro = """
This wizard will help you set up some common config options before launching the application. This wizard will help you set up some common config options before launching the application.
""" """
error_sandbox = "Setup cannot be run from inside Flatpak sandbox.\nPlease launch the application directly."
welcome = "Welcome to {0}!"
### SteamPathPage ### SteamPathPage
heading_steam_path = "Steam path" heading_steam_path = "Steam path"
@@ -19,7 +17,7 @@ desc_flatpak_path = (
desc_ubuntu_path = "This is the default Steam path on <b>Ubuntu-based systems</b>." desc_ubuntu_path = "This is the default Steam path on <b>Ubuntu-based systems</b>."
desc_debian_path = "This is the default Steam path on <b>Debian-based systems</b>." desc_debian_path = "This is the default Steam path on <b>Debian-based systems</b>."
no_valid_paths = ( no_valid_paths = (
"No valid Steam path or Steam command found on system. Please install Steam to continue." "No valid Steam paths found on system. Please install Steam to continue."
) )
button_scan = "Scan for Steam" button_scan = "Scan for Steam"
@@ -48,6 +46,11 @@ You must set up a Steam Web API key in order to browse the global server list.
\nIf you don't have one already, it can be set up via the page below. \nIf you don't have one already, it can be set up via the page below.
\nPlease refer to the DZGUI documentation for more instructions. \nPlease refer to the DZGUI documentation for more instructions.
""" """
heading_bm_api = "Battlemetrics Web API key"
blurb_bm_api = """A Battlemetrics key is <b>optional</b>, but allows you to add/search for servers\n
by numeric ID on the web. For example, in the URL https://www.battlemetrics.net/servers/dayz/24819107,\n
the ID would be <b>24819107</b>.
"""
### PreferencesPage ### PreferencesPage
heading_prefs = "User preferences" heading_prefs = "User preferences"
@@ -61,37 +64,6 @@ radio_mi = "mi (miles)"
label_dist = "Distance display" label_dist = "Distance display"
label_client = "Steam client" 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. "
"Not available if you selected the Flatpak client as your Steam default."
),
)
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.")
### Flatpak page
heading_flatpak = "Flatpak sandbox overrides"
blurb_flatpak = (
"You chose to add a shortcut directly to Flatpak Steam. "
"To launch DZGUI from Flatpak's sandbox, you must grant "
"permission to read from and write to the following locations."
)
tree_column = "Paths"
overrides_applied = "Overrides applied."
button_flatpak = "Grant permissions"
### Completion page ### Completion page
heading_completion = "Setup complete" heading_completion = "Setup complete"
-7
View File
@@ -1,12 +1,5 @@
import shlex import shlex
from dzgui.const.constants import STEAM_CMD
from dzgui.init.proc import is_sandboxed
def concat_bash_args(command: str) -> list[str]: def concat_bash_args(command: str) -> list[str]:
# NOTE: NSGs spawned from sandbox cannot access host application commands,
# so fallback to Steam internal URIs
if is_sandboxed():
return [STEAM_CMD]
return shlex.split(command) return shlex.split(command)
+10 -13
View File
@@ -1,4 +1,3 @@
import logging
import os import os
import platform import platform
@@ -10,9 +9,7 @@ from dzgui.const.constants import APP_NAME
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.config.query import lookup from dzgui.config.query import lookup
from dzgui.init.prefix import get_version from dzgui.init.prefix import get_version
from dzgui.util.redact import redact_home from dzgui.util.redact import redact
logger = logging.getLogger(APP_NAME)
def get_cpu_model() -> str: def get_cpu_model() -> str:
@@ -41,26 +38,24 @@ def print_mods(mods: list[int]) -> str:
def write_diagnostic(config: Path, outfile: Path) -> None: def write_diagnostic(config: Path, outfile: Path) -> None:
# TODO: test availability on other distros
date = datetime.now().isoformat() date = datetime.now().isoformat()
try:
# FIXME: ID_LIKE not available on CachyOS
distro = platform.freedesktop_os_release()["ID_LIKE"] distro = platform.freedesktop_os_release()["ID_LIKE"]
except Exception as e:
logger.warn(e)
distro = "Unknown"
kernel = os.uname().release kernel = os.uname().release
cpu = get_cpu_model() cpu = get_cpu_model()
version = get_version() version = get_version()
branch = lookup(config, Preferences.BRANCH)
debug = lookup(config, Preferences.DEBUG)
install = lookup(config, Preferences.INSTALL)
default = lookup(config, Preferences.DEFAULT) default = lookup(config, Preferences.DEFAULT)
steam_path = Path(default) steam_path = Path(default)
workshop_path = get_local_mod_path(steam_path) workshop_path = get_local_mod_path(steam_path)
steam_redacted = redact_home(default) steam_redacted = redact(default)
workshop_redacted = redact_home(str(workshop_path)) workshop_redacted = redact(str(workshop_path))
mods = get_local_mod_ids(steam_path) mods = get_local_mod_ids(steam_path)
mods_pretty = print_mods(mods) mods_pretty = print_mods(mods)
@@ -70,13 +65,15 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
# FIXME: extraneous newlines in lists of mods # FIXME: extraneous newlines in lists of mods
template = f"""\ template = f"""\
{APP_NAME} version {version} {APP_NAME} version {version} ({branch})
Date: {date} Date: {date}
=============================== ===============================
Distribution: {distro} Distribution: {distro}
Kernel: {kernel} Kernel: {kernel}
CPU: {cpu} CPU: {cpu}
Debug: {debug}
Auto-install: {install}
Steam path: {steam_redacted} Steam path: {steam_redacted}
Workshop path: {workshop_redacted} Workshop path: {workshop_redacted}
-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
+1
View File
@@ -84,5 +84,6 @@ class CalcDist:
except Exception: except Exception:
return None return None
# TODO: handle failed remote dist
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon) haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
return haversine return haversine
-31
View File
@@ -1,31 +0,0 @@
import logging
import subprocess
from dzgui.const.constants import APP_NAME, FLATPAK_APPID, GTK_APP_ID
logger = logging.getLogger(APP_NAME)
def apply_overrides(xdg: dict[str, str]) -> None:
try:
# NOTE: D-Bus registration required for sandbox
args = [
"flatpak",
"--user",
"override",
f"--own-name={GTK_APP_ID}",
FLATPAK_APPID,
]
subprocess.run([*args], capture_output=True)
for path in xdg.values():
args = [
"flatpak",
"--user",
"override",
f"--filesystem={path}",
FLATPAK_APPID,
]
subprocess.run([*args], capture_output=True)
except Exception as e:
logger.critical(e)
+9 -1
View File
@@ -2,13 +2,14 @@ import a2s
import requests import requests
import shutil import shutil
import subprocess import subprocess
from warnings import deprecated
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.const.constants import REQUEST_TIMEOUT from dzgui.const.constants import REQUEST_TIMEOUT
from dzgui.const.endpoints import IP_ECHO from dzgui.const.endpoints import COORDS_API, IP_ECHO
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -126,3 +127,10 @@ def is_valid_port(port: str) -> bool:
if not port.isdigit() or int(port) == 0 or int(port[0]) == 0 or int(port) > 65535: if not port.isdigit() or int(port) == 0 or int(port[0]) == 0 or int(port) > 65535:
return True return True
return False return False
@deprecated("use ips.csv")
def get_local_coords(ip: str) -> str:
url = COORDS_API + "/" + ip
# local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"')
return url
+16 -59
View File
@@ -1,71 +1,28 @@
import logging
import subprocess import subprocess
import sys
import tempfile
import traceback
from pathlib import Path from pathlib import Path
from dzgui.const.constants import APP_NAME, VM_FILE, MIN_COUNT from dzgui.const.constants import VM_FILE, MIN_COUNT
from dzgui.util.bash import concat_bash_args
from dzgui.strings import map_count
from dzgui.views.dialogs.early_alert import EarlyIgnoreDialog
logger = logging.getLogger(APP_NAME)
def is_map_count_valid(count: int | None) -> bool:
if count is None:
# NOTE: permit if count was unreadable
return True
return count >= MIN_COUNT
def get_map_count() -> int | None: def get_map_count() -> int | None:
path = Path(VM_FILE) path = Path(VM_FILE)
try: if path.is_file() is False:
count = int(path.read_text())
except Exception as e:
logger.debug(e)
return None return None
count = int(path.read_text())
return count return count
# TODO: unfinished
#if count < MIN_COUNT:
# print("needs sudo escalation")
# # pop prompt
# # get response
# set_map_count()
#return count
def test_map_count() -> None: # TODO: unfinished
count = get_map_count()
if is_map_count_valid(count):
return
msg = map_count.exit_msg
EarlyIgnoreDialog(msg)
def set_map_count() -> None: def set_map_count() -> None:
count = get_map_count()
if count is None:
print(map_count.failed_to_parse)
return
if is_map_count_valid(count):
msg = map_count.meets_minimum.format(count)
print(msg)
return
conf = "/etc/sysctl.d/dayz.conf" conf = "/etc/sysctl.d/dayz.conf"
value = f"vm.max_map_count={MIN_COUNT}" count = f"vm.max_map_count={MIN_COUNT}"
try: with open(conf, "w") as f:
msg = map_count.prompt.format(conf) f.write(count)
print(msg) # TODO: use concat_bash_args()
with tempfile.NamedTemporaryFile(delete=False) as f: subprocess.run(["/usr/bin/sudo", "sysctl", "-p", conf])
tmp = f.name
Path(tmp).write_text(value)
mv_cmd = f"sudo mv {tmp} {conf}"
reload_cmd = f"sudo sysctl -p {conf}"
for cmd in mv_cmd, reload_cmd:
args = concat_bash_args(cmd)
subprocess.run([*args])
except Exception as e:
logger.debug(e)
trace = traceback.format_exc()
print(map_count.failed_to_update.format(trace))
except KeyboardInterrupt:
print(map_count.user_exit)
sys.exit(0)
+16 -26
View File
@@ -1,30 +1,20 @@
import logging
import re import re
from typing import Literal
api_filter = r"(.*&key=)([^&]*)(.*)" def redact(text: str) -> str:
home_filter = r"(/home/)([^\s'\/]*)(.*)" r = r"(/home/)([^/])*"
user_filter = r"(.*Steam/userdata/)([^/]*)(.*)" cleaned = re.sub(r, r"/home/REDACTED", text)
REDACTED = r"\1REDACTED\3"
REDACTION_PATTERNS = [api_filter, home_filter, user_filter]
def redact_home(text: str) -> str:
pat = re.compile(home_filter)
cleaned = pat.sub(REDACTED, text)
return cleaned return cleaned
def redact_log(record: list) -> list[str]:
class RedactionFilter(logging.Filter): """
def __init__(self, patterns: list[str] | None = None) -> None: requests library includes Steam API key in URL params
super().__init__() """
self._patterns = [re.compile(pat) for pat in (patterns or [])] clean = []
for item in record:
def filter(self, record: logging.LogRecord) -> Literal[True]: if "&key=" in item:
for pattern in self._patterns: pat = r"(.*&key=)(\S+)(.*)"
try: scrubbed = re.sub(pat, r"\1REDACTED\3", item)
record.msg = pattern.sub(REDACTED, record.msg) clean.append(scrubbed)
except TypeError: else:
exception_text = f"{type(record.msg).__name__}: {record.msg}" clean.append(item)
record.msg = pattern.sub(REDACTED, exception_text) return clean
return True
+103 -16
View File
@@ -1,6 +1,27 @@
from dataclasses import dataclass from dataclasses import dataclass
from dzgui.const.constants import SYSTEM_LOG from dzgui.const.constants import SYSTEM_LOG
# TODO: move to util.format.py
def build_missing(build: str) -> str:
msg = (
f"This server is running {build}. You can install "
f"{build} by searching for it in your Steam library. "
f"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
)
return msg
def build_path_invalid(build: str) -> str:
msg = (
f"Steam is reporting that {build} is installed at a non-existent location. "
f"If you recently installed {build} or moved it to a different drive, "
"restart Steam to allow these changes to synchronize, then try again."
)
return msg
# General # General
dialog_header = "DZGUI - Dialog" dialog_header = "DZGUI - Dialog"
@@ -11,6 +32,7 @@ delimiter = "␞"
# GenericDialog # GenericDialog
main_menu = "Return to main menu" main_menu = "Return to main menu"
exit_app = "Exit" exit_app = "Exit"
server_details = "Server details"
modlist = "Modlist" modlist = "Modlist"
input_required = "User input required" input_required = "User input required"
confirm = "Confirmation" confirm = "Confirmation"
@@ -58,6 +80,7 @@ browser_cols = [
] ]
# DetailsDialog # DetailsDialog
server_message = "Server message"
workshop = "Enter/double click a row to open in Steam Workshop." workshop = "Enter/double click a row to open in Steam Workshop."
# LanDialog # LanDialog
@@ -77,7 +100,10 @@ steam_missing = "Local Steam installation is not set, possibly malformed config
build_corrupted = ( build_corrupted = (
"Steam settings or DayZ installation may be corrupted. Try restarting Steam." "Steam settings or DayZ installation may be corrupted. Try restarting Steam."
) )
server_timeout = "Timed out when querying server(s). Check IP or try again later." api_warn_msg = """No servers returned. Please wait and try again.
This usually indicates a transitory network timeout. If this issue persists, your API key or network may be malfunctioning.
"""
server_timeout = "Timed out when querying server, check IP or try again later."
server_error = ( server_error = (
"Error while contacting server, possibly timed out. Please wait and try again." "Error while contacting server, possibly timed out. Please wait and try again."
) )
@@ -104,6 +130,7 @@ servers = {
"Enter/space/double-click": "connect to server", "Enter/space/double-click": "connect to server",
"Right-click/Ctrl-l": "additional context menus", "Right-click/Ctrl-l": "additional context menus",
"Ctrl-r": "refresh players", "Ctrl-r": "refresh players",
"Ctrl-p": "refresh ping",
"Ctrl-f": "jump to keyword search field", "Ctrl-f": "jump to keyword search field",
"Ctrl-m": "jump to maps field", "Ctrl-m": "jump to maps field",
"Ctrl-d": "select default LAN port", "Ctrl-d": "select default LAN port",
@@ -121,8 +148,8 @@ servers = {
vim = { vim = {
"j": "Move down a row/scroll up", "j": "Move down a row/scroll up",
"k": "Move up a row/scroll down", "k": "Move up a row/scroll down",
"h": "Jump to main area from sidebar", "l": "Jump to main area from sidebar",
"l": "Jump to sidebar from main area", "h": "Jump to sidebar from main area",
"g": "Jump to first row/top of page", "g": "Jump to first row/top of page",
"G": "Jump to last row/bottom of page", "G": "Jump to last row/bottom of page",
} }
@@ -156,7 +183,6 @@ filter_duplicate = "Duplicate"
filter_official = "Official" filter_official = "Official"
filter_unofficial = "Unofficial" filter_unofficial = "Unofficial"
filter_modded = "Modded" filter_modded = "Modded"
filter_unmodded = "Unmodded"
# maps # maps
all_maps = "All maps" all_maps = "All maps"
@@ -167,12 +193,11 @@ none_provided = "None provided"
disabled = "Disabled" disabled = "Disabled"
enabled = "Enabled" enabled = "Enabled"
null = "-" null = "-"
none = "None"
unspecified = "not specified" unspecified = "not specified"
# DLC # DLC
dlc_frostline = "Frostline" dlc_frostline = "Frostline"
dlc_badlands = "Badlands"
dlc_none = "None"
# platform # platform
windows = "Windows" windows = "Windows"
@@ -262,8 +287,11 @@ class Thanks:
class Options: class Options:
header: str header: str
steam_web: str steam_web: str
bm_web: str
enter_steam: str enter_steam: str
enter_bm: str
steam_placeholder: str steam_placeholder: str
bm_placeholder: str
name_placeholder: str name_placeholder: str
last_used: str last_used: str
always_fs: str always_fs: str
@@ -284,7 +312,7 @@ class Options:
force_eventbox: str force_eventbox: str
self_update: str self_update: str
no_self_update: str no_self_update: str
api_key: str api_keys: str
prefs: str prefs: str
mods: str mods: str
version: str version: str
@@ -297,7 +325,7 @@ class Options:
init = Init( init = Init(
is_steam_running="Is Steam running? DZGUI must be run on top of Steam.", is_steam_running="Is Steam running? DZGUI must be run on top of Steam.",
is_dayz_running="Is DayZ already running? DZGUI cannot launch DayZ if another process is using it.", is_dayz_running="Is DayZ already running? DZGUI cannot launch DayZ if another process is using it.",
requires_steam="Your chosen client (Steam/Flatpak Steam) must be installed on the system.", requires_steam="DZGUI requires that Steam or Flatpak Steam be installed on the system.",
) )
mod_panel = ModPanelStrings( mod_panel = ModPanelStrings(
@@ -306,8 +334,8 @@ mod_panel = ModPanelStrings(
unhighlight_stale_tooltip="Clears highlight from stale mods", unhighlight_stale_tooltip="Clears highlight from stale mods",
highlight_stale="Highlight stale", highlight_stale="Highlight stale",
highlight_stale_tooltip=( highlight_stale_tooltip=(
"Shows locally-installed mods which are not \n" "Shows locally-installed mods which are not used by any server "
"used by any server in your Saved Servers" "in your Saved Servers"
), ),
unsub_selected="Unsubscribe selected", unsub_selected="Unsubscribe selected",
unsub_selected_tooltip="Unsubscribes from selected mods", unsub_selected_tooltip="Unsubscribes from selected mods",
@@ -351,8 +379,11 @@ thanks = Thanks(
options = Options( options = Options(
header="Options", header="Options",
steam_web="Steam API page", steam_web="Steam API page",
bm_web="Battlemetrics API page",
enter_steam="Enter your Steam API key", enter_steam="Enter your Steam API key",
enter_bm="Enter your Battlemetrics API key",
steam_placeholder="Steam API key", steam_placeholder="Steam API key",
bm_placeholder="Battlemetrics API key",
name_placeholder="Identifies you to other players in-game", name_placeholder="Identifies you to other players in-game",
last_used="Last used dimensions", last_used="Last used dimensions",
always_fs="Always fullscreen", always_fs="Always fullscreen",
@@ -381,7 +412,7 @@ options = Options(
"In-app updates are disabled when DZGUI is " "In-app updates are disabled when DZGUI is "
"installed globally (e.g., via package manager)." "installed globally (e.g., via package manager)."
), ),
api_key="API Key", api_keys="API Keys",
prefs="Preferences", prefs="Preferences",
mods="Mods", mods="Mods",
version="Version", version="Version",
@@ -446,15 +477,13 @@ class Flags:
version: str version: str
uninstall: str uninstall: str
debug: str debug: str
map_count: str
flags = Flags( flags = Flags(
description="DayZ server browser and mod manager", description="DayZ server browser and mod manager",
version="print version information", version="Print version information",
uninstall="clean up state/config files", uninstall="Uninstall data files (use prior to 'pip uninstall')",
debug="enable developer debugging features", debug="Enables developer debugging features",
map_count="check and update system map count value",
) )
@@ -506,6 +535,64 @@ server_labels = ServerLabels(
) )
@dataclass(slots=True, frozen=True)
class ConnectPanel:
connect: str
add: str
add_con: str
placeholder: str
entry_tooltip: str
add_tooltip: str
connect_tooltip: str
connect_panel = ConnectPanel(
connect="Connect",
add="Add",
add_con="Add/connect",
placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)",
entry_tooltip=(
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n"
"- Battlemetrics: numeric server ID\ne.g. 123456"
),
add_tooltip="Add to Saved Servers",
connect_tooltip="Connect to this server",
)
@dataclass(slots=True, frozen=True)
class FavPanel:
heading: str
no_fav: str
fav_panel = FavPanel(
heading="Favorite server",
no_fav="None set. Right click a server and select 'Set favorite' to set.",
)
@dataclass(slots=True, frozen=True)
class LanPanel:
heading: str
default_button: str
custom_button: str
scan_button: str
placeholder: str
entry_tooltip: str
scan_tooltip: str
lan_panel = LanPanel(
heading="LAN query port",
default_button="Default port (27016)",
custom_button="Custom port",
scan_button="Scan",
placeholder="Enter the query port (1-65535)",
entry_tooltip="Specify the port to search for DayZ servers on the local network",
scan_tooltip="Scan for servers",
)
distance_suffix = "Distance: calculating..." distance_suffix = "Distance: calculating..."
dialog_error = "ERROR" dialog_error = "ERROR"
+4 -9
View File
@@ -2,12 +2,7 @@ import logging
from pathlib import Path from pathlib import Path
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
from dzgui.const.constants import ( from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT
APPID_DAYZ,
APPID_DAYZ_EXP,
APP_NAME,
DAYZ_COMMUNITY_ROOT,
)
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.config.query import lookup from dzgui.config.query import lookup
@@ -25,7 +20,6 @@ def expunge_link(file: Path) -> None:
if str(file.stem)[:2] == "@C": if str(file.stem)[:2] == "@C":
file.unlink() file.unlink()
def rebuild_symlinks(config: Path) -> None: def rebuild_symlinks(config: Path) -> None:
# TODO: pass direct path as argument # TODO: pass direct path as argument
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
@@ -69,9 +63,10 @@ def symlink_mission(steam_path: Path, target: str) -> str:
stem = path.name stem = path.name
parent = path.parent parent = path.parent
source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT) source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT)
if source.exists(): try:
source.unlink()
source.symlink_to(parent) source.symlink_to(parent)
except Exception:
return ""
return f"{DAYZ_COMMUNITY_ROOT}/{stem}" return f"{DAYZ_COMMUNITY_ROOT}/{stem}"
+29 -25
View File
@@ -1,23 +1,19 @@
import logging import logging
import signal import signal
import warnings
from typing import TYPE_CHECKING, Literal, Self from typing import TYPE_CHECKING, Literal
from dzgui.const.constants import ( from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, STEAM_ICON
APP_NAME,
APP_NAME_LOWER,
GTK_APP_ID,
)
from dzgui.const.enum import NotebookPage from dzgui.const.enum import NotebookPage
from dzgui.controllers.emitter import Emitter from dzgui.controllers.emitter import Emitter
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
from dzgui.util import css from dzgui.util import css, strings
from dzgui.util.keys import is_ctrl_mask from dzgui.util.keys import is_ctrl_mask
from dzgui.views.components.connect_panel import ConnectPanel from dzgui.views.components.connect_panel import ConnectPanel
from dzgui.views.components.crumbs import Breadcrumbs from dzgui.views.components.crumbs import Breadcrumbs
from dzgui.views.components.right_panel import RightPanel from dzgui.views.components.right_panel import RightPanel
from dzgui.views.components.statusbar import Statusbar from dzgui.views.components.statusbar import Statusbar
from dzgui.views.mixins.colorscheme import ColorAwareApp
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
# TODO: import notebook only and add components there? # TODO: import notebook only and add components there?
@@ -45,21 +41,20 @@ if TYPE_CHECKING:
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
# TODO: drop
# https://bugzilla.gnome.org/show_bug.cgi?id=708676
warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
class OuterWindow(Gtk.ApplicationWindow):
def __init__(self, app: Gtk.Application) -> None: class OuterWindow(Gtk.Window):
super().__init__( def __init__(self) -> None:
application=app, title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER)
)
self.hb = AppHeaderBar() self.hb = AppHeaderBar()
MainController.register_widget("window", self) MainController.register_widget("window", self)
# NOTE: steam deck taskbar may occlude elements # NOTE: steam deck taskbar may occlude elements
is_deck = MainController.get_prefs().is_steam_deck if MainController.get_prefs().is_steam_deck is False:
if is_deck:
self.maximize()
else:
self.set_titlebar(self.hb) self.set_titlebar(self.hb)
self.connect("delete-event", self._on_delete_event) self.connect("delete-event", self._on_delete_event)
@@ -71,8 +66,8 @@ class OuterWindow(Gtk.ApplicationWindow):
self.show_all() self.show_all()
css.load_css() css.load_css()
MainController.set_start_tab()
MainController.open_page(NotebookPage.SERVERS) MainController.open_page(NotebookPage.SERVERS)
MainController.set_start_tab()
self.grid.hide_widgets_on_init() self.grid.hide_widgets_on_init()
MainController.loaded = True MainController.loaded = True
@@ -281,18 +276,13 @@ class Grid(Gtk.Grid):
self.right_panel.refresh_button.set_visible(state) self.right_panel.refresh_button.set_visible(state)
class App(ColorAwareApp, Gtk.Application): # type: ignore class App(Gtk.Application):
def __init__(self, prefs: "UserPrefs") -> None: def __init__(self, prefs: "UserPrefs") -> None:
super().__init__(application_id=GTK_APP_ID)
GLib.set_prgname(APP_NAME) GLib.set_prgname(APP_NAME)
MainController.set_prefs(prefs) MainController.set_prefs(prefs)
self._setup_signals() self.win = OuterWindow()
self.connect("activate", self._on_activate)
def _on_activate(self, app: Self) -> None:
self.win = OuterWindow(app)
accel = Gtk.AccelGroup() accel = Gtk.AccelGroup()
accel.connect( accel.connect(
@@ -303,6 +293,9 @@ class App(ColorAwareApp, Gtk.Application): # type: ignore
) )
self.win.add_accel_group(accel) self.win.add_accel_group(accel)
self._setup_signals()
Gtk.main()
def _setup_signals(self) -> None: def _setup_signals(self) -> None:
SIGNAL_ADD = "signal_add" SIGNAL_ADD = "signal_add"
SIGNAL_ADD_FULL = "signal_add_full" SIGNAL_ADD_FULL = "signal_add_full"
@@ -331,4 +324,15 @@ class App(ColorAwareApp, Gtk.Application): # type: ignore
) -> None: ) -> None:
self.win.halt_proc_and_quit() self.win.halt_proc_and_quit()
MainController = Controller() MainController = Controller()
try:
theme = Gtk.IconTheme.get_default()
icons = theme.list_icons(None)
if STEAM_ICON not in icons:
logger.warn(strings.steam_icon_missing)
warnings.warn(strings.steam_icon_missing, stacklevel=2)
except Exception as e:
logger.warning(e)
warnings.warn(strings.gtk_theme_missing, stacklevel=2)
-8
View File
@@ -23,11 +23,3 @@ class HBox(GenericBox):
class VBox(GenericBox): class VBox(GenericBox):
def __init__(self, spacing: int = 0) -> None: def __init__(self, spacing: int = 0) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing) super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing)
class ShortHBox(HBox):
def __init__(self, widget: Gtk.Widget) -> None:
super().__init__(spacing=5)
self.set_halign(Gtk.Align.START)
self.pack_start(widget, expand=False, fill=False, padding=0)
+5 -68
View File
@@ -1,4 +1,4 @@
from typing import Callable, Literal, Self, TYPE_CHECKING, Union from typing import Callable, Self, TYPE_CHECKING, Union
from dzgui.util.clip import copy_clipboard from dzgui.util.clip import copy_clipboard
from dzgui.util.format import pluralize from dzgui.util.format import pluralize
@@ -6,10 +6,10 @@ from dzgui.strings import buttons
from dzgui.util.strings import ( from dzgui.util.strings import (
alert_button_tooltip, alert_button_tooltip,
atomic_buttons, atomic_buttons,
connect_panel,
) )
from dzgui.const.constants import ( from dzgui.const.constants import (
CLIPBOARD, CLIPBOARD,
CLOSE,
INPUT_KEYBOARD, INPUT_KEYBOARD,
LIST_ADD, LIST_ADD,
REFRESH_ICON, REFRESH_ICON,
@@ -17,7 +17,6 @@ from dzgui.const.constants import (
WARNING, WARNING,
WEB_BROWSER, WEB_BROWSER,
) )
from dzgui.strings import connect_panel
import gi import gi
@@ -64,17 +63,8 @@ class IconButton(Gtk.Button):
self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end) self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end)
self.set_image(self.icon) self.set_image(self.icon)
self.set_image_position(position) self.set_image_position(position)
# self.set_image_position(Gtk.PositionType.RIGHT)
self.set_focus_on_click(False) self.set_focus_on_click(False)
self.set_always_show_image(True)
def swap_icon(self, icon: str) -> None:
start = self.icon.get_margin_start()
end = self.icon.get_margin_end()
alt_icon = Icon(icon, margin_start=start, margin_end=end)
self.set_image(alt_icon)
def revert_icon(self) -> None:
self.set_image(self.icon)
class IconTextButton(IconButton): class IconTextButton(IconButton):
@@ -106,18 +96,11 @@ class ClipboardButton(IconTextButton):
self.controller = controller self.controller = controller
self.connect("clicked", self._on_button_clicked, func) self.connect("clicked", self._on_button_clicked, func)
# TODO: strings
self.set_tooltip_text("Copy to clipboard") self.set_tooltip_text("Copy to clipboard")
def _on_button_clicked(self, button: Self, func: Callable) -> None: def _on_button_clicked(self, button: Self, func: Callable) -> None:
def revert() -> Literal[False]:
self.revert_icon()
return False
self.swap_icon("object-select-symbolic")
data = func() data = func()
copy_clipboard(data) copy_clipboard(data)
GLib.timeout_add(600, revert)
# TODO: determine when controller would be passed to this button or drop # TODO: determine when controller would be passed to this button or drop
@@ -125,18 +108,9 @@ class CopyIpButton(ClipboardButton):
def __init__(self, controller: Union["Controller", None], func: Callable) -> None: def __init__(self, controller: Union["Controller", None], func: Callable) -> None:
super().__init__(controller, func) super().__init__(controller, func)
# TODO: strings
self.set_tooltip_text("Copy IP to clipboard") self.set_tooltip_text("Copy IP to clipboard")
class CloseButton(IconTextButton):
def __init__(self, label: str) -> None:
super().__init__(icon=CLOSE, label=label)
# TODO: strings
self.set_tooltip_text("Unset this server as favorite")
class WebButton(IconTextButton): class WebButton(IconTextButton):
def __init__(self, label: str) -> None: def __init__(self, label: str) -> None:
super().__init__(icon=WEB_BROWSER, label=label) super().__init__(icon=WEB_BROWSER, label=label)
@@ -167,8 +141,6 @@ class RefreshButton(IconTextButton):
def _on_refresh_clicked(self, button: Self) -> None: def _on_refresh_clicked(self, button: Self) -> None:
"""Spawned in a thread""" """Spawned in a thread"""
if self.controller.get_active_treeview().is_loaded() is False:
return
self.is_clicked = True self.is_clicked = True
self.controller.refresh_tree() self.controller.refresh_tree()
# TODO: get server tab enum # TODO: get server tab enum
@@ -222,7 +194,7 @@ class KeysButton(IconTextButton):
class SteamConnectButton(LargeIconTextButton): class SteamConnectButton(LargeIconTextButton):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(icon=STEAM_ICON, label=connect_panel.connect_button) super().__init__(icon=STEAM_ICON, label=connect_panel.connect)
self.set_tooltip_text(connect_panel.connect_tooltip) self.set_tooltip_text(connect_panel.connect_tooltip)
@@ -242,7 +214,7 @@ class SteamWorkshopButton(SteamTextButton):
class AddButton(IconTextButton): class AddButton(IconTextButton):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(icon=LIST_ADD, label=connect_panel.add_button) super().__init__(icon=LIST_ADD, label=connect_panel.add)
self.set_tooltip_text(connect_panel.add_tooltip) self.set_tooltip_text(connect_panel.add_tooltip)
@@ -267,38 +239,3 @@ class LoggerAlertsButton(IconTextButton):
self.set_halign(Gtk.Align.END) self.set_halign(Gtk.Align.END)
self.set_hexpand(True) self.set_hexpand(True)
self.set_tooltip_text(alert_button_tooltip) self.set_tooltip_text(alert_button_tooltip)
class SpinnerButton(Gtk.Button):
def __init__(self, label: str) -> None:
super().__init__()
self.text = label
self.label = Gtk.Label(label=label, halign=Gtk.Align.CENTER)
self.spinner = Gtk.Spinner()
self.spinner.set_halign(Gtk.Align.END)
self.spinner.set_sensitive(False)
grid = Gtk.Grid(column_spacing=10)
for el in self.label, self.spinner:
grid.add(el)
self.add(grid)
self.connect("clicked", self._on_button_clicked)
self.connect("map", self._on_map)
def _on_map(self, button: Self) -> None:
self.spinner.set_visible(False)
def _on_button_clicked(self, button: Self) -> None:
self.start_spinner()
def stop_spinner(self) -> None:
self.spinner.set_visible(False)
self.spinner.stop()
def start_spinner(self) -> None:
self.spinner.set_visible(True)
self.spinner.start()
+68
View File
@@ -0,0 +1,68 @@
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
+5 -24
View File
@@ -6,7 +6,6 @@ from dzgui.strings import connect_panel
from dzgui.util.keys import is_ctrl_mask from dzgui.util.keys import is_ctrl_mask
from dzgui.views.components.buttons import ( from dzgui.views.components.buttons import (
AddButton, AddButton,
CloseButton,
CopyIpButton, CopyIpButton,
Icon, Icon,
IconButton, IconButton,
@@ -188,27 +187,17 @@ class FavPanel(Gtk.Frame):
self.fav_button = SteamConnectButton() self.fav_button = SteamConnectButton()
self.fav_button.connect("clicked", self._on_connect_clicked) self.fav_button.connect("clicked", self._on_connect_clicked)
self.copy_button = CopyIpButton(self.controller, self.get_fav_ip) self.copy_button = CopyIpButton(self.controller, self.get_fav_ip)
# TODO: strings
self.unset_button = CloseButton("Unset")
self.unset_button.connect("clicked", self._on_unset_clicked)
if favorite is None: if favorite is None:
self.toggle_buttons(False) self.toggle_buttons(False)
# NOTE: disable vscrollbar to prevent layout jumping behavior # NOTE: disable vscrollbar to prevent layout jumping behavior
self.scrollable_label = Gtk.ScrolledWindow( scrollable_label = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER)
vscrollbar_policy=Gtk.PolicyType.NEVER, overlay_scrolling=False scrollable_label.add(self.fav_label)
)
self.scrollable_label.add(self.fav_label)
grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5) grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5)
grid.attach(self.scrollable_label, 0, 0, 3, ROWS) grid.attach(scrollable_label, 0, 0, 3, ROWS)
grid.attach_next_to( grid.attach_next_to(
self.unset_button, self.scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS self.copy_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS
)
grid.attach_next_to(
self.copy_button, self.unset_button, Gtk.PositionType.RIGHT, COLS, ROWS
) )
grid.attach_next_to( grid.attach_next_to(
self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS
@@ -216,16 +205,8 @@ class FavPanel(Gtk.Frame):
self.add(grid) self.add(grid)
def _on_unset_clicked(self, button: Gtk.Button) -> None:
try:
self.controller.unset_fav()
except Exception:
return
self.fav_label.set_text(connect_panel.favs_empty)
self.toggle_buttons(False)
def toggle_buttons(self, state: bool) -> None: def toggle_buttons(self, state: bool) -> None:
for button in self.fav_button, self.copy_button, self.unset_button: for button in self.fav_button, self.copy_button:
button.set_sensitive(state) button.set_sensitive(state)
def _on_connect_clicked(self, button: Gtk.Button) -> None: def _on_connect_clicked(self, button: Gtk.Button) -> None:
+15 -8
View File
@@ -3,7 +3,7 @@ from typing import Any, Callable, TYPE_CHECKING
from dzgui.api.servers import validate_ip from dzgui.api.servers import validate_ip
from dzgui.const.constants import VIEW_CONCEAL, VIEW_REVEAL from dzgui.const.constants import VIEW_CONCEAL, VIEW_REVEAL
from dzgui.util.css import add_class, remove_class from dzgui.util.css import add_class, remove_class
from dzgui.strings import connect_panel from dzgui.util.strings import connect_panel, lan_panel
from dzgui.strings.errors import api_popover from dzgui.strings.errors import api_popover
import gi import gi
@@ -26,11 +26,14 @@ def validate_port(text: str) -> bool:
return False return False
def validate_ip_truthy(text: str) -> bool: def validate_ip_or_id(text: str) -> bool:
try: try:
validate_ip(text) validate_ip(text)
return True return True
except Exception: except Exception:
if text.isdigit():
return True
else:
return False return False
@@ -121,9 +124,12 @@ class IpEntry(ValidatedEntry):
def __init__(self, controller: "Controller") -> None: def __init__(self, controller: "Controller") -> None:
super().__init__( super().__init__(
controller, controller,
func=validate_ip_truthy, func=validate_ip_or_id,
placeholder_text=connect_panel.connect_entry_placeholder, placeholder_text=connect_panel.placeholder,
tooltip_text=connect_panel.entry_tooltip,
) )
self.set_placeholder_text(connect_panel.placeholder)
self.set_tooltip_text(connect_panel.entry_tooltip)
class PortEntry(ValidatedEntry): class PortEntry(ValidatedEntry):
@@ -131,9 +137,11 @@ class PortEntry(ValidatedEntry):
super().__init__( super().__init__(
controller, controller,
func=validate_port, func=validate_port,
placeholder_text=connect_panel.lan_entry_placeholder, placeholder_text=connect_panel.placeholder,
tooltip_text=connect_panel.lan_entry_tooltip, tooltip_text=connect_panel.entry_tooltip,
) )
self.set_placeholder_text(lan_panel.placeholder)
self.set_tooltip_text(lan_panel.entry_tooltip)
# TODO: backport to Options page # TODO: backport to Options page
@@ -154,8 +162,7 @@ class APIEntry(Gtk.Box):
self.entry.connect("icon-release", self._on_icon_release) self.entry.connect("icon-release", self._on_icon_release)
self.entry.connect("activate", self._on_field_activated) self.entry.connect("activate", self._on_field_activated)
# TODO: strings self.submit = Gtk.Button(label="Submit")
self.submit = Gtk.Button(label="Validate")
self.submit.set_sensitive(False) self.submit.set_sensitive(False)
self.submit.connect("clicked", self._on_submit) self.submit.connect("clicked", self._on_submit)
-3
View File
@@ -30,9 +30,6 @@ class InfoEventBox(Gtk.EventBox):
self.connect("leave-notify-event", self._on_leave_tooltip) self.connect("leave-notify-event", self._on_leave_tooltip)
self.add(box) self.add(box)
def set_icon_yalign(self, offset: float) -> None:
self.icon.set_property("yalign", offset)
def _on_enter_tooltip( def _on_enter_tooltip(
self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing
) -> None: ) -> None:
+17 -22
View File
@@ -9,6 +9,8 @@ from dzgui.const.constants import (
NO_PADDING, NO_PADDING,
SEARCH_ICON, SEARCH_ICON,
) )
from dzgui.const.enum import FilterMode
from dzgui.model.servers import ServerModelManager
from dzgui.util.format import format_exception from dzgui.util.format import format_exception
from dzgui.util.strings import all_maps from dzgui.util.strings import all_maps
from dzgui.views.components.maps_combo import MapsCombo from dzgui.views.components.maps_combo import MapsCombo
@@ -27,33 +29,21 @@ if TYPE_CHECKING:
class ButtonGrid(Gtk.Grid): class ButtonGrid(Gtk.Grid):
def __init__( def __init__(self, controller: "Controller", defaults: dict) -> None:
self,
controller: "Controller",
defaults: dict[str, str],
tooltips: dict[str, str],
) -> None:
super().__init__( super().__init__(
halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True
) )
row = 1
col = 0
self.controller = controller self.controller = controller
self.emitter = controller.get_emitter() self.emitter = controller.get_emitter()
self.checks: list[Gtk.CheckButton] = [] self.checks: list[Gtk.CheckButton] = []
flowbox = Gtk.FlowBox( # TODO: use enumerated checks
halign=Gtk.Align.CENTER, min_children_per_line=3, max_children_per_line=3, selection_mode=Gtk.SelectionMode.NONE
)
for check in defaults.keys(): for check in defaults.keys():
checkbox = Gtk.CheckButton(label=check) checkbox = Gtk.CheckButton(label=check)
try:
tt = tooltips[check]
except Exception as e:
logger.debug(e)
tt = ""
checkbox.set_tooltip_text(tt)
label = checkbox.get_child() label = checkbox.get_child()
if label is not None: if label is not None:
label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore
@@ -61,12 +51,14 @@ class ButtonGrid(Gtk.Grid):
if defaults[check]: if defaults[check]:
checkbox.set_active(True) checkbox.set_active(True)
col = col + 1
if col > 3:
row += 1
col = 1
self.attach(checkbox, col, row, 1, 1)
checkbox.connect("toggled", self._on_check_toggled) checkbox.connect("toggled", self._on_check_toggled)
flowbox.add(checkbox)
self.checks.append(checkbox) self.checks.append(checkbox)
self.add(flowbox)
def block_toggles(self, state: bool) -> None: def block_toggles(self, state: bool) -> None:
for check in self.checks: for check in self.checks:
self.controller.suppress_signal( self.controller.suppress_signal(
@@ -146,6 +138,10 @@ class KeywordEntry(Gtk.Entry):
self.controller.set_active_keyword(keyword) self.controller.set_active_keyword(keyword)
logger.info(f"User filtered by keyword '{keyword}'") logger.info(f"User filtered by keyword '{keyword}'")
ServerModelManager(
self.controller, self.controller.get_active_treeview()
).refilter(FilterMode.TOGGLE_ON)
class FilterPanel(Gtk.Box): class FilterPanel(Gtk.Box):
def __init__(self, controller: "Controller") -> None: def __init__(self, controller: "Controller") -> None:
@@ -167,12 +163,11 @@ class FilterPanel(Gtk.Box):
filter_man = self.controller.get_filter_man() filter_man = self.controller.get_filter_man()
defaults = filter_man.get_default_filters() defaults = filter_man.get_default_filters()
tooltips = filter_man.get_tooltips()
self.map_store = filter_man.get_map_store() self.map_store = filter_man.get_map_store()
self.enabled_filters = defaults self.enabled_filters = defaults
self.keyword_entry = KeywordEntry(self.controller) self.keyword_entry = KeywordEntry(self.controller)
self.button_grid = ButtonGrid(self.controller, defaults, tooltips) self.button_grid = ButtonGrid(self.controller, defaults)
# TODO: strings # TODO: strings
self.filters_label = BoldLabel("Filters") self.filters_label = BoldLabel("Filters")
-3
View File
@@ -9,6 +9,3 @@ class Icon(Gtk.Image):
super().__init__( super().__init__(
icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin
) )
def set_icon_name(self, name: str) -> None:
super().set_from_icon_name(name, Gtk.IconSize.BUTTON)
+4 -52
View File
@@ -1,11 +1,5 @@
from typing import Self
from warnings import deprecated
from dzgui.const.constants import ERROR, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD
from dzgui.init.prereqs import has_steam_client, has_flatpak_client
from dzgui.strings import options from dzgui.strings import options
from dzgui.views.components.box import HBox from dzgui.const.constants import FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD
from dzgui.views.components.buttons import Icon
import gi import gi
@@ -13,71 +7,29 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa from gi.repository import Gtk # noqa
@deprecated("Client choice is determined at setup time")
class ClientCombo(Gtk.ComboBox): class ClientCombo(Gtk.ComboBox):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
has_steam = has_steam_client() self.client_store = Gtk.ListStore(str, str)
has_flatpak = has_flatpak_client() clients = (
self.client_store = Gtk.ListStore(str, str, bool)
self.clients = (
( (
options.steam_combo, options.steam_combo,
STEAM_CMD, STEAM_CMD,
has_steam,
), ),
( (
options.flatpak_combo, options.flatpak_combo,
FLATPAK_RUN_CMD, FLATPAK_RUN_CMD,
has_flatpak,
), ),
( (
options.flatpak_container_combo, options.flatpak_container_combo,
FLATPAK_SANDBOX, FLATPAK_SANDBOX,
has_flatpak,
), ),
) )
for client in self.clients: for client in clients:
self.client_store.append(client) self.client_store.append(client)
self.set_model(self.client_store) self.set_model(self.client_store)
renderer_text = Gtk.CellRendererText() renderer_text = Gtk.CellRendererText()
self.pack_start(renderer_text, True) self.pack_start(renderer_text, True)
self.add_attribute(renderer_text, "text", 0) self.add_attribute(renderer_text, "text", 0)
self.set_active(0) self.set_active(0)
self.set_cell_data_func(self.get_cells()[0], self.data_func)
def data_func(
self,
combo: Self,
cell: Gtk.CellRendererText,
model: Gtk.ListStore,
_iter: Gtk.TreeIter,
) -> None:
sens = model[_iter][2]
cell.set_property("sensitive", sens)
def get_client_human_readable(self, cmd: str) -> str:
for client in self.clients:
human, command, sens = client
if command == cmd:
return human
raise TypeError("Not a valid Steam client selection")
class ErrorPopover(Gtk.Popover):
def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None:
super().__init__(relative_to=relative_to, position=position)
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, label: str) -> None:
self.label.set_label(label)
+4 -8
View File
@@ -80,18 +80,14 @@ class ModSelectionPanel(Gtk.Box):
self.highlight_stale_button.set_sensitive(True) self.highlight_stale_button.set_sensitive(True)
def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None: def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None:
self._toggle_panel_sensitivity(mods) if mods < 1:
self.main_panel.set_sensitive(False)
def _toggle_panel_sensitivity(self, mods: int) -> None: self.stale_panel.set_sensitive(False)
state = bool(mods)
for el in self.main_panel, self.stale_panel:
el.set_sensitive(state)
def _on_mods_highlighted(self, emitter: "Emitter") -> None: def _on_mods_highlighted(self, emitter: "Emitter") -> None:
self.swap_sensitive(True) self.swap_sensitive(True)
def _on_mod_page_toggled(self, emitter: "Emitter", state: bool, mods: int) -> None: def _on_mod_page_toggled(self, emitter: "Emitter", state: bool) -> None:
self._toggle_panel_sensitivity(mods)
self.set_visible(state) self.set_visible(state)
def swap_sensitive(self, state: bool) -> None: def swap_sensitive(self, state: bool) -> None:
-1
View File
@@ -131,7 +131,6 @@ class RightPanel(Gtk.Box):
return return
self.copying = True self.copying = True
version = self.version_label.get_text() version = self.version_label.get_text()
# TODO: strings
self.version_label.set_text("Copied!") self.version_label.set_text("Copied!")
copy_clipboard(version) copy_clipboard(version)
GLib.timeout_add_seconds(1, revert) GLib.timeout_add_seconds(1, revert)
+3 -6
View File
@@ -4,9 +4,6 @@ from typing import Any, Literal, Self, TYPE_CHECKING
# import time # import time
from enum import Enum from enum import Enum
from dzgui.views.mixins.colorscheme import ColorAwareApp
# TODO: import dialog titles # TODO: import dialog titles
from dzgui.api.mods import remove_stale_signatures as remove_stale from dzgui.api.mods import remove_stale_signatures as remove_stale
from dzgui.config.ipdb import get_ipdb from dzgui.config.ipdb import get_ipdb
@@ -40,11 +37,11 @@ class Success(Enum):
# TODO: strings for "Running", "Failed", etc. # TODO: strings for "Running", "Failed", etc.
# TODO: add margins to tree # TODO: add margins to tree
class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore class BootDialog(Gtk.Dialog):
def __init__(self, parent: "BootWindow", xdg: "Xdg", version: str) -> None: def __init__(self, parent: "BootWindow", xdg: "Xdg", version: str) -> None:
super().__init__( super().__init__(
title=dialog_header, title=dialog_header,
transient_for=parent, parent=parent,
modal=True, modal=True,
) )
@@ -102,7 +99,7 @@ class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
margin_top=30, margin_top=30,
margin_bottom=30, margin_bottom=30,
) )
self.error_label = Gtk.Label(margin_start=15, margin_end=15) self.error_label = Gtk.Label()
# TODO: abstract class # TODO: abstract class
self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text()) self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text())
-457
View File
@@ -1,457 +0,0 @@
import gi
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from typing import Literal
from dzgui.const.constants import DAY_ICON, NIGHT_ICON
from dzgui.strings import calc
from dzgui.views.components.icon import Icon
from dzgui.views.components.labels import BoldLabel
from dzgui.views.components.box import VBox, HBox
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, GObject # type: ignore # noqa E402
DAY_START_TIME = time(hour=6, minute=0, second=0)
DAY_END_TIME = time(hour=18, minute=0, second=0)
NIGHT_START_TIME = time(hour=18, minute=0, second=0)
NIGHT_END_TIME = time(hour=6, minute=0, second=0)
EXTENDED_TIME_FORMAT = "%H:%M:%S"
@dataclass(slots=True, frozen=True)
class Time:
time: str
day_accel: float
night_accel: float
class Emitter(GObject.GObject):
def __init__(self) -> None:
super().__init__()
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, object))
def local_time_incremented(self, time_now: datetime, elapsed: timedelta) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def server_time_incremented(self, server_time: datetime) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def calculate_button_clicked(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def target_time_changed(self, time: timedelta) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def target_time_reset(self, time: timedelta) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def remaining_time_changed(self, time: datetime) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def adjusted_local_time_changed(self, time: datetime) -> None:
pass
class LocalClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
self.start_time = datetime.now()
# NOTE: highest theoretical accel interval is 24 * 64
GLib.timeout_add(500, self.check_time)
def get_time(self) -> str:
return datetime.now().time().strftime(EXTENDED_TIME_FORMAT)
def get_elapsed_time(self) -> timedelta:
return datetime.now() - self.start_time
def check_time(self) -> Literal[True]:
time_now = datetime.now()
elapsed = datetime.now() - self.start_time
self.emitter.emit("local_time_incremented", time_now, elapsed)
return True
class ServerClock:
def __init__(
self, server_time: Time, emitter: Emitter, local_clock: LocalClock
) -> None:
self.local_clock = local_clock
self.emitter = emitter
hour, minute = self.split_time(server_time)
self.day_accel = server_time.day_accel
self.night_accel = server_time.night_accel
self.start_time = datetime.combine(datetime.today(), time(hour, minute))
self.cur_time = datetime.combine(datetime.today(), time(hour, minute))
self._last_elapsed = self.local_clock.get_elapsed_time()
self.emitter.connect("local_time_incremented", self._on_local_time_incremented)
@staticmethod
def split_time(server_time: Time) -> tuple[int, int]:
split = server_time.time.split(":")
return int(split[0]), int(split[1])
def is_day(self) -> bool:
if DAY_START_TIME <= self.cur_time.time() <= DAY_END_TIME:
return True
return False
def _on_local_time_incremented(
self,
emitter: Emitter,
time: datetime,
elapsed: timedelta,
) -> Literal[True]:
delta = elapsed - self._last_elapsed
self._last_elapsed = elapsed
factor = self.get_accel_factor(self.cur_time)
self.cur_time += delta * factor
self.emitter.emit("server_time_incremented", self.cur_time)
return True
def get_accel_factor(self, dt: datetime) -> float:
return (
self.day_accel
if DAY_START_TIME <= dt.time() < DAY_END_TIME
else (self.night_accel * self.day_accel)
)
def get_values(self) -> tuple[int, int]:
return self.cur_time.hour, self.cur_time.minute
def get_datetime(self) -> datetime:
return self.cur_time
def get_time(self) -> time:
return self.cur_time.time()
def calc_delta(self, end_time: datetime) -> timedelta:
total = 0.0
current_time = self.cur_time
if current_time > end_time:
end_time += timedelta(days=1)
while current_time < end_time:
factor = self.get_accel_factor(current_time)
day_start = datetime.combine(current_time.date(), DAY_START_TIME)
day_end = datetime.combine(current_time.date(), DAY_END_TIME)
if current_time.time() < DAY_START_TIME:
edge = day_start
elif current_time.time() < DAY_END_TIME:
edge = day_end
else:
edge = datetime.combine(
current_time.date() + timedelta(days=1), DAY_START_TIME
)
chunk_end = min(end_time, edge)
total += (chunk_end - current_time).total_seconds() / factor
current_time = chunk_end
return timedelta(seconds=total)
class RemainderClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
self.emitter.connect("target_time_changed", self._on_target_time_changed)
self.emitter.connect("target_time_reset", self._on_target_time_reset)
self.emitter.connect("local_time_incremented", self._on_local_time_incremented)
self.reset_time(True)
self.previous = timedelta(0)
def reset_time(self, zero_out: bool = False) -> None:
now = datetime.now()
second, ms = (0, 0) if zero_out else (now.second, now.microsecond)
self.time = datetime.combine(
now, time(hour=0, minute=0, second=second, microsecond=ms)
)
def _on_local_time_incremented(
self, emitter: Emitter, time: datetime, elapsed: timedelta
) -> None:
if self.time == datetime.min.time():
return
delta = elapsed - self.previous
# NOTE: clamp to midnight
self.time = max(
self.time - delta, self.time.replace(hour=0, minute=0, second=0)
)
self.previous = elapsed
self.emitter.emit("remaining_time_changed", self.time)
def _on_target_time_reset(self, emitter: Emitter, time: timedelta) -> None:
self.reset_time(True)
self.emitter.emit("remaining_time_changed", self.time)
# NOTE: restore adjusted time to current time
self.emitter.emit("adjusted_local_time_changed", datetime.now())
def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None:
self.reset_time(True)
self.time += time
self.emitter.emit("remaining_time_changed", self.time)
def get_time(self) -> str:
return self.time.strftime(EXTENDED_TIME_FORMAT)
class AdjustedClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
self.emitter.connect("target_time_changed", self._on_target_time_changed)
self.start_time = datetime.now()
def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None:
self.start_time = datetime.now()
adjusted = self.start_time + time
self.emitter.emit("adjusted_local_time_changed", adjusted)
def get_time(self) -> str:
return self.start_time.strftime(EXTENDED_TIME_FORMAT)
class VerticalSpinBox(Gtk.SpinButton):
def __init__(self, value: int, upper: int) -> None:
super().__init__(
adjustment=Gtk.Adjustment(
value=value,
lower=0,
upper=upper,
step_increment=1,
page_increment=1,
page_size=0,
),
orientation=Gtk.Orientation.VERTICAL,
)
class HourSpinBox(VerticalSpinBox):
def __init__(self, hour: int) -> None:
super().__init__(value=hour, upper=23)
class MinuteSpinBox(VerticalSpinBox):
def __init__(self, minute: int) -> None:
super().__init__(value=minute, upper=59)
self.get_adjustment().set_page_increment(10)
class TimePicker(Gtk.Box):
def __init__(self, hour: int, minute: int) -> None:
super().__init__(
orientation=Gtk.Orientation.HORIZONTAL,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
self.hour_spin = HourSpinBox(hour)
self.minute_spin = MinuteSpinBox(minute)
self.add(self.hour_spin)
self.add(self.minute_spin)
self.hour_spin.connect("output", self._on_output)
self.minute_spin.connect("output", self._on_output)
def _on_output(self, spin_button: VerticalSpinBox) -> Literal[True]:
val = spin_button.get_value()
spin_button.set_text(f"{int(val):02d}")
# NOTE: indicates that rendering completed
return True
def get_values(self) -> tuple[int, int]:
hour = self.get_hour()
minute = self.get_minute()
return hour, minute
def get_hour(self) -> int:
return int(self.hour_spin.get_adjustment().get_value())
def get_minute(self) -> int:
return int(self.minute_spin.get_adjustment().get_value())
def get_time(self) -> datetime:
"""
NOTE: interpolating seconds/microseconds from the current time
may prevent abrupt visual changes when setting a new TimePicker time
while a clock tick is updating the local remainder label.
However, this can introduce false negatives when running tests due to microsecond drift.
Ideally, tests should be decoupled from this logic.
"""
d = datetime.now()
return datetime.combine(
d,
time(
hour=self.get_hour(),
minute=self.get_minute(),
second=0,
microsecond=0,
),
)
def set_time(self, hour: int, minute: int) -> None:
self.hour_spin.get_adjustment().set_value(hour)
self.minute_spin.get_adjustment().set_value(minute)
class AdjustedTimeFrame(Gtk.Frame):
def __init__(self, emitter: Emitter) -> None:
super().__init__(margin_start=50, margin_end=50)
self.emitter = emitter
self.emitter.connect(
"adjusted_local_time_changed", self._on_adjusted_time_changed
)
self.emitter.connect("remaining_time_changed", self._on_remainder_changed)
self.remainder_clock = RemainderClock(emitter)
self.remainder_heading = BoldLabel(calc.heading_remainder_time)
self.remaining_time = Gtk.Label(label=self.remainder_clock.get_time())
self.adjusted_clock = AdjustedClock(emitter)
self.adjusted_heading = BoldLabel(calc.heading_adjusted_time)
self.adjusted_local_time = Gtk.Label(label=self.adjusted_clock.get_time())
flowbox = Gtk.FlowBox(
row_spacing=20,
column_spacing=50,
min_children_per_line=2,
max_children_per_line=2,
margin=15,
halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.NONE,
)
flowbox.add(self.remainder_heading)
flowbox.add(self.adjusted_heading)
flowbox.add(self.remaining_time)
flowbox.add(self.adjusted_local_time)
recalc = Gtk.Button(
label=calc.calculate, halign=Gtk.Align.CENTER, margin_bottom=20
)
recalc.connect("clicked", self._on_recalc_clicked)
vbox = VBox()
vbox.extend([flowbox, recalc])
self.add(vbox)
def _on_remainder_changed(self, emitter: Emitter, time: datetime) -> None:
strtime = str(time.strftime(EXTENDED_TIME_FORMAT))
self.remaining_time.set_text(strtime)
def _on_adjusted_time_changed(self, emitter: Emitter, time: datetime) -> None:
strtime = str(time.strftime(EXTENDED_TIME_FORMAT))
self.adjusted_local_time.set_text(strtime)
def _on_recalc_clicked(self, button: Gtk.Button) -> None:
self.emitter.emit("calculate_button_clicked")
class TimePickerFrame(Gtk.Frame):
def __init__(self, emitter: Emitter, stime: Time) -> None:
super().__init__(margin_start=50, margin_end=50)
self.emitter = emitter
self.emitter.connect("local_time_incremented", self._on_local_clock_increment)
self.emitter.connect("server_time_incremented", self._on_server_clock_increment)
self.emitter.connect(
"calculate_button_clicked", self._on_calculate_button_clicked
)
self.local_clock = LocalClock(self.emitter)
local_clock_time = self.local_clock.get_time()
self.server_time = ServerClock(stime, self.emitter, self.local_clock)
hour, minute = self.server_time.get_values()
self.picker = TimePicker(hour, minute)
self.local_timelabel = Gtk.Label(label=local_clock_time)
self.server_timelabel = Gtk.Label(label=stime.time, halign=Gtk.Align.CENTER)
h0 = BoldLabel(calc.heading_local_time)
h1 = BoldLabel(calc.heading_server_time)
h2 = BoldLabel(calc.heading_target_time)
self.icon_indicator = Icon(DAY_ICON)
self.update_server_icon()
server_time_box = HBox(10)
server_time_box.set_halign(Gtk.Align.CENTER)
server_time_box.extend([self.server_timelabel, self.icon_indicator])
flowbox = Gtk.FlowBox(
row_spacing=10,
column_spacing=50,
min_children_per_line=3,
max_children_per_line=3,
margin=15,
halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.NONE,
)
flowbox.add(h0)
flowbox.add(h1)
flowbox.add(h2)
flowbox.add(self.local_timelabel)
flowbox.add(server_time_box)
flowbox.add(self.picker)
self.add(flowbox)
def update_server_icon(self) -> None:
icon = DAY_ICON if self.server_time.is_day() else NIGHT_ICON
self.icon_indicator.set_icon_name(icon)
def _on_server_clock_increment(self, emitter: Emitter, time_now: datetime) -> None:
t = time_now.time().strftime("%H:%M")
self.server_timelabel.set_text(t)
self.update_server_icon()
def _on_local_clock_increment(
self, emitter: Emitter, time_now: datetime, elapsed: timedelta
) -> None:
t = time_now.time().strftime(EXTENDED_TIME_FORMAT)
self.local_timelabel.set_text(t)
def _on_calculate_button_clicked(self, button: Gtk.Button) -> None:
if (self.picker.get_values()) == (self.server_time.get_values()):
total_time = timedelta(seconds=0)
self.emitter.emit("target_time_reset", total_time)
else:
picker_time = self.picker.get_time()
total_time = self.server_time.calc_delta(picker_time)
self.emitter.emit("target_time_changed", total_time)
class ServerTimeCalculator(Gtk.ScrolledWindow):
def __init__(self, stime: Time) -> None:
super().__init__(propagate_natural_width=True)
self.emitter = Emitter()
outerbox = Gtk.Box(
halign=Gtk.Align.CENTER,
valign=Gtk.Align.START,
spacing=20,
orientation=Gtk.Orientation.VERTICAL,
)
time_picker_frame = TimePickerFrame(self.emitter, stime)
adjusted_time_frame = AdjustedTimeFrame(self.emitter)
disclaimer = Gtk.Label(
label=calc.disclaimer,
justify=Gtk.Justification.CENTER,
)
for el in time_picker_frame, adjusted_time_frame, disclaimer:
outerbox.add(el)
self.add(outerbox)
+10 -20
View File
@@ -3,7 +3,6 @@ import sys
from typing import Self from typing import Self
from dzgui.util.strings import dialog_error, dialog_header from dzgui.util.strings import dialog_error, dialog_header
from dzgui.views.mixins.colorscheme import ColorAwareApp
import gi import gi
@@ -11,26 +10,21 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk # noqa E402
class AbortDialog(ColorAwareApp, Gtk.MessageDialog): # type: ignore class EarlyAlertDialog(Gtk.MessageDialog):
def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None: def __init__(self, string: str) -> None:
super().__init__( super().__init__(
title=dialog_header, title=dialog_header,
text=dialog_error, text=dialog_error,
transient_for=None, transient_for=None,
buttons=buttons, buttons=Gtk.ButtonsType.OK,
) )
msg = textwrap.fill(string, 50) msg = textwrap.fill(string, 50)
self.format_secondary_text(msg) self.format_secondary_text(msg)
ma = self.get_message_area()
label = ma.get_children()[1] # type: ignore
label.set_justify(Gtk.Justification.CENTER)
aa = self.get_action_area() aa = self.get_action_area()
aa.set_margin_bottom(20) aa.set_margin_bottom(20)
aa.set_layout(Gtk.ButtonBoxStyle.CENTER) # type: ignore # self.action_area.set_margin_bottom(20)
self.outer = self.get_content_area() self.outer = self.get_content_area()
self.outer.set_margin_start(30) self.outer.set_margin_start(30)
self.outer.set_margin_end(30) self.outer.set_margin_end(30)
@@ -38,30 +32,26 @@ class AbortDialog(ColorAwareApp, Gtk.MessageDialog): # type: ignore
self.set_default_size(250, 100) self.set_default_size(250, 100)
abort = self.get_widget_for_response(Gtk.ResponseType.OK) abort = self.get_widget_for_response(Gtk.ResponseType.OK)
ignore = self.get_widget_for_response(Gtk.ResponseType.CANCEL)
if abort is not None and hasattr(abort, "set_label"): if abort is not None and hasattr(abort, "set_label"):
abort.set_label("Exit") abort.set_label("Exit")
if ignore is not None and hasattr(ignore, "set_label"):
ignore.set_label("Ignore")
self.connect("response", self._on_response) self.connect("response", self._on_response)
self.run() self.run()
self.destroy() self.destroy()
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
match response: match response:
case Gtk.ResponseType.OK | Gtk.ResponseType.DELETE_EVENT: case Gtk.ResponseType.OK:
sys.exit(1) sys.exit(1)
case Gtk.ResponseType.CANCEL: case Gtk.ResponseType.CANCEL:
return return
class EarlyAlertDialog(AbortDialog): class EarlyIgnoreDialog(EarlyAlertDialog):
def __init__(self, string: str) -> None: def __init__(self, string: str) -> None:
super().__init__(string=string, buttons=Gtk.ButtonsType.OK) super().__init__(string=string)
# TODO: reverse order
self.add_button("Ignore", Gtk.ResponseType.CANCEL)
class EarlyIgnoreDialog(AbortDialog): # TODO: if exit, sys.exit(1), else pass
def __init__(self, string: str) -> None:
super().__init__(string=string, buttons=Gtk.ButtonsType.OK_CANCEL)
+1 -1
View File
@@ -13,7 +13,7 @@ class FilePicker(Gtk.FileChooserDialog):
super().__init__( super().__init__(
title=picker.title, title=picker.title,
action=Gtk.FileChooserAction.SAVE, action=Gtk.FileChooserAction.SAVE,
transient_for=parent, parent=parent,
resizable=True, resizable=True,
) )
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL) self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL)
+51 -152
View File
@@ -1,7 +1,6 @@
from typing import Literal, Self, TYPE_CHECKING from typing import Literal, Self, TYPE_CHECKING
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL
from dzgui.strings import dialogs
from dzgui.util import strings from dzgui.util import strings
from dzgui.views.components.buttons import ClipboardButton from dzgui.views.components.buttons import ClipboardButton
@@ -108,26 +107,23 @@ class WaitDialog(GenericDialog):
self.cur_job = 1 self.cur_job = 1
self.cancel = Gtk.Button(label="Cancel", halign=Gtk.Align.CENTER) self.cancel = Gtk.Button(label="Cancel", halign=Gtk.Align.CENTER)
self.cancel.connect("clicked", self._on_cancel_clicked) self.cancel.connect("clicked", lambda _: self.controller.set_cancel_event())
self.connect("delete-event", lambda widget, event: True) self.connect("delete-event", lambda widget, event: True)
content = self.get_content_area() content = self.get_content_area()
spinner = Gtk.Spinner()
self.prog = Gtk.ProgressBar() self.prog = Gtk.ProgressBar()
spinner_box = Gtk.Box( content.pack_end(self.cancel, NO_EXPAND, NO_FILL, 0)
orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.CENTER content.pack_end(spinner, NO_EXPAND, NO_FILL, 0)
)
spinner = Gtk.Spinner()
spinner_box.add(spinner)
if self.jobs > 1: if self.jobs > 1:
content.pack_start(self.prog, NO_EXPAND, NO_FILL, 0) content.pack_end(self.prog, NO_EXPAND, NO_FILL, 0)
else: else:
content.pack_start(spinner_box, NO_EXPAND, NO_FILL, 0)
spinner.start() spinner.start()
if show_cancel is True: if show_cancel is False:
content.pack_start(self.cancel, NO_EXPAND, NO_FILL, 0) self.connect("realize", lambda _: self.cancel.set_visible(False))
def update_text(self, msg: str) -> None: def update_text(self, msg: str) -> None:
self.format_secondary_text(msg) self.format_secondary_text(msg)
@@ -139,11 +135,6 @@ class WaitDialog(GenericDialog):
self.prog.set_fraction(fraction) self.prog.set_fraction(fraction)
self.cur_job += 1 self.cur_job += 1
def _on_cancel_clicked(self, button: Gtk.Button) -> None:
# TODO: strings
GLib.idle_add(self.update_text, "Caught cancel signal, cleaning up")
self.controller.set_cancel_event()
def show_cancel(self, state: bool) -> None: def show_cancel(self, state: bool) -> None:
self.cancel.set_visible(state) self.cancel.set_visible(state)
@@ -162,7 +153,6 @@ class QuitDialog(GenericDialog):
self.add_button(strings.exit_app, Gtk.ResponseType.OK) self.add_button(strings.exit_app, Gtk.ResponseType.OK)
self.connect("response", self._on_response) self.connect("response", self._on_response)
# TODO: superfluous with the above
self.connect("delete-event", self._on_response) self.connect("delete-event", self._on_response)
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
@@ -170,30 +160,58 @@ class QuitDialog(GenericDialog):
self.controller.save_res_and_quit() self.controller.save_res_and_quit()
class TextBufferDialog(GenericDialog): class ExceptionDialog(GenericDialog):
def __init__( """
self, Error dialog with rich traceback.
controller: "Controller", Usage:
mtype: Gtk.MessageType, try:
heading: str, foo()
secondary: str, except Exception:
text: str, trace = traceback.format_exc()
): dialog = ExceptionDialog(Controller, trace)
dialog.run()
"""
def __init__(self, controller: "Controller", trace: str):
super().__init__( super().__init__(
controller=controller, controller=controller,
text=heading, text=strings.error_heading,
mtype=mtype, mtype=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.NONE, buttons=Gtk.ButtonsType.NONE,
secondary=secondary, secondary=strings.something_wrong,
) )
self.text = text
copy_button = ClipboardButton(controller, self.get_text) self.trace = trace
# NOTE: box expands to end of content area
scrollable = Gtk.ScrolledWindow(
propagate_natural_height=True, max_content_height=500
)
box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL)
# TODO: wrap/truncate long messages
textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10
)
textview.set_buffer(Gtk.TextBuffer(text=self.trace))
box.pack_start(textview, EXPAND, FILL, 10)
scrollable.add(box)
content = self.get_content_area()
content.set_spacing(0)
# TODO: padding around top of content area when traceback is long
content.add(scrollable)
copy_button = ClipboardButton(controller, self.get_trace)
self.add_action_widget(copy_button, Gtk.ResponseType.NONE) self.add_action_widget(copy_button, Gtk.ResponseType.NONE)
self.add_button("OK", Gtk.ResponseType.OK) self.add_button("OK", Gtk.ResponseType.OK)
self.show_all()
self.ok = self.get_widget_for_response(Gtk.ResponseType.OK)
if self.ok is not None:
self.ok.grab_focus()
self.connect("response", self._on_response) self.connect("response", self._on_response)
def get_text(self) -> str: def get_trace(self) -> str:
return self.text return self.trace
def _on_response( def _on_response(
self, dialog: Self, response: Gtk.ResponseType self, dialog: Self, response: Gtk.ResponseType
@@ -209,122 +227,3 @@ class TextBufferDialog(GenericDialog):
return None return None
case _: case _:
return None return None
class DebugDialog(TextBufferDialog):
def __init__(self, controller: "Controller", debug: str):
super().__init__(
controller=controller,
mtype=Gtk.MessageType.INFO,
heading=dialogs.debug_heading,
secondary=dialogs.debug_secondary,
text=debug,
)
scrollable = Gtk.ScrolledWindow(
propagate_natural_height=False,
min_content_height=200,
max_content_height=200,
margin_bottom=10,
)
textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.CHAR, editable=False, left_margin=10, right_margin=10
)
textview.set_buffer(Gtk.TextBuffer(text=debug))
scrollable.add(textview)
content = self.get_content_area()
content.set_spacing(0)
content.add(scrollable)
class ExceptionDialog(TextBufferDialog):
"""
Error dialog with rich traceback.
Usage:
try:
foo()
except Exception:
trace = traceback.format_exc()
dialog = ExceptionDialog(Controller, trace)
dialog.show_all()
dialog.run()
"""
def __init__(self, controller: "Controller", trace: str):
super().__init__(
controller=controller,
heading=strings.error_heading,
secondary=strings.something_wrong,
text=trace,
mtype=Gtk.MessageType.ERROR,
)
# NOTE: box expands to end of content area
scrollable = Gtk.ScrolledWindow(
propagate_natural_height=True, max_content_height=500
)
box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL)
textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.WORD,
editable=False,
left_margin=10,
right_margin=10,
top_margin=15,
bottom_margin=10,
)
textview.set_buffer(Gtk.TextBuffer(text=trace))
box.pack_start(textview, EXPAND, FILL, 0)
self.error_details = Gtk.ScrolledWindow(
overlay_scrolling=False,
max_content_height=150,
propagate_natural_height=False,
)
details_box = Gtk.Box(
hexpand=True,
vexpand=True,
margin_right=5,
orientation=Gtk.Orientation.VERTICAL,
)
self.details_buffer = Gtk.TextBuffer()
details_textview = Gtk.TextView(
wrap_mode=Gtk.WrapMode.WORD_CHAR,
editable=False,
left_margin=10,
right_margin=10,
top_margin=15,
)
details_textview.set_buffer(self.details_buffer)
details_box.pack_start(details_textview, EXPAND, FILL, 10)
self.error_details.add(details_box)
self.error_notebook = Gtk.Notebook(show_tabs=False, margin_bottom=15)
self.error_notebook.append_page(box, Gtk.Label(label="Error"))
self.error_notebook.append_page(self.error_details, Gtk.Label(label="Details"))
self.error_notebook.connect("switch-page", self._on_page_changed)
scrollable.add(self.error_notebook)
content = self.get_content_area()
content.set_spacing(0)
content.add(scrollable)
self.connect("map", self._on_map)
def _on_map(self, dialog: Self) -> None:
button = self.get_widget_for_response(Gtk.ResponseType.OK)
if button is None:
return
button.grab_focus()
def _on_page_changed(
self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int
) -> None:
if child == self.error_details:
GLib.idle_add(self.error_details.set_propagate_natural_height, True)
else:
GLib.idle_add(self.error_details.set_propagate_natural_height, False)
def set_details_buffer(self, text: str) -> None:
self.details_buffer.set_text(text)
self.error_notebook.set_show_tabs(True)
+25 -48
View File
@@ -1,21 +1,19 @@
import gi
import html
from typing import Self, TYPE_CHECKING from typing import Self, TYPE_CHECKING
from dzgui.const.constants import CALCULATOR from dzgui.const.constants import EXPAND, FILL
from dzgui.const.enum import ContextMenuGroup from dzgui.const.enum import ContextMenuGroup
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
from dzgui.util import css from dzgui.util import css
from dzgui.util import strings
from dzgui.util.format import format_hyperlinks, format_server_mods from dzgui.util.format import format_hyperlinks, format_server_mods
from dzgui.strings import server_mods, dialogs from dzgui.strings import server_mods
from dzgui.views.components.buttons import IconTextButton
from dzgui.views.components.box import VBox
from dzgui.views.dialogs.calc import Time, ServerTimeCalculator
from dzgui.views.dialogs.generic import GenericDialog from dzgui.views.dialogs.generic import GenericDialog
from dzgui.views.trees.tree_base import TreeView from dzgui.views.trees.tree_base import TreeView
import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, Pango # noqa from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import Details from dzgui.api.servers import Details
@@ -47,19 +45,12 @@ class ServerDialog(GenericDialog):
self.connect("response", self._on_response) self.connect("response", self._on_response)
self.view.connect("key-press-event", self._on_keypress) self.view.connect("key-press-event", self._on_keypress)
self.scrollable_tree = Gtk.ScrolledWindow(vexpand=True) self.scrollable_tree = Gtk.ScrolledWindow()
self.scrollable_tree.add(self.view) self.scrollable_tree.add(self.view)
self.scrollable_tree.set_size_request(700, 500) self.scrollable_tree.set_size_request(700, 400)
self.stack = Gtk.Stack()
self.content_box = VBox(10)
self.content_box.set_valign(Gtk.Align.FILL)
self.content_box.add(self.scrollable_tree)
self.stack.add_named(self.content_box, "METADATA")
self.content = self.get_content_area() self.content = self.get_content_area()
self.content.pack_start(self.stack, expand=True, fill=True, padding=0) self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0)
def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool: def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool:
# NOTE: ESC normally unfocuses treeview instead of destroying dialog # NOTE: ESC normally unfocuses treeview instead of destroying dialog
@@ -74,7 +65,8 @@ class ServerDialog(GenericDialog):
class ServerDetailsDialog(ServerDialog): class ServerDetailsDialog(ServerDialog):
def __init__(self, controller: "Controller", details: "Details"): def __init__(self, controller: "Controller", details: "Details"):
super().__init__(controller, dialogs.server_details, details.name, menu=None) name = controller.get_server_name()
super().__init__(controller, strings.server_details, name, menu=None)
self.store = Gtk.ListStore(str, str, Pango.Weight) self.store = Gtk.ListStore(str, str, Pango.Weight)
self.view.connect("row-activated", self._on_row_activated) self.view.connect("row-activated", self._on_row_activated)
@@ -91,9 +83,14 @@ class ServerDetailsDialog(ServerDialog):
column.set_sort_column_id(i) column.set_sort_column_id(i)
column.set_expand(True) column.set_expand(True)
scrollable_message = Gtk.ScrolledWindow(vexpand=True) # NOTE: constrain tree area to logical height of metadata
desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START) # leaving room for server message
css.add_class(desc, "server-subheading") self.scrollable_tree.set_size_request(700, 150)
# TODO: make "Server message" text boldface
scrollable_message = Gtk.ScrolledWindow()
desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START)
css.add_class(desc, "details-heading")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER)
self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True) self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True)
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
@@ -101,41 +98,21 @@ class ServerDetailsDialog(ServerDialog):
for el in desc, sep, self.description: for el in desc, sep, self.description:
box.add(el) box.add(el)
scrollable_message.add(box) scrollable_message.add(box)
self.content_box.add(scrollable_message)
self.pack(scrollable_message)
if details.data is None:
return
for row in details.data: for row in details.data:
self.store.append(row + [Pango.Weight.BOLD]) self.store.append(row + [Pango.Weight.BOLD])
self.view.set_model(self.store) self.view.set_model(self.store)
text = details.description text = details.description
text = format_hyperlinks(text) text = format_hyperlinks(text)
self.description.set_markup(html.escape(text)) self.description.set_markup(text)
calc_button = IconTextButton(CALCULATOR, dialogs.toggle_calc)
calc_button.connect("clicked", self._on_calc_pressed)
calc_button.set_halign(Gtk.Align.END)
calc_button.set_valign(Gtk.Align.START)
self.content.pack_start(calc_button, False, False, 0)
self.content.reorder_child(calc_button, 2)
self.scrollable_tree.set_size_request(700, 250)
self.calc = ServerTimeCalculator(
Time(details.gametime, details.day_accel, details.night_accel)
)
self.stack.add_named(self.calc, "CALCULATOR")
self.connect("map", self._on_map)
self.show_all() self.show_all()
def _on_map(self, dialog: Self) -> None:
if widget := self.get_widget_for_response(Gtk.ResponseType.OK):
widget.grab_focus()
def _on_calc_pressed(self, button: Gtk.Button) -> None:
if self.stack.get_visible_child_name() == "METADATA":
self.stack.set_visible_child_name("CALCULATOR")
else:
self.stack.set_visible_child_name("METADATA")
def _on_row_activated( def _on_row_activated(
self, self,
treeview: Gtk.TreeView, treeview: Gtk.TreeView,
-230
View File
@@ -1,230 +0,0 @@
import os
import shutil
import subprocess
from pathlib import Path
from typing import Self
from dzgui.api.shortcuts import Shortcuts
from dzgui.const.constants import APP_NAME
from dzgui.strings import uninstall
from dzgui.util._json import read_json
from dzgui.util.css import load_css
from dzgui.util.format import format_exception
from dzgui.views.components.box import HBox
from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel
from dzgui.views.mixins.colorscheme import ColorAwareApp
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Pango # noqa E402
class Assistant(Gtk.Assistant):
def __init__(self, is_deck: bool, paths: dict[str, str]):
super().__init__()
if is_deck:
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
self.set_default_size(1085, 670)
else:
self.set_default_size(1500, 900)
self.set_forward_page_func(self._advance_page)
pyapp = os.getenv("PYAPP")
self.page1 = UninstallPage(paths, pyapp)
self.page2 = CompletionPage(pyapp)
self.append_page(self.page1)
self.set_page_type(self.page1, Gtk.AssistantPageType.INTRO)
self.set_page_title(self.page1, uninstall.pg1_title)
self.set_page_complete(self.page1, True)
self.append_page(self.page2)
self.set_page_title(self.page2, uninstall.pg2_title)
self.set_page_type(self.page2, Gtk.AssistantPageType.SUMMARY)
self.connect("cancel", self.destroy_and_quit)
self.connect("close", self.destroy_and_quit)
self.show_all()
load_css()
def _advance_page(self, index: int) -> int:
cur_page = self.get_nth_page(index)
if cur_page == self.page1:
self.page1.uninstall()
return index + 1
def destroy_and_quit(self, widget: Self) -> None:
self.destroy()
Gtk.main_quit()
class Window(Gtk.Window):
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None:
super().__init__(title=APP_NAME, icon_name=APP_NAME)
self.assistant = Assistant(is_deck, paths)
class CompletionPage(ScrolledWizardPage):
def __init__(self, pyapp: str | None):
super().__init__(heading=uninstall.pg2_heading, description=uninstall.pg2_blurb)
text = (
uninstall.standalone_uninstall
if pyapp is not None
else uninstall.system_uninstall
)
label = Gtk.Label(
label=text,
wrap_mode=Pango.WrapMode.WORD,
margin=10,
max_width_chars=80,
justify=Gtk.Justification.CENTER,
)
frame = Gtk.Frame()
frame.add(label)
self.add_start(frame)
class FileLabel(HBox):
def __init__(self, path: Path) -> None:
super().__init__(spacing=10)
label = Gtk.Label(label=uninstall.path_remove_prefix, halign=Gtk.Align.START)
textview = Gtk.TextView(
editable=False,
halign=Gtk.Align.START,
left_margin=10,
right_margin=10,
)
textview.set_buffer(Gtk.TextBuffer(text=str(path)))
self.extend([label, textview])
class CheckboxWithPath(CheckboxWithLabel):
def __init__(
self, label: str, details: str, path: Path, active: bool = True
) -> None:
super().__init__(text=label, blurb_text=details)
self.conf_path = path
self.set_active(active)
fl = FileLabel(path)
self.indent_below(fl)
def get_conf_path(self) -> Path:
return self.conf_path
class UninstallPage(ScrolledWizardPage):
def __init__(self, paths: dict[str, str], pyapp: str | None):
super().__init__(heading=uninstall.pg1_heading, description=uninstall.pg1_blurb)
self.pyapp = pyapp
config = Path(paths["XDG_CONFIG_HOME"])
state = Path(paths["XDG_STATE_HOME"])
self.share = Path(paths["XDG_DATA_HOME"])
shortcut = self.share.parent.joinpath("applications/dzgui.desktop")
desktop = config.parent.parent.joinpath("Desktop/dzgui.desktop")
self.steam_path = self.parse_steam_path(config, self.share)
self.boxes: list[CheckboxWithPath] = []
self.checks_area = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
halign=Gtk.Align.CENTER,
spacing=20,
margin_top=20,
margin_bottom=20,
)
self.config_box = CheckboxWithPath(
uninstall.config_label, uninstall.config_details, config, active=False
)
self.desktop_box = CheckboxWithPath(
uninstall.desktop_label, uninstall.desktop_details, desktop
)
self.start_menu_box = CheckboxWithPath(
uninstall.start_menu_label, uninstall.start_menu_details, shortcut
)
self.state_box = CheckboxWithPath(
uninstall.state_label, uninstall.state_details, state
)
self.steam_box = CheckboxWithLabel(
uninstall.steam_shortcut, uninstall.steam_details
)
for box in (
self.config_box,
self.state_box,
self.desktop_box,
self.start_menu_box,
):
self.checks_area.add(box)
self.boxes.append(box)
self.checks_area.add(self.steam_box)
self.add_start(self.checks_area)
self.show_all()
def parse_steam_path(self, config: Path, steam: Path) -> Path | None:
try:
file = config.joinpath("config.json")
conf = read_json(file)
steam = conf["default_steam_path"]
self.steam_path = Path(steam)
return Path(steam)
except Exception as e:
print(format_exception(e))
return None
def wipe_conf_file(self, box: CheckboxWithPath) -> None:
if box.get_active():
path = box.get_conf_path()
try:
if path.is_dir():
shutil.rmtree(path)
else:
path.unlink()
print(f"Deleted '{path}'")
except Exception as e:
print(format_exception(e))
def wipe_steam_shortcut(self) -> None:
if self.steam_path is None:
return
try:
shortcuts = Shortcuts(self.steam_path)
shortcuts.delete_shortcut(self.share)
shutil.rmtree(self.share)
except Exception as e:
print(format_exception(e))
def wipe_pyapp(self) -> None:
if self.pyapp is None:
return
subprocess.run([self.pyapp, "self", "remove"])
def uninstall(self) -> None:
for box in self.boxes:
self.wipe_conf_file(box)
if self.steam_box.get_active():
self.wipe_steam_shortcut()
self.wipe_pyapp()
pass
class UninstallWizard(ColorAwareApp, Gtk.Application): # type: ignore
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None:
super().__init__()
config = Path(paths["XDG_CONFIG_HOME"])
if not config.is_dir() or not config.joinpath("config.json").is_file():
print(uninstall.not_installed)
return
GLib.set_prgname(APP_NAME)
self.win = Window(is_deck, paths)
Gtk.main()
+111 -288
View File
@@ -3,59 +3,42 @@ import textwrap
from enum import Enum from enum import Enum
from importlib import resources from importlib import resources
from pathlib import Path 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 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.api.steam import get_steam_paths
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
APP_NAME_LOWER, APP_NAME_LOWER,
HERO_PATH, HERO_PATH,
FLATPAK_ROOT_PATH,
LEGACY_CONFIG_PATH, LEGACY_CONFIG_PATH,
) )
from dzgui.const.boilerplate import config_boilerplate from dzgui.const.boilerplate import config_boilerplate
from dzgui.const.endpoints import STEAM_API_SETUP from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP
from dzgui.config import freedesktop
from dzgui.init.proc import is_sandboxed, path_to_steam_cmd
from dzgui.init.migrate import migrate_legacy_conf from dzgui.init.migrate import migrate_legacy_conf
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import wizard from dzgui.strings import wizard
from dzgui.util._json import write_json from dzgui.util._json import write_json
from dzgui.util.flatpak import apply_overrides
from dzgui.util.open_links import open_link_by_url from dzgui.util.open_links import open_link_by_url
from dzgui.util.css import add_class, load_css from dzgui.util.css import add_class, load_css
from dzgui.views.components.buttons import WebButton from dzgui.views.components.buttons import WebButton
from dzgui.views.components.entry import APIEntry from dzgui.views.components.entry import APIEntry
from dzgui.views.mixins.colorscheme import ColorAwareApp from dzgui.views.components.misc import ClientCombo
from dzgui.views.trees.tree_simple import SimpleTree
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402 from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402
if TYPE_CHECKING:
from dzgui.config.xdg import Xdg
class PageNum(Enum): class PageNum(Enum):
INTRO = 1 INTRO = 1
HAS_CONFIG = 2 HAS_CONFIG = 2
STEAM_PATH = 3 STEAM_PATH = 3
STEAM_API = 4 STEAM_API = 4
USER_PREFS = 5 BM_API = 5
SHORTCUTS = 6 USER_PREFS = 6
FLATPAK = 7 FINAL = 7
FINAL = 8
class OptionalPageMixin:
"""Marks optional pages as advanceable"""
def _on_map(self, page: "EnumeratedWizardPage") -> None:
EMITTER.emit("step_complete")
class DescriptionArea(Gtk.Box): class DescriptionArea(Gtk.Box):
@@ -74,9 +57,10 @@ class Progress(Gtk.ProgressBar):
class ScrolledWizardPage(Gtk.ScrolledWindow): class ScrolledWizardPage(Gtk.ScrolledWindow):
def __init__(self, heading: str, description: str): def __init__(self, enum: PageNum, heading: str, description: str):
super().__init__(overlay_scrolling=False) super().__init__()
self.enum = enum
self.page_type: Gtk.AssistantPageType self.page_type: Gtk.AssistantPageType
self.title = heading self.title = heading
self.heading = Heading(heading) self.heading = Heading(heading)
@@ -89,6 +73,7 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
height=600, height=600,
preserve_aspect_ratio=True, preserve_aspect_ratio=True,
) )
image = Gtk.Image.new_from_pixbuf(pixbuf)
self.box = Gtk.Box( self.box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL,
@@ -97,13 +82,21 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
margin_top=50, margin_top=50,
spacing=20, spacing=20,
) )
image = Gtk.Image.new_from_pixbuf(pixbuf)
self.box.pack_start(image, expand=False, fill=True, padding=0)
self.add(self.box) self.add(self.box)
self.prog = Progress()
self.box.pack_end(self.prog, expand=False, fill=False, padding=0)
self.box.pack_start(image, expand=False, fill=True, padding=0)
self.box.pack_start(self.heading, expand=False, fill=True, padding=0) self.box.pack_start(self.heading, expand=False, fill=True, padding=0)
self.box.pack_start(self.description, expand=False, fill=True, padding=0) self.box.pack_start(self.description, expand=False, fill=True, padding=0)
self.connect("map", self._on_map)
def get_enum(self) -> PageNum:
return self.enum
def get_progress_bar(self) -> Progress:
return self.prog
def get_page_type(self) -> Gtk.AssistantPageType: def get_page_type(self) -> Gtk.AssistantPageType:
return self.page_type return self.page_type
@@ -122,20 +115,8 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
def get_box(self) -> Gtk.Box: def get_box(self) -> Gtk.Box:
return self.box return self.box
def _on_map(self, page: "ScrolledWizardPage") -> None:
class EnumeratedWizardPage(ScrolledWizardPage): pass
def __init__(self, enum: PageNum, heading: str, description: str) -> None:
super().__init__(heading=heading, description=description)
self.enum = enum
self.prog = Progress()
self.box.pack_end(self.prog, expand=False, fill=False, padding=0)
def get_enum(self) -> PageNum:
return self.enum
def get_progress_bar(self) -> Progress:
return self.prog
class NotificationFrame(Gtk.Frame): class NotificationFrame(Gtk.Frame):
@@ -147,7 +128,7 @@ class NotificationFrame(Gtk.Frame):
margin_start=50, margin_start=50,
margin_end=50, margin_end=50,
) )
wrapped = textwrap.fill(label, width=60) wrapped = textwrap.fill(label, width=80)
self.label = Gtk.Label( self.label = Gtk.Label(
label=wrapped, label=wrapped,
justify=Gtk.Justification.CENTER, justify=Gtk.Justification.CENTER,
@@ -160,12 +141,8 @@ class NotificationFrame(Gtk.Frame):
if error: if error:
add_class(self, "error-frame") add_class(self, "error-frame")
def set_text(self, text: str) -> None:
wrapped = textwrap.fill(text, width=60)
self.label.set_markup(wrapped)
class APIValidationPage(ScrolledWizardPage):
class APIValidationPage(EnumeratedWizardPage):
def __init__( def __init__(
self, enum: PageNum, heading: str, description: str, link: str, func: Callable self, enum: PageNum, heading: str, description: str, link: str, func: Callable
) -> None: ) -> None:
@@ -225,6 +202,23 @@ class APIValidationPage(EnumeratedWizardPage):
self.spinner.stop() self.spinner.stop()
class BMValidationPage(APIValidationPage):
def __init__(self) -> None:
super().__init__(
enum=PageNum.BM_API,
heading=wizard.heading_bm_api,
description=wizard.blurb_bm_api,
link=BM_API_SETUP,
func=self._validate,
)
@call_on_thread("", show_dialog=False)
def _validate(self, key: str) -> None:
is_valid = test_bm_api(key.strip())
cleanup = StoredFunc(self._cleanup, is_valid, key)
self.thread_man.set_cleanup_func(cleanup)
class SteamValidationPage(APIValidationPage): class SteamValidationPage(APIValidationPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
@@ -242,27 +236,16 @@ class SteamValidationPage(APIValidationPage):
self.thread_man.set_cleanup_func(cleanup) self.thread_man.set_cleanup_func(cleanup)
class IntroductionPage(EnumeratedWizardPage): class IntroductionPage(ScrolledWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
enum=PageNum.INTRO, enum=PageNum.INTRO,
heading=wizard.welcome.format(APP_NAME), heading=f"Welcome to {APP_NAME}!",
description=wizard.blurb_intro, description=wizard.blurb_intro,
) )
self.page_type = Gtk.AssistantPageType.INTRO self.page_type = Gtk.AssistantPageType.INTRO
self.set_title(wizard.title_intro) self.set_title(wizard.title_intro)
self.error_box = NotificationFrame(wizard.error_sandbox, error=True)
self.add_start(self.error_box)
self.connect("map", self._on_map)
def _on_map(self, page: "EnumeratedWizardPage") -> None:
self.error_box.hide()
if is_sandboxed():
self.error_box.show()
EMITTER.emit("step_pending")
class Heading(Gtk.Label): class Heading(Gtk.Label):
def __init__(self, label: str): def __init__(self, label: str):
@@ -308,7 +291,7 @@ class RadioFrame(Gtk.Frame):
return self.button return self.button
class ConfigMigrationPage(EnumeratedWizardPage): class ConfigMigrationPage(ScrolledWizardPage):
def __init__(self, config: Path) -> None: def __init__(self, config: Path) -> None:
super().__init__( super().__init__(
enum=PageNum.HAS_CONFIG, enum=PageNum.HAS_CONFIG,
@@ -317,7 +300,6 @@ class ConfigMigrationPage(EnumeratedWizardPage):
) )
self.migrated = False self.migrated = False
self.migrated_conf: dict[str, Any] = {}
self.config = config self.config = config
self.page_type = Gtk.AssistantPageType.INTRO self.page_type = Gtk.AssistantPageType.INTRO
@@ -357,7 +339,7 @@ class ConfigMigrationPage(EnumeratedWizardPage):
def _on_import_clicked(self, button: Gtk.Button) -> None: def _on_import_clicked(self, button: Gtk.Button) -> None:
self.grid.set_sensitive(False) self.grid.set_sensitive(False)
try: try:
self.migrated_conf = migrate_legacy_conf(self.config) migrate_legacy_conf(self.config)
self.migrated = True self.migrated = True
self.success_box.set_visible(True) self.success_box.set_visible(True)
except Exception: except Exception:
@@ -368,7 +350,7 @@ class ConfigMigrationPage(EnumeratedWizardPage):
EMITTER.emit("config", True) EMITTER.emit("config", True)
class PreferencesPage(EnumeratedWizardPage): class PreferencesPage(ScrolledWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
enum=PageNum.USER_PREFS, enum=PageNum.USER_PREFS,
@@ -399,17 +381,26 @@ class PreferencesPage(EnumeratedWizardPage):
self.dist_box.add(self.radio_km) self.dist_box.add(self.radio_km)
self.dist_box.add(self.radio_miles) self.dist_box.add(self.radio_miles)
client_label = Gtk.Label(label=wizard.label_client, halign=Gtk.Align.START)
self.client_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.client_combo = ClientCombo()
self.client_box.add(client_label)
self.client_box.add(self.client_combo)
outer_box = Gtk.Box( outer_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, spacing=10 orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, spacing=10
) )
for el in self.name_box, self.dist_box: for el in self.name_box, self.client_box, self.dist_box:
outer_box.add(el) outer_box.add(el)
self.add_start(outer_box) self.add_start(outer_box)
def get_prefs(self) -> tuple[str, bool]: def get_prefs(self) -> tuple[str, bool, str]:
name = self.name_entry.get_text().strip() name = self.name_entry.get_text().strip()
use_miles = self.radio_miles.get_active() use_miles = self.radio_miles.get_active()
return name, use_miles model = self.client_combo.get_model()
ind = self.client_combo.get_active()
client = model[ind][1]
return name, use_miles, client
def _on_entry_changed(self, entry: Gtk.Entry) -> None: def _on_entry_changed(self, entry: Gtk.Entry) -> None:
text = entry.get_text() text = entry.get_text()
@@ -419,43 +410,7 @@ class PreferencesPage(EnumeratedWizardPage):
EMITTER.emit("step_complete") EMITTER.emit("step_complete")
class FlatpakOverridePage(EnumeratedWizardPage): class CompletionPage(ScrolledWizardPage):
def __init__(self, xdg_paths: dict[str, str]) -> None:
super().__init__(
enum=PageNum.FLATPAK,
heading=wizard.heading_flatpak,
description=wizard.blurb_flatpak,
)
self.xdg_paths = xdg_paths
self.page_type = Gtk.AssistantPageType.INTRO
store = Gtk.ListStore(str)
for path in xdg_paths.values():
store.append((str(path),))
tree = SimpleTree(store, wizard.tree_column)
tree.set_halign(Gtk.Align.CENTER)
self.authorize_button = Gtk.Button(
label=wizard.button_flatpak, halign=Gtk.Align.CENTER
)
self.authorize_button.connect("clicked", self._on_authorize)
self.notice = NotificationFrame(wizard.overrides_applied)
self.connect("map", self._on_map)
for el in tree, self.authorize_button, self.notice:
self.add_start(el)
def _on_map(self, page: "EnumeratedWizardPage") -> None:
self.notice.hide()
def _on_authorize(self, button: Gtk.Button) -> None:
apply_overrides(self.xdg_paths)
self.notice.show()
EMITTER.emit("step_complete")
class CompletionPage(EnumeratedWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
enum=PageNum.FINAL, enum=PageNum.FINAL,
@@ -468,27 +423,24 @@ class CompletionPage(EnumeratedWizardPage):
class Assistant(Gtk.Assistant): class Assistant(Gtk.Assistant):
def __init__(self, is_deck: bool, XDG: "Xdg", xdg_paths: dict[str, str]): def __init__(self, is_deck: bool, config: Path):
super().__init__() super().__init__()
if is_deck: if is_deck:
# NOTE: deemed to be "safe" dimensions that exclude taskbar size self.fullscreen()
self.set_default_size(1085, 670)
else: else:
self.set_default_size(1500, 900) self.set_default_size(1500, 900)
self.config_path = XDG.config self.config_path = config
self.config_values: dict[str, Any] = config_boilerplate self.config_values: dict[str, Any] = config_boilerplate
self.setup_complete = False self.page1 = IntroductionPage()
self.page2 = ConfigMigrationPage(config)
self.page_intro = IntroductionPage() self.page3 = SteamPathPage()
self.page_migration = ConfigMigrationPage(XDG.config) self.page4 = SteamValidationPage()
self.page_paths = SteamPathPage() self.page5 = BMValidationPage()
self.page_api = SteamValidationPage() self.page6 = PreferencesPage()
self.page_prefs = PreferencesPage() self.page7 = CompletionPage()
self.page_shortcuts = ShortcutCreationPage(XDG.shortcut)
self.page_flatpak = FlatpakOverridePage(xdg_paths)
self.page_completion = CompletionPage()
self.set_forward_page_func(self._advance_page) self.set_forward_page_func(self._advance_page)
@@ -496,28 +448,21 @@ class Assistant(Gtk.Assistant):
EMITTER.connect("step_pending", self._mark_page_incomplete) EMITTER.connect("step_pending", self._mark_page_incomplete)
EMITTER.connect("config", self._set_config_state) EMITTER.connect("config", self._set_config_state)
self.pages: dict[EnumeratedWizardPage, int] = {}
legacy_path = Path.home().joinpath(LEGACY_CONFIG_PATH) legacy_path = Path.home().joinpath(LEGACY_CONFIG_PATH)
self.has_legacy_config = legacy_path.is_file() self.has_legacy_config = legacy_path.is_file()
for page in ( for page in (
self.page_intro, self.page1,
self.page_migration, self.page2,
self.page_paths, self.page3,
self.page_api, self.page4,
self.page_prefs, self.page5,
self.page_shortcuts, self.page6,
self.page_flatpak, self.page7,
self.page_completion,
): ):
# NOTE: skip config migration page if no legacy config file # NOTE: skip config migration page if no legacy config file
if ( if page == self.page2 and self.has_legacy_config is False:
isinstance(page, ConfigMigrationPage)
and self.has_legacy_config is False
):
continue continue
ind = self._add_page(page, page.get_page_type()) self._add_page(page, page.get_page_type())
self.pages[page] = ind
self.connect("prepare", self._on_page_prepare) self.connect("prepare", self._on_page_prepare)
self.connect("cancel", self.destroy_and_quit) self.connect("cancel", self.destroy_and_quit)
@@ -525,61 +470,35 @@ class Assistant(Gtk.Assistant):
self.show_all() self.show_all()
load_css() load_css()
def write_config(self, config: dict[str, Any]) -> None: def write_config(self) -> None:
write_json(config, self.config_path) write_json(self.config_values, self.config_path)
def get_completion_page(self) -> int:
return self.pages[self.page_completion]
def get_shortcut_page(self) -> int:
return self.pages[self.page_shortcuts]
def _advance_page(self, index: int) -> int: def _advance_page(self, index: int) -> int:
page = self.get_nth_page(index) page = self.get_nth_page(index)
# TODO: use enums
match page: match page:
case IntroductionPage(): case self.page1:
pass pass
case ConfigMigrationPage(): case self.page2:
if page.is_migrated(): if self.page2.is_migrated():
sp = page.migrated_conf["default_steam_path"] return self.get_n_pages() - 1
self.page_shortcuts.set_steam_path(sp) case self.page3:
self.config_values = self.page_migration.migrated_conf self.config_values["default_steam_path"] = page.get_path_from_radio()
return self.get_shortcut_page() case self.page4:
case SteamPathPage():
path = page.get_path_from_radio()
self.config_values["default_steam_path"] = path
self.config_values["client"] = path_to_steam_cmd(path)
case SteamValidationPage():
self.config_values["steam_api"] = page.get_api_key() self.config_values["steam_api"] = page.get_api_key()
case PreferencesPage(): case self.page5:
self.config_values["bm_api"] = page.get_api_key()
# NOTE: collects config values before advancing to last page # NOTE: collects config values before advancing to last page
name, use_miles = self.page_prefs.get_prefs() case self.page6:
name, use_miles, client = self.page6.get_prefs()
self.config_values["name"] = name self.config_values["name"] = name
self.config_values["use_miles"] = use_miles self.config_values["use_miles"] = use_miles
self.page_shortcuts.set_steam_path( self.config_values["client"] = client
self.config_values["default_steam_path"] self.write_config()
)
case ShortcutCreationPage():
page.create_shortcuts()
if not self.needs_flatpak_override():
return self.get_completion_page()
case FlatpakOverridePage():
pass
case _: case _:
raise AttributeError("Trying to advance a non-canonical page") raise AttributeError("Trying to advance a non-canonical page")
return index + 1 return index + 1
def needs_flatpak_override(self) -> bool:
if (
self.page_shortcuts.is_steam_checkbox_active()
and FLATPAK_ROOT_PATH in self.config_values["default_steam_path"]
):
return True
return False
def is_setup_complete(self) -> bool:
return self.setup_complete
def destroy_and_quit(self, widget: Self) -> None: def destroy_and_quit(self, widget: Self) -> None:
self.destroy() self.destroy()
Gtk.main_quit() Gtk.main_quit()
@@ -598,19 +517,16 @@ class Assistant(Gtk.Assistant):
return return
self.set_page_complete(page, True) self.set_page_complete(page, True)
def _add_page( def _add_page(self, page: ScrolledWizardPage, ptype: Gtk.AssistantPageType) -> None:
self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType self.append_page(page)
) -> int:
ind = self.append_page(page)
self.set_page_type(page, ptype) self.set_page_type(page, ptype)
self.set_page_title(page, page.get_title()) self.set_page_title(page, page.get_title())
self.set_page_complete(page, True) self.set_page_complete(page, True)
return ind
def _set_config_state(self, emitter: "Emitter", state: bool) -> None: def _set_config_state(self, emitter: "Emitter", state: bool) -> None:
self.config = state self.config = state
def _on_page_prepare(self: Self, wizard: Self, page: EnumeratedWizardPage) -> None: def _on_page_prepare(self: Self, wizard: Self, page: ScrolledWizardPage) -> None:
page_num = self.get_current_page() + 1 page_num = self.get_current_page() + 1
total = self.get_n_pages() total = self.get_n_pages()
fraction = page_num / total fraction = page_num / total
@@ -619,105 +535,15 @@ class Assistant(Gtk.Assistant):
bar.set_fraction(fraction) bar.set_fraction(fraction)
bar.set_text(f"{page_num}/{total}") bar.set_text(f"{page_num}/{total}")
if not isinstance(page, IntroductionPage): # NOTE: disable forward action
# TODO: use page enums
if page == self.page5:
return
if page != self.page1:
EMITTER.emit("step_pending") EMITTER.emit("step_pending")
if page == self.page_completion:
self.write_config(self.config_values)
self.setup_complete = True
class SteamPathPage(ScrolledWizardPage):
class CheckboxWithLabel(Gtk.Box):
def __init__(self, text: str, blurb_text: str) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5)
self.indent = 20
self.button = Gtk.CheckButton(label=text)
self.button.set_active(True)
label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=self.indent)
wrapped = textwrap.fill(blurb_text, width=100)
label.set_markup(f"- {wrapped}")
for el in self.button, label:
self.add(el)
def indent_below(self, widget: Gtk.Widget) -> None:
widget.set_margin_start(self.indent)
self.add(widget)
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(OptionalPageMixin, EnumeratedWizardPage): # type: ignore
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 set_steam_path(self, path: Path) -> None:
self.steam_path = path
def is_steam_checkbox_active(self) -> bool:
return self.steam_checkbox.get_active()
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(EnumeratedWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
enum=PageNum.USER_PREFS, enum=PageNum.USER_PREFS,
@@ -772,21 +598,18 @@ class SteamPathPage(EnumeratedWizardPage):
self.err_box.set_visible(False) self.err_box.set_visible(False)
class SetupWizard(ColorAwareApp, Gtk.Application): # type: ignore class SetupWizard(Gtk.Application):
def __init__(self, is_deck: bool, XDG: "Xdg", xdg_paths: dict[str, str]) -> None: def __init__(self, is_deck: bool, config: Path) -> None:
super().__init__() super().__init__()
GLib.set_prgname(APP_NAME) GLib.set_prgname(APP_NAME)
self.win = Window(is_deck, XDG, xdg_paths) Window(is_deck, config)
Gtk.main() Gtk.main()
def is_setup_complete(self) -> int:
return self.win.assistant.is_setup_complete()
class Window(Gtk.Window): class Window(Gtk.Window):
def __init__(self, is_deck: bool, XDG: "Xdg", xdg_paths: dict[str, str]) -> None: def __init__(self, is_deck: bool, config: Path) -> None:
super().__init__(title=APP_NAME, icon_name=APP_NAME) super().__init__(title=APP_NAME, icon_name=APP_NAME)
self.assistant = Assistant(is_deck, XDG, xdg_paths) Assistant(is_deck, config)
class Emitter(GObject.GObject): class Emitter(GObject.GObject):
-37
View File
@@ -1,37 +0,0 @@
from typing import Any
from dzgui.const.constants import (
GIO_SETTINGS_KEY,
GIO_SETTINGS_INTERFACE,
GIO_SETTINGS_PROP,
GIO_SETTINGS_VAL,
)
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gio # noqa
class ColorAwareApp:
"""
Polls the local theme's color scheme settings.
Intended to be consumed by Gtk.Applications (or ephemeral widgets like EarlyAlertDialogs)
on initialization, prior to being realized. Applying scheme changes too late in the chain
may cause a blinking effect.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.interface_settings = Gio.Settings.new(GIO_SETTINGS_INTERFACE)
self.interface_settings.connect(
"changed::color-scheme", self._on_color_scheme_changed
)
self.default_settings = Gtk.Settings.get_default()
self._on_color_scheme_changed(self.interface_settings, GIO_SETTINGS_PROP)
def _on_color_scheme_changed(self, settings: Gio.Settings, prop: str) -> None:
state = self.interface_settings.get_string(prop) == GIO_SETTINGS_VAL
if self.default_settings is None:
return
self.default_settings.set_property(GIO_SETTINGS_KEY, state)
+9
View File
@@ -104,11 +104,20 @@ class ContextMixin(TreeView):
return False return False
menu = self.context_menu menu = self.context_menu
children = menu.get_children() children = menu.get_children()
sel = menu.get_selected_item()
for i, child in enumerate(children):
if sel is child:
ind = i
break
match event.keyval: match event.keyval:
case Gdk.KEY_j: case Gdk.KEY_j:
if ind + 1 > len(children) - 1:
return False
menu.emit("move-current", Gtk.MenuDirectionType.NEXT) menu.emit("move-current", Gtk.MenuDirectionType.NEXT)
case Gdk.KEY_k: case Gdk.KEY_k:
if ind - 1 < 0:
return False
menu.emit("move-current", Gtk.MenuDirectionType.PREV) menu.emit("move-current", Gtk.MenuDirectionType.PREV)
case Gdk.KEY_g: case Gdk.KEY_g:
menu.select_item(children[0]) menu.select_item(children[0])
-4
View File
@@ -37,11 +37,7 @@ class CursorMixin:
end = len(model) - 1 end = len(model) - 1
else: else:
return False return False
try:
cur_row = self.get_focused_row_index() # type: ignore cur_row = self.get_focused_row_index() # type: ignore
except Exception:
return False
if position == CursorPosition.DOWN: if position == CursorPosition.DOWN:
if cur_row == end: if cur_row == end:
+6 -92
View File
@@ -1,12 +1,9 @@
import logging import logging
import re
import textwrap
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from importlib import resources from importlib import resources
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, CHANGELOG_PATH from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, CHANGELOG_PATH
from dzgui.strings import changelog as clog
from dzgui.util.strings import missing_changelog from dzgui.util.strings import missing_changelog
from dzgui.util.format import format_pango from dzgui.util.format import format_pango
from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin
@@ -33,100 +30,17 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
logger.critical(e) logger.critical(e)
changelog = missing_changelog changelog = missing_changelog
# TODO: should long text be wrapped?
self.controller = controller self.controller = controller
self.box = Gtk.Box( formatted = format_pango(changelog)
orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10 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.add(self.box)
expand_all = Gtk.Button(
label=clog.expand_all, halign=Gtk.Align.START, margin_start=20
)
expand_all.connect("clicked", self._on_expand_all_clicked)
self.box.add(expand_all)
self.expanded = False
self.expanders: list[Gtk.Expander] = []
self.connect("key-press-event", self._on_keypress) self.connect("key-press-event", self._on_keypress)
self.connect("key-press-event", self._on_esc_keypress) self.connect("key-press-event", self._on_esc_keypress)
changes = self._parse(changelog)
self._generate_nodes(changes)
self.show_all()
def _on_expand_all_clicked(self, button: Gtk.Button) -> None:
self.expanded = not self.expanded
for expander in self.expanders:
# NOTE: simply setting set_expanded() does not trigger activate() signal,
# so margins are not applied
if expander.get_expanded() == self.expanded:
continue
expander.activate()
label = clog.collapse_all if self.expanded else clog.expand_all
button.set_label(label)
def grab_content_area(self) -> None: def grab_content_area(self) -> None:
self.grab_focus() 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)
container = Gtk.Box(valign=Gtk.Align.START, halign=Gtk.Align.START)
label = Gtk.Label()
label.set_markup(formatted)
container.add(label)
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(container)
expander.connect("activate", self._on_expand, container)
self.box.add(expander)
self.expanders.append(expander)
def _on_expand(self, expander: Gtk.Box, container: Gtk.Box) -> None:
"""
Wayland/some themes do not expect widgets to have a size allocation until expanded.
Trying to set margins a priori may cause negative allocation,
as the margins are subtracted from zero. In effect, the widget is not realized
until it is expanded for the first time.
"""
container.set_margin_start(15)
container.set_margin_top(15)
container.set_margin_bottom(15)
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(textwrap.fill(line.rstrip(), width=120))
return releases
+1 -1
View File
@@ -103,4 +103,4 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
return grid return grid
def grab_content_area(self) -> None: def grab_content_area(self) -> None:
self.grab_focus() self.box.grab_focus()
+2 -7
View File
@@ -64,15 +64,10 @@ class Mods(Gtk.Box):
self.controller.open_offline() self.controller.open_offline()
def _on_unmap(self, widget: Self) -> None: 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: def _on_map(self, widget: Self) -> None:
model = self.tree.get_model() self.emitter.emit("mod_page_toggled", True)
if model is None:
mods = 0
else:
mods = len(model)
self.emitter.emit("mod_page_toggled", True, mods)
# TODO: delegation # TODO: delegation
# NOTE: handles going back from offline mods page # NOTE: handles going back from offline mods page
# more generic cache restoration method # more generic cache restoration method
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff. Show more