mirror of
https://github.com/aclist/dztui.git
synced 2026-10-25 12:12:31 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47cb482f5 | ||
|
|
152999d728 | ||
|
|
cdcb3b794f | ||
|
|
cd23f403c9 | ||
|
|
a40bef8f82 | ||
|
|
5c07a77b63 | ||
|
|
91773c933c | ||
|
|
6a30ed44fc | ||
|
|
bbaec35a5f | ||
|
|
c46477f0a7 | ||
|
|
f47446a2e1 | ||
|
|
cdb311cb1f | ||
|
|
11cdcb62b2 | ||
|
|
1913f69e7b | ||
|
|
8c2cea0fdc | ||
|
|
8c29b9b35a | ||
|
|
d8239f1c34 | ||
|
|
8c3ec4512a | ||
|
|
1a18c8ff02 | ||
|
|
92c23ec609 | ||
|
|
e9d4054e76 | ||
|
|
1c9e026f70 | ||
|
|
eb076b7dcd | ||
|
|
8629e02670 | ||
|
|
3ba38f30ad | ||
|
|
7c37fd8b6e | ||
|
|
1a065123bb | ||
|
|
2586306323 | ||
|
|
1ccb99d935 | ||
|
|
054fe968d1 | ||
|
|
1760e6e928 | ||
|
|
a2a39e9927 | ||
|
|
e664f68624 | ||
|
|
2e41856348 | ||
|
|
27d444ea6f | ||
|
|
26a2362172 | ||
|
|
50cf347164 | ||
|
|
dd69bc88b1 | ||
|
|
5c84b5ae0a | ||
|
|
6fdf5547dc | ||
|
|
fc1311579c | ||
|
|
da993db2e2 | ||
|
|
207d8fd407 | ||
|
|
72b10ad543 | ||
|
|
35680b1373 | ||
|
|
5121e6d005 | ||
|
|
2f61cc7ff7 | ||
|
|
8733094267 | ||
|
|
9d14b7f219 | ||
|
|
0084af4959 | ||
|
|
caf071f997 | ||
|
|
b50d061e1f | ||
|
|
e913c95c70 | ||
|
|
d6bf63886e | ||
|
|
ec8f6a3d36 | ||
|
|
b8fbbbb1cd | ||
|
|
fb2772cde2 | ||
|
|
6c36b741f4 | ||
|
|
aa43b017d1 | ||
|
|
fc86b75ca1 | ||
|
|
6a3b4b2bfb | ||
|
|
8652259053 | ||
|
|
e92df8c062 | ||
|
|
eff31e2bd3 | ||
|
|
494311e8df | ||
|
|
a2d1f9b0e1 | ||
|
|
b731fb5566 | ||
|
|
e8d991ccb5 | ||
|
|
f4aa6c749e | ||
|
|
3ed4747d08 | ||
|
|
d2042a3dde | ||
|
|
09ef0f4f9a | ||
|
|
a91ce17501 | ||
|
|
48f0f3913c | ||
|
|
33e367182e | ||
|
|
0ce3ab3915 | ||
|
|
90c7d9f73e | ||
|
|
5923d5d935 | ||
|
|
9f73c545ef | ||
|
|
041438cb7f | ||
|
|
c6e69f9aa0 | ||
|
|
8f1e4ff9d0 | ||
|
|
92792b0b4f | ||
|
|
a7cfd4c346 | ||
|
|
fa5f638697 | ||
|
|
78151b3bb3 | ||
|
|
2418d8be68 | ||
|
|
9fa7172056 | ||
|
|
d75f87768e | ||
|
|
5bb4271d36 | ||
|
|
6c86bff869 | ||
|
|
92ba7c2267 | ||
|
|
6c56f16633 | ||
|
|
2b87272b52 | ||
|
|
4de5147899 | ||
|
|
4c31cd7ecd | ||
|
|
61f90d6016 | ||
|
|
cd1fd1b179 | ||
|
|
1b25c8fadb | ||
|
|
18146bf66f | ||
|
|
8ae54d436a | ||
|
|
4d94551f87 | ||
|
|
1154789f8b | ||
|
|
cbccd4c5f6 | ||
|
|
8cac752a0d | ||
|
|
580273053c | ||
|
|
8d75c1f7b2 | ||
|
|
320c2f692e | ||
|
|
413d84b2b5 | ||
|
|
c751b277d0 | ||
|
|
7f36a3acad | ||
|
|
5780d3daff | ||
|
|
e692e1d139 | ||
|
|
38d44a7952 | ||
|
|
611e3e6e58 | ||
|
|
d5b415316e | ||
|
|
0eeec490c2 | ||
|
|
782157006a | ||
|
|
3be7bc0e02 | ||
|
|
9e7102aaba | ||
|
|
6516e7ae3c | ||
|
|
46c1da232e | ||
|
|
5280121e43 | ||
|
|
17c63cf85f | ||
|
|
488d98800f | ||
|
|
4eac5055cb | ||
|
|
81168d3952 | ||
|
|
ee90be42ee | ||
|
|
48f341bae2 | ||
|
|
0653f064b2 | ||
|
|
525dc24508 | ||
|
|
4e7c831a53 | ||
|
|
e41bef7b00 | ||
|
|
27136a0906 | ||
|
|
cd95a71782 | ||
|
|
0c014a7859 | ||
|
|
65c159505f | ||
|
|
e056ac924a | ||
|
|
d540680330 | ||
|
|
98c45afe7f | ||
|
|
e86040747f | ||
|
|
a559485d0a | ||
|
|
8695793af4 | ||
|
|
a2aeac0242 | ||
|
|
879d0650d4 | ||
|
|
5c85c746f1 | ||
|
|
fa8792ba69 | ||
|
|
5e3392ddec | ||
|
|
796f6b8435 | ||
|
|
780402d14f | ||
|
|
3ae0c8428a | ||
|
|
ed20a7cc84 | ||
|
|
9225f3bc06 | ||
|
|
075a2e5a74 | ||
|
|
58a7878f60 | ||
|
|
f839001097 | ||
|
|
ab2b651b6c | ||
|
|
e5934848c7 | ||
|
|
cab10efc96 | ||
|
|
0a4efba905 | ||
|
|
1e4e09e52c | ||
|
|
910c068109 | ||
|
|
1dd773a9bf | ||
|
|
f4d065d766 | ||
|
|
1fe66076c6 | ||
|
|
668ec1d7a0 | ||
|
|
412f0d3193 | ||
|
|
894a9b106d | ||
|
|
1b0a9c9a69 | ||
|
|
c4818d2097 | ||
|
|
69135150c7 | ||
|
|
4d8949427b | ||
|
|
0139f6bfba | ||
|
|
8d77d46eda | ||
|
|
179f92234a | ||
|
|
650a5ecd7b | ||
|
|
8cebb362e9 | ||
|
|
bfd1d3e0f9 | ||
|
|
e8b18d83a5 | ||
|
|
d305f47bb9 | ||
|
|
43f6c17c4b | ||
|
|
38bfef3836 | ||
|
|
c818945a28 | ||
|
|
5cb4f69500 | ||
|
|
cee9052dea | ||
|
|
b3b98aa407 | ||
|
|
b8230cbf43 | ||
|
|
4b16edbbee | ||
|
|
966d935032 | ||
|
|
8b8c6268ab | ||
|
|
513c445b24 | ||
|
|
55f82c2e91 | ||
|
|
ad589ba850 | ||
|
|
85f6fa053d | ||
|
|
f7a42e2cdb | ||
|
|
e1cb682e74 | ||
|
|
a8b7ea5008 | ||
|
|
6a6b4c8027 | ||
|
|
c2ef7449e5 | ||
|
|
96fb484b38 | ||
|
|
1e6ff9c4f7 | ||
|
|
4313075b61 | ||
|
|
88270382f1 | ||
|
|
ad85bb7343 | ||
|
|
77df66979a | ||
|
|
d4825390f7 | ||
|
|
d5a857fe3a | ||
|
|
241df72bf3 | ||
|
|
4f51d962e7 | ||
|
|
98b550cec6 | ||
|
|
20c850a977 | ||
|
|
5a8d85ac92 | ||
|
|
450d54d7fd | ||
|
|
65fb07c8da | ||
|
|
8123893a90 | ||
|
|
3c9b5c9ed3 | ||
|
|
f9be6dfae4 | ||
|
|
586f7e6c66 | ||
|
|
49eb3caf0a | ||
|
|
56da5717fa | ||
|
|
9983e91526 | ||
|
|
820deb2955 | ||
|
|
0137bf4610 | ||
|
|
687b1a915e | ||
|
|
401c8625ad | ||
|
|
8503d7e698 | ||
|
|
b35b6148bc | ||
|
|
45fd180dcb | ||
|
|
486da7749a | ||
|
|
606df3423c | ||
|
|
ab439144ea | ||
|
|
db14c5252c | ||
|
|
7d8b3b402f | ||
|
|
4506a0cfda | ||
|
|
978edc5f5f | ||
|
|
2466887014 | ||
|
|
6050a370f8 | ||
|
|
d4a99f8229 | ||
|
|
46fba4eef1 | ||
|
|
bc1118a29f | ||
|
|
775f582ea6 | ||
|
|
2dadd70be3 | ||
|
|
3473f03350 | ||
|
|
e081d0ad16 | ||
|
|
e6026e84f9 | ||
|
|
b17b8c24ff | ||
|
|
4b9687a84d | ||
|
|
0c0f280d15 | ||
|
|
831bd9145d | ||
|
|
0070fe4061 | ||
|
|
9d1443cf65 |
No files matched your search
+33
-28
@@ -1,6 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## Added
|
||||
## [7.0.0] Unreleased
|
||||
### Added
|
||||
- Setup wizard
|
||||
- Changelog text wrapping and formatting
|
||||
- Changelog ships with source
|
||||
@@ -40,8 +41,11 @@
|
||||
- Preboot progress dialog
|
||||
- Choose to jump into splash screen instead of server
|
||||
- Collapsible connection panel
|
||||
- Play offline (load mods directly)
|
||||
- Generate Steam shortcuts and cover art
|
||||
- Warn user if background downloads are disabled
|
||||
|
||||
## Changed
|
||||
### Changed
|
||||
- Conform to PEP 440 versioning for beta versions
|
||||
- Reduce padding on keys button
|
||||
- Boldface breadcrumbs
|
||||
@@ -56,59 +60,60 @@
|
||||
- Disable overlay scrollbars on server tables
|
||||
- Reduce size of geolocation DB on disk (~100MB)
|
||||
- Enable LAN page Empty/Full filters on first run of app
|
||||
- Propagate subscribed mods to Steam client
|
||||
|
||||
## Dropped
|
||||
### Dropped
|
||||
- Debug mode
|
||||
- Branch switching
|
||||
- Manual mod install mode (describe rationale)
|
||||
- Force update mods
|
||||
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
|
||||
- Center server title text on server dialogs
|
||||
- Rare segfaults when changing maps (threading)
|
||||
- Moved dialogs out of threads
|
||||
|
||||
## Unreleased
|
||||
### Unreleased
|
||||
- Load offline mods
|
||||
- Local documentation
|
||||
- Raw debug command in context menu
|
||||
|
||||
## Developer-facing
|
||||
### Developer-facing
|
||||
- Add pyproject.toml file
|
||||
- Show deprecation warnings
|
||||
- Options -> Dev page
|
||||
|
||||
## [6.0.5] 2026-05-17
|
||||
## Changed
|
||||
### Changed
|
||||
- Update symlink hash logic to align with DZGUI 7 beta
|
||||
- Update A2S helper to latest commit
|
||||
|
||||
## [6.0.4] 2026-04-13
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Update A2S module checksums to support servers with missing description field
|
||||
|
||||
## [6.0.3] 2026-04-12
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Use GLibUnix namespace where available when setting up SIGINT callback
|
||||
|
||||
## [6.0.2] 2026-01-26
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Explicitly use Python 3.13 when calling subprocesses
|
||||
- Raise error correctly from module
|
||||
- Do not spawn dialogs from inside of thread when fetching prereqs
|
||||
## Changed
|
||||
### Changed
|
||||
- Optimize time complexity of startup checks
|
||||
- Optimize coordinate calculation by using local records first
|
||||
- Rename some internal functions
|
||||
- Wrap entire startup process in dialog
|
||||
|
||||
## [6.0.1] 2026-01-16
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Explicitly use Python 3.13 when calling subprocesses
|
||||
|
||||
## [6.0.0] 2026-01-14
|
||||
## Added
|
||||
### Added
|
||||
- Support DayZ Experimental
|
||||
- Ping display on server tables
|
||||
- Save descriptive text notes on a per-server basis
|
||||
@@ -134,7 +139,7 @@
|
||||
- Commandline usage help text (GaryBlackbourne)
|
||||
- More descriptive help dialogs when initial dependencies are missing
|
||||
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Script failing to start when remote endpoints are unavailable (GaryBlackbourne)
|
||||
- Key stickiness when quickly navigating through entries in tables
|
||||
- Servers returning malformed A2S_INFO blocking server browser from loading
|
||||
@@ -173,7 +178,7 @@
|
||||
- Path discovery during first-time setup when parsing filepaths with whitespaces
|
||||
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
|
||||
|
||||
## Changed
|
||||
### Changed
|
||||
- Require Python 3.13
|
||||
- Reduced global API cooldown from 60s to 30s
|
||||
- Clarify dialog messages when DayZ path could not be found
|
||||
@@ -185,50 +190,50 @@
|
||||
- Rewrote distance calculation module (GaryBlackbourne)
|
||||
- Changed preferred client setting from radio toggle to combobox
|
||||
|
||||
## Dropped
|
||||
### Dropped
|
||||
- Ping readout in statusbar
|
||||
- Extraneous information from right statusbar
|
||||
|
||||
## [5.8.3] 2026-01-04
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Normalize checksum numbers and dates
|
||||
|
||||
## [5.8.2] 2026-01-04
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Fix regression in symlink routine introduced by previous hotfix
|
||||
|
||||
## [5.8.1] 2025-12-27
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Path discovery during first-time setup when parsing filepaths with whitespaces
|
||||
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
|
||||
|
||||
## [5.8.0] 2025-07-06
|
||||
## Added
|
||||
### Added
|
||||
- Filter servers by official/unofficial status
|
||||
- Automatically fetch geolocation records
|
||||
## Changed
|
||||
### Changed
|
||||
- Updated internal versioning of helper files
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Corrected erroneous 2024 date in prior changelog entries
|
||||
|
||||
## [5.7.0] 2025-04-17
|
||||
## Added
|
||||
### Added
|
||||
- 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
|
||||
## Changed
|
||||
### Changed
|
||||
- More direct server ping query method that should return marginally more accurate times
|
||||
|
||||
## [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
|
||||
|
||||
## [5.6.6] 2025-03-16
|
||||
## Changed
|
||||
### Changed
|
||||
- Update IP database records for 2025-03
|
||||
|
||||
## [5.6.5] 2025-03-04
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Livonia server results being dropped from batch queries
|
||||
|
||||
## [5.6.4] 2025-02-10
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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.
|
||||
@@ -24,13 +24,8 @@ Please refer to the documentation for installation and setup instructions:
|
||||
|
||||
Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
This tool uses [python-a2s](https://github.com/Yepoleb/python-a2s) and [dayzquery](https://github.com/Yepoleb/dayzquery) as submodules; licenses for these submodules can be found in the LICENSES file
|
||||
of the project root.
|
||||
|
||||
Both the geolocation records and submodules listed above are not shipped with the source code, but are retrieved and assembled at runtime.
|
||||
|
||||
Finally, executable versions of DZGUI shipped as release binaries are thin wrappers around the Python interpreter, and also retrieve and assemble the above dependencies at runtime on the end-user's
|
||||
machine, rather than using pre-compiled source code. Users wishing to review these dependencies can inspect the 'pyproject.toml' manifest in the project root.
|
||||
Executable versions of DZGUI published as release binaries ship with various runtime dependencies and the Python interpreter built in.
|
||||
Users wishing to review the licenses to these components can inspect the `LICENSE` file located in the release tarball.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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.
|
||||
@@ -26,7 +26,7 @@ You can install it via the following set of commands:
|
||||
.. code:: console
|
||||
|
||||
sudo apt update
|
||||
sudo apt install libgirepository-2.0
|
||||
sudo apt install libgirepository-2.0-0
|
||||
|
||||
Next steps
|
||||
---------------
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,119 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from warnings import deprecated
|
||||
|
||||
from dzgui.const.constants import APP_NAME
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
class ACF:
|
||||
def __init__(self, file: Path) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.dict: dict[str, Any]
|
||||
self.load(file)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return self.dict
|
||||
|
||||
def load(self, file: Path) -> None:
|
||||
delimiter = r"\t\t"
|
||||
lines = []
|
||||
# TODO: illegal file handling
|
||||
with open(file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
els = re.split(delimiter, line, maxsplit=1)
|
||||
lines.append(els)
|
||||
self.dict = self.parse(iter(lines))
|
||||
|
||||
@classmethod
|
||||
def enquote(cls, s: str) -> str:
|
||||
return f'"{s}"'
|
||||
|
||||
@classmethod
|
||||
def dequote(cls, s: str) -> str:
|
||||
return s.rstrip('"').lstrip('"')
|
||||
|
||||
def parse(self, lines: Iterator[list[str]]) -> dict[str, str]:
|
||||
acf: dict[str, Any] = {}
|
||||
try:
|
||||
while True:
|
||||
line = next(lines)
|
||||
if len(line) == 1:
|
||||
key = line[0]
|
||||
if key == "{":
|
||||
continue
|
||||
return acf
|
||||
elif key == "}":
|
||||
return acf
|
||||
else:
|
||||
key = self.dequote(key)
|
||||
acf[key] = self.parse(lines)
|
||||
elif len(line) == 2:
|
||||
k, v = line
|
||||
k = self.dequote(k)
|
||||
v = self.dequote(v)
|
||||
try:
|
||||
n = list(acf.keys())[-1]
|
||||
acf[n][k] = v
|
||||
except Exception:
|
||||
acf[k] = v
|
||||
except StopIteration:
|
||||
return acf
|
||||
|
||||
def get_allows_downloads(self) -> int | None:
|
||||
try:
|
||||
# TODO: consider coercing strs to ints on initial import
|
||||
flag = int(self.dict["AppState"]["AllowOtherDownloadsWhileRunning"])
|
||||
return flag
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return None
|
||||
|
||||
|
||||
@deprecated(
|
||||
"Serializing workshop files is no longer supported, use dzgui.api.steam.unsubscribe"
|
||||
)
|
||||
class WorkshopACF(ACF):
|
||||
def __init__(self, file: Path) -> None:
|
||||
super().__init__(file)
|
||||
|
||||
def unpack(self, d: dict, lines: list[Any] = []) -> str:
|
||||
t1 = "AppWorkshop"
|
||||
t2 = ("WorkshopItemsInstalled", "WorkshopItemDetails")
|
||||
for k, v in d.items():
|
||||
if type(v) is dict:
|
||||
if k in t1:
|
||||
self.indent = 0
|
||||
elif k in t2:
|
||||
self.indent = 1
|
||||
else:
|
||||
self.indent = 2
|
||||
pref = "\t" * self.indent
|
||||
lines.append(pref + self.enquote(k))
|
||||
lines.append(pref + "{")
|
||||
self.unpack(v, lines)
|
||||
lines.append(pref + "}")
|
||||
else:
|
||||
pref = "\t" * (self.indent + 1)
|
||||
lines.append(pref + self.enquote(k) + "\t\t" + self.enquote(v))
|
||||
s = ""
|
||||
for line in lines:
|
||||
s += line + "\n"
|
||||
return s
|
||||
|
||||
def to_file(self, file: Path) -> None:
|
||||
s = self.unpack(self.dict)
|
||||
file.write_text(s)
|
||||
|
||||
def delete(self, modid: int) -> None:
|
||||
del self.dict["AppWorkshop"]["WorkshopItemsInstalled"][modid]
|
||||
del self.dict["AppWorkshop"]["WorkshopItemDetails"][modid]
|
||||
+63
-47
@@ -1,18 +1,18 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import shlex
|
||||
import re
|
||||
|
||||
from concurrent.futures import wait
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import dzgui.api.pefile as PeFile
|
||||
from dzgui.api.steam import get_app_path
|
||||
from dzgui.api.servers import get_rules, fqip_to_record
|
||||
from dzgui.const.constants import (
|
||||
APP_NAME,
|
||||
APPID_DAYZ,
|
||||
LIBRARYFOLDERS_PATH,
|
||||
DAYZ_COMMUNITY_ROOT,
|
||||
WORKSHOP_PATH,
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ def get_local_mod_ids(steam_path: Path) -> list[int]:
|
||||
|
||||
|
||||
def get_local_mod_path(steam_path: Path) -> Path:
|
||||
p = PeFile.get_app_path(steam_path / Path(LIBRARYFOLDERS_PATH), APPID_DAYZ)
|
||||
p = get_app_path(steam_path, APPID_DAYZ)
|
||||
workshop_path = p / WORKSHOP_PATH
|
||||
return workshop_path
|
||||
|
||||
@@ -49,34 +49,33 @@ def get_local_mods(workshop_path: Path) -> list[Path]:
|
||||
return mods
|
||||
|
||||
|
||||
def parse_meta(file: Path) -> ModMeta | None:
|
||||
mod = file / "meta.cpp"
|
||||
if mod.exists() is False:
|
||||
def is_mission(path: Path) -> bool:
|
||||
# TODO: parse integrity of other files
|
||||
parent = path.parent.name
|
||||
if parent != DAYZ_COMMUNITY_ROOT:
|
||||
return False
|
||||
file = path / "init.c"
|
||||
return file.exists()
|
||||
|
||||
|
||||
def tokenize(mod: Path) -> dict[str, Any] | None:
|
||||
file = mod.joinpath("meta.cpp")
|
||||
delimiter = r"\s*=\s*"
|
||||
modmeta = {}
|
||||
try:
|
||||
with open(file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip().rstrip(";")
|
||||
if not line:
|
||||
continue
|
||||
els = re.split(delimiter, line, maxsplit=1)
|
||||
if len(els) == 2:
|
||||
key, value = els
|
||||
modmeta[key.strip()] = value.strip('"')
|
||||
return modmeta
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return None
|
||||
with open(file / "meta.cpp", "r") as f:
|
||||
st = f.read()
|
||||
lex = shlex.shlex(st)
|
||||
lex.whitespace += "=;"
|
||||
v = []
|
||||
while True:
|
||||
tok = lex.get_token()
|
||||
if not tok:
|
||||
break
|
||||
if tok == "protocol" or tok == "publishedid":
|
||||
ntok = lex.get_token()
|
||||
elif tok == "timestamp":
|
||||
# NOTE: some malformed .NET tick conversions result in numbers < 0
|
||||
ntok = lex.get_token()
|
||||
if ntok == "-":
|
||||
ntok += str(lex.get_token())
|
||||
elif tok == "name":
|
||||
ntok = lex.get_token()
|
||||
if ntok is not None:
|
||||
ntok = ntok.split('"')[1]
|
||||
if ntok is not None:
|
||||
v.append(ntok)
|
||||
meta = ModMeta(*v)
|
||||
return meta
|
||||
|
||||
|
||||
def get_mod_size(path: Path) -> float:
|
||||
@@ -87,49 +86,66 @@ def get_mod_size(path: Path) -> float:
|
||||
return size
|
||||
|
||||
|
||||
def get_delimited_mods(steam_path: Path) -> list[Any]:
|
||||
workshop_path = get_local_mod_path(steam_path)
|
||||
mods = get_local_mods(workshop_path)
|
||||
def get_custom_mods(path: Path) -> list[Any]:
|
||||
# TODO: rename this function for clarity ("get local vs. get custom")
|
||||
mods = get_local_mods(path)
|
||||
# TODO: error handling
|
||||
return parse_mods(mods, use_custom=True)
|
||||
|
||||
|
||||
def parse_mods(mods: list[Path], use_custom: bool = False) -> list[Any]:
|
||||
clean = []
|
||||
for mod in mods:
|
||||
mod_dir = mod.name
|
||||
symlink = _hash(mod_dir)
|
||||
# FIXME: malformed .cpp files could break this
|
||||
# mention that mods may be downloading
|
||||
meta = parse_meta(mod)
|
||||
symlink = _hash(mod_dir, use_custom)
|
||||
meta = tokenize(mod)
|
||||
if meta is None:
|
||||
continue
|
||||
size = get_mod_size(mod)
|
||||
# NOTE: final col is cell renderer highlight toggle
|
||||
clean.append([meta.name, symlink, mod_dir, size, False])
|
||||
clean.append([meta["name"], symlink, mod_dir, size, False])
|
||||
clean.sort(key=lambda row: str(row[0]).casefold())
|
||||
return clean
|
||||
|
||||
|
||||
def get_delimited_mods(steam_path: Path) -> list[Any]:
|
||||
workshop_path = get_local_mod_path(steam_path)
|
||||
mods = get_local_mods(workshop_path)
|
||||
return parse_mods(mods)
|
||||
|
||||
|
||||
def get_missing_mods(local: list, remote: list) -> list:
|
||||
return [mod for mod in remote if mod not in local]
|
||||
|
||||
|
||||
def _hash(uid: str) -> str:
|
||||
def _hash(uid: str, use_custom: bool = False) -> str:
|
||||
md5 = hashlib.md5()
|
||||
md5.update(uid.encode("ascii"))
|
||||
return "@" + md5.hexdigest()[:8]
|
||||
prefix = "@C" if use_custom else "@"
|
||||
return prefix + md5.hexdigest()[:8]
|
||||
|
||||
|
||||
def remove_stale_signatures(config: Path, versions: Path) -> None:
|
||||
def remove_stale_signatures(
|
||||
config: Path, versions: Path, ids: list[int] | None = None
|
||||
) -> None:
|
||||
if versions.is_file() is False:
|
||||
logger.warning("Creating new version signatures file")
|
||||
versions.touch()
|
||||
return
|
||||
|
||||
path = lookup(config, Preferences.DEFAULT)
|
||||
steam_path = Path(path)
|
||||
ids = get_local_mod_ids(steam_path)
|
||||
if ids is None:
|
||||
local_ids = get_local_mod_ids(steam_path)
|
||||
|
||||
with open(versions, "r") as f:
|
||||
lines = f.readlines()
|
||||
for line in lines:
|
||||
uid = int(line.split(",")[0])
|
||||
if uid not in ids:
|
||||
lines.remove(line)
|
||||
|
||||
if ids is None:
|
||||
lines = [line for line in lines if int(line.split(",")[0]) in local_ids]
|
||||
else:
|
||||
lines = [line for line in lines if int(line.split(",")[0]) not in ids]
|
||||
|
||||
with open(versions, "w") as f:
|
||||
for line in lines:
|
||||
f.write(line)
|
||||
|
||||
+2
-49
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
import struct
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -12,9 +11,8 @@ from dzgui.const.constants import (
|
||||
APPNAME_DAYZ,
|
||||
APPNAME_DAYZ_EXP,
|
||||
DAYZ_BINARY,
|
||||
LIBRARYFOLDERS_PATH,
|
||||
)
|
||||
from dzgui.api.steam import vdf2json
|
||||
from dzgui.api.steam import get_app_path
|
||||
|
||||
# https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
|
||||
endian = "<"
|
||||
@@ -257,25 +255,6 @@ class PeFileError(Exception):
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AppNotInstalledError(Exception):
|
||||
"""App not present in user's libraryfolders"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AppMovedError(Exception):
|
||||
"""VDF points to a nonexistent location on disk"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class VDFLoadError(Exception):
|
||||
"""Malformed VDF or JSON conversion"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def parse_version_number(data: BinaryIO) -> FileVersion:
|
||||
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
|
||||
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF
|
||||
@@ -419,37 +398,11 @@ def get_pefile_path(steam_path: Path, appid: int) -> Path:
|
||||
name = identifier[appid]
|
||||
binary = DAYZ_BINARY
|
||||
|
||||
app_path = get_app_path(steam_path / LIBRARYFOLDERS_PATH, appid)
|
||||
app_path = get_app_path(steam_path, appid)
|
||||
pe_path = app_path / f"steamapps/common/{name}/{binary}"
|
||||
return pe_path
|
||||
|
||||
|
||||
def get_app_path(folders_path: Path, appid: int) -> Path:
|
||||
app_path = None
|
||||
|
||||
try:
|
||||
j = json.loads(vdf2json(folders_path))
|
||||
except Exception:
|
||||
raise VDFLoadError("Failed to parse libraryfolders")
|
||||
|
||||
for obj in j["libraryfolders"]:
|
||||
if str(appid) in j["libraryfolders"][obj]["apps"]:
|
||||
app_path = j["libraryfolders"][obj]["path"]
|
||||
if Path(app_path).exists():
|
||||
break
|
||||
|
||||
if app_path is None:
|
||||
raise AppNotInstalledError(
|
||||
f"Failed to find a libraryfolder for the appid {appid}"
|
||||
)
|
||||
if Path(app_path).exists() is False:
|
||||
raise AppMovedError(
|
||||
f"The location '{app_path}' pointed to by '{appid}' no longer exists and may have been changed on the disk."
|
||||
)
|
||||
|
||||
return Path(app_path)
|
||||
|
||||
|
||||
def get_pretty_version(steam_path: Path, appid: int) -> str | None:
|
||||
try:
|
||||
pe_file_path = get_pefile_path(steam_path, appid)
|
||||
|
||||
@@ -289,13 +289,14 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] |
|
||||
|
||||
|
||||
def get_details(record: Record) -> Details:
|
||||
# TODO: consolidate try/except statements
|
||||
ip = record.ip
|
||||
qport = record.qport
|
||||
default_str = strings.none_provided
|
||||
|
||||
try:
|
||||
info = a2s.info((ip, qport))
|
||||
except TimeoutError:
|
||||
except (TimeoutError, a2s.exceptions.BrokenMessageError):
|
||||
return Details(None, default_str, False)
|
||||
try:
|
||||
rules = dayzquery.dayz_rules((ip, qport))
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import binascii
|
||||
import ctypes
|
||||
import logging
|
||||
import shutil
|
||||
import vdf # type: ignore
|
||||
|
||||
from dataclasses import dataclass
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dzgui.api.steam import find_user_id_32
|
||||
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH
|
||||
from dzgui.util.dirs import copy_dzgui_to_xdg_data
|
||||
from dzgui.util.strings import unknown
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ShortcutMetadata:
|
||||
appname: str
|
||||
appid: int
|
||||
exe_path: str
|
||||
start_dir: str
|
||||
icon: str
|
||||
|
||||
|
||||
class Shortcuts:
|
||||
def __init__(self, steam_path: Path) -> None:
|
||||
self.user_config_path: Path
|
||||
self.shortcuts_path = self.find_shortcuts_path(steam_path)
|
||||
self._load_shortcuts(self.shortcuts_path)
|
||||
|
||||
@classmethod
|
||||
def gen_bpid(cls, uid: str) -> int:
|
||||
u = uid.encode()
|
||||
encoded = binascii.crc32(u)
|
||||
return encoded | 0x80000000
|
||||
|
||||
@classmethod
|
||||
def gen_signed_appid(cls, appid: int) -> int:
|
||||
return ctypes.c_int(appid).value
|
||||
|
||||
@classmethod
|
||||
def gen_exe_uid(cls, appname: str, exe: Path) -> str:
|
||||
wrapped_exe = f'"{exe}"'
|
||||
return appname + wrapped_exe
|
||||
|
||||
def find_appname_by_unsigned_id(self, appid: int) -> str:
|
||||
# NOTE: bitmask signed int back to 32-bit CRC
|
||||
# varying client versions treat case sensitivity differently
|
||||
try:
|
||||
for key in self.shortcuts["shortcuts"].keys():
|
||||
if self.shortcuts["shortcuts"][key]["appid"] & 0xFFFFFFFF == appid:
|
||||
try:
|
||||
return str(self.shortcuts["shortcuts"][key]["appname"])
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
try:
|
||||
return str(self.shortcuts["shortcuts"][key]["AppName"])
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
return unknown
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
return unknown
|
||||
|
||||
def get_shortcuts(self) -> Any:
|
||||
return self.shortcuts
|
||||
|
||||
def _load_shortcuts(self, path: Path) -> None:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
shortcuts = vdf.binary_load(f)
|
||||
self.shortcuts = shortcuts
|
||||
logger.debug("Loaded shortcuts file")
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
raise e
|
||||
|
||||
def find_shortcuts_path(self, steam_path: Path) -> Path:
|
||||
uid = find_user_id_32(steam_path)
|
||||
self.user_config_path = steam_path.joinpath(f"userdata/{uid}/config")
|
||||
return self.user_config_path.joinpath("shortcuts.vdf")
|
||||
|
||||
def add_shortcut(
|
||||
self, appname: str, start_dir: Path, exe_path: Path, icon: Path
|
||||
) -> None:
|
||||
uid = self.gen_exe_uid(appname, exe_path)
|
||||
self.bpid = self.gen_bpid(uid)
|
||||
self.signed_appid = self.gen_signed_appid(self.bpid)
|
||||
|
||||
meta = ShortcutMetadata(
|
||||
appname, self.signed_appid, str(exe_path), str(start_dir), str(icon)
|
||||
)
|
||||
entry = self._create_entry(meta)
|
||||
self._insert_at_last_index(entry)
|
||||
|
||||
def add_grid_images(self, images: Path) -> None:
|
||||
"""
|
||||
{BPID}_hero.png: hero (splash)
|
||||
{BPID}p.ping: portrait boxart in library
|
||||
{BPID}_logo.png: logo overlay on top of hero
|
||||
{BPID}.png: legacy Big Picture/landscape image
|
||||
"""
|
||||
|
||||
grid_path = self.user_config_path.joinpath("grid")
|
||||
for img in ("_hero", "p", "_logo"):
|
||||
filename = str(self.bpid) + img
|
||||
dest = grid_path.joinpath(filename).with_suffix(".png")
|
||||
b = images.joinpath(img).with_suffix(".png").read_bytes()
|
||||
dest.write_bytes(b)
|
||||
|
||||
# NOTE: legacy Big Picture header
|
||||
b = images.joinpath("_hero.png").read_bytes()
|
||||
dest = grid_path.joinpath(str(self.bpid)).with_suffix(".png")
|
||||
dest.write_bytes(b)
|
||||
|
||||
def _insert_at_last_index(self, entry: dict[str, Any]) -> None:
|
||||
try:
|
||||
last = list(self.shortcuts["shortcuts"].keys())[-1]
|
||||
n = int(last) + 1
|
||||
except Exception:
|
||||
n = 0
|
||||
self.shortcuts["shortcuts"][str(n)] = entry
|
||||
|
||||
@classmethod
|
||||
def _create_entry(cls, meta: ShortcutMetadata) -> dict[str, Any]:
|
||||
"""
|
||||
https://developer.valvesoftware.com/wiki/Add_Non-Steam_Game
|
||||
https://developer.valvesoftware.com/wiki/Steam_Library_Shortcuts
|
||||
|
||||
Key case is not internally consistent and varies between client versions
|
||||
Most keys use Pascal case, but some do not
|
||||
|
||||
appid: signed int CRC
|
||||
Exe: absolute path to the executable, must be wrapped in literal quotes
|
||||
StartDir: directory the executable starts in, generally the parent
|
||||
"""
|
||||
|
||||
NEW_ENTRY: dict[str, Any] = {}
|
||||
NEW_ENTRY["appid"] = meta.appid
|
||||
NEW_ENTRY["AppName"] = meta.appname
|
||||
NEW_ENTRY["Exe"] = f'"{meta.exe_path}"'
|
||||
NEW_ENTRY["StartDir"] = f"{meta.start_dir}"
|
||||
NEW_ENTRY["icon"] = meta.icon
|
||||
NEW_ENTRY["ShortcutPath"] = ""
|
||||
NEW_ENTRY["LaunchOptions"] = ""
|
||||
NEW_ENTRY["IsHidden"] = 0
|
||||
NEW_ENTRY["AllowDesktopConfig"] = 1
|
||||
NEW_ENTRY["AllowOverlay"] = 1
|
||||
NEW_ENTRY["OpenVR"] = 0
|
||||
NEW_ENTRY["Devkit"] = 0
|
||||
NEW_ENTRY["DevkitGameID"] = ""
|
||||
NEW_ENTRY["DevkitOverrideAppID"] = 0
|
||||
NEW_ENTRY["LastPlayTime"] = 0
|
||||
NEW_ENTRY["FlatpakAppID"] = ""
|
||||
NEW_ENTRY["sortas"] = ""
|
||||
NEW_ENTRY["tags"] = {}
|
||||
return NEW_ENTRY
|
||||
|
||||
def save_shortcuts(self) -> None:
|
||||
try:
|
||||
backup = self.shortcuts_path.with_suffix(".vdf.bak")
|
||||
shutil.copy(self.shortcuts_path, backup)
|
||||
with open(self.shortcuts_path, "wb") as f:
|
||||
vdf.binary_dump(self.shortcuts, f)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
|
||||
|
||||
def add_steam_shortcut(steam_path: Path, exe_path: Path) -> None:
|
||||
try:
|
||||
start_dir = exe_path.parent
|
||||
|
||||
traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH)
|
||||
images = Path(str(traversable))
|
||||
icon = images.joinpath("icon.png")
|
||||
|
||||
shortcuts = Shortcuts(Path(steam_path))
|
||||
shortcuts.add_shortcut(APP_NAME, start_dir, exe_path, icon)
|
||||
shortcuts.save_shortcuts()
|
||||
shortcuts.add_grid_images(images)
|
||||
|
||||
copy_dzgui_to_xdg_data(exe_path)
|
||||
logger.debug("Finished creating Steam shortcut")
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
+276
-75
@@ -1,29 +1,59 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import psutil
|
||||
import requests
|
||||
import subprocess
|
||||
import vdf # type: ignore
|
||||
|
||||
from shlex import shlex
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
from warnings import deprecated
|
||||
|
||||
from dzgui.api.acf import ACF
|
||||
from dzgui.init.prereqs import has_steam_client
|
||||
from dzgui.const.constants import (
|
||||
APPID_DAYZ,
|
||||
APPID_DAYZ_EXP,
|
||||
APP_NAME,
|
||||
DAYZ_BINARY,
|
||||
DEBIAN_STEAM_PATH,
|
||||
DEFAULT_STEAM_PATH,
|
||||
FLATPAK_STEAM_PATH,
|
||||
LIBRARYFOLDERS_PATH,
|
||||
UBUNTU_STEAM_PATH,
|
||||
REQUEST_TIMEOUT,
|
||||
VDF_PATH,
|
||||
)
|
||||
from dzgui.const.endpoints import STEAM_PUBLISHED_FILES
|
||||
from dzgui.const.endpoints import (
|
||||
APP_DETAILS,
|
||||
SUB_ENDPOINT,
|
||||
STEAM_PUBLISHED_FILES,
|
||||
UNSUB_ENDPOINT,
|
||||
)
|
||||
from dzgui.strings import wizard
|
||||
from dzgui.util.bash import concat_bash_args
|
||||
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
class AppNotInstalledError(Exception):
|
||||
"""App not present in user's libraryfolders"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AppMovedError(Exception):
|
||||
"""VDF points to a nonexistent location on disk"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class VDFLoadError(Exception):
|
||||
"""Malformed VDF or JSON conversion"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def get_steam_paths() -> list[tuple[Path, str]]:
|
||||
paths = []
|
||||
if has_steam_client():
|
||||
@@ -53,7 +83,8 @@ def get_steam_paths() -> list[tuple[Path, str]]:
|
||||
|
||||
|
||||
def concat_mods(mods: list[str]) -> str:
|
||||
from dzgui.util.symlink import _hash
|
||||
# TODO: circular import workaround
|
||||
from dzgui.api.mods import _hash
|
||||
|
||||
hashes = []
|
||||
for mod in mods:
|
||||
@@ -68,26 +99,21 @@ def get_local_signatures(version_file: Path) -> dict[str, int]:
|
||||
for line in lines:
|
||||
data = line.split(",")
|
||||
_id = data[0]
|
||||
_hash = int(data[1])
|
||||
hashes[_id] = _hash
|
||||
mod_hash = int(data[1])
|
||||
hashes[_id] = mod_hash
|
||||
return hashes
|
||||
|
||||
|
||||
def enqueue_mod(client: str, mod: str, appid: int) -> None:
|
||||
client_args = concat_bash_args(client)
|
||||
subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod])
|
||||
|
||||
|
||||
def get_needs_update(
|
||||
version_file: Path, remote_hashes: list[tuple[str, str, int, int]]
|
||||
) -> list[tuple[str, str, int, int]]:
|
||||
local_hashes = get_local_signatures(version_file)
|
||||
local_stamps = get_local_signatures(version_file)
|
||||
needs_update: list[tuple[str, str, int, int]] = []
|
||||
for title, _id, _hash, size in remote_hashes:
|
||||
if _id not in local_hashes:
|
||||
needs_update.append((title, _id, _hash, size))
|
||||
elif _hash != local_hashes[_id]:
|
||||
needs_update.append((title, _id, _hash, size))
|
||||
for title, _id, stamp, size in remote_hashes:
|
||||
if _id not in local_stamps:
|
||||
needs_update.append((title, _id, stamp, size))
|
||||
elif stamp != local_stamps[_id]:
|
||||
needs_update.append((title, _id, stamp, size))
|
||||
else:
|
||||
continue
|
||||
return needs_update
|
||||
@@ -158,69 +184,244 @@ def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int:
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def launch_offline(
|
||||
client: str, appid: int, name: str, mods: list[str], mission: str
|
||||
) -> int:
|
||||
"""Launch offline with specific mods/missions"""
|
||||
symlinks = ";".join(mods)
|
||||
client_args = concat_bash_args(client)
|
||||
params = [
|
||||
"-applaunch",
|
||||
str(appid),
|
||||
"-nolauncher",
|
||||
"-nosplash",
|
||||
"-skipintro",
|
||||
f"-name={name}",
|
||||
f"-mod={symlinks}",
|
||||
]
|
||||
if len(mission) > 0:
|
||||
arg = f"-mission={mission}"
|
||||
params.append(arg)
|
||||
proc = subprocess.run([*client_args, *params])
|
||||
return proc.returncode
|
||||
|
||||
def find_loginusers(path: Path) -> Path:
|
||||
return path / "config" / "loginusers.vdf"
|
||||
|
||||
def find_user_id(path: Path) -> str | None:
|
||||
resolved_path = path / "config" / "loginusers.vdf"
|
||||
resolved_path = find_loginusers(path)
|
||||
try:
|
||||
vdf = vdf2json(resolved_path)
|
||||
j = json.loads(vdf)
|
||||
for user in j["users"]:
|
||||
if j["users"][user]["MostRecent"] == "1":
|
||||
return str(user)
|
||||
return None
|
||||
with open(resolved_path, "r") as f:
|
||||
v = vdf.load(f)
|
||||
# NOTE: beta client
|
||||
# /package/beta
|
||||
if len(v["users"]) == 1:
|
||||
return str(list(v["users"].keys())[0])
|
||||
for user in v["users"]:
|
||||
if v["users"][user]["MostRecent"] == "1":
|
||||
return str(user)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
logger.debug(e)
|
||||
return None
|
||||
|
||||
|
||||
def vdf2json(path: Path) -> str:
|
||||
def _istr(indent: int, string: str) -> str:
|
||||
return (indent * " ") + string
|
||||
|
||||
jbuf = "{\n"
|
||||
indent = 1
|
||||
|
||||
with open(path, "r") as f:
|
||||
st = f.read()
|
||||
lex = shlex(st)
|
||||
|
||||
while True:
|
||||
tok = lex.get_token()
|
||||
if not tok:
|
||||
return jbuf + "}\n"
|
||||
if tok == "}":
|
||||
indent -= 1
|
||||
jbuf += _istr(indent, "}")
|
||||
ntok = lex.get_token()
|
||||
if ntok is not None:
|
||||
lex.push_token(ntok)
|
||||
if ntok and ntok != "}":
|
||||
jbuf += ","
|
||||
jbuf += "\n"
|
||||
else:
|
||||
ntok = lex.get_token()
|
||||
if ntok == "{":
|
||||
jbuf += _istr(indent, tok + ": {\n")
|
||||
indent += 1
|
||||
else:
|
||||
if ntok is not None:
|
||||
jbuf += _istr(indent, tok + ": " + ntok)
|
||||
ntok = lex.get_token()
|
||||
if ntok is not None:
|
||||
lex.push_token(ntok)
|
||||
if ntok != "}":
|
||||
jbuf += ","
|
||||
jbuf += "\n"
|
||||
def find_user_id_32(path: Path) -> int:
|
||||
uid = find_user_id(path)
|
||||
if uid is None:
|
||||
raise ValueError("Failed to parse a valid Steam32 ID")
|
||||
return int(uid) & 0xFFFFFFFF
|
||||
|
||||
|
||||
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
|
||||
def update_workshop(key: str, mod: int, endpoint: str) -> None:
|
||||
payload: dict[str, Union[int, str]] = {
|
||||
"publishedfileid": mod,
|
||||
"appid": APPID_DAYZ,
|
||||
"key": key,
|
||||
"list_type": 1,
|
||||
"notify_client": 1,
|
||||
}
|
||||
try:
|
||||
res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
|
||||
|
||||
def subscribe(key: str, mod: int) -> None:
|
||||
update_workshop(key, mod, SUB_ENDPOINT)
|
||||
|
||||
|
||||
def unsubscribe(key: str, mod: int) -> None:
|
||||
update_workshop(key, mod, UNSUB_ENDPOINT)
|
||||
|
||||
|
||||
@deprecated("Use subscribe()")
|
||||
def enqueue_mod(client: str, mod: str, appid: int) -> None:
|
||||
client_args = concat_bash_args(client)
|
||||
subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod])
|
||||
|
||||
|
||||
@deprecated("Cf. https://github.com/ValveSoftware/steam-for-linux/issues/9672")
|
||||
def get_registry() -> Any | None:
|
||||
home = os.getenv("HOME")
|
||||
try:
|
||||
with open(f"{home}/.steam/registry.vdf") as f:
|
||||
registry = vdf.load(f)
|
||||
return registry
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return None
|
||||
|
||||
|
||||
def _is_dayz_running() -> bool:
|
||||
registry = get_registry()
|
||||
if registry is None:
|
||||
return False
|
||||
apps = registry["Registry"]["HKCU"]["Software"]["Valve"]["Steam"]["apps"].items()
|
||||
for app in apps:
|
||||
k, v = app
|
||||
if k in (APPID_DAYZ, APPID_DAYZ_EXP):
|
||||
try:
|
||||
state = v["Running"]
|
||||
# NOTE: 0 denotes False
|
||||
return bool(int(state))
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _get_running_app() -> int | None:
|
||||
registry = get_registry()
|
||||
if registry is None:
|
||||
return None
|
||||
try:
|
||||
return int(
|
||||
registry["Registry"]["HKCU"]["Software"]["Valve"]["Steam"]["RunningAppID"]
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_running_app() -> int | None:
|
||||
PROC_NAME = "steam"
|
||||
SUBPROC_NAME = "reaper"
|
||||
FLAG = "AppId"
|
||||
# NOTE: may cause conflicts if multiple apps are running
|
||||
try:
|
||||
for proc in psutil.process_iter():
|
||||
if proc.name() == PROC_NAME:
|
||||
subprocs = proc.children()
|
||||
filtered = (proc for proc in subprocs if proc.name() == SUBPROC_NAME)
|
||||
try:
|
||||
proc = next(filtered)
|
||||
except StopIteration:
|
||||
return None
|
||||
args = proc.cmdline()
|
||||
appid = (row for row in args if FLAG in row)
|
||||
try:
|
||||
return int(next(appid).split("=")[1])
|
||||
except StopIteration:
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
return None
|
||||
|
||||
|
||||
def get_app_allows_downloads(path: Path, appid: int) -> bool:
|
||||
try:
|
||||
root_path = get_app_path(path, appid)
|
||||
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
|
||||
flag = ACF(acf).get_allows_downloads()
|
||||
except AppNotInstalledError:
|
||||
flag = 0
|
||||
match flag:
|
||||
# NOTE: adheres to global client setting
|
||||
case 0:
|
||||
return get_client_allows_downloads(path)
|
||||
# NOTE: always allow
|
||||
case 1:
|
||||
return True
|
||||
# NOTE: never allow
|
||||
case 2:
|
||||
return False
|
||||
# NOTE: no other known values at this time, fallback (assume permits)
|
||||
case _:
|
||||
return True
|
||||
|
||||
|
||||
def get_config(path: Path) -> Path:
|
||||
return path.joinpath("config/config.vdf")
|
||||
|
||||
|
||||
def get_client_allows_downloads(path: Path) -> bool:
|
||||
config = get_config(path)
|
||||
try:
|
||||
with open(config) as f:
|
||||
settings = vdf.load(f)
|
||||
# NOTE: "1" denotes "allow"
|
||||
allow = bool(
|
||||
int(
|
||||
settings["InstallConfigStore"]["Software"]["Valve"]["Steam"][
|
||||
"AllowDownloadsDuringGameplay"
|
||||
]
|
||||
)
|
||||
)
|
||||
return allow
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return True
|
||||
|
||||
|
||||
def is_dayz_running() -> bool:
|
||||
"""Subprocesses spawned from Steam will not show up in regular process tree"""
|
||||
procs = []
|
||||
substring = DAYZ_BINARY
|
||||
for proc in psutil.process_iter():
|
||||
try:
|
||||
procs.append(proc.cmdline())
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
continue
|
||||
return any(substring in item for sublist in procs for item in sublist)
|
||||
|
||||
|
||||
def get_app_path(folders_path: Path, appid: int) -> Path:
|
||||
app_path = None
|
||||
|
||||
try:
|
||||
with open(folders_path / LIBRARYFOLDERS_PATH) as f:
|
||||
folders = vdf.load(f)
|
||||
except Exception:
|
||||
raise VDFLoadError("Failed to parse libraryfolders")
|
||||
|
||||
for obj in folders["libraryfolders"]:
|
||||
if str(appid) in folders["libraryfolders"][obj]["apps"]:
|
||||
app_path = folders["libraryfolders"][obj]["path"]
|
||||
if Path(app_path).exists():
|
||||
break
|
||||
|
||||
if app_path is None:
|
||||
raise AppNotInstalledError(
|
||||
f"Failed to find a libraryfolder for the appid {appid}"
|
||||
)
|
||||
if Path(app_path).exists() is False:
|
||||
raise AppMovedError(
|
||||
f"The location '{app_path}' pointed to by '{appid}' no longer exists and may have been changed on the disk."
|
||||
)
|
||||
|
||||
return Path(app_path)
|
||||
|
||||
|
||||
def get_app_name(appid: int) -> str | None:
|
||||
payload = {"appids": [appid]}
|
||||
res = requests.get(APP_DETAILS, params=payload)
|
||||
if res.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
return str(res.json()[str(appid)]["data"]["name"])
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
return None
|
||||
@@ -0,0 +1,156 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.config.userprefs import UserPrefs
|
||||
from dzgui.config.xdg import get_xdg_paths, parse_filepaths
|
||||
from dzgui.init.migrate import (
|
||||
has_new_config,
|
||||
copy_state_files,
|
||||
migrate_cols_file,
|
||||
)
|
||||
from dzgui.init.flock import lock_acquire
|
||||
from dzgui.init.prereqs import has_steam_client
|
||||
from dzgui.strings import boot
|
||||
|
||||
# from dzgui.util.map_count import get_map_count
|
||||
from dzgui.util.deck import is_steam_deck, is_game_mode
|
||||
from dzgui.util.dirs import make_parents
|
||||
from dzgui.util.localize import set_locale
|
||||
from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS
|
||||
from dzgui.util.strings import init
|
||||
|
||||
from dzgui.views.base import App
|
||||
from dzgui.views.dialogs.boot import BootWindow
|
||||
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
|
||||
from dzgui.views.dialogs.wizard import SetupWizard
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
# TODO: profile load time
|
||||
|
||||
|
||||
def setup_logger(log_path: "Path") -> None:
|
||||
# TODO: put in consts?
|
||||
_format = (
|
||||
"%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s"
|
||||
)
|
||||
|
||||
fh = logging.FileHandler(log_path)
|
||||
formatter = logging.Formatter(_format)
|
||||
fh.setFormatter(formatter)
|
||||
fh.setLevel(logging.DEBUG)
|
||||
|
||||
_filter = RedactionFilter(patterns=REDACTION_PATTERNS)
|
||||
fh.addFilter(_filter)
|
||||
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.addHandler(fh)
|
||||
|
||||
|
||||
def copy_bare_configs(config: "Path", resolution: "Path") -> tuple[bool, bool]:
|
||||
# NOTE: temporary workaround for #375
|
||||
conf = "config.json"
|
||||
state = [
|
||||
"dzg.history",
|
||||
"dzg.versions",
|
||||
"dzg.res.json",
|
||||
"dzg.filters.json",
|
||||
"dzg.columns.json",
|
||||
"dzg.notes.json",
|
||||
"ips.csv",
|
||||
".month",
|
||||
]
|
||||
config_changed = False
|
||||
state_changed = False
|
||||
if config.parent.exists() is False:
|
||||
new_file = config
|
||||
old_file = config.parent.parent / conf
|
||||
if old_file.is_file():
|
||||
try:
|
||||
make_parents(new_file)
|
||||
shutil.copy(old_file, new_file)
|
||||
config_changed = True
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
if resolution.parent.exists() is False:
|
||||
state_path = resolution.parent
|
||||
for state_file in state:
|
||||
old_file = state_path.parent / state_file
|
||||
if old_file.is_file():
|
||||
try:
|
||||
new_file = state_path / state_file
|
||||
make_parents(new_file)
|
||||
shutil.copy(old_file, new_file)
|
||||
state_changed = True
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return (config_changed, state_changed)
|
||||
|
||||
|
||||
def load_gui(version: str, is_debug: bool) -> None:
|
||||
lock = lock_acquire() # noqa
|
||||
|
||||
set_locale()
|
||||
# NOTE: consider aborting this check if steam deck
|
||||
xdg_paths = get_xdg_paths()
|
||||
XDG = parse_filepaths(xdg_paths)
|
||||
|
||||
copy_bare_configs(XDG.config, XDG.resolution)
|
||||
|
||||
if XDG.resolution.parent.is_dir() is False:
|
||||
make_parents(XDG.resolution)
|
||||
|
||||
if XDG.debug.is_file() is False:
|
||||
make_parents(XDG.debug)
|
||||
|
||||
_is_steam_deck = is_steam_deck()
|
||||
_is_game_mode = is_game_mode() if _is_steam_deck else False
|
||||
if _is_game_mode:
|
||||
# NOTE: this may no longer be necessary on newer versions of SteamOS
|
||||
del os.environ["GTK_IM_MODULE"]
|
||||
|
||||
if has_new_config(XDG.config) is False:
|
||||
migrate_cols_file(XDG.columns)
|
||||
copy_state_files(xdg_paths["XDG_STATE_HOME"])
|
||||
# TODO: add logging inside wizard
|
||||
wizard = SetupWizard(_is_steam_deck, XDG)
|
||||
|
||||
if not wizard.is_setup_complete():
|
||||
return
|
||||
|
||||
setup_logger(XDG.debug)
|
||||
with open(XDG.debug, "w") as f:
|
||||
f.truncate(0)
|
||||
|
||||
if _is_steam_deck is False:
|
||||
# TODO: sudo escalation dialog
|
||||
# count = get_map_count()
|
||||
# TODO: move into module
|
||||
if has_steam_client() is False:
|
||||
EarlyAlertDialog(init.requires_steam)
|
||||
|
||||
bootwin = BootWindow(XDG, version)
|
||||
local_coords, latest_release = bootwin.get_results()
|
||||
|
||||
use_miles = lookup(XDG.config, Preferences.DIST)
|
||||
prefs = UserPrefs(
|
||||
is_steam_deck=_is_steam_deck,
|
||||
is_game_mode=_is_game_mode,
|
||||
is_debug=is_debug,
|
||||
coords=local_coords,
|
||||
version=version,
|
||||
paths=XDG,
|
||||
latest_release=latest_release,
|
||||
use_miles=use_miles,
|
||||
)
|
||||
print(boot.all_ok)
|
||||
App(prefs)
|
||||
@@ -3,6 +3,8 @@ import shlex
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dzgui.const.boilerplate import config_boilerplate
|
||||
|
||||
|
||||
"""
|
||||
Convert legacy dztuirc to config.json
|
||||
@@ -89,4 +91,8 @@ def rc2json(file: Path) -> str:
|
||||
keys["ip_list"] = ips
|
||||
keys["use_miles"] = False
|
||||
keys["start_tab"] = 0
|
||||
# NOTE: workaround for DZGUI 6 logic (see #375)
|
||||
for key in config_boilerplate.keys():
|
||||
if key not in keys:
|
||||
keys[key] = config_boilerplate[key]
|
||||
return json.dumps(keys, indent=2)
|
||||
@@ -1,18 +1,43 @@
|
||||
import stat
|
||||
import textwrap
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.util.dirs import copy_dzgui_to_xdg_data, find_icon_resource, make_parents
|
||||
|
||||
|
||||
def get_share_path(exe_path: Path) -> Path:
|
||||
return exe_path.parent.parent
|
||||
|
||||
|
||||
def write_desktop_file(exe_path: Path) -> Path:
|
||||
icon = find_icon_resource()
|
||||
|
||||
def write_desktop_file(share_path: Path) -> None:
|
||||
template = f"""\
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Terminal=false
|
||||
Exec={share_path}/launch.sh
|
||||
Exec={exe_path}
|
||||
Name=DZGUI
|
||||
Comment=dzgui
|
||||
Icon={share_path}/dzgui
|
||||
Icon={icon}
|
||||
Categories=Game"""
|
||||
|
||||
file = share_path / "dzgui.desktop"
|
||||
file.write_text(textwrap.dedent(template))
|
||||
copy_dzgui_to_xdg_data(exe_path)
|
||||
|
||||
share_path = get_share_path(exe_path)
|
||||
desktop_file = share_path.joinpath("applications/dzgui.desktop")
|
||||
make_parents(desktop_file)
|
||||
desktop_file.write_text(textwrap.dedent(template))
|
||||
desktop_file.chmod(desktop_file.stat().st_mode | stat.S_IEXEC)
|
||||
return desktop_file
|
||||
|
||||
|
||||
def write_desktop_shortcut(desktop_file: Path) -> None:
|
||||
# NOTE: necessarily depends on the above (UI blocks creation without XDG entry first)
|
||||
link = Path.home().joinpath(f"Desktop/{APP_NAME}.desktop")
|
||||
if link.exists():
|
||||
link.unlink()
|
||||
link.symlink_to(desktop_file)
|
||||
+12
-6
@@ -18,6 +18,7 @@ class Xdg:
|
||||
debug: Path
|
||||
ips: Path
|
||||
filters: Path
|
||||
shortcut: Path
|
||||
|
||||
|
||||
def is_writeable(path_str: str) -> bool:
|
||||
@@ -29,9 +30,9 @@ def is_writeable(path_str: str) -> bool:
|
||||
if not path.exists():
|
||||
try:
|
||||
path.mkdir(parents=True)
|
||||
path.unlink()
|
||||
except OSError:
|
||||
return False
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
try:
|
||||
@@ -55,17 +56,19 @@ def get_xdg_paths() -> dict:
|
||||
|
||||
resolved_paths = {}
|
||||
for path in xdg_paths:
|
||||
rp = os.environ.get(path)
|
||||
if rp is not None and is_writeable(rp):
|
||||
resolved_paths[path] = Path(rp)
|
||||
real_path = os.environ.get(path)
|
||||
if real_path is not None and is_writeable(real_path):
|
||||
new_path = Path(real_path)
|
||||
else:
|
||||
resolved_paths[path] = xdg_paths[path] / APP_NAME_LOWER
|
||||
new_path = xdg_paths[path]
|
||||
resolved_paths[path] = new_path / APP_NAME_LOWER
|
||||
return resolved_paths
|
||||
|
||||
|
||||
def parse_filepaths(xdg: dict) -> Xdg:
|
||||
config = xdg["XDG_CONFIG_HOME"]
|
||||
state = xdg["XDG_STATE_HOME"]
|
||||
share = xdg["XDG_DATA_HOME"]
|
||||
|
||||
config = config / "config.json"
|
||||
|
||||
@@ -80,6 +83,8 @@ def parse_filepaths(xdg: dict) -> Xdg:
|
||||
ips = state / "ips.csv"
|
||||
filters = state / "dzg.filters.json"
|
||||
|
||||
shortcut = share / APP_NAME_LOWER
|
||||
|
||||
return Xdg(
|
||||
config,
|
||||
columns,
|
||||
@@ -90,5 +95,6 @@ def parse_filepaths(xdg: dict) -> Xdg:
|
||||
system,
|
||||
debug,
|
||||
ips,
|
||||
filters
|
||||
filters,
|
||||
shortcut,
|
||||
)
|
||||
@@ -4,6 +4,8 @@ UDP_PORT = 27016
|
||||
VM_FILE = "/proc/sys/vm/max_map_count"
|
||||
MIN_COUNT = 1048576
|
||||
|
||||
API_RATE_LIMIT = 1
|
||||
CLIENT_RATE_LIMIT = 3
|
||||
REQUEST_TIMEOUT = 10
|
||||
|
||||
APPNAME_DAYZ = "DayZ"
|
||||
@@ -32,9 +34,12 @@ HEX_GREEN = "#32CD32"
|
||||
HEX_RED = "#FF0000"
|
||||
HEX_ORANGE = "#FFAC1C"
|
||||
|
||||
# FIXME: normalize icon names
|
||||
CARET_DOWN = "go-down-symbolic"
|
||||
CARET_UP = "go-up-symbolic"
|
||||
CLIPBOARD = "edit-copy-symbolic"
|
||||
FOLDER = "folder-symbolic"
|
||||
EDIT_DELETE = "edit-delete-symbolic"
|
||||
ERROR = "dialog-error-symbolic"
|
||||
HELP_BUBBLE = "help-about-symbolic"
|
||||
INPUT_KEYBOARD = "input-keyboard-symbolic"
|
||||
@@ -67,7 +72,9 @@ LEGACY_IPS_PATH = ".local/share/dzgui/helpers/ips.csv"
|
||||
DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG"
|
||||
SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG"
|
||||
CHANGELOG_PATH = "data/CHANGELOG.md"
|
||||
HERO_PATH = "data/images/hero.png"
|
||||
IMAGES_PATH = "data/images"
|
||||
|
||||
HERO_PATH = "data/images/_hero.png"
|
||||
CSS_PATH = "data/app.css"
|
||||
VDF_PATH = "steamapps/libraryfolders.vdf"
|
||||
DEFAULT_STEAM_PATH = ".local/share/Steam"
|
||||
@@ -80,3 +87,5 @@ LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG")
|
||||
TMP_PATH = "/tmp"
|
||||
TMP_TARBALL = "/tmp/dzgui.tar.gz"
|
||||
TMP_EXE = "/tmp/dzgui/dzgui"
|
||||
|
||||
DAYZ_COMMUNITY_ROOT = "Missions"
|
||||
@@ -1,6 +1,12 @@
|
||||
# internal
|
||||
STEAM_PUBLISHED_FILES = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json"
|
||||
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?"
|
||||
STEAM_PUBLISHED_FILES = (
|
||||
"https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1"
|
||||
)
|
||||
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1"
|
||||
SUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Subscribe/v1"
|
||||
UNSUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Unsubscribe/v1"
|
||||
APP_DETAILS = "https://store.steampowered.com/api/appdetails?"
|
||||
|
||||
BM_SERVERS = "https://api.battlemetrics.com/servers?"
|
||||
GITHUB = "https://github.com/aclist"
|
||||
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
|
||||
|
||||
+7
-5
@@ -103,6 +103,7 @@ class Preferences(EnumWithAttrs):
|
||||
|
||||
|
||||
class NotebookPage(EnumWithAttrs):
|
||||
OFFLINE = {"crumbs": strings.crumbs.offline, "statusbar": True}
|
||||
CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": True}
|
||||
DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False}
|
||||
HELP = {"crumbs": strings.crumbs._help, "statusbar": True}
|
||||
@@ -167,7 +168,7 @@ class ContextMenu(EnumWithAttrs):
|
||||
COPY_LOG_CLIPBOARD = {"label": strings.copy_log}
|
||||
COPY_SERVER_IP = {"label": strings.copy_ip}
|
||||
COPY_SERVER_NAME = {"label": strings.copy_name}
|
||||
DELETE_MOD = {"label": strings.delete_mod}
|
||||
UNSUB_MOD = {"label": strings.unsub_mod}
|
||||
OPEN_WORKSHOP = {"label": strings.open_workshop}
|
||||
REFRESH_PLAYERS = {"label": strings.refresh_players}
|
||||
REMOVE_HISTORY = {"label": strings.remove_history}
|
||||
@@ -183,7 +184,8 @@ class ContextMenuGroup(Enum):
|
||||
"""
|
||||
|
||||
SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,)
|
||||
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.DELETE_MOD)
|
||||
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.UNSUB_MOD)
|
||||
MOD_OFFLINE = (None,)
|
||||
LOG = (ContextMenu.COPY_LOG_CLIPBOARD,)
|
||||
SERVER_BROWSER = (
|
||||
ContextMenu.CONNECT,
|
||||
@@ -248,9 +250,9 @@ class ModButton(EnumWithAttrs):
|
||||
"label": strings.mod_panel.unhighlight_stale,
|
||||
"tooltip": strings.mod_panel.unhighlight_stale_tooltip,
|
||||
}
|
||||
DELETE_SELECTED = {
|
||||
"label": strings.mod_panel.delete_selected,
|
||||
"tooltip": strings.mod_panel.delete_selected_tooltip,
|
||||
UNSUB_SELECTED = {
|
||||
"label": strings.mod_panel.unsub_selected,
|
||||
"tooltip": strings.mod_panel.unsub_selected_tooltip,
|
||||
}
|
||||
SELECT_STALE = {
|
||||
"label": strings.mod_panel.select_stale,
|
||||
|
||||
@@ -8,6 +8,8 @@ from gi.repository import Gdk, GObject # noqa E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.const.enum import NotebookPage, ServerTab
|
||||
from dzgui.model.model_factory import FastInsertListStore
|
||||
from dzgui.views.pages.offline import FolderHBox
|
||||
|
||||
|
||||
# TODO: if servers fail to load, may leave dangling widgets waiting for a signal
|
||||
@@ -79,8 +81,8 @@ class Emitter(GObject.GObject):
|
||||
def distcalc_started(self) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
|
||||
def mod_page_toggled(self, state: bool) -> None:
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool, int))
|
||||
def mod_page_toggled(self, state: bool, mods: int) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(bool,))
|
||||
@@ -154,3 +156,23 @@ class Emitter(GObject.GObject):
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
|
||||
def lan_page_initialized(self) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str))
|
||||
def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
|
||||
def custom_mods_unloaded(self, widget: "FolderHBox") -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, bool))
|
||||
def custom_mission_loaded(self, folder: str, is_valid: bool) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
|
||||
def invalid_custom_mods(self) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
|
||||
def all_mods_synched(self) -> None:
|
||||
pass
|
||||
+35
-15
@@ -20,11 +20,10 @@ from dzgui.controllers.emitter import Emitter
|
||||
from dzgui.managers.config import ConfigManager
|
||||
from dzgui.managers.connection import ConnectionManager
|
||||
from dzgui.managers.contextmenu import ContextMenuManager
|
||||
from dzgui.managers.mods import ModManager
|
||||
from dzgui.managers.notes import NoteManager
|
||||
from dzgui.model.servers import ServerModelManager
|
||||
from dzgui.util.diag import write_diagnostic
|
||||
from dzgui.util.format import format_player_count
|
||||
from dzgui.util.format import format_exception, format_player_count
|
||||
from dzgui.util.open_links import open_user_workshop, open_workshop_page
|
||||
from dzgui.views.dialogs.filepicker import FilePicker
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
@@ -47,12 +46,13 @@ if TYPE_CHECKING:
|
||||
from dzgui.views.components.filter_panel import FilterPanel
|
||||
from dzgui.views.components.right_panel import RightPanel
|
||||
from dzgui.views.components.statusbar import Statusbar
|
||||
from dzgui.views.pages.offline import OfflineLoader
|
||||
from dzgui.views.pages.options import Options
|
||||
from dzgui.views.pages.preconnect import PreConnectionAssistant
|
||||
from dzgui.views.pages.servers import ServerNotebook
|
||||
from dzgui.views.trees.tree_log import LogTreeView
|
||||
from dzgui.views.trees.tree_menu import MenuTreeView
|
||||
from dzgui.views.trees.tree_mods import ModTreeView
|
||||
from dzgui.views.trees.tree_mods import ModTreeView, OfflineModTreeView
|
||||
from dzgui.views.trees.tree_servers import ServerTreeView
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ class AppNavigation:
|
||||
filters: "FilterPanel"
|
||||
preconnect: "PreConnectionAssistant"
|
||||
options: "Options"
|
||||
offline_loader: "OfflineLoader"
|
||||
|
||||
|
||||
class Controller(GObject.GObject):
|
||||
@@ -202,7 +203,9 @@ class Controller(GObject.GObject):
|
||||
try:
|
||||
# TODO: where to put config file check
|
||||
self.mediator.grid.notebook.settings.populate_settings()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
msg = format_exception(e)
|
||||
logger.critical(msg)
|
||||
return
|
||||
case ButtonType.MODS:
|
||||
self.load_mods()
|
||||
@@ -218,19 +221,29 @@ class Controller(GObject.GObject):
|
||||
open_user_workshop(uid, client)
|
||||
|
||||
def load_mods(self) -> None:
|
||||
self.mod_man = ModManager(self)
|
||||
self.mediator.modtreeview.load_mods()
|
||||
|
||||
def uncolorize_mods(self) -> None:
|
||||
self.mod_man.uncolorize_mods()
|
||||
mod_man = self.mediator.modtreeview.get_mod_man()
|
||||
mod_man.uncolorize_mods()
|
||||
|
||||
def highlight_stale(self) -> None:
|
||||
self.mod_man.highlight_stale()
|
||||
mod_man = self.mediator.modtreeview.get_mod_man()
|
||||
mod_man.highlight_stale()
|
||||
|
||||
def toggle_mod_selection(self, state: bool) -> None:
|
||||
self.mod_man.toggle_mod_selection(state)
|
||||
mod_man = self.mediator.modtreeview.get_mod_man()
|
||||
mod_man.toggle_mod_selection(state)
|
||||
|
||||
def delete_mods(self) -> None:
|
||||
self.mod_man.delete_mods()
|
||||
def unsub_mods(
|
||||
self, treeview: Union["ModTreeView", "OfflineModTreeView", None] = None
|
||||
) -> None:
|
||||
if treeview is None:
|
||||
view = self.mediator.modtreeview
|
||||
else:
|
||||
view = treeview
|
||||
mod_man = view.get_mod_man()
|
||||
mod_man.unsub_mods()
|
||||
|
||||
def get_mod_store(self) -> Gtk.TreeModel | None:
|
||||
return self.mediator.modtreeview.get_model()
|
||||
@@ -257,7 +270,8 @@ class Controller(GObject.GObject):
|
||||
dialog.run()
|
||||
|
||||
def select_colorized(self) -> None:
|
||||
self.mod_man.select_colorized()
|
||||
mod_man = self.mediator.modtreeview.get_mod_man()
|
||||
mod_man.select_colorized()
|
||||
|
||||
def get_all_tree_filters(self) -> dict[str, dict[str, bool]]:
|
||||
servers = self.get_servers()
|
||||
@@ -511,11 +525,11 @@ class Controller(GObject.GObject):
|
||||
ind = self.config_man.get_start_tab()
|
||||
self.get_servers().notebook.set_current_page(ind)
|
||||
|
||||
def update_and_load_to_menu(self, raise_window: bool) -> None:
|
||||
self.connection_man.update_and_connect(raise_window, menu_only=True)
|
||||
def update_and_load_to_menu(self) -> None:
|
||||
self.connection_man.update_and_connect(menu_only=True)
|
||||
|
||||
def update_and_connect(self, raise_window: bool) -> None:
|
||||
self.connection_man.update_and_connect(raise_window)
|
||||
def update_and_connect(self) -> None:
|
||||
self.connection_man.update_and_connect()
|
||||
|
||||
def update_status(self) -> None:
|
||||
self.mediator.preconnect.mark_finished()
|
||||
@@ -533,3 +547,9 @@ class Controller(GObject.GObject):
|
||||
self.clear_cancel_event()
|
||||
return True
|
||||
return False
|
||||
|
||||
def open_offline(self) -> None:
|
||||
self.open_page(NotebookPage.OFFLINE)
|
||||
mod_man = self.mediator.modtreeview.get_mod_man()
|
||||
store = mod_man.store
|
||||
self.mediator.offline_loader.populate(store)
|
||||
+33
-28
@@ -1,6 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## Added
|
||||
## [7.0.0] Unreleased
|
||||
### Added
|
||||
- Setup wizard
|
||||
- Changelog text wrapping and formatting
|
||||
- Changelog ships with source
|
||||
@@ -40,8 +41,11 @@
|
||||
- Preboot progress dialog
|
||||
- Choose to jump into splash screen instead of server
|
||||
- Collapsible connection panel
|
||||
- Play offline (load mods directly)
|
||||
- Generate Steam shortcuts and cover art
|
||||
- Warn user if background downloads are disabled
|
||||
|
||||
## Changed
|
||||
### Changed
|
||||
- Conform to PEP 440 versioning for beta versions
|
||||
- Reduce padding on keys button
|
||||
- Boldface breadcrumbs
|
||||
@@ -56,59 +60,60 @@
|
||||
- Disable overlay scrollbars on server tables
|
||||
- Reduce size of geolocation DB on disk (~100MB)
|
||||
- Enable LAN page Empty/Full filters on first run of app
|
||||
- Propagate subscribed mods to Steam client
|
||||
|
||||
## Dropped
|
||||
### Dropped
|
||||
- Debug mode
|
||||
- Branch switching
|
||||
- Manual mod install mode (describe rationale)
|
||||
- Force update mods
|
||||
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input
|
||||
- Center server title text on server dialogs
|
||||
- Rare segfaults when changing maps (threading)
|
||||
- Moved dialogs out of threads
|
||||
|
||||
## Unreleased
|
||||
### Unreleased
|
||||
- Load offline mods
|
||||
- Local documentation
|
||||
- Raw debug command in context menu
|
||||
|
||||
## Developer-facing
|
||||
### Developer-facing
|
||||
- Add pyproject.toml file
|
||||
- Show deprecation warnings
|
||||
- Options -> Dev page
|
||||
|
||||
## [6.0.5] 2026-05-17
|
||||
## Changed
|
||||
### Changed
|
||||
- Update symlink hash logic to align with DZGUI 7 beta
|
||||
- Update A2S helper to latest commit
|
||||
|
||||
## [6.0.4] 2026-04-13
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Update A2S module checksums to support servers with missing description field
|
||||
|
||||
## [6.0.3] 2026-04-12
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Use GLibUnix namespace where available when setting up SIGINT callback
|
||||
|
||||
## [6.0.2] 2026-01-26
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Explicitly use Python 3.13 when calling subprocesses
|
||||
- Raise error correctly from module
|
||||
- Do not spawn dialogs from inside of thread when fetching prereqs
|
||||
## Changed
|
||||
### Changed
|
||||
- Optimize time complexity of startup checks
|
||||
- Optimize coordinate calculation by using local records first
|
||||
- Rename some internal functions
|
||||
- Wrap entire startup process in dialog
|
||||
|
||||
## [6.0.1] 2026-01-16
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Explicitly use Python 3.13 when calling subprocesses
|
||||
|
||||
## [6.0.0] 2026-01-14
|
||||
## Added
|
||||
### Added
|
||||
- Support DayZ Experimental
|
||||
- Ping display on server tables
|
||||
- Save descriptive text notes on a per-server basis
|
||||
@@ -134,7 +139,7 @@
|
||||
- Commandline usage help text (GaryBlackbourne)
|
||||
- More descriptive help dialogs when initial dependencies are missing
|
||||
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Script failing to start when remote endpoints are unavailable (GaryBlackbourne)
|
||||
- Key stickiness when quickly navigating through entries in tables
|
||||
- Servers returning malformed A2S_INFO blocking server browser from loading
|
||||
@@ -173,7 +178,7 @@
|
||||
- Path discovery during first-time setup when parsing filepaths with whitespaces
|
||||
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
|
||||
|
||||
## Changed
|
||||
### Changed
|
||||
- Require Python 3.13
|
||||
- Reduced global API cooldown from 60s to 30s
|
||||
- Clarify dialog messages when DayZ path could not be found
|
||||
@@ -185,50 +190,50 @@
|
||||
- Rewrote distance calculation module (GaryBlackbourne)
|
||||
- Changed preferred client setting from radio toggle to combobox
|
||||
|
||||
## Dropped
|
||||
### Dropped
|
||||
- Ping readout in statusbar
|
||||
- Extraneous information from right statusbar
|
||||
|
||||
## [5.8.3] 2026-01-04
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Normalize checksum numbers and dates
|
||||
|
||||
## [5.8.2] 2026-01-04
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Fix regression in symlink routine introduced by previous hotfix
|
||||
|
||||
## [5.8.1] 2025-12-27
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Path discovery during first-time setup when parsing filepaths with whitespaces
|
||||
- First-time setup dialog continuously triggering when DayZ install path had whitespaces in it
|
||||
|
||||
## [5.8.0] 2025-07-06
|
||||
## Added
|
||||
### Added
|
||||
- Filter servers by official/unofficial status
|
||||
- Automatically fetch geolocation records
|
||||
## Changed
|
||||
### Changed
|
||||
- Updated internal versioning of helper files
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Corrected erroneous 2024 date in prior changelog entries
|
||||
|
||||
## [5.7.0] 2025-04-17
|
||||
## Added
|
||||
### Added
|
||||
- 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
|
||||
## Changed
|
||||
### Changed
|
||||
- More direct server ping query method that should return marginally more accurate times
|
||||
|
||||
## [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
|
||||
|
||||
## [5.6.6] 2025-03-16
|
||||
## Changed
|
||||
### Changed
|
||||
- Update IP database records for 2025-03
|
||||
|
||||
## [5.6.5] 2025-03-04
|
||||
## Fixed
|
||||
### Fixed
|
||||
- Livonia server results being dropped from batch queries
|
||||
|
||||
## [5.6.4] 2025-02-10
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+4
-7
@@ -1,14 +1,12 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.api.steam import get_app_path
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.const.constants import APPID_DAYZ, APP_NAME, LIBRARYFOLDERS_PATH
|
||||
from dzgui.const.constants import APPID_DAYZ, APP_NAME
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
|
||||
|
||||
import dzgui.api.pefile as PeFile
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
@@ -16,8 +14,7 @@ logger = logging.getLogger(APP_NAME)
|
||||
def is_dayz_installed(config: Path) -> None:
|
||||
try:
|
||||
path = lookup(config, Preferences.DEFAULT)
|
||||
PeFile.get_app_path(Path(path) / LIBRARYFOLDERS_PATH, APPID_DAYZ)
|
||||
get_app_path(Path(path), APPID_DAYZ)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
EarlyAlertDialog(str(e))
|
||||
sys.exit(1)
|
||||
raise e
|
||||
+4
-5
@@ -1,11 +1,12 @@
|
||||
import platform
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
from ctypes.util import find_library
|
||||
from warnings import deprecated
|
||||
|
||||
LIB = "libgirepository-2.0"
|
||||
|
||||
|
||||
@deprecated("currently unused")
|
||||
def is_debian() -> bool:
|
||||
"""
|
||||
@@ -39,12 +40,10 @@ def is_debian() -> bool:
|
||||
|
||||
|
||||
def has_libgi() -> bool:
|
||||
parent_dir = Path("/usr/lib64")
|
||||
exists = any(LIB in subdir.name for subdir in parent_dir.iterdir())
|
||||
return exists
|
||||
return bool(find_library("girepository-2.0"))
|
||||
|
||||
|
||||
def test_missing_lib() -> bool:
|
||||
def test_libgi_missing() -> None:
|
||||
msg = f"System is missing the required library '{LIB}'. Install it via your system package manager."
|
||||
if has_libgi() is False:
|
||||
print(msg)
|
||||
|
||||
+3
-37
@@ -1,11 +1,10 @@
|
||||
import psutil
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
from warnings import deprecated
|
||||
import logging
|
||||
|
||||
from dzgui.const.constants import (
|
||||
DAYZ_BINARY,
|
||||
APP_NAME,
|
||||
STEAM_CMD,
|
||||
FLATPAK_APPID,
|
||||
FLATPAK_CMD,
|
||||
@@ -13,18 +12,7 @@ from dzgui.const.constants import (
|
||||
FLATPAK_SANDBOX,
|
||||
)
|
||||
|
||||
|
||||
# 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)
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
def is_steam_running(cmd: str) -> bool:
|
||||
@@ -61,25 +49,3 @@ def has_cmd(cmd: str) -> bool:
|
||||
if shutil.which(cmd) is not None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@deprecated("dropped in favor of Gtk native methods")
|
||||
def foreground(cmd: str, pid: int) -> None:
|
||||
if cmd == "wmctrl":
|
||||
proc = subprocess.run(["wmctrl", "-ilp"], capture_output=True, text=True)
|
||||
lines = proc.stdout.splitlines()
|
||||
for line in lines:
|
||||
els = line.split(" ")
|
||||
if str(pid) in els:
|
||||
wid = els[0]
|
||||
break
|
||||
subprocess.run(["wmctrl", "-ia", wid])
|
||||
elif cmd == "xdotool":
|
||||
args = [cmd, "search", "--pid", str(pid)]
|
||||
proc = subprocess.run([*args], capture_output=True, text=True)
|
||||
lines = proc.stdout.splitlines()
|
||||
## NOTE: some forked subprocesses may fail, so skip over them
|
||||
for line in lines:
|
||||
subprocess.run(
|
||||
["xdotool", "windowactivate", line], stderr=subprocess.DEVNULL
|
||||
)
|
||||
+6
-113
@@ -1,41 +1,11 @@
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.config.userprefs import UserPrefs
|
||||
from dzgui.config.xdg import get_xdg_paths, parse_filepaths
|
||||
from dzgui.init.dayz import is_dayz_installed
|
||||
from dzgui.init.flock import lock_acquire
|
||||
from dzgui.init.migrate import (
|
||||
has_new_config,
|
||||
copy_state_files,
|
||||
migrate_cols_file,
|
||||
)
|
||||
from dzgui.init.libgi import test_libgi_missing
|
||||
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)
|
||||
from dzgui.util.strings import flags
|
||||
|
||||
parser = argparse.ArgumentParser(description=flags.description)
|
||||
parser.add_argument("-v", "--version", action="store_true", help=flags.version)
|
||||
@@ -44,11 +14,6 @@ parser.add_argument("-d", "--debug", action="store_true", help=flags.debug)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
# TODO: profile load time
|
||||
def make_parents(path: "Path") -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def uninstall() -> None:
|
||||
# TODO: uninstall data files (-u)
|
||||
# -u removes state, log, freedesktop
|
||||
@@ -58,21 +23,7 @@ def uninstall() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def setup_logger(log_path: "Path") -> None:
|
||||
_format = (
|
||||
"%(asctime)s␞%(levelname)s␞%(filename)s::%(funcName)s::%(lineno)s␞%(message)s"
|
||||
)
|
||||
fh = logging.FileHandler(log_path)
|
||||
formatter = logging.Formatter(_format)
|
||||
fh.setFormatter(formatter)
|
||||
fh.setLevel(logging.DEBUG)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.addHandler(fh)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
lock = lock_acquire() # noqa
|
||||
|
||||
if args.version is True:
|
||||
print(get_version())
|
||||
sys.exit(0)
|
||||
@@ -83,69 +34,11 @@ def main() -> None:
|
||||
warnings.filterwarnings("default", category=DeprecationWarning)
|
||||
|
||||
version = get_version()
|
||||
set_locale()
|
||||
|
||||
print(f"{APP_NAME} {version}")
|
||||
test_libgi_missing()
|
||||
|
||||
# NOTE: consider aborting this check if steam deck
|
||||
xdg_paths = get_xdg_paths()
|
||||
XDG = parse_filepaths(xdg_paths)
|
||||
# NOTE: only import ligbirepository modules after the above check
|
||||
from dzgui.app_init import load_gui
|
||||
|
||||
if XDG.resolution.parent.is_dir() is False:
|
||||
make_parents(XDG.resolution)
|
||||
|
||||
if XDG.debug.is_file() is False:
|
||||
make_parents(XDG.debug)
|
||||
|
||||
_is_steam_deck = is_steam_deck()
|
||||
_is_game_mode = is_game_mode() if _is_steam_deck else False
|
||||
if _is_game_mode:
|
||||
# NOTE: this may no longer be necessary on newer versions of SteamOS
|
||||
del os.environ["GTK_IM_MODULE"]
|
||||
|
||||
if has_new_config(XDG.config) is False:
|
||||
migrate_cols_file(XDG.columns)
|
||||
copy_state_files(xdg_paths["XDG_STATE_HOME"])
|
||||
# TODO: add logging inside wizard
|
||||
SetupWizard(_is_steam_deck, XDG.config)
|
||||
|
||||
# NOTE: implies that setup wizard failed or was closed
|
||||
if has_new_config(XDG.config) is False:
|
||||
return
|
||||
|
||||
setup_logger(XDG.debug)
|
||||
with open(XDG.debug, "w") as f:
|
||||
f.truncate(0)
|
||||
|
||||
if _is_steam_deck is False:
|
||||
# TODO: sudo escalation dialog
|
||||
# count = get_map_count()
|
||||
# TODO: move into module
|
||||
if has_steam_client() is False:
|
||||
EarlyAlertDialog(init.requires_steam)
|
||||
|
||||
is_dayz_installed(XDG.config)
|
||||
|
||||
bootwin = BootWindow(XDG, version)
|
||||
local_coords, latest_release = bootwin.get_results()
|
||||
|
||||
# rebuild_symlinks(XDG.config)
|
||||
# remove_stale_signatures(XDG.config, XDG.version)
|
||||
|
||||
## TODO: handle IP DB failure and use coords fallback
|
||||
# local_coords = get_local_coords(XDG.ips)
|
||||
# latest_release = check_updates(version)
|
||||
|
||||
use_miles = lookup(XDG.config, Preferences.DIST)
|
||||
prefs = UserPrefs(
|
||||
is_steam_deck=_is_steam_deck,
|
||||
is_game_mode=_is_game_mode,
|
||||
is_debug=args.debug,
|
||||
coords=local_coords,
|
||||
version=version,
|
||||
paths=XDG,
|
||||
latest_release=latest_release,
|
||||
use_miles=use_miles,
|
||||
)
|
||||
print(boot.all_ok)
|
||||
App(prefs)
|
||||
load_gui(version, args.debug)
|
||||
@@ -55,8 +55,11 @@ class ConfigManager:
|
||||
self.update_config(Preferences.IP_LIST, ips)
|
||||
|
||||
def update_history_file(self, fqip: str) -> None:
|
||||
with open(self.prefs.paths.history, "r") as f:
|
||||
ips = [line.rstrip() for line in f.readlines()]
|
||||
try:
|
||||
with open(self.prefs.paths.history, "r") as f:
|
||||
ips = [line.rstrip() for line in f.readlines()]
|
||||
except FileNotFoundError:
|
||||
ips = []
|
||||
|
||||
seen = set()
|
||||
ips.append(fqip)
|
||||
|
||||
@@ -9,13 +9,17 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import dzgui.api.pefile as PeFile
|
||||
import dzgui.api.servers as Servers
|
||||
from dzgui.api.shortcuts import Shortcuts
|
||||
|
||||
from dzgui.api.steam import (
|
||||
connect,
|
||||
enqueue_mod,
|
||||
get_remote_signatures,
|
||||
get_app_allows_downloads,
|
||||
get_app_name,
|
||||
get_needs_update,
|
||||
get_remote_signatures,
|
||||
get_running_app,
|
||||
load_to_menu,
|
||||
subscribe,
|
||||
)
|
||||
|
||||
from dzgui.api.mods import (
|
||||
@@ -25,16 +29,22 @@ from dzgui.api.mods import (
|
||||
update_signatures,
|
||||
)
|
||||
from dzgui.const.constants import (
|
||||
API_RATE_LIMIT,
|
||||
APP_NAME,
|
||||
APPID_DAYZ,
|
||||
APPID_DAYZ_EXP,
|
||||
APPNAME_DAYZ,
|
||||
APPNAME_DAYZ_EXP_HUMAN,
|
||||
)
|
||||
from dzgui.api.steam import is_dayz_running
|
||||
from dzgui.const.enum import NotebookPage, Preferences
|
||||
from dzgui.init.proc import is_dayz_running, is_steam_running
|
||||
from dzgui.init.proc import is_steam_running
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.strings.dialogs import waiting_for_launch, waiting_for_mods
|
||||
from dzgui.strings.dialogs import (
|
||||
waiting_for_launch,
|
||||
waiting_for_mods,
|
||||
waiting_for_directories,
|
||||
)
|
||||
from dzgui.strings.server_mods import checkmark, resync
|
||||
from dzgui.util.format import format_mib
|
||||
from dzgui.util.strings import dialog, server_timeout
|
||||
@@ -76,6 +86,7 @@ class Prerequisites:
|
||||
mods: list[list[str]]
|
||||
game_mode: bool
|
||||
is_last_server: bool
|
||||
allows_downloads: tuple[bool, str]
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
@@ -143,21 +154,14 @@ class ConnectionManager:
|
||||
remote_mods: list[list[str]] = []
|
||||
if res.is_modded():
|
||||
try:
|
||||
remote_mods = self._query_modlist(record)
|
||||
remote_mods, missing_mods = self._query_modlist(record)
|
||||
self.missing_mods = missing_mods
|
||||
self.remote_mod_ids = [mod[1] for mod in remote_mods]
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
|
||||
return
|
||||
|
||||
hashes = get_remote_signatures(self.remote_mod_ids)
|
||||
version_file = prefs.paths.version
|
||||
self.missing_mods = get_needs_update(version_file, hashes)
|
||||
|
||||
for mod in remote_mods:
|
||||
if any(mod[1] in tuple for tuple in self.missing_mods):
|
||||
mod[2] = resync
|
||||
|
||||
if local_version is not None:
|
||||
pefile_path = PeFile.get_pefile_path(steam_path, info.game_id)
|
||||
total, used, free = shutil.disk_usage(pefile_path)
|
||||
@@ -168,6 +172,21 @@ class ConnectionManager:
|
||||
|
||||
dayz_running = is_dayz_running()
|
||||
|
||||
running_app = get_running_app()
|
||||
if running_app is not None:
|
||||
allows_dl = get_app_allows_downloads(steam_path, running_app)
|
||||
running_app_name = get_app_name(running_app)
|
||||
# NOTE: for out-of-range appid, assume NSG.
|
||||
# dzgui.api.steam.get_app_name() will return None
|
||||
if running_app_name is None:
|
||||
s = Shortcuts(steam_path)
|
||||
# NOTE: returns "Unknown" if unparseable
|
||||
running_app_name = s.find_appname_by_unsigned_id(running_app)
|
||||
allows_downloads = (allows_dl, running_app_name)
|
||||
else:
|
||||
allows_downloads = (True, "")
|
||||
|
||||
|
||||
client_name = self.controller.get_steam_client_name()
|
||||
client = self.controller.query_config(Preferences.CLIENT)
|
||||
running = is_steam_running(client)
|
||||
@@ -193,6 +212,7 @@ class ConnectionManager:
|
||||
mods=remote_mods,
|
||||
game_mode=game_mode,
|
||||
is_last_server=is_last,
|
||||
allows_downloads=allows_downloads,
|
||||
)
|
||||
|
||||
func = StoredFunc(self.controller.open_connection_assistant, prereqs)
|
||||
@@ -221,10 +241,13 @@ class ConnectionManager:
|
||||
StoredFunc(self._present_details_dialog, details), destroy_first=True
|
||||
)
|
||||
|
||||
def _query_modlist(self, record: Servers.Record) -> list[list[str]]:
|
||||
def _query_modlist(
|
||||
self, record: Servers.Record
|
||||
) -> tuple[list[list[str]], list[tuple[str, str, int, int]]]:
|
||||
mods = Servers.get_rules(record)
|
||||
steam_path = self.controller.query_config(Preferences.DEFAULT)
|
||||
local = get_local_mod_ids(steam_path)
|
||||
local = get_local_mod_ids(Path(steam_path))
|
||||
|
||||
alpha_mods: list[list[str]] = [
|
||||
[
|
||||
mod.name,
|
||||
@@ -234,12 +257,23 @@ class ConnectionManager:
|
||||
for mod in mods
|
||||
]
|
||||
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
|
||||
|
||||
return alpha_mods, missing_mods
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def query_modlist_and_present(self, record: Servers.Record) -> None:
|
||||
try:
|
||||
mods = self._query_modlist(record)
|
||||
mods, missing_mods = self._query_modlist(record)
|
||||
except Exception:
|
||||
self.thread_man.set_cleanup_func(
|
||||
StoredFunc(self._server_timeout), destroy_first=True
|
||||
@@ -275,9 +309,9 @@ class ConnectionManager:
|
||||
self.thread_man.set_cleanup_func(func)
|
||||
return
|
||||
|
||||
self.thread_man.show_cancel(False)
|
||||
self.thread_man.update_dialog(waiting_for_launch)
|
||||
while True:
|
||||
# FIXME: cancel should not be visible here per setting above
|
||||
if self.controller.is_cancel_pending():
|
||||
# TODO: some facility to also close spawned steam process
|
||||
return
|
||||
@@ -292,21 +326,18 @@ class ConnectionManager:
|
||||
self.controller.add_to_history(self.history, self.record)
|
||||
self.controller.open_page(NotebookPage.SERVERS)
|
||||
|
||||
def _update_mods(self, raise_window: bool, menu_only: bool = False) -> None:
|
||||
# NOTE: fast enqueue all mods in auto mode
|
||||
def _update_mods(self, menu_only: bool = False) -> None:
|
||||
prefs = self.controller.get_prefs()
|
||||
config_man = self.controller.get_config_man()
|
||||
key = config_man.lookup(Preferences.STEAM)
|
||||
|
||||
for title, mod, stamp, size in self.missing_mods:
|
||||
if self.controller.is_cancel_pending():
|
||||
return
|
||||
enqueue_mod(self.client, mod, self.appid)
|
||||
# NOTE: prevents rate limiting
|
||||
time.sleep(3)
|
||||
|
||||
if raise_window is True:
|
||||
logger.info("Bringing window to foreground")
|
||||
GLib.idle_add(self.controller.present_window)
|
||||
subscribe(key, int(mod))
|
||||
time.sleep(API_RATE_LIMIT)
|
||||
|
||||
self.thread_man.update_dialog(waiting_for_directories)
|
||||
for title, mod, stamp, size in self.missing_mods:
|
||||
mod_path = self.workshop / mod
|
||||
|
||||
@@ -324,13 +355,17 @@ class ConnectionManager:
|
||||
time.sleep(1)
|
||||
|
||||
update_signatures(self.missing_mods, prefs.paths.version)
|
||||
# TODO: just push steam path directly
|
||||
rebuild_symlinks(prefs.paths.config)
|
||||
|
||||
# NOTE: update table status in main loop
|
||||
self.thread_man.update_emitter("all_mods_synched")
|
||||
|
||||
# TODO: just push steam path directly
|
||||
self._connect_steam(menu_only)
|
||||
|
||||
@call_on_thread(waiting_for_mods, show_cancel=True)
|
||||
def update_and_connect(self, raise_window: bool, menu_only: bool = False) -> None:
|
||||
def update_and_connect(self, menu_only: bool = False) -> None:
|
||||
if len(self.missing_mods) > 0:
|
||||
self._update_mods(raise_window, menu_only)
|
||||
self._update_mods(menu_only)
|
||||
else:
|
||||
self._connect_steam(menu_only)
|
||||
@@ -8,7 +8,7 @@ from dzgui.util.clip import copy_clipboard
|
||||
from dzgui.util.open_links import open_workshop_page
|
||||
|
||||
from dzgui.views.dialogs.note import NoteDialog
|
||||
from dzgui.views.trees.tree_mods import ModTreeView
|
||||
from dzgui.views.trees.tree_mods import ModTreeView, OfflineModTreeView
|
||||
from dzgui.views.trees.tree_log import LogTreeView
|
||||
from dzgui.views.trees.tree_servers import ServerTreeView
|
||||
from dzgui.views.trees.tree_server_mods import ServerModTreeView
|
||||
@@ -27,7 +27,13 @@ if TYPE_CHECKING:
|
||||
class ContextMenuManager:
|
||||
def __init__(
|
||||
self,
|
||||
treeview: LogTreeView | ModTreeView | ServerTreeView | ServerModTreeView,
|
||||
treeview: (
|
||||
LogTreeView
|
||||
| ModTreeView
|
||||
| ServerTreeView
|
||||
| ServerModTreeView
|
||||
| OfflineModTreeView
|
||||
),
|
||||
controller: "Controller",
|
||||
) -> None:
|
||||
self.controller = controller
|
||||
@@ -72,10 +78,10 @@ class ContextMenuManager:
|
||||
case ContextMenu.SHOW_MODS:
|
||||
self.controller.get_modlist(record)
|
||||
|
||||
if isinstance(self.treeview, ModTreeView):
|
||||
if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)):
|
||||
match action:
|
||||
case ContextMenu.DELETE_MOD:
|
||||
self.controller.delete_mods()
|
||||
case ContextMenu.UNSUB_MOD:
|
||||
self.controller.unsub_mods(self.treeview)
|
||||
case ContextMenu.OPEN_WORKSHOP:
|
||||
self.open_mod_page()
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ class FilterManager:
|
||||
if maps is None:
|
||||
return
|
||||
if len(maps) < 1:
|
||||
self.reinit_map_store()
|
||||
return
|
||||
|
||||
self.reinit_map_store()
|
||||
@@ -89,8 +90,9 @@ class FilterManager:
|
||||
continue
|
||||
self.append_map([m])
|
||||
|
||||
# TODO: currently unused
|
||||
def get_unique_maps(self) -> list[str]:
|
||||
return [row[0] for row in self.map_store if row != "All maps"]
|
||||
return [row[0] for row in self.map_store if row[0] != "All maps"]
|
||||
|
||||
def get_all_filters(self) -> tuple:
|
||||
map_name = self.get_active_map_name()
|
||||
|
||||
+58
-31
@@ -1,23 +1,29 @@
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dzgui.api.steam import unsubscribe
|
||||
from dzgui.api.mods import (
|
||||
get_delimited_mods,
|
||||
get_local_mod_path,
|
||||
find_stale_mods,
|
||||
_hash,
|
||||
remove_stale_signatures,
|
||||
)
|
||||
from dzgui.const.constants import APP_NAME, APPID_DAYZ, APPID_DAYZ_EXP
|
||||
from dzgui.const.constants import (
|
||||
API_RATE_LIMIT,
|
||||
APP_NAME,
|
||||
APPID_DAYZ,
|
||||
APPID_DAYZ_EXP,
|
||||
)
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.model.model_factory import FastInsertListStore, ModelFactory
|
||||
from dzgui.strings import dialogs
|
||||
from dzgui.util.format import format_mods
|
||||
from dzgui.util.strings import server_timeout
|
||||
from dzgui.util.symlink import rebuild_symlinks
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
|
||||
|
||||
@@ -40,39 +46,48 @@ class ModManager:
|
||||
this manager is instantiated each time the Mods page is opened
|
||||
"""
|
||||
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
def __init__(self, tree: Gtk.TreeView, controller: "Controller") -> None:
|
||||
self.treeview = tree
|
||||
self.controller = controller
|
||||
|
||||
self.emitter = controller.get_emitter()
|
||||
self.prefs = controller.get_prefs()
|
||||
self.path = controller.query_config(Preferences.DEFAULT)
|
||||
self.treeview = self.controller.get_modtreeview()
|
||||
|
||||
self.store: FastInsertListStore
|
||||
self.store: FastInsertListStore | None = None
|
||||
|
||||
self.thread_man = ThreadingManager(controller)
|
||||
self._get_mods()
|
||||
|
||||
@call_on_thread(dialogs.fetching_mods)
|
||||
def _get_mods(self) -> None:
|
||||
mods = get_delimited_mods(self.path)
|
||||
def load_mods(self) -> None:
|
||||
mods = get_delimited_mods(Path(self.path))
|
||||
if len(mods) < 1:
|
||||
msg = self.format_mod_statusbar()
|
||||
func = StoredFunc(lambda: self.emitter.emit("mods_updated", msg, 0))
|
||||
self.thread_man.set_cleanup_func(func)
|
||||
return
|
||||
|
||||
func = StoredFunc(self._on_mods_loaded, mods)
|
||||
self.thread_man.set_cleanup_func(func)
|
||||
prefs = self.controller.get_prefs()
|
||||
rebuild_symlinks(prefs.paths.config)
|
||||
|
||||
def _on_mods_loaded(self, mods: list[list[Any]]) -> None:
|
||||
self.store = ModelFactory().make_mod_store()
|
||||
self.store.extend(mods)
|
||||
func = StoredFunc(self._on_mods_loaded)
|
||||
|
||||
self.thread_man.set_cleanup_func(func)
|
||||
|
||||
def set_store(self, store: "FastInsertListStore") -> None:
|
||||
self.store = store
|
||||
|
||||
def _on_mods_loaded(self) -> None:
|
||||
self.treeview.set_model(self.store)
|
||||
if self.store is None:
|
||||
return
|
||||
msg = self.format_mod_statusbar()
|
||||
total_mods = len(self.store)
|
||||
self.emitter.emit("mods_updated", msg, total_mods)
|
||||
|
||||
def delete_mods(self) -> None:
|
||||
def unsub_mods(self) -> None:
|
||||
sel = self.treeview.get_selection()
|
||||
model, pathlist = sel.get_selected_rows()
|
||||
# NOTE: reverse when multiple selection
|
||||
@@ -83,7 +98,8 @@ class ModManager:
|
||||
continue
|
||||
mod, _iter = res
|
||||
mods.append((mod, _iter))
|
||||
self.delete_mods_on_system(mods)
|
||||
self.thread_man.set_job_count(len(mods))
|
||||
self.unsub_all_mods(mods)
|
||||
|
||||
def get_mod_from_tree_path(
|
||||
self, tree_path: Gtk.TreePath
|
||||
@@ -96,43 +112,54 @@ class ModManager:
|
||||
return mod, tree_iter
|
||||
|
||||
@call_on_thread(dialogs.deleting_mods)
|
||||
def delete_mods_on_system(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:
|
||||
self.delete_single_mod(mod)
|
||||
self.unsub_atomic_mod(mod)
|
||||
self.thread_man.increment_dialog()
|
||||
time.sleep(API_RATE_LIMIT)
|
||||
|
||||
iters = [_iter for mod, _iter in mods]
|
||||
func = StoredFunc(self._on_mods_deleted, iters)
|
||||
func = StoredFunc(self._on_mods_unsubbed, mods)
|
||||
self.thread_man.set_cleanup_func(func)
|
||||
|
||||
def delete_single_mod(self, mod: str) -> None:
|
||||
def unsub_atomic_mod(self, mod: str) -> None:
|
||||
config_man = self.controller.get_config_man()
|
||||
key = config_man.lookup(Preferences.STEAM)
|
||||
unsubscribe(key, int(mod))
|
||||
|
||||
steam_path = Path(self.path)
|
||||
mods_path = get_local_mod_path(steam_path)
|
||||
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
|
||||
|
||||
md5 = _hash(mod)
|
||||
symlink = app_path / md5
|
||||
symlink.unlink()
|
||||
shutil.rmtree(mods_path / mod)
|
||||
|
||||
try:
|
||||
md5 = _hash(mod)
|
||||
symlink = app_path / md5
|
||||
symlink.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
# NOTE: second pass to unlink DAYZ_EXP mods
|
||||
# TODO: test this with working APPID_DAYZ_EXP installation
|
||||
try:
|
||||
app_path_exp = PeFile.get_nested_app_path(steam_path, APPID_DAYZ_EXP)
|
||||
symlink = app_path_exp / md5
|
||||
symlink.unlink()
|
||||
except PeFile.AppNotInstalledError:
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(API_RATE_LIMIT)
|
||||
|
||||
def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None:
|
||||
def _on_mods_unsubbed(self, mod_iter: list[tuple[str, Gtk.TreeIter]]) -> None:
|
||||
mods = [int(mod) for mod, _iter in mod_iter]
|
||||
iters = [_iter for mod, _iter in mod_iter]
|
||||
if self.store is None:
|
||||
return
|
||||
for _iter in iters:
|
||||
self.store.remove(_iter)
|
||||
remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version)
|
||||
# TODO: process config path in called function
|
||||
remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version, mods)
|
||||
model = self.treeview.get_model()
|
||||
if model is None:
|
||||
return
|
||||
mods = len(model)
|
||||
total_mods = len(model)
|
||||
msg = self.format_mod_statusbar()
|
||||
self.emitter.emit("mods_updated", msg, mods)
|
||||
self.emitter.emit("mods_updated", msg, total_mods)
|
||||
|
||||
def uncolorize_mods(self) -> None:
|
||||
model = self.treeview.get_model()
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, TYPE_CHECKING, Union
|
||||
|
||||
import dzgui.api.pefile as PeFile
|
||||
from dzgui.api.mods import is_mission, get_custom_mods
|
||||
from dzgui.api.steam import is_dayz_running, launch_offline
|
||||
from dzgui.const.constants import APP_NAME, APPID_DAYZ_EXP
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.model.model_factory import ModelFactory
|
||||
from dzgui.strings import dialogs
|
||||
from dzgui.util.symlink import create_custom_symlinks, rebuild_symlinks, symlink_mission
|
||||
from dzgui.views.dialogs.filepicker import FolderPicker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
from dzgui.model.model_factory import FastInsertListStore
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
class OfflineManager:
|
||||
def __init__(
|
||||
self,
|
||||
controller: "Controller",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.controller = controller
|
||||
self.emitter = controller.get_emitter()
|
||||
self.thread_man = ThreadingManager(controller)
|
||||
|
||||
self.appid: int
|
||||
self.local_mods: list[str] | None
|
||||
self.custom_mods: list[str] | None
|
||||
|
||||
# TODO: more robust test
|
||||
def is_custom_folder_valid(self, folder: str) -> bool:
|
||||
return Path(folder).exists()
|
||||
|
||||
# TODO: set properties as members of offline manager on demand and check them here
|
||||
# rather than passing strings again
|
||||
def is_mission_valid(self, folder: str) -> bool:
|
||||
return is_mission(Path(folder))
|
||||
|
||||
def get_mission(self) -> None:
|
||||
folder = self.open_folderpicker(dialogs.mission_dialog)
|
||||
if folder is None:
|
||||
return
|
||||
is_valid = is_mission(folder)
|
||||
self.emitter.emit("custom_mission_loaded", str(folder), is_valid)
|
||||
|
||||
def find_custom_mods(self, callback: Callable) -> None:
|
||||
folder = self.open_folderpicker(dialogs.custom_mod_dialog)
|
||||
window = self.controller.get_window()
|
||||
window.set_sensitive(False)
|
||||
if folder is None:
|
||||
window.set_sensitive(True)
|
||||
return
|
||||
# NOTE: starts spinner in calling UI
|
||||
callback()
|
||||
self.parse_custom_mods(folder)
|
||||
|
||||
@call_on_thread(dialogs.parsing_mods, show_dialog=False)
|
||||
def parse_custom_mods(self, folder: str) -> None:
|
||||
mods = get_custom_mods(Path(folder))
|
||||
store = ModelFactory().make_mod_store()
|
||||
store.extend(mods)
|
||||
func = StoredFunc(self.post_mod_loading, store, folder)
|
||||
self.thread_man.set_cleanup_func(func)
|
||||
|
||||
def post_mod_loading(self, store: "FastInsertListStore", folder: str) -> None:
|
||||
window = self.controller.get_window()
|
||||
window.set_sensitive(True)
|
||||
self.emitter.emit("custom_mods_loaded", store, folder)
|
||||
|
||||
@call_on_thread(dialogs.waiting_for_launch, show_cancel=True)
|
||||
def launch(
|
||||
self,
|
||||
appid: int,
|
||||
mission: str,
|
||||
local_mods: list[str],
|
||||
custom_folder: str,
|
||||
custom_mods: list[str],
|
||||
) -> None:
|
||||
|
||||
# NOTE: local_mods and custom_mods are lists of symlinks
|
||||
client = self.controller.query_config(Preferences.CLIENT)
|
||||
name = self.controller.query_config(Preferences.NAME)
|
||||
steam_path = self.controller.query_config(Preferences.DEFAULT)
|
||||
|
||||
combined_mods: list[str] = []
|
||||
|
||||
if len(local_mods) > 0:
|
||||
combined_mods.extend(local_mods)
|
||||
config = self.controller.get_prefs().paths.config
|
||||
rebuild_symlinks(config)
|
||||
|
||||
if len(custom_mods) > 0:
|
||||
new_symlinks = create_custom_symlinks(
|
||||
Path(steam_path), Path(custom_folder), custom_mods
|
||||
)
|
||||
combined_mods.extend(new_symlinks)
|
||||
|
||||
relative_link = ""
|
||||
if len(mission) > 0:
|
||||
relative_link = symlink_mission(Path(steam_path), mission)
|
||||
|
||||
launch_offline(client, appid, name, combined_mods, relative_link)
|
||||
while True:
|
||||
if is_dayz_running():
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
def open_folderpicker(self, title: str) -> Union["Path", None]:
|
||||
picker = FolderPicker(self.controller.get_window(), title)
|
||||
folder = picker.pick_folder()
|
||||
picker.destroy()
|
||||
return folder
|
||||
|
||||
def has_dayz_exp(self) -> bool:
|
||||
try:
|
||||
default_steam_path = self.controller.query_config(Preferences.DEFAULT)
|
||||
steam_path = Path(default_steam_path)
|
||||
dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP)
|
||||
if dayz_exp is None:
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
return False
|
||||
@@ -81,6 +81,11 @@ class ThreadingManager:
|
||||
def set_job_count(self, jobs: int) -> None:
|
||||
self.jobs = jobs
|
||||
|
||||
def update_emitter(self, signal: str) -> None:
|
||||
if self.controller:
|
||||
emitter = self.controller.get_emitter()
|
||||
GLib.idle_add(lambda: emitter.emit(signal))
|
||||
|
||||
def update_dialog(self, msg: str) -> None:
|
||||
GLib.idle_add(lambda: self.wait_dialog.update_text(msg))
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ from dataclasses import dataclass
|
||||
from typing import Any, Sequence, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.enum import HELP_MENU_ROWS
|
||||
from dzgui.util.redact import redact_log
|
||||
from dzgui.util.strings import delimiter
|
||||
|
||||
import gi
|
||||
@@ -134,9 +133,7 @@ class ModelFactory:
|
||||
with open(path, "r") as f:
|
||||
lines = [line.split(delimiter) for line in f.read().splitlines()]
|
||||
for record in lines:
|
||||
# NOTE: strips PII and API keys
|
||||
clean = redact_log(record)
|
||||
store.append(clean)
|
||||
store.append(record)
|
||||
return store
|
||||
|
||||
def new_model_from_class(self, cls: type) -> FastInsertListStore:
|
||||
@@ -165,3 +162,6 @@ class ModelFactory:
|
||||
|
||||
def make_server_store(self) -> FastInsertListStore:
|
||||
return self.new_model_from_class(ServerCols)
|
||||
|
||||
# def convert_model_to_list(self, model: "FastInsertListStore") -> list:
|
||||
# return [[el for el in row] for row in model]
|
||||
@@ -374,7 +374,7 @@ class ServerModelManager:
|
||||
proxy = self._get_proxy_man().get_proxy_model()
|
||||
self.tv.set_model(proxy)
|
||||
|
||||
if self.controller.get_active_treeview().get_enum == ServerTab.SAVED:
|
||||
if self.controller.get_active_treeview().get_enum() == ServerTab.SAVED:
|
||||
self.emitter.emit("servers_loaded", self.enum)
|
||||
|
||||
filter_man = self.tv.get_filter_man()
|
||||
@@ -395,8 +395,10 @@ class ServerModelManager:
|
||||
self.first_iteration = False
|
||||
|
||||
def _cleanup_on_success(self) -> None:
|
||||
proxy = self._get_proxy_man().get_proxy_model()
|
||||
proxy = self.proxy_man.get_proxy_model()
|
||||
self.tv.set_model(proxy)
|
||||
if self.preserve_on_fail is True:
|
||||
self.proxy_man.wipe_cache()
|
||||
|
||||
self.emitter.emit("servers_loaded", self.enum)
|
||||
if self.first_iteration:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
waiting_for_launch = "Waiting for DayZ to launch"
|
||||
waiting_for_mods = "Waiting for Steam to update mods"
|
||||
waiting_for_mods = "Queueing up mods (step 1/2)"
|
||||
waiting_for_directories = "Steam is staging mods (step 2/2)"
|
||||
|
||||
fetching_update = "Fetching update"
|
||||
failed_to_update = "Failed to update DZGUI executable"
|
||||
@@ -7,7 +8,15 @@ update_success = "Updated successfully. Please exit and relaunch."
|
||||
|
||||
load_error_lan = "Failed to find any servers on your network.\nCheck the server query port or your firewall settings."
|
||||
fetching_mods = "Fetching mod metadata"
|
||||
deleting_mods = "Deleting mods"
|
||||
deleting_mods = "Unsubscribing mods"
|
||||
scanning_mods = "Scanning mods"
|
||||
parsing_mods = "Parsing mods"
|
||||
|
||||
checking_api = "Checking API key"
|
||||
|
||||
running = "Running"
|
||||
failed = "FAILED"
|
||||
ok = "OK"
|
||||
|
||||
mission_dialog = "Set a mission folder location"
|
||||
custom_mod_dialog = "Set location to a custom mods folder"
|
||||
@@ -3,3 +3,4 @@ api_validation_error = (
|
||||
)
|
||||
|
||||
api_popover = "API key validation failed."
|
||||
dayz_not_found = "Could not find a valid DayZ installation."
|
||||
@@ -0,0 +1 @@
|
||||
selectable_tree = "Ctrl-click to select multiple; Shift-click to select a range."
|
||||
@@ -0,0 +1,22 @@
|
||||
heading = "Play offline"
|
||||
|
||||
mission_button = "Select mission folder"
|
||||
mission_frame = "Mission"
|
||||
|
||||
version = "DayZ Version"
|
||||
|
||||
custom_frame = "Custom mods"
|
||||
custom_button = "Set custom mod folder"
|
||||
|
||||
local_frame = "Installed mods"
|
||||
|
||||
no_local_mods = "No local mods found"
|
||||
no_mods = "No valid mods found"
|
||||
no_mission = "Not a valid mission"
|
||||
folder_changed = "Folder missing or changed on disk"
|
||||
|
||||
custom_eventbox = "Set the root folder. Mods must be in atomic folders and have a meta.cpp file at a minimum."
|
||||
mission_eventbox = "Select a single mission folder containing an init.c file."
|
||||
|
||||
folder_prefix = "<b>Current folder: </b>"
|
||||
unset_button = "Unset folder"
|
||||
@@ -3,3 +3,4 @@ signatures = "Updating mod signatures"
|
||||
geo = "Checking geolocation records"
|
||||
coords = "Checking local coordinates"
|
||||
updates = "Checking for updates"
|
||||
dayz = "Looking for DayZ"
|
||||
@@ -10,6 +10,7 @@ warnings = "Warnings"
|
||||
errors = "Errors"
|
||||
mods = "Mods"
|
||||
total_mods = "Total mods: "
|
||||
up_to_date = "All mods are up to date."
|
||||
all_updated = "All mods updated."
|
||||
"If you recently installed {build} or moved it to a different drive, "
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
@@ -64,6 +64,25 @@ radio_mi = "mi (miles)"
|
||||
label_dist = "Distance display"
|
||||
label_client = "Steam client"
|
||||
|
||||
### Shortcuts page
|
||||
heading_shortcuts = "Create shortcuts"
|
||||
blurb_shortcuts = (
|
||||
"You can optionally create shortcuts to facilitate launching DZGUI faster."
|
||||
)
|
||||
button_shortcuts = "Create shortcuts"
|
||||
checkbox_steam_shortcut = (
|
||||
"Add DZGUI to Steam library",
|
||||
(
|
||||
"This creates a standalone copy of DZGUI and adds it to Steam. "
|
||||
"You <b>must restart Steam</b> for the shortcut to appear. This will not replace or update existing shortcuts. "
|
||||
"If you update DZGUI when it is launched via Steam, updates will be applied to the Steam version."
|
||||
),
|
||||
)
|
||||
checkbox_start_menu = (
|
||||
"Add a start menu shortcut",
|
||||
"Depends on local window manager settings. Will work on Steam Deck.",
|
||||
)
|
||||
checkbox_desktop_shortcut = ("Add a desktop shortcut", "Depends on the option above.")
|
||||
|
||||
### Completion page
|
||||
heading_completion = "Setup complete"
|
||||
|
||||
+14
-7
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
|
||||
@@ -9,7 +10,9 @@ from dzgui.const.constants import APP_NAME
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.init.prefix import get_version
|
||||
from dzgui.util.redact import redact
|
||||
from dzgui.util.redact import redact_home
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
def get_cpu_model() -> str:
|
||||
@@ -38,15 +41,19 @@ def print_mods(mods: list[int]) -> str:
|
||||
|
||||
|
||||
def write_diagnostic(config: Path, outfile: Path) -> None:
|
||||
# TODO: test availability on other distros
|
||||
date = datetime.now().isoformat()
|
||||
|
||||
distro = platform.freedesktop_os_release()["ID_LIKE"]
|
||||
try:
|
||||
# FIXME: ID_LIKE not available on CachyOS
|
||||
distro = platform.freedesktop_os_release()["ID_LIKE"]
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
distro = "Unknown"
|
||||
|
||||
kernel = os.uname().release
|
||||
cpu = get_cpu_model()
|
||||
version = get_version()
|
||||
|
||||
branch = lookup(config, Preferences.BRANCH)
|
||||
debug = lookup(config, Preferences.DEBUG)
|
||||
install = lookup(config, Preferences.INSTALL)
|
||||
default = lookup(config, Preferences.DEFAULT)
|
||||
@@ -54,8 +61,8 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
|
||||
steam_path = Path(default)
|
||||
workshop_path = get_local_mod_path(steam_path)
|
||||
|
||||
steam_redacted = redact(default)
|
||||
workshop_redacted = redact(str(workshop_path))
|
||||
steam_redacted = redact_home(default)
|
||||
workshop_redacted = redact_home(str(workshop_path))
|
||||
|
||||
mods = get_local_mod_ids(steam_path)
|
||||
mods_pretty = print_mods(mods)
|
||||
@@ -65,7 +72,7 @@ def write_diagnostic(config: Path, outfile: Path) -> None:
|
||||
|
||||
# FIXME: extraneous newlines in lists of mods
|
||||
template = f"""\
|
||||
{APP_NAME} version {version} ({branch})
|
||||
{APP_NAME} version {version}
|
||||
Date: {date}
|
||||
===============================
|
||||
Distribution: {distro}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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,6 +1,7 @@
|
||||
import re
|
||||
|
||||
from dzgui.util.localize import number
|
||||
from dzgui.strings.generic import selectable_tree
|
||||
from dzgui.util.strings import no_mods, no_servers, workshop
|
||||
|
||||
import gi
|
||||
@@ -46,8 +47,7 @@ def format_mods(size: int, mods: int) -> str:
|
||||
return no_mods
|
||||
l_size = number(size)
|
||||
plural = pluralize("mods", mods)
|
||||
# TODO: strings
|
||||
suffix = "Ctrl-click to select multiple; Shift-click to select a range."
|
||||
suffix = selectable_tree
|
||||
return f"Found {mods:n} {plural} taking up {l_size} MiB. {suffix}"
|
||||
|
||||
|
||||
|
||||
+25
-16
@@ -1,20 +1,29 @@
|
||||
import logging
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
def redact(text: str) -> str:
|
||||
r = r"(/home/)([^/])*"
|
||||
cleaned = re.sub(r, r"/home/REDACTED", text)
|
||||
api_filter = r"(.*&key=)([^&]*)(.*)"
|
||||
home_filter = r"(/home/)([^\s'\/]*)(.*)"
|
||||
REDACTED = r"\1REDACTED\3"
|
||||
REDACTION_PATTERNS = [api_filter, home_filter]
|
||||
|
||||
|
||||
def redact_home(text: str) -> str:
|
||||
pat = re.compile(home_filter)
|
||||
cleaned = pat.sub(REDACTED, text)
|
||||
return cleaned
|
||||
|
||||
def redact_log(record: list) -> list[str]:
|
||||
"""
|
||||
requests library includes Steam API key in URL params
|
||||
"""
|
||||
clean = []
|
||||
for item in record:
|
||||
if "&key=" in item:
|
||||
pat = r"(.*&key=)(\S+)(.*)"
|
||||
scrubbed = re.sub(pat, r"\1REDACTED\3", item)
|
||||
clean.append(scrubbed)
|
||||
else:
|
||||
clean.append(item)
|
||||
return clean
|
||||
|
||||
class RedactionFilter(logging.Filter):
|
||||
def __init__(self, patterns: list[str] | None = None) -> None:
|
||||
super().__init__()
|
||||
self._patterns = [re.compile(pat) for pat in (patterns or [])]
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> Literal[True]:
|
||||
for pattern in self._patterns:
|
||||
try:
|
||||
record.msg = pattern.sub(REDACTED, record.msg)
|
||||
except TypeError:
|
||||
exception_text = f"{type(record.msg).__name__}: {record.msg}"
|
||||
record.msg = pattern.sub(REDACTED, exception_text)
|
||||
return True
|
||||
+11
-9
@@ -52,7 +52,7 @@ show_mods = "Show server-side mods"
|
||||
show_details = "Show server details"
|
||||
refresh_players = "Refresh player count"
|
||||
open_workshop = "Open in Steam Workshop"
|
||||
delete_mod = "Delete mod"
|
||||
unsub_mod = "Unsubscribe mod"
|
||||
copy_name = "Copy name to clipboard"
|
||||
copy_ip = "Copy IP to clipboard"
|
||||
copy_log = "Copy record(s) to clipboard"
|
||||
@@ -64,7 +64,7 @@ connect = "Connect"
|
||||
|
||||
# Columns
|
||||
server_mod_cols = ["Mod", "ID", "Installed"]
|
||||
mod_cols = ["Mod", "Symlink", "Dir", "Size (MiB)", "Color"]
|
||||
mod_cols = ["Mod", "Symlink", "ID", "Size (MiB)", "Color"]
|
||||
log_cols = ["Timestamp", "Flag", "Traceback", "Message"]
|
||||
browser_cols = [
|
||||
"Name",
|
||||
@@ -130,7 +130,6 @@ servers = {
|
||||
"Enter/space/double-click": "connect to server",
|
||||
"Right-click/Ctrl-l": "additional context menus",
|
||||
"Ctrl-r": "refresh players",
|
||||
"Ctrl-p": "refresh ping",
|
||||
"Ctrl-f": "jump to keyword search field",
|
||||
"Ctrl-m": "jump to maps field",
|
||||
"Ctrl-d": "select default LAN port",
|
||||
@@ -148,8 +147,8 @@ servers = {
|
||||
vim = {
|
||||
"j": "Move down a row/scroll up",
|
||||
"k": "Move up a row/scroll down",
|
||||
"l": "Jump to main area from sidebar",
|
||||
"h": "Jump to sidebar from main area",
|
||||
"h": "Jump to main area from sidebar",
|
||||
"l": "Jump to sidebar from main area",
|
||||
"g": "Jump to first row/top of page",
|
||||
"G": "Jump to last row/bottom of page",
|
||||
}
|
||||
@@ -215,8 +214,8 @@ class ModPanelStrings:
|
||||
unhighlight_stale_tooltip: str
|
||||
highlight_stale: str
|
||||
highlight_stale_tooltip: str
|
||||
delete_selected: str
|
||||
delete_selected_tooltip: str
|
||||
unsub_selected: str
|
||||
unsub_selected_tooltip: str
|
||||
unselect_all: str
|
||||
unselect_all_tooltip: str
|
||||
select_all: str
|
||||
@@ -273,6 +272,7 @@ class Crumbs:
|
||||
thanks: str
|
||||
developers: str
|
||||
default: str
|
||||
offline: str
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
@@ -336,8 +336,8 @@ mod_panel = ModPanelStrings(
|
||||
"Shows locally-installed mods which are not used by any server "
|
||||
"in your Saved Servers"
|
||||
),
|
||||
delete_selected="Delete selected",
|
||||
delete_selected_tooltip="Deletes selected mods from the system",
|
||||
unsub_selected="Unsubscribe selected",
|
||||
unsub_selected_tooltip="Unsubscribes from selected mods",
|
||||
unselect_all="Unselect all",
|
||||
unselect_all_tooltip="Bulk unselects all mods",
|
||||
select_all="Select all",
|
||||
@@ -366,6 +366,7 @@ thanks = Thanks(
|
||||
"Johnofwrong",
|
||||
"MatheusLasserr",
|
||||
"nolan-perez",
|
||||
"OnniSaarni",
|
||||
"scandalouss",
|
||||
"SnackSBR",
|
||||
"StevelDusa",
|
||||
@@ -462,6 +463,7 @@ crumbs = Crumbs(
|
||||
thanks="Help > Special thanks",
|
||||
developers="Options > Developers",
|
||||
default="Servers > ",
|
||||
offline="Mods > Play offline",
|
||||
)
|
||||
|
||||
no_mods = "No local mods found."
|
||||
|
||||
+44
-4
@@ -2,7 +2,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
|
||||
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME
|
||||
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.config.query import lookup
|
||||
|
||||
@@ -11,14 +11,23 @@ import dzgui.api.pefile as PeFile
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
def expunge_link(file: Path) -> None:
|
||||
if not file.is_symlink():
|
||||
return
|
||||
# NOTE: unlink stale symlinks
|
||||
if file.exists() is False:
|
||||
file.unlink()
|
||||
if str(file.stem)[:2] == "@C":
|
||||
file.unlink()
|
||||
|
||||
def rebuild_symlinks(config: Path) -> None:
|
||||
# TODO: pass direct path as argument
|
||||
path = lookup(config, Preferences.DEFAULT)
|
||||
steam_path = Path(path)
|
||||
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
|
||||
# NOTE: unlink stale symlinks
|
||||
for file in dayz_path.iterdir():
|
||||
if file.is_symlink() and file.exists() is False:
|
||||
file.unlink()
|
||||
expunge_link(file)
|
||||
|
||||
workshop = get_local_mod_path(steam_path)
|
||||
# NOTE: create symlinks for missing mods
|
||||
for mod_id in get_local_mod_ids(steam_path):
|
||||
@@ -30,6 +39,37 @@ def rebuild_symlinks(config: Path) -> None:
|
||||
clone_symlinks(steam_path)
|
||||
|
||||
|
||||
def create_custom_symlinks(
|
||||
steam_path: Path, custom_dir: Path, uids: list[str]
|
||||
) -> list[str]:
|
||||
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
|
||||
links = []
|
||||
for uid in uids:
|
||||
md5sum = _hash(uid, use_custom=True)
|
||||
source = dayz_path.joinpath(md5sum)
|
||||
target = custom_dir.joinpath(uid)
|
||||
links.append(md5sum)
|
||||
if source.exists() is False:
|
||||
source.symlink_to(target)
|
||||
clone_symlinks(steam_path)
|
||||
return links
|
||||
|
||||
|
||||
def symlink_mission(steam_path: Path, target: str) -> str:
|
||||
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
|
||||
for file in dayz_path.iterdir():
|
||||
expunge_link(file)
|
||||
path = Path(target)
|
||||
stem = path.name
|
||||
parent = path.parent
|
||||
source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT)
|
||||
try:
|
||||
source.symlink_to(parent)
|
||||
except Exception:
|
||||
return ""
|
||||
return f"{DAYZ_COMMUNITY_ROOT}/{stem}"
|
||||
|
||||
|
||||
def clone_symlinks(steam_path: Path) -> None:
|
||||
"""
|
||||
Shares symlinks between builds. Used after any symlink operation
|
||||
|
||||
+5
-1
@@ -23,6 +23,8 @@ from dzgui.views.pages.help import Help
|
||||
from dzgui.views.pages.keys import Keybindings
|
||||
from dzgui.views.pages.log import Log
|
||||
from dzgui.views.pages.mods import Mods
|
||||
|
||||
from dzgui.views.pages.offline import OfflineLoader
|
||||
from dzgui.views.pages.options import Options
|
||||
from dzgui.views.pages.preconnect import PreConnectionAssistant
|
||||
from dzgui.views.pages.servers import ServerNotebook
|
||||
@@ -68,7 +70,6 @@ class OuterWindow(Gtk.Window):
|
||||
MainController.set_start_tab()
|
||||
self.grid.hide_widgets_on_init()
|
||||
|
||||
# TODO: POC, trigger page change here
|
||||
MainController.loaded = True
|
||||
MainController.populate_model(MainController.get_active_treeview())
|
||||
|
||||
@@ -110,8 +111,11 @@ class Notebook(ScrollableMixin, Gtk.Notebook): # type: ignore
|
||||
|
||||
self.connection = PreConnectionAssistant(MainController)
|
||||
|
||||
self.offline = OfflineLoader(MainController)
|
||||
|
||||
self.pages = {
|
||||
self.help: NotebookPage.HELP,
|
||||
self.offline: NotebookPage.OFFLINE,
|
||||
self.clog: NotebookPage.CHANGELOG,
|
||||
self.keys: NotebookPage.KEYS,
|
||||
self.settings: NotebookPage.OPTIONS,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from typing import Sequence
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa
|
||||
|
||||
|
||||
class GenericBox(Gtk.Box):
|
||||
def __init__(self, orientation: Gtk.Orientation, spacing: int = 0) -> None:
|
||||
super().__init__(orientation=orientation, spacing=spacing)
|
||||
|
||||
def extend(self, els: Sequence[Gtk.Widget]) -> None:
|
||||
for el in els:
|
||||
self.add(el)
|
||||
|
||||
|
||||
class HBox(GenericBox):
|
||||
def __init__(self, spacing: int = 0) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=spacing)
|
||||
|
||||
|
||||
class VBox(GenericBox):
|
||||
def __init__(self, spacing: int = 0) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing)
|
||||
@@ -31,40 +31,61 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class Icon(Gtk.Image):
|
||||
def __init__(self, name: str, l_margin: int = 0) -> None:
|
||||
def __init__(self, name: str, margin_start: int = 0, margin_end: int = 0) -> None:
|
||||
super().__init__(
|
||||
icon_name=name,
|
||||
icon_size=Gtk.IconSize.BUTTON,
|
||||
margin_start=l_margin,
|
||||
margin_start=margin_start,
|
||||
margin_end=margin_end,
|
||||
ypad=2,
|
||||
)
|
||||
|
||||
|
||||
class LargeIcon(Gtk.Image):
|
||||
def __init__(self, name: str, l_margin: int = 5) -> None:
|
||||
def __init__(self, name: str, margin_start: int = 5, margin_end: int = 0) -> None:
|
||||
super().__init__(
|
||||
icon_name=name, icon_size=Gtk.IconSize.LARGE_TOOLBAR, margin_start=l_margin
|
||||
icon_name=name,
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR,
|
||||
margin_start=margin_start,
|
||||
margin_end=margin_end,
|
||||
)
|
||||
|
||||
|
||||
class IconButton(Gtk.Button):
|
||||
def __init__(self, icon: str, margin: int = 0) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
icon: str,
|
||||
position: Gtk.PositionType = Gtk.PositionType.RIGHT,
|
||||
margin_start: int = 0,
|
||||
margin_end: int = 0,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.icon = Icon(icon, l_margin=margin)
|
||||
self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end)
|
||||
self.set_image(self.icon)
|
||||
self.set_image_position(Gtk.PositionType.RIGHT)
|
||||
self.set_image_position(position)
|
||||
# self.set_image_position(Gtk.PositionType.RIGHT)
|
||||
self.set_focus_on_click(False)
|
||||
|
||||
|
||||
class IconTextButton(IconButton):
|
||||
def __init__(self, icon: str, label: str) -> None:
|
||||
super().__init__(icon, margin=5)
|
||||
def __init__(
|
||||
self, icon: str, label: str, position: Gtk.PositionType = Gtk.PositionType.RIGHT
|
||||
) -> None:
|
||||
match position:
|
||||
case Gtk.PositionType.RIGHT:
|
||||
super().__init__(icon, position, margin_start=5)
|
||||
case Gtk.PositionType.LEFT:
|
||||
super().__init__(icon, position, margin_end=5)
|
||||
case _:
|
||||
raise ValueError("Unsupported position type")
|
||||
self.set_label(label)
|
||||
|
||||
|
||||
class LargeIconTextButton(IconButton):
|
||||
def __init__(self, icon: str, label: str) -> None:
|
||||
super().__init__(icon)
|
||||
def __init__(
|
||||
self, icon: str, label: str, position: Gtk.PositionType = Gtk.PositionType.RIGHT
|
||||
) -> None:
|
||||
super().__init__(icon, position)
|
||||
self.set_label(label)
|
||||
self.set_image(LargeIcon(icon))
|
||||
|
||||
@@ -120,6 +141,8 @@ class RefreshButton(IconTextButton):
|
||||
|
||||
def _on_refresh_clicked(self, button: Self) -> None:
|
||||
"""Spawned in a thread"""
|
||||
if self.controller.get_active_treeview().is_loaded() is False:
|
||||
return
|
||||
self.is_clicked = True
|
||||
self.controller.refresh_tree()
|
||||
# TODO: get server tab enum
|
||||
@@ -187,6 +210,8 @@ class SteamWorkshopButton(SteamTextButton):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(label=buttons.workshop)
|
||||
self.set_tooltip_text(buttons.workshop_tooltip)
|
||||
self.set_margin_top(10)
|
||||
self.set_margin_bottom(10)
|
||||
|
||||
|
||||
class AddButton(IconTextButton):
|
||||
|
||||
@@ -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
|
||||
@@ -20,7 +20,8 @@ gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk # noqa E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller, Emitter
|
||||
from dzgui.controllers.mc import Controller
|
||||
from dzgui.controllers.emitter import Emitter
|
||||
|
||||
COLS = 1
|
||||
ROWS = 1
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from typing import Self
|
||||
from dzgui.util import css
|
||||
|
||||
import gi
|
||||
@@ -6,16 +7,23 @@ gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa
|
||||
|
||||
|
||||
# TODO: subclass Gtk.Frame directly
|
||||
class HeadingFrame(Gtk.Box):
|
||||
def __init__(self, widget: Gtk.Widget, heading: str) -> None:
|
||||
def __init__(self, heading: str = "") -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
label = Gtk.Label(label=heading)
|
||||
label.set_halign(Gtk.Align.START)
|
||||
css.add_class(label, "settings-subheading")
|
||||
self.label = Gtk.Label(label=heading)
|
||||
self.label.set_halign(Gtk.Align.START)
|
||||
css.add_class(self.label, "settings-subheading")
|
||||
|
||||
frame = Gtk.Frame(hexpand=True)
|
||||
frame.add(widget)
|
||||
frame.set_label_widget(label)
|
||||
self.frame = Gtk.Frame(hexpand=True)
|
||||
self.frame.set_label_widget(self.label)
|
||||
|
||||
self.add(frame)
|
||||
self.add(self.frame)
|
||||
|
||||
@classmethod
|
||||
def new_with_widget_and_label(cls, widget: Gtk.Widget, label: str) -> Self:
|
||||
n = cls()
|
||||
n.frame.add(widget)
|
||||
n.label.set_label(label)
|
||||
return n
|
||||
@@ -11,7 +11,8 @@ gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller, Emitter
|
||||
from dzgui.controllers.mc import Controller
|
||||
from dzgui.controllers.emitter import Emitter
|
||||
|
||||
|
||||
class EnumeratedModButton(Gtk.Button):
|
||||
@@ -48,7 +49,7 @@ class ModSelectionPanel(Gtk.Box):
|
||||
buttons = (
|
||||
ModButton.SELECT_ALL,
|
||||
ModButton.UNSELECT_ALL,
|
||||
ModButton.DELETE_SELECTED,
|
||||
ModButton.UNSUB_SELECTED,
|
||||
)
|
||||
for button in buttons:
|
||||
b = EnumeratedModButton(button)
|
||||
@@ -79,14 +80,18 @@ class ModSelectionPanel(Gtk.Box):
|
||||
self.highlight_stale_button.set_sensitive(True)
|
||||
|
||||
def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None:
|
||||
if mods < 1:
|
||||
self.main_panel.set_sensitive(False)
|
||||
self.stale_panel.set_sensitive(False)
|
||||
self._toggle_panel_sensitivity(mods)
|
||||
|
||||
def _toggle_panel_sensitivity(self, mods: int) -> None:
|
||||
state = bool(mods)
|
||||
for el in self.main_panel, self.stale_panel:
|
||||
el.set_sensitive(state)
|
||||
|
||||
def _on_mods_highlighted(self, emitter: "Emitter") -> None:
|
||||
self.swap_sensitive(True)
|
||||
|
||||
def _on_mod_page_toggled(self, emitter: "Emitter", state: bool) -> None:
|
||||
def _on_mod_page_toggled(self, emitter: "Emitter", state: bool, mods: int) -> None:
|
||||
self._toggle_panel_sensitivity(mods)
|
||||
self.set_visible(state)
|
||||
|
||||
def swap_sensitive(self, state: bool) -> None:
|
||||
@@ -100,8 +105,8 @@ class ModSelectionPanel(Gtk.Box):
|
||||
self.controller.toggle_mod_selection(True)
|
||||
case ModButton.UNSELECT_ALL:
|
||||
self.controller.toggle_mod_selection(False)
|
||||
case ModButton.DELETE_SELECTED:
|
||||
self.controller.delete_mods()
|
||||
case ModButton.UNSUB_SELECTED:
|
||||
self.controller.unsub_mods()
|
||||
case ModButton.HIGHLIGHT_STALE:
|
||||
self.controller.highlight_stale()
|
||||
case ModButton.UNHIGHLIGHT_STALE:
|
||||
|
||||
@@ -71,6 +71,7 @@ class Statusbar(Gtk.Grid):
|
||||
NotebookPage.CHANGELOG,
|
||||
NotebookPage.LOG,
|
||||
NotebookPage.CONNECTION,
|
||||
NotebookPage.OFFLINE,
|
||||
):
|
||||
self.prior_enum = enum
|
||||
self.set_by_context(enum, esc_to_return)
|
||||
@@ -106,6 +107,7 @@ class Statusbar(Gtk.Grid):
|
||||
self.prior_status = ""
|
||||
# FIXME: CalcDist is being called when table is not loaded
|
||||
if dist is None:
|
||||
self.set_by_context(context, "")
|
||||
return
|
||||
else:
|
||||
pretty = self.append_distance(dist)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import sys
|
||||
from typing import Literal, TYPE_CHECKING
|
||||
from typing import Any, Literal, Self, TYPE_CHECKING
|
||||
|
||||
# import time
|
||||
from enum import Enum
|
||||
from typing import Any, Self
|
||||
|
||||
# TODO: import dialog titles
|
||||
from dzgui.api.mods import remove_stale_signatures as remove_stale
|
||||
from dzgui.config.ipdb import get_ipdb
|
||||
from dzgui.const.constants import HEX_GREEN, HEX_RED
|
||||
from dzgui.init.coords import get_local_coords
|
||||
from dzgui.init.dayz import is_dayz_installed
|
||||
from dzgui.init.update import check_updates
|
||||
from dzgui.const.constants import EXPAND, FILL
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.strings import preboot
|
||||
from dzgui.strings import preboot, dialogs
|
||||
from dzgui.util.format import format_exception
|
||||
from dzgui.util.strings import dialog_header
|
||||
from dzgui.util.symlink import rebuild_symlinks
|
||||
@@ -58,6 +58,9 @@ class BootDialog(Gtk.Dialog):
|
||||
self.view = Gtk.TreeView(enable_search=False, headers_visible=False)
|
||||
self.view.set_model(self.store)
|
||||
|
||||
# TODO: capture sigint in early dialogs
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
|
||||
for i, column_title in enumerate(["Task", "State"]):
|
||||
renderer = Gtk.CellRendererText()
|
||||
column = Gtk.TreeViewColumn(column_title, renderer, text=i)
|
||||
@@ -119,6 +122,7 @@ class BootDialog(Gtk.Dialog):
|
||||
self.error_box.hide()
|
||||
|
||||
steps = [
|
||||
(StoredFunc(is_dayz_installed, self.xdg.config), preboot.dayz, False),
|
||||
(StoredFunc(rebuild_symlinks, self.xdg.config), preboot.symlinks, False),
|
||||
(
|
||||
StoredFunc(remove_stale, self.xdg.config, self.xdg.version),
|
||||
@@ -126,6 +130,7 @@ class BootDialog(Gtk.Dialog):
|
||||
False,
|
||||
),
|
||||
(StoredFunc(get_ipdb, self.xdg.ips), preboot.geo, False),
|
||||
# TODO: handle IP DB failure and use coords fallback
|
||||
(StoredFunc(get_local_coords, self.xdg.ips), preboot.coords, True),
|
||||
(StoredFunc(check_updates, self.version), preboot.updates, True),
|
||||
]
|
||||
@@ -135,6 +140,10 @@ class BootDialog(Gtk.Dialog):
|
||||
|
||||
GLib.timeout_add(100, self.pulse_spinner)
|
||||
|
||||
def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None:
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
self.exit_button.emit("clicked")
|
||||
|
||||
def pulse_spinner(self) -> Literal[True]:
|
||||
for row in self.store:
|
||||
if row[2]:
|
||||
@@ -181,10 +190,10 @@ class BootDialog(Gtk.Dialog):
|
||||
self.thread_man.set_cleanup_func(callback)
|
||||
|
||||
def update_task(self, task: str) -> None:
|
||||
self.store.append((task, "Running", True, 0))
|
||||
self.store.append((task, dialogs.running, True, 0))
|
||||
|
||||
def update_status(self, state: Success) -> None:
|
||||
d = {Success.OK: "OK", Success.FAIL: "FAILED"}
|
||||
d = {Success.OK: dialogs.ok, Success.FAIL: dialogs.failed}
|
||||
msg = d[state]
|
||||
self.store[len(self.store) - 1][1] = msg
|
||||
self.iter_step()
|
||||
@@ -197,7 +206,7 @@ class BootDialog(Gtk.Dialog):
|
||||
it: Gtk.TreeIter,
|
||||
data: Any,
|
||||
) -> None:
|
||||
if model[it][1] == "Running":
|
||||
if model[it][1] == dialogs.running:
|
||||
cell.set_property("visible", True)
|
||||
else:
|
||||
cell.set_property("visible", False)
|
||||
@@ -214,9 +223,9 @@ class BootDialog(Gtk.Dialog):
|
||||
state = model[it][1]
|
||||
if column.get_sort_column_id() != 1:
|
||||
return
|
||||
if state == "OK":
|
||||
if state == dialogs.ok:
|
||||
cell.set_property(prop, HEX_GREEN)
|
||||
elif state == "FAILED":
|
||||
elif state == dialogs.failed:
|
||||
cell.set_property(prop, HEX_RED)
|
||||
else:
|
||||
cell.set_property(prop, None)
|
||||
|
||||
@@ -33,3 +33,36 @@ class FilePicker(Gtk.FileChooserDialog):
|
||||
self.destroy()
|
||||
return Path(file)
|
||||
return None
|
||||
|
||||
|
||||
class FolderPicker(Gtk.FileChooserDialog):
|
||||
def __init__(self, parent: Gtk.Window, title: str) -> None:
|
||||
super().__init__(
|
||||
title=title,
|
||||
action=Gtk.FileChooserAction.SELECT_FOLDER,
|
||||
parent=parent,
|
||||
resizable=True,
|
||||
)
|
||||
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL)
|
||||
self.add_buttons("_OK", Gtk.ResponseType.OK)
|
||||
self.set_default_response(Gtk.ResponseType.OK)
|
||||
|
||||
def pick_folder(self) -> Path | None:
|
||||
res = self.run()
|
||||
if res in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
|
||||
self.destroy()
|
||||
return None
|
||||
if res == Gtk.ResponseType.OK:
|
||||
folder = self.get_current_folder()
|
||||
if folder is None:
|
||||
try:
|
||||
uri = self.get_uri()
|
||||
if uri is None:
|
||||
return None
|
||||
return Path.from_uri(uri)
|
||||
except Exception:
|
||||
# FIXME: edge cases
|
||||
return Path("/")
|
||||
self.destroy()
|
||||
return Path(folder)
|
||||
return None
|
||||
+136
-15
@@ -1,11 +1,13 @@
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
from enum import Enum
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Self
|
||||
from typing import Any, Callable, Self, TYPE_CHECKING
|
||||
|
||||
from dzgui.api.probe import test_steam_api, test_bm_api
|
||||
from dzgui.api.shortcuts import add_steam_shortcut
|
||||
from dzgui.api.steam import get_steam_paths
|
||||
from dzgui.const.constants import (
|
||||
APP_NAME,
|
||||
@@ -15,6 +17,9 @@ from dzgui.const.constants import (
|
||||
)
|
||||
from dzgui.const.boilerplate import config_boilerplate
|
||||
from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.config import freedesktop
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.init.migrate import migrate_legacy_conf
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.strings import wizard
|
||||
@@ -30,6 +35,9 @@ import gi
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.config.xdg import Xdg
|
||||
|
||||
|
||||
class PageNum(Enum):
|
||||
INTRO = 1
|
||||
@@ -38,7 +46,8 @@ class PageNum(Enum):
|
||||
STEAM_API = 4
|
||||
BM_API = 5
|
||||
USER_PREFS = 6
|
||||
FINAL = 7
|
||||
SHORTCUTS = 7
|
||||
FINAL = 8
|
||||
|
||||
|
||||
class DescriptionArea(Gtk.Box):
|
||||
@@ -58,7 +67,7 @@ class Progress(Gtk.ProgressBar):
|
||||
|
||||
class ScrolledWizardPage(Gtk.ScrolledWindow):
|
||||
def __init__(self, enum: PageNum, heading: str, description: str):
|
||||
super().__init__()
|
||||
super().__init__(overlay_scrolling=False)
|
||||
|
||||
self.enum = enum
|
||||
self.page_type: Gtk.AssistantPageType
|
||||
@@ -141,6 +150,10 @@ class NotificationFrame(Gtk.Frame):
|
||||
if error:
|
||||
add_class(self, "error-frame")
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
wrapped = textwrap.fill(text, width=80)
|
||||
self.label.set_markup(wrapped)
|
||||
|
||||
|
||||
class APIValidationPage(ScrolledWizardPage):
|
||||
def __init__(
|
||||
@@ -339,6 +352,7 @@ class ConfigMigrationPage(ScrolledWizardPage):
|
||||
def _on_import_clicked(self, button: Gtk.Button) -> None:
|
||||
self.grid.set_sensitive(False)
|
||||
try:
|
||||
# TODO: this could be deferred to the final page (prevents accidental destruction of dialog via ESC)
|
||||
migrate_legacy_conf(self.config)
|
||||
self.migrated = True
|
||||
self.success_box.set_visible(True)
|
||||
@@ -423,24 +437,28 @@ class CompletionPage(ScrolledWizardPage):
|
||||
|
||||
|
||||
class Assistant(Gtk.Assistant):
|
||||
def __init__(self, is_deck: bool, config: Path):
|
||||
def __init__(self, is_deck: bool, XDG: "Xdg"):
|
||||
super().__init__()
|
||||
if is_deck:
|
||||
self.fullscreen()
|
||||
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
|
||||
self.set_default_size(1085, 670)
|
||||
else:
|
||||
self.set_default_size(1500, 900)
|
||||
|
||||
self.config_path = config
|
||||
self.config_path = XDG.config
|
||||
|
||||
self.config_values: dict[str, Any] = config_boilerplate
|
||||
|
||||
self.setup_complete = False
|
||||
|
||||
self.page1 = IntroductionPage()
|
||||
self.page2 = ConfigMigrationPage(config)
|
||||
self.page2 = ConfigMigrationPage(XDG.config)
|
||||
self.page3 = SteamPathPage()
|
||||
self.page4 = SteamValidationPage()
|
||||
self.page5 = BMValidationPage()
|
||||
self.page6 = PreferencesPage()
|
||||
self.page7 = CompletionPage()
|
||||
self.page7 = ShortcutCreationPage(XDG.shortcut)
|
||||
self.page8 = CompletionPage()
|
||||
|
||||
self.set_forward_page_func(self._advance_page)
|
||||
|
||||
@@ -458,9 +476,16 @@ class Assistant(Gtk.Assistant):
|
||||
self.page5,
|
||||
self.page6,
|
||||
self.page7,
|
||||
self.page8,
|
||||
):
|
||||
# NOTE: skip config migration page if no legacy config file
|
||||
if page == self.page2 and self.has_legacy_config is False:
|
||||
if (
|
||||
isinstance(page, ConfigMigrationPage)
|
||||
and self.has_legacy_config is False
|
||||
):
|
||||
continue
|
||||
# NOTE: disabled for now on system-provided packages
|
||||
if isinstance(page, ShortcutCreationPage) and os.getenv("PYAPP") is None:
|
||||
continue
|
||||
self._add_page(page, page.get_page_type())
|
||||
|
||||
@@ -475,13 +500,15 @@ class Assistant(Gtk.Assistant):
|
||||
|
||||
def _advance_page(self, index: int) -> int:
|
||||
page = self.get_nth_page(index)
|
||||
# TODO: use enums
|
||||
# TODO: use enums/isinstance
|
||||
match page:
|
||||
case self.page1:
|
||||
pass
|
||||
case self.page2:
|
||||
if self.page2.is_migrated():
|
||||
return self.get_n_pages() - 1
|
||||
steam_path = lookup(self.config_path, Preferences.DEFAULT)
|
||||
self.page7.set_steam_path(steam_path)
|
||||
return self.get_n_pages() - 2
|
||||
case self.page3:
|
||||
self.config_values["default_steam_path"] = page.get_path_from_radio()
|
||||
case self.page4:
|
||||
@@ -495,10 +522,17 @@ class Assistant(Gtk.Assistant):
|
||||
self.config_values["use_miles"] = use_miles
|
||||
self.config_values["client"] = client
|
||||
self.write_config()
|
||||
self.page7.set_steam_path(self.config_values["default_steam_path"])
|
||||
case self.page7:
|
||||
self.page7.create_shortcuts()
|
||||
self.setup_complete = True
|
||||
case _:
|
||||
raise AttributeError("Trying to advance a non-canonical page")
|
||||
return index + 1
|
||||
|
||||
def is_setup_complete(self) -> bool:
|
||||
return self.setup_complete
|
||||
|
||||
def destroy_and_quit(self, widget: Self) -> None:
|
||||
self.destroy()
|
||||
Gtk.main_quit()
|
||||
@@ -543,6 +577,90 @@ class Assistant(Gtk.Assistant):
|
||||
EMITTER.emit("step_pending")
|
||||
|
||||
|
||||
class CheckboxWithLabel(Gtk.Box):
|
||||
def __init__(self, text: str, blurb_text: str) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5)
|
||||
|
||||
self.button = Gtk.CheckButton(label=text)
|
||||
self.button.set_active(True)
|
||||
label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=20)
|
||||
wrapped = textwrap.fill(blurb_text, width=100)
|
||||
label.set_markup(f"- {wrapped}")
|
||||
|
||||
for el in self.button, label:
|
||||
self.add(el)
|
||||
|
||||
def get_checkbox(self) -> Gtk.CheckButton:
|
||||
return self.button
|
||||
|
||||
def get_active(self) -> bool:
|
||||
return self.button.get_active()
|
||||
|
||||
def set_active(self, state: bool) -> None:
|
||||
self.button.set_active(state)
|
||||
|
||||
|
||||
class ShortcutCreationPage(ScrolledWizardPage):
|
||||
def __init__(self, shortcut: Path) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.SHORTCUTS,
|
||||
heading=wizard.heading_shortcuts,
|
||||
description=wizard.blurb_shortcuts,
|
||||
)
|
||||
|
||||
self.steam_path: Path
|
||||
self.shortcut_path = shortcut
|
||||
self.checks_area = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=20
|
||||
)
|
||||
self.page_type = Gtk.AssistantPageType.INTRO
|
||||
|
||||
label, blurb = wizard.checkbox_steam_shortcut
|
||||
self.steam_checkbox = CheckboxWithLabel(label, blurb)
|
||||
|
||||
label, blurb = wizard.checkbox_start_menu
|
||||
self.start_menu_checkbox = CheckboxWithLabel(label, blurb)
|
||||
cb = self.start_menu_checkbox.get_checkbox()
|
||||
cb.connect("toggled", self._on_start_menu_toggled)
|
||||
|
||||
label, blurb = wizard.checkbox_desktop_shortcut
|
||||
self.desktop_checkbox = CheckboxWithLabel(label, blurb)
|
||||
|
||||
for el in (
|
||||
self.steam_checkbox,
|
||||
self.start_menu_checkbox,
|
||||
self.desktop_checkbox,
|
||||
):
|
||||
self.checks_area.add(el)
|
||||
|
||||
self.add_start(self.checks_area)
|
||||
self.show_all()
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def _on_start_menu_toggled(self, button: Gtk.CheckButton) -> None:
|
||||
state = button.get_active()
|
||||
if not state:
|
||||
self.desktop_checkbox.set_active(state)
|
||||
self.desktop_checkbox.set_sensitive(state)
|
||||
|
||||
def _on_map(self, page: "ScrolledWizardPage") -> None:
|
||||
EMITTER.emit("step_complete")
|
||||
|
||||
def set_steam_path(self, path: Path) -> None:
|
||||
self.steam_path = path
|
||||
|
||||
def create_shortcuts(self) -> None:
|
||||
# NOTE: best-effort, permissive even on failure (page is already marked as complete)
|
||||
if self.steam_checkbox.get_active():
|
||||
add_steam_shortcut(self.steam_path, self.shortcut_path)
|
||||
|
||||
if self.start_menu_checkbox.get_active():
|
||||
desktop_file = freedesktop.write_desktop_file(self.shortcut_path)
|
||||
|
||||
if self.desktop_checkbox.get_active():
|
||||
freedesktop.write_desktop_shortcut(desktop_file)
|
||||
|
||||
|
||||
class SteamPathPage(ScrolledWizardPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
@@ -599,17 +717,20 @@ class SteamPathPage(ScrolledWizardPage):
|
||||
|
||||
|
||||
class SetupWizard(Gtk.Application):
|
||||
def __init__(self, is_deck: bool, config: Path) -> None:
|
||||
def __init__(self, is_deck: bool, XDG: "Xdg") -> None:
|
||||
super().__init__()
|
||||
GLib.set_prgname(APP_NAME)
|
||||
Window(is_deck, config)
|
||||
self.win = Window(is_deck, XDG)
|
||||
Gtk.main()
|
||||
|
||||
def is_setup_complete(self) -> int:
|
||||
return self.win.assistant.is_setup_complete()
|
||||
|
||||
|
||||
class Window(Gtk.Window):
|
||||
def __init__(self, is_deck: bool, config: Path) -> None:
|
||||
def __init__(self, is_deck: bool, XDG: "Xdg") -> None:
|
||||
super().__init__(title=APP_NAME, icon_name=APP_NAME)
|
||||
Assistant(is_deck, config)
|
||||
self.assistant = Assistant(is_deck, XDG)
|
||||
|
||||
|
||||
class Emitter(GObject.GObject):
|
||||
|
||||
@@ -44,6 +44,8 @@ class ContextMixin(TreeView):
|
||||
self.context_menu.connect("key-press-event", self._on_key)
|
||||
|
||||
for row in group.value:
|
||||
if row is None:
|
||||
return False
|
||||
item = self._process_dynamic_row(row)
|
||||
self.context_menu.append(item)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from importlib import resources
|
||||
@@ -32,15 +33,63 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
|
||||
|
||||
# TODO: should long text be wrapped?
|
||||
self.controller = controller
|
||||
formatted = format_pango(changelog)
|
||||
self.changelog_label = Gtk.Label(valign=Gtk.Align.START, margin=15)
|
||||
self.changelog_label.set_markup(formatted)
|
||||
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.box.add(self.changelog_label)
|
||||
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5, margin_top=10)
|
||||
self.add(self.box)
|
||||
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
self.connect("key-press-event", self._on_esc_keypress)
|
||||
|
||||
changes = self._parse(changelog)
|
||||
self._generate_nodes(changes)
|
||||
self.show_all()
|
||||
|
||||
def grab_content_area(self) -> None:
|
||||
self.grab_focus()
|
||||
|
||||
def _parse_version(self, version: str) -> tuple[str, str]:
|
||||
pattern = r"(^##\s\[)(.+)(\] )(.+$)"
|
||||
match = re.match(pattern, version)
|
||||
if match:
|
||||
version = match.group(2)
|
||||
date = match.group(4)
|
||||
return version, date
|
||||
raise ValueError("Changelog parse error")
|
||||
|
||||
|
||||
def _generate_nodes(self, releases: list[tuple[str, list[str]]]) -> None:
|
||||
for header, changes in releases:
|
||||
for ind in (0, -1):
|
||||
if changes[ind] == "":
|
||||
del changes[ind]
|
||||
text = "\n".join(changes)
|
||||
formatted = format_pango(text)
|
||||
|
||||
label = Gtk.Label(valign=Gtk.Align.START, margin=15, halign=Gtk.Align.START)
|
||||
label.set_markup(formatted)
|
||||
|
||||
version, date = self._parse_version(header)
|
||||
button_text = f"{version} ({date})"
|
||||
button = Gtk.Button(label=button_text, margin_start=5)
|
||||
|
||||
expander = Gtk.Expander()
|
||||
expander.set_label_widget(button)
|
||||
expander.add(label)
|
||||
self.box.add(expander)
|
||||
|
||||
|
||||
def _parse(self, changelog: str) -> list[tuple[str, list[str]]]:
|
||||
release = ""
|
||||
releases: list[tuple[str, list[str]]] = []
|
||||
release_notes: list[str] = []
|
||||
for line in changelog.splitlines():
|
||||
if line.startswith("# "):
|
||||
continue
|
||||
if line.startswith("## "):
|
||||
if release != "":
|
||||
releases.append((release, release_notes))
|
||||
release_notes = []
|
||||
release = ""
|
||||
release = line
|
||||
continue
|
||||
release_notes.append(line.rstrip())
|
||||
return releases
|
||||
@@ -1,4 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from typing import Self, TYPE_CHECKING
|
||||
from dzgui.api.steam import find_user_id
|
||||
from dzgui.const.enum import NotebookPage, Preferences
|
||||
from dzgui.views.components.box import HBox
|
||||
from dzgui.views.components.buttons import SteamWorkshopButton
|
||||
from dzgui.views.components.scrollable import NoOverlayScrolledWindow
|
||||
from dzgui.views.trees.tree_mods import ModTreeView
|
||||
|
||||
@@ -11,26 +17,69 @@ if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
|
||||
|
||||
|
||||
class Mods(NoOverlayScrolledWindow):
|
||||
class Mods(Gtk.Box):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__()
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.controller = controller
|
||||
self.emitter = controller.get_emitter()
|
||||
self.box = NoOverlayScrolledWindow()
|
||||
self.tree = ModTreeView(controller)
|
||||
self.tree.set_vexpand(True)
|
||||
self.box.add(self.tree)
|
||||
|
||||
self.add(self.tree)
|
||||
self.statusbar_cache = ""
|
||||
self.controller = controller
|
||||
self.controller.register_widget("modtreeview", self.tree)
|
||||
self.emitter = controller.get_emitter()
|
||||
|
||||
# TODO: move
|
||||
default_steam_path = self.controller.query_config(Preferences.DEFAULT)
|
||||
steam_path = Path(default_steam_path)
|
||||
uid = find_user_id(steam_path)
|
||||
|
||||
pretty_uid = "" if uid is None else uid
|
||||
hbox = HBox(spacing=10)
|
||||
workshop_button = SteamWorkshopButton()
|
||||
workshop_button.connect(
|
||||
"clicked", lambda _: self.controller.open_user_workshop(pretty_uid)
|
||||
)
|
||||
self.offline_button = Gtk.Button(
|
||||
label="Play offline",
|
||||
halign=Gtk.Align.START,
|
||||
margin_top=10,
|
||||
margin_bottom=10,
|
||||
)
|
||||
self.offline_button.connect("clicked", self._on_offline_clicked)
|
||||
|
||||
hbox.add(workshop_button)
|
||||
hbox.add(self.offline_button)
|
||||
|
||||
self.add(hbox)
|
||||
self.add(self.box)
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
self.connect("unmap", self._on_unmap)
|
||||
|
||||
def _on_offline_clicked(self, button: Gtk.Button) -> None:
|
||||
self.statusbar_cache = self.controller.get_statusbar().get_cache()
|
||||
self.controller.open_offline()
|
||||
|
||||
def _on_unmap(self, widget: Self) -> None:
|
||||
self.emitter.emit("mod_page_toggled", False)
|
||||
self.emitter.emit("mod_page_toggled", False, 0)
|
||||
|
||||
def _on_map(self, widget: Self) -> None:
|
||||
self.emitter.emit("mod_page_toggled", True)
|
||||
model = self.tree.get_model()
|
||||
if model is None:
|
||||
mods = 0
|
||||
else:
|
||||
mods = len(model)
|
||||
self.emitter.emit("mod_page_toggled", True, mods)
|
||||
# TODO: delegation
|
||||
# NOTE: handles going back from offline mods page
|
||||
# more generic cache restoration method
|
||||
if len(self.statusbar_cache) > 0:
|
||||
self.controller.get_statusbar().set_by_context(
|
||||
NotebookPage.MODS, self.statusbar_cache
|
||||
)
|
||||
|
||||
def grab_content_area(self) -> None:
|
||||
self.tree.grab_focus()
|
||||
@@ -0,0 +1,513 @@
|
||||
from __future__ import annotations
|
||||
from enum import Enum
|
||||
from typing import Self, TYPE_CHECKING, Union
|
||||
|
||||
from dzgui.util import css
|
||||
from dzgui.const.constants import (
|
||||
APPID_DAYZ,
|
||||
APPID_DAYZ_EXP,
|
||||
APPNAME_DAYZ,
|
||||
APPNAME_DAYZ_EXP_HUMAN,
|
||||
EDIT_DELETE,
|
||||
ERROR,
|
||||
FOLDER,
|
||||
WARNING,
|
||||
)
|
||||
from dzgui.const.enum import ContextMenuGroup, NotebookPage
|
||||
from dzgui.managers.offline import OfflineManager
|
||||
from dzgui.strings import generic, offline
|
||||
from dzgui.views.components.box import HBox, VBox
|
||||
from dzgui.views.components.buttons import Icon, IconTextButton
|
||||
from dzgui.views.components.eventbox import InfoEventBox
|
||||
from dzgui.views.components.frame import HeadingFrame
|
||||
from dzgui.views.components.scrollable import NoOverlayScrolledWindow
|
||||
from dzgui.views.trees.tree_mods import OfflineModTreeView
|
||||
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk, GObject # noqa
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
from dzgui.controllers.emitter import Emitter
|
||||
from dzgui.model.model_factory import FastInsertListStore
|
||||
|
||||
|
||||
class FolderError(Enum):
|
||||
NO_VALID_MODS = 1
|
||||
NO_VALID_MISSION = 2
|
||||
FOLDER_CHANGED = 3
|
||||
|
||||
|
||||
class PageHeading(Gtk.Label):
|
||||
def __init__(self, label: str) -> None:
|
||||
super().__init__(label=label, halign=Gtk.Align.CENTER)
|
||||
|
||||
css.add_class(self, "page-heading")
|
||||
|
||||
|
||||
class ErrorPopover(Gtk.Popover):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(position=Gtk.PositionType.RIGHT)
|
||||
|
||||
self.hbox = HBox()
|
||||
self.label = Gtk.Label(label="", margin_start=10, margin_end=10)
|
||||
error_icon = Icon(ERROR, margin_start=10)
|
||||
self.hbox.extend([error_icon, self.label])
|
||||
self.add(self.hbox)
|
||||
self.show_all()
|
||||
self.popdown()
|
||||
|
||||
def set_label(self, error: FolderError, msg: str) -> None:
|
||||
match error:
|
||||
case FolderError.NO_VALID_MODS:
|
||||
prefix = offline.no_mods
|
||||
case FolderError.NO_VALID_MISSION:
|
||||
prefix = offline.no_mission
|
||||
case FolderError.FOLDER_CHANGED:
|
||||
prefix = offline.folder_changed
|
||||
self.label.set_label(prefix)
|
||||
return
|
||||
self.label.set_label(f"{prefix}: '{msg}'")
|
||||
|
||||
|
||||
class FolderHBox(HBox):
|
||||
def __init__(self, controller: "Controller", btn_label: str, eb_text: str) -> None:
|
||||
super().__init__(spacing=10)
|
||||
|
||||
self.set_margin_start(5)
|
||||
self.set_margin_end(10)
|
||||
self.set_margin_bottom(5)
|
||||
|
||||
self.folder = ""
|
||||
self.controller = controller
|
||||
self.emitter = controller.get_emitter()
|
||||
|
||||
self.eb = InfoEventBox(eb_text, controller)
|
||||
self.button = IconTextButton(FOLDER, btn_label, Gtk.PositionType.LEFT)
|
||||
self.button.set_halign(Gtk.Align.START)
|
||||
self.button.set_image_position(Gtk.PositionType.LEFT)
|
||||
|
||||
self.set_can_focus(True)
|
||||
|
||||
self.scrolled_label = Gtk.ScrolledWindow(
|
||||
propagate_natural_width=True, halign=Gtk.Align.START
|
||||
)
|
||||
self.label = Gtk.Label()
|
||||
self.scrolled_label.add(self.label)
|
||||
|
||||
self.unset_button = IconTextButton(EDIT_DELETE, offline.unset_button)
|
||||
self.unset_button.connect("clicked", self._on_unset_clicked)
|
||||
|
||||
self.spinner = Gtk.Spinner()
|
||||
self.extend(
|
||||
[self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button]
|
||||
)
|
||||
|
||||
self.pop = ErrorPopover()
|
||||
self.pop.set_relative_to(self.button)
|
||||
self.pop.connect("unmap", lambda _: self.grab_focus())
|
||||
|
||||
self.sidepop = ErrorPopover()
|
||||
self.sidepop.set_position(Gtk.PositionType.BOTTOM)
|
||||
self.sidepop.set_relative_to(self.scrolled_label)
|
||||
self.sidepop.connect("unmap", lambda _: self.grab_focus())
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
self.connect("unmap", self._on_unmap)
|
||||
|
||||
def start_spinner(self) -> None:
|
||||
self.spinner.show()
|
||||
self.spinner.start()
|
||||
|
||||
def stop_spinner(self) -> None:
|
||||
self.spinner.hide()
|
||||
self.spinner.stop()
|
||||
|
||||
def _on_unmap(self, widget: Self) -> None:
|
||||
self.unset_button.hide()
|
||||
self.label.set_label("")
|
||||
self.scrolled_label.hide()
|
||||
|
||||
def _on_map(self, widget: Self) -> None:
|
||||
self.spinner.hide()
|
||||
self.scrolled_label.show()
|
||||
self.unset_button.hide()
|
||||
|
||||
def _on_unset_clicked(self, button: Gtk.Button) -> None:
|
||||
self.folder = ""
|
||||
self.label.set_label("")
|
||||
self.unset_button.hide()
|
||||
# FIXME: rename signal
|
||||
self.emitter.emit("custom_mods_unloaded", self)
|
||||
|
||||
def get_button(self) -> Gtk.Button:
|
||||
return self.button
|
||||
|
||||
def hide_label(self) -> None:
|
||||
self.label.hide()
|
||||
|
||||
def get_folder(self) -> str:
|
||||
return self.folder
|
||||
|
||||
def set_folder(self, folder: str) -> None:
|
||||
prefix = offline.folder_prefix
|
||||
self.folder = folder
|
||||
self.label.set_markup(prefix + folder)
|
||||
self.label.show()
|
||||
self.unset_button.show()
|
||||
|
||||
def present_error(self, error: FolderError, msg: str) -> None:
|
||||
if error == FolderError.FOLDER_CHANGED:
|
||||
self.sidepop.set_label(error, msg)
|
||||
self.sidepop.popup()
|
||||
return
|
||||
self.folder = ""
|
||||
self.label.set_text("")
|
||||
self.unset_button.hide()
|
||||
self.pop.set_label(error, msg)
|
||||
self.pop.popup()
|
||||
|
||||
|
||||
class ModFrame(HeadingFrame):
|
||||
def __init__(
|
||||
self, parent: OfflineLoader, controller: "Controller", label: str
|
||||
) -> None:
|
||||
super().__init__(heading=label)
|
||||
|
||||
self.parent = parent
|
||||
self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.tree = OfflineModTreeView(controller)
|
||||
self.tree.set_model(None)
|
||||
self.tree.set_valign(Gtk.Align.FILL)
|
||||
|
||||
self.scrolled = NoOverlayScrolledWindow()
|
||||
self.scrolled.set_margin_end(5)
|
||||
self.scrolled.set_vexpand(True)
|
||||
self.scrolled.add(self.tree)
|
||||
|
||||
self.status = Gtk.Label(
|
||||
halign=Gtk.Align.START, margin_start=5, margin_top=3, margin_bottom=3
|
||||
)
|
||||
|
||||
self.tree_vbox = VBox()
|
||||
self.tree_vbox.extend([self.scrolled, self.status])
|
||||
|
||||
self.no_mods = HBox(spacing=10)
|
||||
no_mods_icon = Icon(WARNING, margin_start=10)
|
||||
no_mods_label = Gtk.Label(label=offline.no_local_mods, halign=Gtk.Align.START)
|
||||
self.no_mods.extend([no_mods_icon, no_mods_label])
|
||||
|
||||
self.vbox.pack_end(self.no_mods, expand=True, fill=True, padding=3)
|
||||
self.vbox.pack_end(self.tree_vbox, expand=True, fill=True, padding=3)
|
||||
self.frame.add(self.vbox)
|
||||
|
||||
sel = self.tree.get_selection()
|
||||
sel.connect("changed", self._on_selection_changed)
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
self.connect("unmap", self._on_unmap)
|
||||
|
||||
def start_empty(self) -> None:
|
||||
self.tree_vbox.hide()
|
||||
self.no_mods.show()
|
||||
|
||||
def _on_map(self, widget: Self) -> None:
|
||||
self.no_mods.hide()
|
||||
self.tree.set_model(None)
|
||||
self.tree_vbox.hide()
|
||||
|
||||
def _on_unmap(self, widget: Self) -> None:
|
||||
self.tree.set_model(None)
|
||||
self.tree_vbox.hide()
|
||||
self.no_mods.hide()
|
||||
|
||||
def start(self, store: "FastInsertListStore") -> None:
|
||||
# TODO: this manipulates mod man store out of band
|
||||
self.tree.set_model(store)
|
||||
self.tree.mod_man.set_store(store)
|
||||
self.show_tree()
|
||||
|
||||
def get_mods(self) -> list[str]:
|
||||
model, treeiters = self.tree.get_selection().get_selected_rows()
|
||||
if model is None:
|
||||
return []
|
||||
if type(self) is CustomModFrame:
|
||||
# NOTE: custom mod dirs to be hashed
|
||||
return [model[_iter][2] for _iter in treeiters]
|
||||
else:
|
||||
# NOTE: pre-existing, canonical symlinks to published mods
|
||||
return [model[_iter][1] for _iter in treeiters]
|
||||
|
||||
def show_tree(self) -> None:
|
||||
self.tree_vbox.show()
|
||||
|
||||
def collapse_tree(self) -> None:
|
||||
self.tree_vbox.hide()
|
||||
|
||||
def get_tree(self) -> OfflineModTreeView:
|
||||
return self.tree
|
||||
|
||||
def pack(self, widget: Gtk.Widget) -> None:
|
||||
self.vbox.pack_start(widget, expand=False, fill=False, padding=5)
|
||||
|
||||
def set_model(self, model: Union["FastInsertListStore", None]) -> None:
|
||||
self.tree.set_model(model)
|
||||
self.tree.mod_man.store = model
|
||||
self.set_cursor()
|
||||
|
||||
def _on_selection_changed(self, sel: Gtk.TreeSelection) -> None:
|
||||
model, rows = sel.get_selected_rows()
|
||||
if len(rows) == 0:
|
||||
status = generic.selectable_tree
|
||||
else:
|
||||
status = f"Mods selected: {len(rows)}"
|
||||
self.status.set_label(status)
|
||||
# TODO: cleaner delegation
|
||||
# simply send int value or use emitter
|
||||
self.parent.check_button()
|
||||
|
||||
def set_cursor(self) -> None:
|
||||
path = Gtk.TreePath.new_from_indices([0])
|
||||
self.tree.set_cursor(path)
|
||||
self.tree.get_selection().unselect_all()
|
||||
|
||||
|
||||
class CustomModFrame(ModFrame):
|
||||
def __init__(
|
||||
self, parent: OfflineLoader, controller: "Controller", heading: str
|
||||
) -> None:
|
||||
super().__init__(parent, controller, heading)
|
||||
|
||||
self.tree.set_menu(ContextMenuGroup.MOD_OFFLINE)
|
||||
self.parent = parent
|
||||
|
||||
self.controller = controller
|
||||
self.emitter = controller.get_emitter()
|
||||
|
||||
self.custom_hbox = FolderHBox(
|
||||
controller, offline.custom_button, offline.custom_eventbox
|
||||
)
|
||||
self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked)
|
||||
|
||||
self.pack(self.custom_hbox)
|
||||
|
||||
self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded)
|
||||
self.emitter.connect("custom_mods_unloaded", self._on_custom_mods_unloaded)
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def _on_map(self, widget: Self) -> None:
|
||||
self.hide_tree()
|
||||
|
||||
def _on_custom_mods_unloaded(self, emitter: "Emitter", widget: FolderHBox) -> None:
|
||||
# TODO: kludgy workaround for generic button emitting global signal
|
||||
if widget == self.custom_hbox:
|
||||
self.hide_tree()
|
||||
|
||||
def hide_tree(self) -> None:
|
||||
self.custom_hbox.hide_label()
|
||||
self.tree.set_model(None)
|
||||
self.tree_vbox.hide()
|
||||
self.no_mods.hide()
|
||||
|
||||
def present_folder_changed(self, folder: str) -> None:
|
||||
self.custom_hbox.present_error(FolderError.FOLDER_CHANGED, folder)
|
||||
|
||||
def present_error(self, folder: str) -> None:
|
||||
self.hide_tree()
|
||||
self.custom_hbox.present_error(FolderError.NO_VALID_MODS, folder)
|
||||
|
||||
def present_tree(self, store: "FastInsertListStore", folder: str) -> None:
|
||||
self.custom_hbox.set_folder(folder)
|
||||
self.tree.set_model(store)
|
||||
self.tree_vbox.show()
|
||||
|
||||
def _on_custom_mods_loaded(
|
||||
self,
|
||||
emitter: "Emitter",
|
||||
store: "FastInsertListStore",
|
||||
folder: str,
|
||||
) -> None:
|
||||
self.custom_hbox.stop_spinner()
|
||||
if len(store) == 0:
|
||||
self.present_error(folder)
|
||||
else:
|
||||
self.present_tree(store, folder)
|
||||
|
||||
def _on_custom_button_clicked(self, button: Gtk.Button) -> None:
|
||||
callback = self.custom_hbox.start_spinner
|
||||
# TODO: cleaner delegation
|
||||
self.parent.offline_man.find_custom_mods(callback)
|
||||
|
||||
def get_folder(self) -> str:
|
||||
return self.custom_hbox.get_folder()
|
||||
|
||||
|
||||
class MissionFrame(HeadingFrame):
|
||||
def __init__(self, parent: OfflineLoader, controller: "Controller") -> None:
|
||||
super().__init__(heading=offline.mission_frame)
|
||||
|
||||
self.parent = parent
|
||||
self.controller = controller
|
||||
self.emitter = controller.get_emitter()
|
||||
|
||||
self.mission_hbox = FolderHBox(
|
||||
controller, offline.mission_button, offline.mission_eventbox
|
||||
)
|
||||
self.mission_button = self.mission_hbox.get_button()
|
||||
self.mission_button.connect("clicked", self._on_mission_button_clicked)
|
||||
|
||||
self.frame.add(self.mission_hbox)
|
||||
|
||||
self.emitter.connect("custom_mission_loaded", self._on_mission_loaded)
|
||||
|
||||
def _on_mission_loaded(
|
||||
self, emitter: "Emitter", folder: str, is_valid: bool
|
||||
) -> None:
|
||||
if is_valid:
|
||||
self.mission_hbox.set_folder(folder)
|
||||
else:
|
||||
self.mission_hbox.present_error(FolderError.NO_VALID_MISSION, folder)
|
||||
self.parent.check_button()
|
||||
|
||||
def _on_mission_button_clicked(self, button: Gtk.Button) -> None:
|
||||
self.parent.offline_man.get_mission()
|
||||
|
||||
def get_mission(self) -> str:
|
||||
return self.mission_hbox.get_folder()
|
||||
|
||||
def present_folder_changed(self, folder: str) -> None:
|
||||
self.mission_hbox.present_error(FolderError.FOLDER_CHANGED, folder)
|
||||
|
||||
|
||||
class RadioFrame(HeadingFrame):
|
||||
def __init__(self, parent: OfflineLoader, controller: "Controller") -> None:
|
||||
super().__init__(heading=offline.version)
|
||||
|
||||
self.controller = controller
|
||||
|
||||
self.id_map = {APPNAME_DAYZ: APPID_DAYZ, APPNAME_DAYZ_EXP_HUMAN: APPID_DAYZ_EXP}
|
||||
self.appid = APPID_DAYZ
|
||||
|
||||
self.dayz = Gtk.RadioButton.new_with_label(None, APPNAME_DAYZ)
|
||||
self.dayz_exp = Gtk.RadioButton.new_with_label_from_widget(
|
||||
self.dayz, APPNAME_DAYZ_EXP_HUMAN
|
||||
)
|
||||
|
||||
self.radio_box = HBox()
|
||||
self.radio_box.extend([self.dayz, self.dayz_exp])
|
||||
self.radio_box.set_margin_start(10)
|
||||
self.radio_box.set_margin_bottom(5)
|
||||
|
||||
self.frame.add(self.radio_box)
|
||||
|
||||
# TODO: cleaner delegation
|
||||
has_dayz_exp = parent.offline_man.has_dayz_exp()
|
||||
self.dayz.connect("toggled", self._on_radio_toggled)
|
||||
if has_dayz_exp is False:
|
||||
self.dayz_exp.set_sensitive(False)
|
||||
|
||||
def _on_radio_toggled(self, radio: Gtk.RadioButton) -> None:
|
||||
for el in self.dayz, self.dayz_exp:
|
||||
if el.get_active():
|
||||
label = el.get_label()
|
||||
self.appid = self.id_map[label]
|
||||
|
||||
def get_appid(self) -> int:
|
||||
return self.appid
|
||||
|
||||
|
||||
class OfflineLoader(Gtk.Box):
|
||||
def __init__(self, controller: "Controller"):
|
||||
super().__init__(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
margin_start=10,
|
||||
margin_end=10,
|
||||
)
|
||||
|
||||
self.controller = controller
|
||||
self.controller.register_widget("offline_loader", self)
|
||||
self.emitter = controller.get_emitter()
|
||||
self.offline_man = OfflineManager(controller)
|
||||
|
||||
self.add(PageHeading(offline.heading))
|
||||
|
||||
self.local_frame = ModFrame(self, controller, offline.local_frame)
|
||||
self.custom_frame = CustomModFrame(self, controller, offline.custom_frame)
|
||||
|
||||
self.custom_tree = OfflineModTreeView(controller)
|
||||
self.mission_frame = MissionFrame(self, controller)
|
||||
self.radio_frame = RadioFrame(self, controller)
|
||||
|
||||
self.scrollable = Gtk.ScrolledWindow(
|
||||
vexpand=True, propagate_natural_height=True
|
||||
)
|
||||
|
||||
self.content_box = VBox(spacing=5)
|
||||
self.content_box.extend(
|
||||
[
|
||||
self.local_frame,
|
||||
self.custom_frame,
|
||||
self.mission_frame,
|
||||
self.radio_frame,
|
||||
]
|
||||
)
|
||||
|
||||
# TODO: share ConnectBox class with preconnect dialog?
|
||||
self.button_box = HBox(spacing=5)
|
||||
self.button_box.set_halign(Gtk.Align.END)
|
||||
self.button_box.set_margin_top(15)
|
||||
self.back = Gtk.Button(label="Back")
|
||||
self.ok = Gtk.Button(label="Launch", sensitive=False)
|
||||
self.back.connect("clicked", self._on_back_clicked)
|
||||
self.ok.connect("clicked", self._on_ok_clicked)
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
self.emitter.connect("custom_mods_unloaded", lambda _, __: self.check_button())
|
||||
|
||||
self.button_box.extend([self.back, self.ok])
|
||||
|
||||
self.scrollable.add(self.content_box)
|
||||
self.add(self.scrollable)
|
||||
self.add(self.button_box)
|
||||
|
||||
def check_button(self) -> None:
|
||||
local_mods = self.local_frame.get_mods()
|
||||
custom_mods = self.custom_frame.get_mods()
|
||||
mission = self.mission_frame.get_mission()
|
||||
if len(local_mods) == 0 and len(custom_mods) == 0 and len(mission) == 0:
|
||||
self.ok.set_sensitive(False)
|
||||
else:
|
||||
self.ok.set_sensitive(True)
|
||||
|
||||
def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None:
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
self.back.emit("clicked")
|
||||
|
||||
def populate(self, store: Union["FastInsertListStore", None]) -> None:
|
||||
if store is None:
|
||||
self.local_frame.start_empty()
|
||||
return
|
||||
self.local_frame.start(store)
|
||||
|
||||
def _on_back_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.open_page(NotebookPage.MODS)
|
||||
|
||||
def _on_ok_clicked(self, button: Gtk.Button) -> None:
|
||||
appid = self.radio_frame.get_appid()
|
||||
mission = self.mission_frame.get_mission()
|
||||
local_mods = self.local_frame.get_mods()
|
||||
custom_folder = self.custom_frame.get_folder()
|
||||
custom_mods = self.custom_frame.get_mods()
|
||||
|
||||
if custom_folder and not self.offline_man.is_custom_folder_valid(custom_folder):
|
||||
self.custom_frame.present_folder_changed(mission)
|
||||
return
|
||||
|
||||
if mission and not self.offline_man.is_mission_valid(mission):
|
||||
self.mission_frame.present_folder_changed(mission)
|
||||
return
|
||||
self.offline_man.launch(appid, mission, local_mods, custom_folder, custom_mods)
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dzgui.api import pefile as PeFile
|
||||
from dzgui.api.steam import find_user_id
|
||||
|
||||
from dzgui.config import query
|
||||
from dzgui.const.constants import (
|
||||
APPID_DAYZ,
|
||||
@@ -19,9 +19,8 @@ from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP
|
||||
from dzgui.const.enum import Preferences, ServerTab
|
||||
from dzgui.strings import errors, options
|
||||
from dzgui.util import strings, css, open_links
|
||||
from dzgui.views.components.buttons import SteamWorkshopButton
|
||||
|
||||
from dzgui.views.components.labels import LeftLabel
|
||||
from dzgui.views.components.eventbox import InfoEventBox
|
||||
from dzgui.views.components.buttons import WebButton
|
||||
from dzgui.views.components.frame import HeadingFrame
|
||||
from dzgui.views.components.misc import ClientCombo
|
||||
@@ -134,16 +133,6 @@ class Options(Gtk.Box):
|
||||
[LeftLabel(strings.options.name), self.player_box],
|
||||
]
|
||||
|
||||
eb = InfoEventBox(options.workshop_eventbox, controller)
|
||||
|
||||
workshop_button = SteamWorkshopButton()
|
||||
workshop_button.connect(
|
||||
"clicked", lambda _: self.controller.open_user_workshop(self.uid)
|
||||
)
|
||||
mod_rows = [
|
||||
[LeftLabel(options.workshop_label), workshop_button, eb],
|
||||
]
|
||||
|
||||
self.dayz_version_label = Gtk.Label(label=strings.null)
|
||||
self.dayz_exp_version_label = Gtk.Label(label=strings.null)
|
||||
|
||||
@@ -169,7 +158,6 @@ class Options(Gtk.Box):
|
||||
api_box.add(api_links_box)
|
||||
|
||||
prefs_grid = self._make_grid(pref_rows)
|
||||
mods_grid = self._make_grid(mod_rows)
|
||||
version_grid = self._make_grid(version_rows)
|
||||
|
||||
col = 1
|
||||
@@ -187,12 +175,13 @@ class Options(Gtk.Box):
|
||||
if prefs.is_debug:
|
||||
grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT)
|
||||
|
||||
for frame in [
|
||||
HeadingFrame(api_box, strings.options.api_keys),
|
||||
HeadingFrame(prefs_grid, strings.options.prefs),
|
||||
HeadingFrame(mods_grid, strings.options.mods),
|
||||
HeadingFrame(version_grid, strings.options.version),
|
||||
for pair in [
|
||||
(api_box, strings.options.api_keys),
|
||||
(prefs_grid, strings.options.prefs),
|
||||
(version_grid, strings.options.version),
|
||||
]:
|
||||
|
||||
frame = HeadingFrame.new_with_widget_and_label(*pair)
|
||||
grid.attach(frame, col, row, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT)
|
||||
row += 1
|
||||
|
||||
@@ -299,6 +288,7 @@ class Options(Gtk.Box):
|
||||
|
||||
def _on_api_change_failed(self, emitter: "Emitter") -> None:
|
||||
self.old_entry.set_text(self.old_text)
|
||||
# TODO: use popover
|
||||
dialog = ExceptionDialog(self.controller, errors.api_validation_error)
|
||||
dialog.run()
|
||||
|
||||
@@ -403,7 +393,7 @@ class Options(Gtk.Box):
|
||||
if prefs.paths.config.is_file() is False:
|
||||
dialog = ExceptionDialog(self.controller, strings.config_not_found)
|
||||
dialog.run()
|
||||
raise Exception
|
||||
raise OSError(f"Config file '{prefs.paths.config}' not found")
|
||||
|
||||
config = query.get_config(prefs.paths.config)
|
||||
|
||||
@@ -414,9 +404,6 @@ class Options(Gtk.Box):
|
||||
bm = self.controller.query_config(Preferences.BM)
|
||||
|
||||
steam_path = Path(default_steam_path)
|
||||
# NOTE: this is a best effort guess at the most recent user
|
||||
uid = find_user_id(steam_path)
|
||||
self.uid = "" if uid is None else uid
|
||||
|
||||
self.old_steam = steam
|
||||
self.old_bm = bm
|
||||
|
||||
@@ -23,6 +23,7 @@ from gi.repository import Gdk, Gtk # noqa E402
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.managers.connection import Prerequisites
|
||||
from dzgui.controllers.mc import Controller
|
||||
from dzgui.controllers.emitter import Emitter
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -112,18 +113,6 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
tooltip_text=preconnect.connect_last_tooltip,
|
||||
)
|
||||
|
||||
# TODO: abstract
|
||||
self.raise_window = Gtk.CheckButton(
|
||||
label="Foreground DZGUI while downloading",
|
||||
halign=Gtk.Align.END,
|
||||
hexpand=True,
|
||||
valign=Gtk.Align.END,
|
||||
visible=False,
|
||||
has_tooltip=True,
|
||||
sensitive=False,
|
||||
tooltip_text="Foreground the DZGUI window after mod downloads are queued",
|
||||
active=True,
|
||||
)
|
||||
self.button_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
valign=Gtk.Align.END,
|
||||
@@ -133,8 +122,7 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
||||
for button in self.back, self.ok, self.connect_last:
|
||||
box.add(button)
|
||||
for el in self.raise_window, box:
|
||||
self.button_box.add(el)
|
||||
self.button_box.add(box)
|
||||
|
||||
self.back.connect("clicked", self._on_back_clicked)
|
||||
self.ok.connect("clicked", self._on_ok_clicked)
|
||||
@@ -154,7 +142,7 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
self.progress_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15)
|
||||
self.progress_box.add(self.mod_count)
|
||||
|
||||
self.scrolled = Gtk.ScrolledWindow()
|
||||
self.scrolled = Gtk.ScrolledWindow(margin_end=10)
|
||||
self.scrolled.add(self.tree)
|
||||
self.scrolled.set_size_request(600, 400)
|
||||
|
||||
@@ -166,7 +154,9 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
self.mods_placeholder = Placeholder("This server has no mods.")
|
||||
self.tree_box.add(self.mods_placeholder)
|
||||
|
||||
self.tree_frame = HeadingFrame(self.tree_box, preconnect.mods)
|
||||
self.tree_frame = HeadingFrame.new_with_widget_and_label(
|
||||
self.tree_box, preconnect.mods
|
||||
)
|
||||
|
||||
# TODO: abstract into components
|
||||
self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
@@ -175,7 +165,9 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
self.warning_placeholder = Placeholder("No warnings.")
|
||||
self.warning_box.add(self.warning_tree)
|
||||
self.warning_box.add(self.warning_placeholder)
|
||||
self.warning_frame = HeadingFrame(self.warning_box, preconnect.warnings)
|
||||
self.warning_frame = HeadingFrame.new_with_widget_and_label(
|
||||
self.warning_box, preconnect.warnings
|
||||
)
|
||||
|
||||
self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.error_tree = MaskedTree(ERROR)
|
||||
@@ -183,7 +175,9 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
self.error_placeholder = Placeholder("No errors.")
|
||||
self.error_box.add(self.error_tree)
|
||||
self.error_box.add(self.error_placeholder)
|
||||
self.error_frame = HeadingFrame(self.error_box, preconnect.errors)
|
||||
self.error_frame = HeadingFrame.new_with_widget_and_label(
|
||||
self.error_box, preconnect.errors
|
||||
)
|
||||
|
||||
self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||
self.box.add(self.title)
|
||||
@@ -198,9 +192,14 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
self.add(self.scrolled_box)
|
||||
self.add(self.button_box)
|
||||
|
||||
emitter = self.controller.get_emitter()
|
||||
emitter.connect("all_mods_synched", self._on_mods_synched)
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def _on_mods_synched(self, emitter: "Emitter") -> None:
|
||||
self.tree.mark_mods_synched()
|
||||
|
||||
def _on_map(self, widget: Self) -> None:
|
||||
widgets = (
|
||||
self.tree_frame,
|
||||
@@ -211,8 +210,6 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
for child in widgets:
|
||||
child.set_visible(True)
|
||||
|
||||
self.raise_window.set_visible(False)
|
||||
self.raise_window.set_sensitive(False)
|
||||
self.ok.set_sensitive(True)
|
||||
self.ok.set_label(preconnect.update_mods)
|
||||
|
||||
@@ -224,10 +221,10 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
self.ok.emit("clicked")
|
||||
|
||||
def _on_connect_last_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.update_and_load_to_menu(self.raise_window.get_active())
|
||||
self.controller.update_and_load_to_menu()
|
||||
|
||||
def _on_ok_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.update_and_connect(self.raise_window.get_active())
|
||||
self.controller.update_and_connect()
|
||||
|
||||
def _on_back_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.open_page(NotebookPage.SERVERS)
|
||||
@@ -275,6 +272,15 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
"It looks like DayZ is already running in the background. Exit DayZ before connecting."
|
||||
)
|
||||
|
||||
allows_dl, running_app = prereqs.allows_downloads
|
||||
if len(prereqs.mods) > 0 and allows_dl is False:
|
||||
msg = (
|
||||
f"The game '{running_app}' is currently running in Steam, but background downloads are not enabled.\n"
|
||||
"Either stop the game first, or update your global Steam settings or the game's local settings.\n"
|
||||
"Otherwise, mods may be queued for download but never update."
|
||||
)
|
||||
warnings.append(msg)
|
||||
|
||||
self.add_warnings(warnings)
|
||||
self.add_errors(errors)
|
||||
|
||||
@@ -306,20 +312,16 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
self._hide_mod_area()
|
||||
else:
|
||||
self._show_mod_area()
|
||||
msg = "All mods are up to date."
|
||||
msg = preconnect.up_to_date
|
||||
self.mod_count.set_text(msg)
|
||||
|
||||
if prereqs.required_space == 0:
|
||||
self.ok.set_label(preconnect.connect)
|
||||
self.raise_window.set_visible(False)
|
||||
else:
|
||||
self.raise_window.set_visible(True)
|
||||
self.raise_window.set_sensitive(True)
|
||||
|
||||
if prereqs.required_space != 0:
|
||||
pretty = number(prereqs.required_space)
|
||||
suffix = f" Need to download {pretty} MiB of mod updates."
|
||||
prefix = preconnect.total_mods
|
||||
self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}")
|
||||
else:
|
||||
self.ok.set_label(preconnect.connect)
|
||||
|
||||
if prereqs.is_last_server:
|
||||
self.connect_last.show()
|
||||
|
||||
@@ -16,7 +16,7 @@ logger = logging.getLogger(APP_NAME)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.controllers.mc import Controller
|
||||
from dzgui.controllers.mc import Emitter
|
||||
from dzgui.controllers.emitter import Emitter
|
||||
|
||||
|
||||
class ScrollableTree(Gtk.ScrolledWindow):
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Any, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import APP_NAME, HEX_RED
|
||||
from dzgui.const.enum import ContextMenuGroup
|
||||
from dzgui.managers.mods import ModManager
|
||||
from dzgui.util import strings, localize
|
||||
from dzgui.views.mixins.context_mixin import ContextMixin
|
||||
from dzgui.views.mixins.mods_mixin import ModsMixin
|
||||
@@ -22,9 +23,14 @@ logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(controller, menu=ContextMenuGroup.MOD)
|
||||
def __init__(
|
||||
self, controller: "Controller", menu: ContextMenuGroup = ContextMenuGroup.MOD
|
||||
) -> None:
|
||||
super().__init__(controller, menu=menu)
|
||||
|
||||
self.controller = controller
|
||||
self.mod_man = ModManager(self, controller)
|
||||
|
||||
emitter = self.controller.get_emitter()
|
||||
emitter.connect("mods_updated", self._on_mods_updated)
|
||||
emitter.connect("mods_highlighted", self._on_mods_highlighted)
|
||||
@@ -56,6 +62,33 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore
|
||||
self.connect("button-press-event", self.present_menu)
|
||||
self.connect("key-press-event", self.present_menu)
|
||||
|
||||
self.connect("button-press-event", self.on_row_click)
|
||||
|
||||
def on_row_click(self, widget: Gtk.TreeView, event: Gdk.EventButton) -> bool:
|
||||
if event.state is Gdk.ModifierType.CONTROL_MASK and event.button == 1:
|
||||
path_info = widget.get_path_at_pos(int(event.x), int(event.y))
|
||||
if path_info is None:
|
||||
return False
|
||||
path, column, cellx, celly = path_info
|
||||
model = self.get_model()
|
||||
if model is None:
|
||||
return False
|
||||
if path is None:
|
||||
return False
|
||||
_iter = model.get_iter(path)
|
||||
if self.get_selection().iter_is_selected(_iter):
|
||||
self.get_selection().unselect_iter(_iter)
|
||||
else:
|
||||
self.get_selection().select_iter(_iter)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_mod_man(self) -> ModManager:
|
||||
return self.mod_man
|
||||
|
||||
def load_mods(self) -> None:
|
||||
self.mod_man.load_mods()
|
||||
|
||||
def _on_mods_highlighted(self, emitter: "Emitter") -> None:
|
||||
self.get_selection().unselect_all()
|
||||
|
||||
@@ -105,3 +138,13 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore
|
||||
val = model[it][3]
|
||||
formatted = localize.number(val)
|
||||
cell.set_property("text", formatted)
|
||||
# NOTE: reapply color property after float conversion
|
||||
self._format_color(column, cell, model, it, data)
|
||||
|
||||
def set_menu(self, context: ContextMenuGroup) -> None:
|
||||
self.menu = context
|
||||
|
||||
|
||||
class OfflineModTreeView(ModTreeView):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(controller)
|
||||
@@ -48,6 +48,10 @@ class ServerModTreeView(ContextMixin, TreeView): # type: ignore
|
||||
case _:
|
||||
pass
|
||||
|
||||
def mark_mods_synched(self) -> None:
|
||||
for row in self.mod_store:
|
||||
row[2] = server_mods.checkmark
|
||||
|
||||
# TODO: could be problematic if user downloads mods out of band
|
||||
def _on_row_activated(
|
||||
self,
|
||||
|
||||
+5
-2
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
|
||||
authors = [
|
||||
{name = "aclist"}
|
||||
]
|
||||
version = "7.0.0b11"
|
||||
version = "7.0.0b19"
|
||||
license = "GPL-3.0-or-later"
|
||||
license-files = ["LICENSE"]
|
||||
readme = "README.md"
|
||||
@@ -27,6 +27,7 @@ dependencies = [
|
||||
"psutil==7.1.3",
|
||||
"python-a2s",
|
||||
"requests==2.32.5",
|
||||
"vdf==3.4"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -75,7 +76,7 @@ build-backend = "setuptools.build_meta"
|
||||
line-length = 88
|
||||
indent-width = 4
|
||||
extend-exclude = ["*lib/a2s"]
|
||||
lint.unfixable = ["F401"]
|
||||
lint.unfixable = ["F401", "I001"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
@@ -107,8 +108,10 @@ config-settings-package = { pygobject-stubs = { config = "Gtk3,Gdk3,Soup2" } }
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"config: config file keys/values",
|
||||
"mods: tests mod metadata/link creation",
|
||||
"pefile: validate PE files",
|
||||
"post_install: requires a completed installation",
|
||||
"redact: log redaction mechanisms",
|
||||
"slow: long-running tests",
|
||||
"webtest: checks remote endpoints"
|
||||
]
|
||||
@@ -12,6 +12,7 @@ This file includes the combined licenses to the following components:
|
||||
- psutil
|
||||
- PyGObject
|
||||
- python-a2s
|
||||
- vdf
|
||||
|
||||
When shipped as a binary installer, these components are collectively bundled
|
||||
as a turnkey application and used to run the DZGUI utility.\n\n
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
"InstallConfigStore"
|
||||
{
|
||||
"Software"
|
||||
{
|
||||
"Valve"
|
||||
{
|
||||
"Steam"
|
||||
{
|
||||
"AllowDownloadsDuringGameplay" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"InstallConfigStore"
|
||||
{
|
||||
"Software"
|
||||
{
|
||||
"Valve"
|
||||
{
|
||||
"Steam"
|
||||
{
|
||||
"AllowDownloadsDuringGameplay" "0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
File renamed without changes.
Vendored
File renamed without changes.
+13
@@ -0,0 +1,13 @@
|
||||
"users"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"AccountName" "STEAMUSER"
|
||||
"PersonaName" "STEAMUSER"
|
||||
"RememberPassword" "0"
|
||||
"WantsOfflineMode" "0"
|
||||
"SkipOfflineModeWarning" "0"
|
||||
"AutoLogin" "1"
|
||||
"Timestamp" "0"
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
"users"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"AccountName" "STEAMUSER"
|
||||
"PersonaName" "STEAMUSER"
|
||||
"RememberPassword" "0"
|
||||
"WantsOfflineMode" "0"
|
||||
"SkipOfflineModeWarning" "0"
|
||||
"AllowAutoLogin" "1"
|
||||
"MostRecent" "1"
|
||||
"Timestamp" "0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"users"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"AccountName" "STEAMUSER"
|
||||
"PersonaName" "STEAMUSER"
|
||||
"RememberPassword" "0"
|
||||
"WantsOfflineMode" "0"
|
||||
"SkipOfflineModeWarning" "0"
|
||||
"AllowAutoLogin" "1"
|
||||
"MostRecent" "0"
|
||||
"Timestamp" "0"
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"AccountName" "STEAMUSER2"
|
||||
"PersonaName" "STEAMUSER2"
|
||||
"RememberPassword" "0"
|
||||
"WantsOfflineMode" "0"
|
||||
"SkipOfflineModeWarning" "0"
|
||||
"AllowAutoLogin" "1"
|
||||
"MostRecent" "1"
|
||||
"Timestamp" "0"
|
||||
}
|
||||
}
|
||||
File renamed without changes.
Vendored
BIN
Binary file not shown.
File renamed without changes.
@@ -0,0 +1,53 @@
|
||||
"AppState"
|
||||
{
|
||||
"appid" "111"
|
||||
"Universe" "1"
|
||||
"name" "TEST APP"
|
||||
"StateFlags" "4"
|
||||
"installdir" "TESTAPP"
|
||||
"LastUpdated" "0"
|
||||
"LastPlayed" "0"
|
||||
"SizeOnDisk" "0"
|
||||
"StagingSize" "0"
|
||||
"buildid" "0"
|
||||
"LastOwner" "0"
|
||||
"DownloadType" "2"
|
||||
"UpdateResult" "0"
|
||||
"BytesToDownload" "0"
|
||||
"BytesDownloaded" "0"
|
||||
"BytesToStage" "0"
|
||||
"BytesStaged" "0"
|
||||
"TargetBuildID" "0"
|
||||
"AutoUpdateBehavior" "0"
|
||||
"AllowOtherDownloadsWhileRunning" "1"
|
||||
"ScheduledAutoUpdate" "0"
|
||||
"FullValidateAfterNextUpdate" "1"
|
||||
"InstalledDepots"
|
||||
{
|
||||
"1110"
|
||||
{
|
||||
"manifest" "0"
|
||||
"size" "0"
|
||||
}
|
||||
}
|
||||
"SharedDepots"
|
||||
{
|
||||
"228983" "228980"
|
||||
"228985" "228980"
|
||||
"228988" "228980"
|
||||
"228990" "228980"
|
||||
"229004" "228980"
|
||||
}
|
||||
"UserConfig"
|
||||
{
|
||||
"language" "english"
|
||||
"platform_override_dest" "linux"
|
||||
"platform_override_source" "windows"
|
||||
}
|
||||
"MountedConfig"
|
||||
{
|
||||
"language" "english"
|
||||
"platform_override_dest" "linux"
|
||||
"platform_override_source" "windows"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"AppState"
|
||||
{
|
||||
"appid" "222"
|
||||
"Universe" "1"
|
||||
"name" "TEST APP"
|
||||
"StateFlags" "4"
|
||||
"installdir" "TESTAPP"
|
||||
"LastUpdated" "0"
|
||||
"LastPlayed" "0"
|
||||
"SizeOnDisk" "0"
|
||||
"StagingSize" "0"
|
||||
"buildid" "0"
|
||||
"LastOwner" "0"
|
||||
"DownloadType" "2"
|
||||
"UpdateResult" "0"
|
||||
"BytesToDownload" "0"
|
||||
"BytesDownloaded" "0"
|
||||
"BytesToStage" "0"
|
||||
"BytesStaged" "0"
|
||||
"TargetBuildID" "0"
|
||||
"AutoUpdateBehavior" "0"
|
||||
"AllowOtherDownloadsWhileRunning" "2"
|
||||
"ScheduledAutoUpdate" "0"
|
||||
"FullValidateAfterNextUpdate" "1"
|
||||
"InstalledDepots"
|
||||
{
|
||||
"2220"
|
||||
{
|
||||
"manifest" "0"
|
||||
"size" "0"
|
||||
}
|
||||
}
|
||||
"SharedDepots"
|
||||
{
|
||||
"228983" "228980"
|
||||
"228985" "228980"
|
||||
"228988" "228980"
|
||||
"228990" "228980"
|
||||
"229004" "228980"
|
||||
}
|
||||
"UserConfig"
|
||||
{
|
||||
"language" "english"
|
||||
"platform_override_dest" "linux"
|
||||
"platform_override_source" "windows"
|
||||
}
|
||||
"MountedConfig"
|
||||
{
|
||||
"language" "english"
|
||||
"platform_override_dest" "linux"
|
||||
"platform_override_source" "windows"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"AppState"
|
||||
{
|
||||
"appid" "333"
|
||||
"Universe" "1"
|
||||
"name" "TEST APP"
|
||||
"StateFlags" "4"
|
||||
"installdir" "TESTAPP"
|
||||
"LastUpdated" "0"
|
||||
"LastPlayed" "0"
|
||||
"SizeOnDisk" "0"
|
||||
"StagingSize" "0"
|
||||
"buildid" "0"
|
||||
"LastOwner" "0"
|
||||
"DownloadType" "2"
|
||||
"UpdateResult" "0"
|
||||
"BytesToDownload" "0"
|
||||
"BytesDownloaded" "0"
|
||||
"BytesToStage" "0"
|
||||
"BytesStaged" "0"
|
||||
"TargetBuildID" "0"
|
||||
"AutoUpdateBehavior" "0"
|
||||
"AllowOtherDownloadsWhileRunning" "0"
|
||||
"ScheduledAutoUpdate" "0"
|
||||
"FullValidateAfterNextUpdate" "1"
|
||||
"InstalledDepots"
|
||||
{
|
||||
"3330"
|
||||
{
|
||||
"manifest" "0"
|
||||
"size" "0"
|
||||
}
|
||||
}
|
||||
"SharedDepots"
|
||||
{
|
||||
"228983" "228980"
|
||||
"228985" "228980"
|
||||
"228988" "228980"
|
||||
"228990" "228980"
|
||||
"229004" "228980"
|
||||
}
|
||||
"UserConfig"
|
||||
{
|
||||
"language" "english"
|
||||
"platform_override_dest" "linux"
|
||||
"platform_override_source" "windows"
|
||||
}
|
||||
"MountedConfig"
|
||||
{
|
||||
"language" "english"
|
||||
"platform_override_dest" "linux"
|
||||
"platform_override_source" "windows"
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
protocol = 1;
|
||||
publishedid = 1234567890;
|
||||
name = "ModName";
|
||||
timestamp = 638278452000000000;
|
||||
hash = "a1b2c3d4e5f6g7h8i9j0";
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
protocol = 1
|
||||
publishedid = 1234567890
|
||||
name = "ModName"
|
||||
timestamp = 638278452000000000
|
||||
hash = "a1b2c3d4e5f6g7h8i9j0"
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
protocol=1
|
||||
publishedid=1234567890
|
||||
name="ModName"
|
||||
timestamp=638278452000000000
|
||||
hash="a1b2c3d4e5f6g7h8i9j0"
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
protocol=1
|
||||
publishedid=1234567890
|
||||
name=ModName
|
||||
timestamp=638278452000000000
|
||||
hash=a1b2c3d4e5f6g7h8i9j0
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
protocol=1;
|
||||
publishedid=1234567890;
|
||||
name=ModName;
|
||||
timestamp=638278452000000000;
|
||||
hash=a1b2c3d4e5f6g7h8i9j0;
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
protocol=1
|
||||
publishedid=1234567890;
|
||||
name="ModName";
|
||||
timestamp=638278452000000000;
|
||||
hash= a1b2c3d4e5f6g7h8i9j0;
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
1559212036,1771519119
|
||||
1564026768,1770917948
|
||||
2545327648,1780174455
|
||||
2276010135,1756744083
|
||||
1654462998,1780501511
|
||||
3410710885,1752864732
|
||||
2918418331,1780413260
|
||||
3739934289,1780855136
|
||||
File renamed without changes.
File renamed without changes.
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
#Path to DayZ installation
|
||||
steam_path="/tmp"
|
||||
|
||||
#Battlemetrics API key
|
||||
api_key=""
|
||||
|
||||
#Favorited server IP:PORT array
|
||||
ip_list=(
|
||||
)
|
||||
|
||||
#Favorite server to fast-connect to (limit one)
|
||||
fav_server=""
|
||||
|
||||
#Favorite server label (human readable)
|
||||
fav_label=""
|
||||
|
||||
#Custom player name (optional, required by some servers)
|
||||
name=""
|
||||
|
||||
#Set to 1 to perform dry-run and print launch options
|
||||
debug="1"
|
||||
|
||||
#Toggle stable/testing branch
|
||||
branch="testing"
|
||||
|
||||
#Steam API key
|
||||
steam_api=""
|
||||
|
||||
#Auto-install mods
|
||||
auto_install="2"
|
||||
|
||||
#Automod staging directory
|
||||
staging_dir="/tmp"
|
||||
|
||||
#Path to default Steam client
|
||||
default_steam_path="/tmp"
|
||||
|
||||
#Preferred Steam launch command (for Flatpak support)
|
||||
preferred_client="steam"
|
||||
|
||||
#DZGUI source path
|
||||
src_path=""
|
||||
@@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.api.steam import get_app_allows_downloads
|
||||
from tests.fixtures import fixture_path
|
||||
|
||||
pytestmark = pytest.mark.apitest
|
||||
|
||||
|
||||
def mock_path(p: Path, appid: int) -> Path:
|
||||
return Path(fixture_path("api"))
|
||||
|
||||
|
||||
def mock_config_allows(p: Path) -> Path:
|
||||
return Path(fixture_path("api/client_allows_downloads.vdf"))
|
||||
|
||||
|
||||
def mock_config_disallows(p: Path) -> Path:
|
||||
return Path(fixture_path("api/client_disallows_downloads.vdf"))
|
||||
|
||||
|
||||
def mock_config_missing(p: Path) -> Path:
|
||||
return Path(fixture_path(""))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_allows() -> Path:
|
||||
return Path(fixture_path("api/client_allows_downloads.vdf"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def steam_path() -> Path:
|
||||
return Path(fixture_path("api"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def patch_api() -> None:
|
||||
mp = MonkeyPatch()
|
||||
mp.setattr("dzgui.api.steam.get_app_path", mock_path)
|
||||
yield
|
||||
mp.undo()
|
||||
|
||||
|
||||
def test_app_allows_downloads(steam_path) -> None:
|
||||
assert get_app_allows_downloads(steam_path, 111) is True
|
||||
|
||||
|
||||
def test_app_disallows_downloads(steam_path):
|
||||
assert get_app_allows_downloads(steam_path, 222) is False
|
||||
|
||||
|
||||
def test_app_delegates_downloads(monkeypatch, steam_path, client_allows):
|
||||
monkeypatch.setattr("dzgui.api.steam.get_config", mock_config_disallows)
|
||||
assert get_app_allows_downloads(steam_path, 333) is False
|
||||
monkeypatch.setattr("dzgui.api.steam.get_config", mock_config_allows)
|
||||
assert get_app_allows_downloads(steam_path, 333) is True
|
||||
monkeypatch.setattr("dzgui.api.steam.get_config", mock_config_missing)
|
||||
assert get_app_allows_downloads(steam_path, 333) is True
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user