mirror of
https://github.com/aclist/dztui.git
synced 2026-10-25 12:12:31 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28cd6b8c45 | ||
|
|
81e327fb35 | ||
|
|
37e247d1d9 | ||
|
|
286e3f2d80 | ||
|
|
2ef27a55d8 | ||
|
|
d0ac09ffe5 | ||
|
|
c0015c05ec | ||
|
|
682b5bb4c0 | ||
|
|
3551877192 | ||
|
|
b22aba8593 | ||
|
|
f85f1ef2a5 | ||
|
|
010d7984b6 | ||
|
|
70e1e9e740 | ||
|
|
734af7cf22 | ||
|
|
d8f4977ffe | ||
|
|
c9fd00f938 | ||
|
|
f4db1484d8 | ||
|
|
99ef9a31b9 | ||
|
|
4c392dc571 | ||
|
|
155ffea15d | ||
|
|
57d231331d | ||
|
|
d5fe3c35dc | ||
|
|
3cf547e903 | ||
|
|
bd0bfcb03d | ||
|
|
4f6c583294 | ||
|
|
5e5a6b4eaf | ||
|
|
ff59d6dc35 | ||
|
|
1c154a709d | ||
|
|
fb47564618 | ||
|
|
30e309442b | ||
|
|
3e4ed95908 | ||
|
|
93edbac5b0 | ||
|
|
c947bc2c9b | ||
|
|
06315972b1 | ||
|
|
4e36824ed7 | ||
|
|
0bb618b703 | ||
|
|
b8e11d5b59 | ||
|
|
db8b1acbc6 | ||
|
|
1e5d66c9be | ||
|
|
202620c062 | ||
|
|
673038a1ab | ||
|
|
c97e34c336 | ||
|
|
799ea263a0 | ||
|
|
08cbd88262 | ||
|
|
5b5e91c2fa | ||
|
|
f8be7e33d1 | ||
|
|
54c950fc41 | ||
|
|
1e266c3cd2 | ||
|
|
16959a692d | ||
|
|
7c79b17fdc | ||
|
|
87e0af33cc | ||
|
|
3b4a736ce6 | ||
|
|
b70428fe3d | ||
|
|
3f037cdad6 | ||
|
|
9b63d38c5a | ||
|
|
4c26d85afd | ||
|
|
35b8d21181 | ||
|
|
e368e98a1a | ||
|
|
2ea6490a9b | ||
|
|
7b57f95f5f | ||
|
|
a811e5945d | ||
|
|
9b305d806b | ||
|
|
1955abd3e6 | ||
|
|
aac29c0a5c | ||
|
|
218ed43403 | ||
|
|
380f6f82a1 | ||
|
|
cef9e1984f | ||
|
|
0520ced2ea | ||
|
|
4bbe179940 | ||
|
|
1527239567 | ||
|
|
64c8b905c4 | ||
|
|
52ab81f4ec | ||
|
|
ec7828df85 | ||
|
|
525de56036 | ||
|
|
7edb66c311 | ||
|
|
dc2a635d2f | ||
|
|
8528300851 | ||
|
|
daa4aa0059 | ||
|
|
71a0481600 | ||
|
|
ba1b980a7f | ||
|
|
1dc20b838d | ||
|
|
0c6421fa63 | ||
|
|
b47cb482f5 | ||
|
|
152999d728 | ||
|
|
cdcb3b794f | ||
|
|
cd23f403c9 | ||
|
|
a40bef8f82 | ||
|
|
a4f9a67da1 | ||
|
|
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 | ||
|
|
20dab38fff | ||
|
|
1ddc4a5ff1 | ||
|
|
d605447fca | ||
|
|
94fe3f9dff | ||
|
|
a966993999 | ||
|
|
8ee3b36816 | ||
|
|
3af53775cd | ||
|
|
e2d6a462db | ||
|
|
4b9687a84d | ||
|
|
0c0f280d15 | ||
|
|
831bd9145d | ||
|
|
0070fe4061 | ||
|
|
9d1443cf65 | ||
|
|
c4cc9f04c4 | ||
|
|
bcd59162b1 | ||
|
|
2f0bac75ac | ||
|
|
9191f43c8e | ||
|
|
2419fedd19 | ||
|
|
83a7996189 | ||
|
|
9d3aa8d9f5 | ||
|
|
0de0dd4a03 | ||
|
|
aeefc830cd | ||
|
|
647f75cde3 | ||
|
|
7e8b8eb7f7 | ||
|
|
79af78a7e0 |
No files matched your search
+36
-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,12 @@
|
||||
- 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
|
||||
- Dynamic copy button
|
||||
|
||||
## Changed
|
||||
### Changed
|
||||
- Conform to PEP 440 versioning for beta versions
|
||||
- Reduce padding on keys button
|
||||
- Boldface breadcrumbs
|
||||
@@ -56,59 +61,62 @@
|
||||
- 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
|
||||
- Battlemetrics API support
|
||||
- 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
|
||||
- History table not being in lock-step with state file
|
||||
|
||||
## 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 +142,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 +181,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 +193,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]
|
||||
@@ -1,40 +0,0 @@
|
||||
import logging
|
||||
import requests
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.const.endpoints import BM_SERVERS
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.api.servers import Record
|
||||
|
||||
|
||||
def get_attributes(key: str, uid: int) -> Any:
|
||||
# TODO: handle if key is not set
|
||||
# TODO: tests for malformed IDs/values
|
||||
|
||||
hdr = {"Authorization": "Bearer " + key}
|
||||
payload: dict[str, str] = {
|
||||
"filter[game]": "dayz",
|
||||
"sort": "-players",
|
||||
"filter[ids][whitelist]": str(uid),
|
||||
}
|
||||
res = requests.get(BM_SERVERS, params=payload, headers=hdr)
|
||||
res.raise_for_status()
|
||||
j = res.json()["data"][0]["attributes"]
|
||||
return j
|
||||
|
||||
|
||||
def map_id_to_record(key: str, uid: int) -> Optional["Record"]:
|
||||
from dzgui.api.servers import Record
|
||||
|
||||
try:
|
||||
record = get_attributes(key, uid)
|
||||
ip = record["ip"]
|
||||
port = int(record["port"])
|
||||
qport = int(record["portQuery"])
|
||||
return Record(ip, port, qport)
|
||||
except Exception:
|
||||
return None
|
||||
+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)
|
||||
|
||||
@@ -41,21 +41,6 @@ def test_ipdb() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def test_bm_api(key: str) -> bool:
|
||||
payload: dict[str, str] = {
|
||||
"filter[game]": "dayz",
|
||||
}
|
||||
hdr = {"Authorization": "Bearer " + key}
|
||||
try:
|
||||
res = requests.get(
|
||||
endpoints.BM_SERVERS, params=payload, headers=hdr, timeout=REQUEST_TIMEOUT
|
||||
)
|
||||
return is_remote_up(res)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return False
|
||||
|
||||
|
||||
def is_remote_up(res: "Response") -> bool:
|
||||
if res.status_code == 200:
|
||||
return True
|
||||
|
||||
+6
-14
@@ -11,9 +11,9 @@ import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, TYPE_CHECKING, Union
|
||||
|
||||
from dzgui.api.bm import map_id_to_record
|
||||
from dzgui.const.constants import APP_NAME, REQUEST_TIMEOUT
|
||||
from dzgui.const.endpoints import STEAM_SERVERS
|
||||
from dzgui.util.format import format_exception
|
||||
from dzgui.util import strings
|
||||
|
||||
import a2s
|
||||
@@ -272,8 +272,9 @@ def source_info_to_dict(ip: str, qport: int, info: "SourceInfo") -> dict[str, An
|
||||
res["ping"] = ping
|
||||
return res
|
||||
except Exception as e:
|
||||
# TODO: generalized function
|
||||
logger.critical(f"{type(e).__name__}: {e} ({ip}:{qport})")
|
||||
msg = format_exception(e)
|
||||
msg += f" ({ip}:{qport})"
|
||||
logger.critical(msg)
|
||||
raise e
|
||||
|
||||
|
||||
@@ -287,13 +288,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))
|
||||
@@ -475,16 +477,6 @@ def query_by_ip(addr: str) -> A2SInfo:
|
||||
return query_by_record(record, update_gameport=True)
|
||||
|
||||
|
||||
def query_by_id(server_id: int, key: str) -> A2SInfo:
|
||||
"""
|
||||
Used with numeric Battlemetrics IDs
|
||||
"""
|
||||
record = map_id_to_record(key, server_id)
|
||||
if record is None:
|
||||
return A2SInfo(Record("0", 0, 0), None)
|
||||
return query_by_record(record)
|
||||
|
||||
|
||||
def query_by_record(record: Record, update_gameport: bool = False) -> A2SInfo:
|
||||
try:
|
||||
info = a2s.info((record.ip, record.qport), 3.0)
|
||||
|
||||
@@ -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)
|
||||
+282
-82
@@ -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,31 +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(mod: str, appid: int) -> None:
|
||||
args = [
|
||||
"steam",
|
||||
f"steam://url/CommunityFilePage/{mod}+workshop_download_item",
|
||||
str(appid),
|
||||
mod,
|
||||
]
|
||||
subprocess.Popen(["/usr/bin/env", "bash", *args])
|
||||
|
||||
|
||||
def get_needs_update(
|
||||
version_file: Path, remote_hashes: list[tuple[str, str, int, int]]
|
||||
) -> list[tuple[str, str, int, int]]:
|
||||
local_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
|
||||
@@ -118,6 +139,9 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
|
||||
j = r.json()
|
||||
rows = j["response"]["publishedfiledetails"]
|
||||
for row in rows:
|
||||
# NOTE: not a valid published file
|
||||
if row["result"] == 9:
|
||||
continue
|
||||
title = str(row["title"])
|
||||
_id = str(row["publishedfileid"])
|
||||
time = int(row["time_updated"])
|
||||
@@ -146,7 +170,7 @@ def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> i
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def load_to_menu(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int:
|
||||
def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int:
|
||||
"""Loads to the menu screen with the selected mods; used for AFK/pre-joining"""
|
||||
concat = concat_mods(mods)
|
||||
client_args = concat_bash_args(client)
|
||||
@@ -163,69 +187,245 @@ def load_to_menu(client: str, addr: str, appid: int, name: str, mods: list[str])
|
||||
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
|
||||
@@ -40,6 +42,7 @@ def rc2json(file: Path) -> str:
|
||||
|
||||
toggles = ["fullscreen"]
|
||||
deprecated = [
|
||||
"api_key",
|
||||
"staging_dir",
|
||||
"src_path",
|
||||
"steam_path",
|
||||
@@ -64,8 +67,6 @@ def rc2json(file: Path) -> str:
|
||||
value = str2bool(ntok)
|
||||
elif tok == "preferred_client":
|
||||
tok = "client"
|
||||
elif tok == "api_key":
|
||||
tok = "bm_api"
|
||||
elif tok == "ip_list":
|
||||
while True:
|
||||
ntok = lex.get_token()
|
||||
@@ -89,4 +90,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.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
|
||||
Comment=DayZ server browser and mod manager
|
||||
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("Desktop/dzgui.desktop")
|
||||
make_parents(link)
|
||||
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,
|
||||
)
|
||||
@@ -2,7 +2,6 @@
|
||||
Generic defaults used when initializing a config file from scratch
|
||||
"""
|
||||
config_boilerplate = {
|
||||
"bm_api": "",
|
||||
"fav_server": "",
|
||||
"fav_label": "",
|
||||
"name": "",
|
||||
|
||||
@@ -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"
|
||||
@@ -79,4 +86,6 @@ LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG")
|
||||
|
||||
TMP_PATH = "/tmp"
|
||||
TMP_TARBALL = "/tmp/dzgui.tar.gz"
|
||||
TMP_EXE = "/tmp/dzgui"
|
||||
TMP_EXE = "/tmp/dzgui/dzgui"
|
||||
|
||||
DAYZ_COMMUNITY_ROOT = "Missions"
|
||||
@@ -1,7 +1,12 @@
|
||||
# internal
|
||||
STEAM_PUBLISHED_FILES = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json"
|
||||
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?"
|
||||
BM_SERVERS = "https://api.battlemetrics.com/servers?"
|
||||
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?"
|
||||
|
||||
GITHUB = "https://github.com/aclist"
|
||||
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
|
||||
CODEBERG_RELEASES = "https://codeberg.org/api/v1/repos/aclist/dzgui/releases/latest"
|
||||
@@ -21,5 +26,3 @@ GITHUB_ISSUES = "https://github.com/aclist/dzgui/issues"
|
||||
FORUM = "https://old.reddit.com/r/dzgui"
|
||||
SPONSORS = "https://github.com/sponsors/aclist"
|
||||
STEAM_API_SETUP = "https://steamcommunity.com/dev/apikey"
|
||||
BM_API_SETUP = "https://www.battlemetrics.com/developers"
|
||||
BM_BROWSE = "https://www.battlemetrics.com/servers/dayz"
|
||||
+7
-8
@@ -61,9 +61,6 @@ class Preferences(EnumWithAttrs):
|
||||
STEAM = {
|
||||
"key": "steam_api",
|
||||
}
|
||||
BM = {
|
||||
"key": "bm_api",
|
||||
}
|
||||
CLIENT = {
|
||||
"key": "client",
|
||||
}
|
||||
@@ -103,6 +100,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 +165,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 +181,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 +247,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
|
||||
+38
-24
@@ -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()
|
||||
@@ -399,7 +413,7 @@ class Controller(GObject.GObject):
|
||||
|
||||
def add_by_str(self, addr: str) -> None:
|
||||
saved_tree = self.get_servers().get_saved()
|
||||
ServerModelManager(self, saved_tree).add_by_str(addr)
|
||||
ServerModelManager(self, saved_tree).add_by_ip(addr)
|
||||
|
||||
def add_by_record(self, record: "Record") -> None:
|
||||
saved_tree = self.get_servers().get_saved()
|
||||
@@ -423,14 +437,8 @@ class Controller(GObject.GObject):
|
||||
ServerModelManager(self, tv).remove_from_history(record)
|
||||
|
||||
def connect_by_str(self, addr: str) -> None:
|
||||
if addr.isdigit():
|
||||
config_man = self.get_config_man()
|
||||
key = config_man.lookup(Preferences.BM)
|
||||
self.connection_man = ConnectionManager(self)
|
||||
self.connection_man.connect_by_id(int(addr), key)
|
||||
else:
|
||||
self.connection_man = ConnectionManager(self)
|
||||
self.connection_man.connect_by_ip(addr)
|
||||
self.connection_man = ConnectionManager(self)
|
||||
self.connection_man.connect_by_ip(addr)
|
||||
|
||||
def connect_by_record(self, record: "Record") -> None:
|
||||
self.connection_man = ConnectionManager(self)
|
||||
@@ -511,11 +519,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 +541,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)
|
||||
@@ -12,7 +12,7 @@ from dzgui.const.constants import (
|
||||
WINDOW_DEFAULT_X,
|
||||
WINDOW_DEFAULT_Y,
|
||||
)
|
||||
from dzgui.api.probe import test_steam_api, test_bm_api
|
||||
from dzgui.api.probe import test_steam_api
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
@@ -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)
|
||||
@@ -99,8 +102,6 @@ class ConfigManager:
|
||||
def update_api_key(self, key: Preferences, text: str) -> None:
|
||||
if key is Preferences.STEAM:
|
||||
res = test_steam_api(text)
|
||||
else:
|
||||
res = test_bm_api(text)
|
||||
if res is True:
|
||||
self.update_config(key, text)
|
||||
else:
|
||||
|
||||
@@ -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,8 @@ class Prerequisites:
|
||||
mods: list[list[str]]
|
||||
game_mode: bool
|
||||
is_last_server: bool
|
||||
invalid_mods: list[tuple[str, str]]
|
||||
allows_downloads: tuple[bool, str]
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
@@ -88,14 +100,10 @@ class ConnectionManager:
|
||||
self.record: Record
|
||||
self.workshop: Path
|
||||
|
||||
self.client: str
|
||||
self.remote_mod_ids: list[str] = []
|
||||
self.missing_mods: list[tuple[str, str, int, int]] = []
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def connect_by_id(self, _id: int, key: str) -> None:
|
||||
res = Servers.query_by_id(_id, key)
|
||||
self._prepare_connection(res)
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def connect_by_ip(self, addr: str) -> None:
|
||||
res = Servers.query_by_ip(addr)
|
||||
@@ -142,21 +150,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)
|
||||
@@ -167,15 +168,33 @@ class ConnectionManager:
|
||||
|
||||
dayz_running = is_dayz_running()
|
||||
|
||||
running_app = get_running_app()
|
||||
if running_app is not None:
|
||||
allows_dl = get_app_allows_downloads(steam_path, running_app)
|
||||
running_app_name = get_app_name(running_app)
|
||||
# NOTE: for out-of-range appid, assume NSG.
|
||||
# dzgui.api.steam.get_app_name() will return None
|
||||
if running_app_name is None:
|
||||
s = Shortcuts(steam_path)
|
||||
# NOTE: returns "Unknown" if unparseable
|
||||
running_app_name = s.find_appname_by_unsigned_id(running_app)
|
||||
allows_downloads = (allows_dl, running_app_name)
|
||||
else:
|
||||
allows_downloads = (True, "")
|
||||
|
||||
client_name = self.controller.get_steam_client_name()
|
||||
client = self.controller.query_config(Preferences.CLIENT)
|
||||
running = is_steam_running(client)
|
||||
steam_proc = SteamProcess(client_name, running)
|
||||
self.client = client
|
||||
|
||||
game_mode = prefs.is_game_mode
|
||||
|
||||
is_last = self.is_last_server()
|
||||
|
||||
# TODO: strings
|
||||
invalid_mods = [(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod"]
|
||||
|
||||
prereqs = Prerequisites(
|
||||
name=info.server_name,
|
||||
appid=info.game_id,
|
||||
@@ -191,6 +210,8 @@ class ConnectionManager:
|
||||
mods=remote_mods,
|
||||
game_mode=game_mode,
|
||||
is_last_server=is_last,
|
||||
invalid_mods=invalid_mods,
|
||||
allows_downloads=allows_downloads,
|
||||
)
|
||||
|
||||
func = StoredFunc(self.controller.open_connection_assistant, prereqs)
|
||||
@@ -219,10 +240,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,
|
||||
@@ -232,14 +256,29 @@ 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
|
||||
for mod in alpha_mods:
|
||||
# NOTE: if the mod is neither synched or out of date, it is a malformed mod
|
||||
if mod[2] == "":
|
||||
# TODO: strings
|
||||
mod[2] = "Invalid mod"
|
||||
|
||||
return alpha_mods, missing_mods
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def query_modlist_and_present(self, record: Servers.Record) -> None:
|
||||
try:
|
||||
mods = self._query_modlist(record)
|
||||
except Exception as e:
|
||||
print(f"{type(e).__name__}: {e}")
|
||||
mods, missing_mods = self._query_modlist(record)
|
||||
except Exception:
|
||||
self.thread_man.set_cleanup_func(
|
||||
StoredFunc(self._server_timeout), destroy_first=True
|
||||
)
|
||||
@@ -264,20 +303,19 @@ class ConnectionManager:
|
||||
def _connect_steam(self, menu_only: bool) -> None:
|
||||
addr = f"{self.record.ip}:{self.record.gameport}"
|
||||
playername = self.controller.query_config(Preferences.NAME)
|
||||
client = self.controller.query_config(Preferences.CLIENT)
|
||||
if menu_only:
|
||||
rc = load_to_menu(client, addr, self.appid, playername, self.remote_mod_ids)
|
||||
rc = load_to_menu(self.client, self.appid, playername, self.remote_mod_ids)
|
||||
else:
|
||||
rc = connect(client, addr, self.appid, playername, self.remote_mod_ids)
|
||||
rc = connect(self.client, addr, self.appid, playername, self.remote_mod_ids)
|
||||
if rc != 0:
|
||||
# TODO: log/pop the error
|
||||
func = StoredFunc(self.controller.update_status)
|
||||
self.thread_man.set_cleanup_func(func)
|
||||
return
|
||||
|
||||
self.thread_man.show_cancel(False)
|
||||
self.thread_man.update_dialog(waiting_for_launch)
|
||||
while True:
|
||||
# FIXME: cancel should not be visible here per setting above
|
||||
if self.controller.is_cancel_pending():
|
||||
# TODO: some facility to also close spawned steam process
|
||||
return
|
||||
@@ -292,20 +330,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(mod, self.appid)
|
||||
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
|
||||
|
||||
@@ -323,13 +359,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))
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ class UpdateManager:
|
||||
with tarfile.open(TMP_TARBALL) as tar:
|
||||
tar.extractall(TMP_PATH)
|
||||
|
||||
shutil.move(TMP_EXE, exe_path)
|
||||
shutil.copy(TMP_EXE, exe_path)
|
||||
|
||||
proc = subprocess.run([exe_path, "self", "restore"])
|
||||
if proc.returncode == 0:
|
||||
@@ -53,7 +53,7 @@ class UpdateManager:
|
||||
func = StoredFunc(self._on_update_failure, msg)
|
||||
self.thread_man.set_cleanup_func(func, destroy_first=True)
|
||||
except Exception as e:
|
||||
func = StoredFunc(self._on_update_failure, e)
|
||||
func = StoredFunc(self._on_update_failure, str(e))
|
||||
self.thread_man.set_cleanup_func(func, destroy_first=True)
|
||||
logger.warning(e)
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -1,7 +1,6 @@
|
||||
import re
|
||||
|
||||
from typing import Any, TYPE_CHECKING, Union
|
||||
from warnings import deprecated
|
||||
|
||||
from dzgui.const.enum import FilterMode
|
||||
from dzgui.model.model_factory import ModelFactory
|
||||
@@ -86,8 +85,8 @@ class ProxyModelManager:
|
||||
if found is False:
|
||||
self.control_model.append(history)
|
||||
|
||||
# if len(self.control_model) == 11:
|
||||
# del self.control_model[0]
|
||||
if len(self.control_model) == 11:
|
||||
del self.control_model[0]
|
||||
|
||||
self.filter(FilterMode.INITIAL, skip_cache=True)
|
||||
|
||||
@@ -259,7 +258,7 @@ class ProxyModelManager:
|
||||
def set_cache(self, filters: tuple[str], filtered_rows: list[tuple]) -> None:
|
||||
self.filter_cache[filters] = filtered_rows
|
||||
|
||||
@deprecated("Currently unused")
|
||||
# @deprecated("Currently unused")
|
||||
# def convert_model_to_list(self, model: "FastInsertListStore") -> list:
|
||||
# return [[el for el in row] for row in model]
|
||||
|
||||
|
||||
+24
-29
@@ -15,7 +15,7 @@ from dzgui.const.constants import (
|
||||
from dzgui.const.enum import FilterMode, Preferences, ServerTab
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.strings import dialogs
|
||||
from dzgui.util.strings import api_warn_msg, dialog
|
||||
from dzgui.util.strings import server_timeout, dialog
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
|
||||
import gi
|
||||
@@ -175,29 +175,29 @@ class ServerModelManager:
|
||||
# TODO: wrap except
|
||||
if self.controller.get_exit_event().is_set():
|
||||
return
|
||||
res = future.result(timeout=API_TIMEOUT)
|
||||
self.thread_man.increment_dialog()
|
||||
# NOTE: failing entries are culled
|
||||
if res is None:
|
||||
# TODO: log which servers failed
|
||||
continue
|
||||
servers.append(res)
|
||||
if len(servers) == 0:
|
||||
self.thread_man.set_cleanup_func(
|
||||
StoredFunc(self._cleanup_on_failure), destroy_first=True
|
||||
)
|
||||
return
|
||||
|
||||
"""
|
||||
Collects futures in linear order after completion
|
||||
for lock-step representation of the state file
|
||||
"""
|
||||
for future in futures:
|
||||
res = future.result(timeout=API_TIMEOUT)
|
||||
# NOTE: failing entries are culled
|
||||
if res is None:
|
||||
# TODO: log which servers failed
|
||||
# query_direct() currently does not return enough information
|
||||
continue
|
||||
servers.append(res)
|
||||
if len(servers) == 0:
|
||||
self.thread_man.set_cleanup_func(
|
||||
StoredFunc(self._cleanup_on_failure), destroy_first=True
|
||||
)
|
||||
return
|
||||
|
||||
parsed = Servers.parse_json(servers)
|
||||
self._push_data(parsed)
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def add_by_id(self, _id: str) -> None:
|
||||
config_man = self.controller.get_config_man()
|
||||
key = config_man.lookup(Preferences.BM)
|
||||
res = Servers.query_by_id(int(_id), key)
|
||||
self._parse_single_record(res)
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def add_by_ip(self, addr: str) -> None:
|
||||
res = Servers.query_by_ip(addr)
|
||||
@@ -241,7 +241,6 @@ class ServerModelManager:
|
||||
|
||||
self.first_iteration = False
|
||||
self.emitter.emit("load_maps", store)
|
||||
# self.emitter.emit("servers_loaded_init")
|
||||
|
||||
# TODO: dataclass for record rows; check for other dict annotations
|
||||
def add_to_history(self, data: tuple[dict[str, Any], "Record"]) -> None:
|
||||
@@ -263,12 +262,6 @@ class ServerModelManager:
|
||||
proxy_man.remove_row_from_control(record)
|
||||
self.update_history()
|
||||
|
||||
def add_by_str(self, addr: str) -> None:
|
||||
if addr.isdigit():
|
||||
self.add_by_id(addr)
|
||||
else:
|
||||
self.add_by_ip(addr)
|
||||
|
||||
@call_on_thread(dialog.querying)
|
||||
def update_playercount(
|
||||
self, treeiter: Gtk.TreeIter, record: Servers.Record
|
||||
@@ -374,7 +367,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 +388,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:
|
||||
@@ -424,7 +419,7 @@ class ServerModelManager:
|
||||
# TODO: distinguish signals, e.g. "servers_failed_to_load", "servers_loaded_empty"
|
||||
# customize statusbar and dialog accordingly
|
||||
if show_dialog:
|
||||
dialog = ExceptionDialog(self.controller, api_warn_msg)
|
||||
dialog = ExceptionDialog(self.controller, server_timeout)
|
||||
dialog.run()
|
||||
|
||||
def _push_data(self, data: list[Any]) -> None:
|
||||
|
||||
@@ -5,10 +5,9 @@ add_tooltip="Add to Saved Servers"
|
||||
connect_button="Connect"
|
||||
connect_tooltip="Connect to this server"
|
||||
connect_entry_tooltip=(
|
||||
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n"
|
||||
"- Battlemetrics: numeric server ID\ne.g. 123456"
|
||||
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016"
|
||||
)
|
||||
connect_entry_placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)"
|
||||
connect_entry_placeholder="Enter IP (IP:Query port)"
|
||||
|
||||
fav_heading="Favorite server"
|
||||
favs_empty="None set. Right click a server and select 'Set favorite' to set."
|
||||
|
||||
@@ -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,25 @@
|
||||
heading = "Play offline"
|
||||
heading_disclaimer = (
|
||||
"Not all mod combinations will function offline. Use at your own discretion."
|
||||
)
|
||||
|
||||
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."
|
||||
+19
-5
@@ -46,11 +46,6 @@ You must set up a Steam Web API key in order to browse the global server list.
|
||||
\nIf you don't have one already, it can be set up via the page below.
|
||||
\nPlease refer to the DZGUI documentation for more instructions.
|
||||
"""
|
||||
heading_bm_api = "Battlemetrics Web API key"
|
||||
blurb_bm_api = """A Battlemetrics key is <b>optional</b>, but allows you to add/search for servers\n
|
||||
by numeric ID on the web. For example, in the URL https://www.battlemetrics.net/servers/dayz/24819107,\n
|
||||
the ID would be <b>24819107</b>.
|
||||
"""
|
||||
|
||||
### PreferencesPage
|
||||
heading_prefs = "User preferences"
|
||||
@@ -64,6 +59,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}"
|
||||
|
||||
|
||||
@@ -83,3 +83,7 @@ def format_player_count(model: Gtk.TreeModel | None, control: list) -> str:
|
||||
|
||||
def embolden(text: str) -> str:
|
||||
return f"<b>{text}</b>"
|
||||
|
||||
|
||||
def format_exception(e: Exception) -> str:
|
||||
return f"{type(e).__name__}: {e}"
|
||||
+26
-16
@@ -1,20 +1,30 @@
|
||||
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'\/]*)(.*)"
|
||||
user_filter = r"(.*Steam/userdata/)([^/]*)(.*)"
|
||||
REDACTED = r"\1REDACTED\3"
|
||||
REDACTION_PATTERNS = [api_filter, home_filter, user_filter]
|
||||
|
||||
|
||||
def redact_home(text: str) -> str:
|
||||
pat = re.compile(home_filter)
|
||||
cleaned = pat.sub(REDACTED, text)
|
||||
return cleaned
|
||||
|
||||
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
|
||||
+19
-27
@@ -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",
|
||||
@@ -100,10 +100,7 @@ steam_missing = "Local Steam installation is not set, possibly malformed config
|
||||
build_corrupted = (
|
||||
"Steam settings or DayZ installation may be corrupted. Try restarting Steam."
|
||||
)
|
||||
api_warn_msg = """No servers returned. Please wait and try again.
|
||||
This usually indicates a transitory network timeout. If this issue persists, your API key or network may be malfunctioning.
|
||||
"""
|
||||
server_timeout = "Timed out when querying server, check IP or try again later."
|
||||
server_timeout = "Timed out when querying server(s). Check IP or try again later."
|
||||
server_error = (
|
||||
"Error while contacting server, possibly timed out. Please wait and try again."
|
||||
)
|
||||
@@ -130,7 +127,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 +144,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",
|
||||
}
|
||||
@@ -181,7 +177,7 @@ filter_lowpop = "Low pop"
|
||||
filter_nonascii = "Non-ASCII"
|
||||
filter_duplicate = "Duplicate"
|
||||
filter_official = "Official"
|
||||
filter_unofficial = "Unoffic."
|
||||
filter_unofficial = "Unofficial"
|
||||
filter_modded = "Modded"
|
||||
|
||||
# maps
|
||||
@@ -215,8 +211,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 +269,7 @@ class Crumbs:
|
||||
thanks: str
|
||||
developers: str
|
||||
default: str
|
||||
offline: str
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
@@ -286,11 +283,8 @@ class Thanks:
|
||||
class Options:
|
||||
header: str
|
||||
steam_web: str
|
||||
bm_web: str
|
||||
enter_steam: str
|
||||
enter_bm: str
|
||||
steam_placeholder: str
|
||||
bm_placeholder: str
|
||||
name_placeholder: str
|
||||
last_used: str
|
||||
always_fs: str
|
||||
@@ -311,7 +305,7 @@ class Options:
|
||||
force_eventbox: str
|
||||
self_update: str
|
||||
no_self_update: str
|
||||
api_keys: str
|
||||
api_key: str
|
||||
prefs: str
|
||||
mods: str
|
||||
version: str
|
||||
@@ -333,11 +327,11 @@ mod_panel = ModPanelStrings(
|
||||
unhighlight_stale_tooltip="Clears highlight from stale mods",
|
||||
highlight_stale="Highlight stale",
|
||||
highlight_stale_tooltip=(
|
||||
"Shows locally-installed mods which are not used by any server "
|
||||
"in your Saved Servers"
|
||||
"Shows locally-installed mods which are not \n"
|
||||
"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 +360,7 @@ thanks = Thanks(
|
||||
"Johnofwrong",
|
||||
"MatheusLasserr",
|
||||
"nolan-perez",
|
||||
"OnniSaarni",
|
||||
"scandalouss",
|
||||
"SnackSBR",
|
||||
"StevelDusa",
|
||||
@@ -377,11 +372,8 @@ thanks = Thanks(
|
||||
options = Options(
|
||||
header="Options",
|
||||
steam_web="Steam API page",
|
||||
bm_web="Battlemetrics API page",
|
||||
enter_steam="Enter your Steam API key",
|
||||
enter_bm="Enter your Battlemetrics API key",
|
||||
steam_placeholder="Steam API key",
|
||||
bm_placeholder="Battlemetrics API key",
|
||||
name_placeholder="Identifies you to other players in-game",
|
||||
last_used="Last used dimensions",
|
||||
always_fs="Always fullscreen",
|
||||
@@ -410,7 +402,7 @@ options = Options(
|
||||
"In-app updates are disabled when DZGUI is "
|
||||
"installed globally (e.g., via package manager)."
|
||||
),
|
||||
api_keys="API Keys",
|
||||
api_key="API Key",
|
||||
prefs="Preferences",
|
||||
mods="Mods",
|
||||
version="Version",
|
||||
@@ -462,6 +454,7 @@ crumbs = Crumbs(
|
||||
thanks="Help > Special thanks",
|
||||
developers="Options > Developers",
|
||||
default="Servers > ",
|
||||
offline="Mods > Play offline",
|
||||
)
|
||||
|
||||
no_mods = "No local mods found."
|
||||
@@ -547,10 +540,9 @@ connect_panel = ConnectPanel(
|
||||
connect="Connect",
|
||||
add="Add",
|
||||
add_con="Add/connect",
|
||||
placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)",
|
||||
placeholder="Enter IP (IP:Query port)",
|
||||
entry_tooltip=(
|
||||
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n"
|
||||
"- Battlemetrics: numeric server ID\ne.g. 123456"
|
||||
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016"
|
||||
),
|
||||
add_tooltip="Add to Saved Servers",
|
||||
connect_tooltip="Connect to this server",
|
||||
|
||||
+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
|
||||
|
||||
+9
-2
@@ -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
|
||||
@@ -52,7 +54,10 @@ class OuterWindow(Gtk.Window):
|
||||
MainController.register_widget("window", self)
|
||||
|
||||
# NOTE: steam deck taskbar may occlude elements
|
||||
if MainController.get_prefs().is_steam_deck is False:
|
||||
is_deck = MainController.get_prefs().is_steam_deck
|
||||
if is_deck:
|
||||
self.maximize()
|
||||
else:
|
||||
self.set_titlebar(self.hb)
|
||||
|
||||
self.connect("delete-event", self._on_delete_event)
|
||||
@@ -68,7 +73,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 +114,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)
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Callable, Self, TYPE_CHECKING, Union
|
||||
from typing import Callable, Literal, Self, TYPE_CHECKING, Union
|
||||
|
||||
from dzgui.util.clip import copy_clipboard
|
||||
from dzgui.util.format import pluralize
|
||||
@@ -31,40 +31,70 @@ 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)
|
||||
|
||||
def swap_icon(self, icon: str) -> None:
|
||||
start = self.icon.get_margin_start()
|
||||
end = self.icon.get_margin_end()
|
||||
alt_icon = Icon(icon, margin_start=start, margin_end=end)
|
||||
self.set_image(alt_icon)
|
||||
|
||||
def revert_icon(self) -> None:
|
||||
self.set_image(self.icon)
|
||||
|
||||
|
||||
class IconTextButton(IconButton):
|
||||
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))
|
||||
|
||||
@@ -75,11 +105,18 @@ class ClipboardButton(IconTextButton):
|
||||
self.controller = controller
|
||||
self.connect("clicked", self._on_button_clicked, func)
|
||||
|
||||
# TODO: strings
|
||||
self.set_tooltip_text("Copy to clipboard")
|
||||
|
||||
def _on_button_clicked(self, button: Self, func: Callable) -> None:
|
||||
def revert() -> Literal[False]:
|
||||
self.revert_icon()
|
||||
return False
|
||||
|
||||
self.swap_icon("object-select-symbolic")
|
||||
data = func()
|
||||
copy_clipboard(data)
|
||||
GLib.timeout_add(600, revert)
|
||||
|
||||
|
||||
# TODO: determine when controller would be passed to this button or drop
|
||||
@@ -87,6 +124,7 @@ class CopyIpButton(ClipboardButton):
|
||||
def __init__(self, controller: Union["Controller", None], func: Callable) -> None:
|
||||
super().__init__(controller, func)
|
||||
|
||||
# TODO: strings
|
||||
self.set_tooltip_text("Copy IP to clipboard")
|
||||
|
||||
|
||||
@@ -120,6 +158,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 +227,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
|
||||
|
||||
@@ -26,15 +26,12 @@ def validate_port(text: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def validate_ip_or_id(text: str) -> bool:
|
||||
def validate_ip_truthy(text: str) -> bool:
|
||||
try:
|
||||
validate_ip(text)
|
||||
return True
|
||||
except Exception:
|
||||
if text.isdigit():
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
class ValidatedEntry(Gtk.Entry):
|
||||
@@ -124,7 +121,7 @@ class IpEntry(ValidatedEntry):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(
|
||||
controller,
|
||||
func=validate_ip_or_id,
|
||||
func=validate_ip_truthy,
|
||||
placeholder_text=connect_panel.placeholder,
|
||||
tooltip_text=connect_panel.entry_tooltip,
|
||||
)
|
||||
@@ -162,7 +159,8 @@ class APIEntry(Gtk.Box):
|
||||
self.entry.connect("icon-release", self._on_icon_release)
|
||||
self.entry.connect("activate", self._on_field_activated)
|
||||
|
||||
self.submit = Gtk.Button(label="Submit")
|
||||
# TODO: strings
|
||||
self.submit = Gtk.Button(label="Validate")
|
||||
self.submit.set_sensitive(False)
|
||||
self.submit.connect("clicked", self._on_submit)
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ class InfoEventBox(Gtk.EventBox):
|
||||
self.connect("leave-notify-event", self._on_leave_tooltip)
|
||||
self.add(box)
|
||||
|
||||
def set_icon_yalign(self, offset: float) -> None:
|
||||
self.icon.set_property("yalign", offset)
|
||||
|
||||
def _on_enter_tooltip(
|
||||
self, eventbox: Gtk.EventBox, eventcrossing: Gdk.EventCrossing
|
||||
) -> None:
|
||||
|
||||
@@ -11,6 +11,7 @@ from dzgui.const.constants import (
|
||||
)
|
||||
from dzgui.const.enum import FilterMode
|
||||
from dzgui.model.servers import ServerModelManager
|
||||
from dzgui.util.format import format_exception
|
||||
from dzgui.util.strings import all_maps
|
||||
from dzgui.views.components.maps_combo import MapsCombo
|
||||
from dzgui.views.components.labels import BoldLabel
|
||||
@@ -333,6 +334,10 @@ class FilterPanel(Gtk.Box):
|
||||
self.emitter.emit("map_selection_changed", name)
|
||||
|
||||
def set_filters(self, filters: dict[str, bool]) -> None:
|
||||
for check in self.button_grid.checks:
|
||||
label = check.get_label()
|
||||
check.set_active(filters[label])
|
||||
try:
|
||||
for check in self.button_grid.checks:
|
||||
label = check.get_label()
|
||||
check.set_active(filters[label])
|
||||
except Exception as e:
|
||||
msg = format_exception(e)
|
||||
logger.warning(msg)
|
||||
@@ -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:
|
||||
|
||||
@@ -131,6 +131,7 @@ class RightPanel(Gtk.Box):
|
||||
return
|
||||
self.copying = True
|
||||
version = self.version_label.get_text()
|
||||
# TODO: strings
|
||||
self.version_label.set_text("Copied!")
|
||||
copy_clipboard(version)
|
||||
GLib.timeout_add_seconds(1, revert)
|
||||
@@ -1,5 +1,4 @@
|
||||
from typing import Self, Union, TYPE_CHECKING
|
||||
from warnings import deprecated
|
||||
|
||||
from dzgui.const.enum import NotebookPage, ServerTab
|
||||
from dzgui.util.strings import esc_to_return, question_to_return
|
||||
@@ -8,7 +7,7 @@ from dzgui.views.components.buttons import LoggerAlertsButton
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GObject # noqa E402
|
||||
from gi.repository import Gtk # noqa E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.const.enum import ServerTab
|
||||
@@ -26,6 +25,8 @@ class Statusbar(Gtk.Grid):
|
||||
self.controller.register_widget("statusbar", self)
|
||||
self.emitter = controller.get_emitter()
|
||||
|
||||
self.prior_enum: NotebookPage
|
||||
self.prior_status: str
|
||||
self.playercount = ""
|
||||
self.statusbar = Gtk.Statusbar()
|
||||
|
||||
@@ -34,14 +35,6 @@ class Statusbar(Gtk.Grid):
|
||||
self.attach(self.statusbar, 0, 0, 3, 1)
|
||||
self.attach_next_to(self.spinner, self.statusbar, Gtk.PositionType.RIGHT, 3, 1)
|
||||
|
||||
# TODO: pack version event box in right panel into hbox with update button
|
||||
# TODO: spawns a modal or just jumps right to install page
|
||||
# from dzgui.views.components.buttons import IconTextButton
|
||||
# b = IconTextButton("dialog-information-symbolic", label="Updates available")
|
||||
# b.set_halign(Gtk.Align.END)
|
||||
# b.set_hexpand(True)
|
||||
# self.attach_next_to(b, self.spinner, Gtk.PositionType.RIGHT, 3, 1)
|
||||
|
||||
self.players = ""
|
||||
|
||||
controller.get_menu().connect("generic_treesel_changed", self._help_row_changed)
|
||||
@@ -78,7 +71,9 @@ class Statusbar(Gtk.Grid):
|
||||
NotebookPage.CHANGELOG,
|
||||
NotebookPage.LOG,
|
||||
NotebookPage.CONNECTION,
|
||||
NotebookPage.OFFLINE,
|
||||
):
|
||||
self.prior_enum = enum
|
||||
self.set_by_context(enum, esc_to_return)
|
||||
return
|
||||
|
||||
@@ -88,16 +83,11 @@ class Statusbar(Gtk.Grid):
|
||||
case NotebookPage.KEYS:
|
||||
bar = question_to_return
|
||||
case _:
|
||||
return
|
||||
bar = self.prior_status
|
||||
|
||||
self.prior_enum = enum
|
||||
self.set_by_context(enum, bar)
|
||||
|
||||
# TODO: unused
|
||||
def _on_notebook_page_returned(
|
||||
self, statusbar: Self, prior_context: NotebookPage
|
||||
) -> None:
|
||||
self.pop(prior_context)
|
||||
|
||||
def _on_server_row_changed(self, statusbar: Self) -> None:
|
||||
self.spinner.start()
|
||||
|
||||
@@ -111,8 +101,13 @@ class Statusbar(Gtk.Grid):
|
||||
context: Union["ServerTab", NotebookPage],
|
||||
) -> None:
|
||||
self.spinner.stop()
|
||||
|
||||
if type(context) is NotebookPage:
|
||||
self.prior_enum = context
|
||||
self.prior_status = ""
|
||||
# FIXME: CalcDist is being called when table is not loaded
|
||||
if dist is None:
|
||||
self.set_by_context(context, "")
|
||||
return
|
||||
else:
|
||||
pretty = self.append_distance(dist)
|
||||
@@ -132,15 +127,11 @@ class Statusbar(Gtk.Grid):
|
||||
cid = self.statusbar.get_context_id(str(context))
|
||||
self.statusbar.pop(cid)
|
||||
|
||||
@deprecated("currently unused")
|
||||
# def get_text(self) -> str:
|
||||
# area = self.statusbar.get_message_area()
|
||||
# label = area.get_children()[0]
|
||||
# return str(label.get_text())
|
||||
|
||||
def set_by_context(
|
||||
self, context: Union[NotebookPage, "ServerTab"], string: str
|
||||
) -> None:
|
||||
if context != NotebookPage.KEYS:
|
||||
self.prior_status = string
|
||||
meta = self.statusbar.get_context_id(str(context))
|
||||
self.statusbar.push(meta, string)
|
||||
self.set_cache(string)
|
||||
|
||||
+20
-11
@@ -1,19 +1,20 @@
|
||||
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
|
||||
from dzgui.views.components.buttons import ClipboardButton
|
||||
@@ -40,7 +41,7 @@ class BootDialog(Gtk.Dialog):
|
||||
def __init__(self, parent: "BootWindow", xdg: "Xdg", version: str) -> None:
|
||||
super().__init__(
|
||||
title=dialog_header,
|
||||
parent=parent,
|
||||
transient_for=parent,
|
||||
modal=True,
|
||||
)
|
||||
|
||||
@@ -57,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)
|
||||
@@ -118,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),
|
||||
@@ -125,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),
|
||||
]
|
||||
@@ -134,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]:
|
||||
@@ -175,16 +185,15 @@ class BootDialog(Gtk.Dialog):
|
||||
self.thread_man.set_cleanup_func(callback)
|
||||
except Exception as e:
|
||||
self.failed = True
|
||||
# TODO: generic exception wrapper
|
||||
self.exception = f"{type(e).__name__}: {e}"
|
||||
self.exception = format_exception(e)
|
||||
callback = StoredFunc(self.update_status, Success.FAIL)
|
||||
self.thread_man.set_cleanup_func(callback)
|
||||
|
||||
def update_task(self, task: str) -> None:
|
||||
self.store.append((task, "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)
|
||||
|
||||
@@ -13,7 +13,7 @@ class FilePicker(Gtk.FileChooserDialog):
|
||||
super().__init__(
|
||||
title=picker.title,
|
||||
action=Gtk.FileChooserAction.SAVE,
|
||||
parent=parent,
|
||||
transient_for=parent,
|
||||
resizable=True,
|
||||
)
|
||||
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL)
|
||||
@@ -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
|
||||
@@ -111,19 +111,22 @@ class WaitDialog(GenericDialog):
|
||||
|
||||
self.connect("delete-event", lambda widget, event: True)
|
||||
content = self.get_content_area()
|
||||
spinner = Gtk.Spinner()
|
||||
self.prog = Gtk.ProgressBar()
|
||||
|
||||
content.pack_end(self.cancel, NO_EXPAND, NO_FILL, 0)
|
||||
content.pack_end(spinner, NO_EXPAND, NO_FILL, 0)
|
||||
spinner_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.CENTER
|
||||
)
|
||||
spinner = Gtk.Spinner()
|
||||
spinner_box.add(spinner)
|
||||
|
||||
if self.jobs > 1:
|
||||
content.pack_end(self.prog, NO_EXPAND, NO_FILL, 0)
|
||||
content.pack_start(self.prog, NO_EXPAND, NO_FILL, 0)
|
||||
else:
|
||||
content.pack_start(spinner_box, NO_EXPAND, NO_FILL, 0)
|
||||
spinner.start()
|
||||
|
||||
if show_cancel is False:
|
||||
self.connect("realize", lambda _: self.cancel.set_visible(False))
|
||||
if show_cancel is True:
|
||||
content.pack_start(self.cancel, NO_EXPAND, NO_FILL, 0)
|
||||
|
||||
def update_text(self, msg: str) -> None:
|
||||
self.format_secondary_text(msg)
|
||||
|
||||
+153
-51
@@ -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.probe import test_steam_api
|
||||
from dzgui.api.shortcuts import add_steam_shortcut
|
||||
from dzgui.api.steam import get_steam_paths
|
||||
from dzgui.const.constants import (
|
||||
APP_NAME,
|
||||
@@ -14,7 +16,10 @@ from dzgui.const.constants import (
|
||||
LEGACY_CONFIG_PATH,
|
||||
)
|
||||
from dzgui.const.boilerplate import config_boilerplate
|
||||
from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP
|
||||
from dzgui.const.endpoints import 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,17 +35,27 @@ 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
|
||||
HAS_CONFIG = 2
|
||||
STEAM_PATH = 3
|
||||
STEAM_API = 4
|
||||
BM_API = 5
|
||||
USER_PREFS = 6
|
||||
USER_PREFS = 5
|
||||
SHORTCUTS = 6
|
||||
FINAL = 7
|
||||
|
||||
|
||||
class OptionalPageMixin:
|
||||
"""Marks optional pages as advanceable"""
|
||||
|
||||
def _on_map(self, page: "ScrolledWizardPage") -> None:
|
||||
EMITTER.emit("step_complete")
|
||||
|
||||
|
||||
class DescriptionArea(Gtk.Box):
|
||||
def __init__(self, text: str):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
@@ -58,7 +73,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
|
||||
@@ -82,6 +97,7 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
|
||||
margin_top=50,
|
||||
spacing=20,
|
||||
)
|
||||
|
||||
self.add(self.box)
|
||||
self.prog = Progress()
|
||||
self.box.pack_end(self.prog, expand=False, fill=False, padding=0)
|
||||
@@ -141,6 +157,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__(
|
||||
@@ -202,23 +222,6 @@ class APIValidationPage(ScrolledWizardPage):
|
||||
self.spinner.stop()
|
||||
|
||||
|
||||
class BMValidationPage(APIValidationPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.BM_API,
|
||||
heading=wizard.heading_bm_api,
|
||||
description=wizard.blurb_bm_api,
|
||||
link=BM_API_SETUP,
|
||||
func=self._validate,
|
||||
)
|
||||
|
||||
@call_on_thread("", show_dialog=False)
|
||||
def _validate(self, key: str) -> None:
|
||||
is_valid = test_bm_api(key.strip())
|
||||
cleanup = StoredFunc(self._cleanup, is_valid, key)
|
||||
self.thread_man.set_cleanup_func(cleanup)
|
||||
|
||||
|
||||
class SteamValidationPage(APIValidationPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
@@ -339,6 +342,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,23 +427,27 @@ 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.is_binary = False if os.getenv("PYAPP") is None else True
|
||||
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.page5 = PreferencesPage()
|
||||
self.page6 = ShortcutCreationPage(XDG.shortcut)
|
||||
self.page7 = CompletionPage()
|
||||
|
||||
self.set_forward_page_func(self._advance_page)
|
||||
@@ -460,7 +468,13 @@ class Assistant(Gtk.Assistant):
|
||||
self.page7,
|
||||
):
|
||||
# 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 not self.is_binary:
|
||||
continue
|
||||
self._add_page(page, page.get_page_type())
|
||||
|
||||
@@ -475,30 +489,38 @@ class Assistant(Gtk.Assistant):
|
||||
|
||||
def _advance_page(self, index: int) -> int:
|
||||
page = self.get_nth_page(index)
|
||||
# TODO: use enums
|
||||
match page:
|
||||
case self.page1:
|
||||
case IntroductionPage():
|
||||
pass
|
||||
case self.page2:
|
||||
if self.page2.is_migrated():
|
||||
return self.get_n_pages() - 1
|
||||
case self.page3:
|
||||
case ConfigMigrationPage():
|
||||
if page.is_migrated():
|
||||
steam_path = lookup(self.config_path, Preferences.DEFAULT)
|
||||
self.page6.set_steam_path(steam_path)
|
||||
offset = 1 if not self.is_binary else 2
|
||||
self.setup_complete = True
|
||||
return self.get_n_pages() - offset
|
||||
case SteamPathPage():
|
||||
self.config_values["default_steam_path"] = page.get_path_from_radio()
|
||||
case self.page4:
|
||||
case SteamValidationPage():
|
||||
self.config_values["steam_api"] = page.get_api_key()
|
||||
case self.page5:
|
||||
self.config_values["bm_api"] = page.get_api_key()
|
||||
# NOTE: collects config values before advancing to last page
|
||||
case self.page6:
|
||||
name, use_miles, client = self.page6.get_prefs()
|
||||
case PreferencesPage():
|
||||
# NOTE: collects config values before advancing to last page
|
||||
name, use_miles, client = self.page5.get_prefs()
|
||||
self.config_values["name"] = name
|
||||
self.config_values["use_miles"] = use_miles
|
||||
self.config_values["client"] = client
|
||||
self.write_config()
|
||||
self.page6.set_steam_path(self.config_values["default_steam_path"])
|
||||
self.setup_complete = True
|
||||
case ShortcutCreationPage():
|
||||
page.create_shortcuts()
|
||||
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()
|
||||
@@ -535,14 +557,91 @@ class Assistant(Gtk.Assistant):
|
||||
bar.set_fraction(fraction)
|
||||
bar.set_text(f"{page_num}/{total}")
|
||||
|
||||
# NOTE: disable forward action
|
||||
# TODO: use page enums
|
||||
if page == self.page5:
|
||||
return
|
||||
if page != self.page1:
|
||||
if not isinstance(page, IntroductionPage):
|
||||
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(OptionalPageMixin, ScrolledWizardPage): # type: ignore
|
||||
def __init__(self, shortcut: Path) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.SHORTCUTS,
|
||||
heading=wizard.heading_shortcuts,
|
||||
description=wizard.blurb_shortcuts,
|
||||
)
|
||||
|
||||
self.steam_path: Path
|
||||
self.shortcut_path = shortcut
|
||||
self.checks_area = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=20
|
||||
)
|
||||
self.page_type = Gtk.AssistantPageType.INTRO
|
||||
|
||||
label, blurb = wizard.checkbox_steam_shortcut
|
||||
self.steam_checkbox = CheckboxWithLabel(label, blurb)
|
||||
|
||||
label, blurb = wizard.checkbox_start_menu
|
||||
self.start_menu_checkbox = CheckboxWithLabel(label, blurb)
|
||||
cb = self.start_menu_checkbox.get_checkbox()
|
||||
cb.connect("toggled", self._on_start_menu_toggled)
|
||||
|
||||
label, blurb = wizard.checkbox_desktop_shortcut
|
||||
self.desktop_checkbox = CheckboxWithLabel(label, blurb)
|
||||
|
||||
for el in (
|
||||
self.steam_checkbox,
|
||||
self.start_menu_checkbox,
|
||||
self.desktop_checkbox,
|
||||
):
|
||||
self.checks_area.add(el)
|
||||
|
||||
self.add_start(self.checks_area)
|
||||
self.show_all()
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def _on_start_menu_toggled(self, button: Gtk.CheckButton) -> None:
|
||||
state = button.get_active()
|
||||
if not state:
|
||||
self.desktop_checkbox.set_active(state)
|
||||
self.desktop_checkbox.set_sensitive(state)
|
||||
|
||||
def set_steam_path(self, path: Path) -> None:
|
||||
self.steam_path = path
|
||||
|
||||
def 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 +698,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)
|
||||
|
||||
|
||||
@@ -37,7 +37,11 @@ class CursorMixin:
|
||||
end = len(model) - 1
|
||||
else:
|
||||
return False
|
||||
cur_row = self.get_focused_row_index() # type: ignore
|
||||
|
||||
try:
|
||||
cur_row = self.get_focused_row_index() # type: ignore
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if position == CursorPosition.DOWN:
|
||||
if cur_row == end:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from importlib import resources
|
||||
@@ -32,15 +33,79 @@ 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)
|
||||
|
||||
container = Gtk.Box(
|
||||
valign=Gtk.Align.START, halign=Gtk.Align.START
|
||||
)
|
||||
label = Gtk.Label()
|
||||
label.set_markup(formatted)
|
||||
container.add(label)
|
||||
|
||||
version, date = self._parse_version(header)
|
||||
button_text = f"{version} ({date})"
|
||||
button = Gtk.Button(label=button_text, margin_start=5)
|
||||
|
||||
expander = Gtk.Expander()
|
||||
expander.set_label_widget(button)
|
||||
expander.add(container)
|
||||
expander.connect("activate", self._on_expand, container)
|
||||
self.box.add(expander)
|
||||
|
||||
def _on_expand(self, expander: Gtk.Box, container: Gtk.Box) -> None:
|
||||
"""
|
||||
Wayland/some themes do not expect widgets to have a size allocation until expanded.
|
||||
Trying to set margins a priori may cause negative allocation,
|
||||
as the margins are subtracted from zero. In effect, the widget is not realized
|
||||
until it is expanded for the first time.
|
||||
"""
|
||||
container.set_margin_start(15)
|
||||
container.set_margin_top(15)
|
||||
container.set_margin_bottom(15)
|
||||
|
||||
def _parse(self, changelog: str) -> list[tuple[str, list[str]]]:
|
||||
release = ""
|
||||
releases: list[tuple[str, list[str]]] = []
|
||||
release_notes: list[str] = []
|
||||
for line in changelog.splitlines():
|
||||
if line.startswith("# "):
|
||||
continue
|
||||
if line.startswith("## "):
|
||||
if release != "":
|
||||
releases.append((release, release_notes))
|
||||
release_notes = []
|
||||
release = ""
|
||||
release = line
|
||||
continue
|
||||
release_notes.append(line.rstrip())
|
||||
return releases
|
||||
@@ -103,4 +103,4 @@ class Keybindings(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
|
||||
return grid
|
||||
|
||||
def grab_content_area(self) -> None:
|
||||
self.box.grab_focus()
|
||||
self.grab_focus()
|
||||
@@ -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,526 @@
|
||||
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.Box):
|
||||
def __init__(self, label: str, controller: "Controller") -> None:
|
||||
super().__init__(
|
||||
halign=Gtk.Align.CENTER, orientation=Gtk.Orientation.HORIZONTAL
|
||||
)
|
||||
|
||||
self.label = Gtk.Label(label=label)
|
||||
self.eb = InfoEventBox(offline.heading_disclaimer, controller)
|
||||
self.eb.set_icon_yalign(0.7)
|
||||
|
||||
for el in self.label, self.eb:
|
||||
self.add(el)
|
||||
|
||||
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)
|
||||
|
||||
heading = PageHeading(offline.heading, controller)
|
||||
self.add(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)
|
||||
|
||||
def grab_content_area(self) -> None:
|
||||
self.local_frame.tree.grab_focus()
|
||||
@@ -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,
|
||||
@@ -15,17 +15,18 @@ from dzgui.const.constants import (
|
||||
VIEW_CONCEAL,
|
||||
VIEW_REVEAL,
|
||||
)
|
||||
from dzgui.const.endpoints import STEAM_API_SETUP, BM_API_SETUP
|
||||
from dzgui.const.endpoints import STEAM_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.box import VBox
|
||||
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
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
|
||||
|
||||
|
||||
import gi
|
||||
@@ -45,10 +46,9 @@ class ShortHBox(Gtk.Box):
|
||||
self.pack_start(widget, NO_EXPAND, NO_FILL, NO_PADDING)
|
||||
|
||||
|
||||
class Options(Gtk.Box):
|
||||
class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
|
||||
def __init__(self, controller: "Controller"):
|
||||
super().__init__(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
margin_start=10,
|
||||
margin_end=10,
|
||||
)
|
||||
@@ -61,29 +61,16 @@ class Options(Gtk.Box):
|
||||
self.DEFAULT_WIDTH = 1
|
||||
self.DEFAULT_HEIGHT = 1
|
||||
|
||||
label = Gtk.Label(label=strings.options.header)
|
||||
label.set_halign(Gtk.Align.CENTER)
|
||||
css.add_class(label, "page-heading")
|
||||
self.add(label)
|
||||
|
||||
self.steam_entry: Gtk.Entry
|
||||
self.bm_entry: Gtk.Entry
|
||||
|
||||
self.steam = WebButton(label=strings.options.steam_web)
|
||||
self.steam.connect("clicked", self._on_link_button_clicked, STEAM_API_SETUP)
|
||||
|
||||
self.bm = WebButton(label=strings.options.bm_web)
|
||||
self.bm.connect("clicked", self._on_link_button_clicked, BM_API_SETUP)
|
||||
|
||||
self.steam_box = self._make_submit_field(
|
||||
strings.options.enter_steam, Preferences.STEAM, True
|
||||
)
|
||||
self.bm_box = self._make_submit_field(
|
||||
strings.options.enter_bm, Preferences.BM, True
|
||||
)
|
||||
api_rows = [
|
||||
[LeftLabel(strings.options.steam_placeholder), self.steam_box],
|
||||
[LeftLabel(strings.options.bm_placeholder), self.bm_box],
|
||||
[LeftLabel(strings.options.steam_placeholder), self.steam_box, self.steam],
|
||||
]
|
||||
|
||||
self.player_box = self._make_submit_field(
|
||||
@@ -134,16 +121,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)
|
||||
|
||||
@@ -155,21 +132,8 @@ class Options(Gtk.Box):
|
||||
api_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
api_grid = self._make_grid(api_rows)
|
||||
api_box.add(api_grid)
|
||||
api_links_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
margin_start=5,
|
||||
margin_end=5,
|
||||
margin_top=5,
|
||||
margin_bottom=10,
|
||||
homogeneous=True,
|
||||
spacing=10,
|
||||
)
|
||||
api_links_box.add(self.steam)
|
||||
api_links_box.add(self.bm)
|
||||
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,18 +151,26 @@ 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_key),
|
||||
(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
|
||||
|
||||
self.scrollable = Gtk.ScrolledWindow(vexpand=True)
|
||||
self.scrollable.add(grid)
|
||||
self.add(self.scrollable)
|
||||
label = Gtk.Label(label=strings.options.header)
|
||||
label.set_halign(Gtk.Align.CENTER)
|
||||
css.add_class(label, "page-heading")
|
||||
|
||||
box = VBox()
|
||||
box.add(label)
|
||||
box.add(grid)
|
||||
self.add(box)
|
||||
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
|
||||
def get_client_name(self) -> str:
|
||||
model = self.client_combo.get_model()
|
||||
@@ -206,13 +178,11 @@ class Options(Gtk.Box):
|
||||
return str(model[ind][0])
|
||||
|
||||
def block_text_entry(self) -> None:
|
||||
for entry in self.steam_entry, self.bm_entry:
|
||||
entry.set_position(-1)
|
||||
entry.set_can_focus(False)
|
||||
self.steam_entry.set_position(-1)
|
||||
self.steam_entry.set_can_focus(False)
|
||||
|
||||
def unblock_text_entry(self) -> None:
|
||||
for entry in self.steam_entry, self.bm_entry:
|
||||
entry.set_can_focus(True)
|
||||
self.steam_entry.set_can_focus(True)
|
||||
|
||||
def _on_developers_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.show_developers_page()
|
||||
@@ -245,8 +215,6 @@ class Options(Gtk.Box):
|
||||
|
||||
if context == Preferences.STEAM:
|
||||
self.steam_entry = entry
|
||||
else:
|
||||
self.bm_entry = entry
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
box.add(entry)
|
||||
@@ -293,12 +261,13 @@ class Options(Gtk.Box):
|
||||
case Preferences.NAME:
|
||||
value = entry.get_text().strip()
|
||||
self.controller.update_config(enum, value)
|
||||
case Preferences.BM | Preferences.STEAM:
|
||||
case Preferences.STEAM:
|
||||
text = "".join(entry.get_text().split())
|
||||
self.controller.update_api_key(enum, text)
|
||||
|
||||
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()
|
||||
|
||||
@@ -308,9 +277,6 @@ class Options(Gtk.Box):
|
||||
def revert(self, mode: Preferences) -> None:
|
||||
if mode == Preferences.STEAM:
|
||||
self.steam_entry.set_text(self.old_steam)
|
||||
else:
|
||||
self.bm_entry.set_text(self.old_bm)
|
||||
pass
|
||||
|
||||
def _on_start_tab_changed(self, combo: Gtk.ComboBoxText) -> None:
|
||||
_iter = combo.get_active_iter()
|
||||
@@ -346,8 +312,6 @@ class Options(Gtk.Box):
|
||||
old = self.old_name
|
||||
case Preferences.STEAM:
|
||||
old = self.old_steam
|
||||
case Preferences.BM:
|
||||
old = self.old_bm
|
||||
if text == old:
|
||||
return False
|
||||
return True
|
||||
@@ -403,7 +367,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)
|
||||
|
||||
@@ -411,19 +375,13 @@ class Options(Gtk.Box):
|
||||
name = self.controller.query_config(Preferences.NAME)
|
||||
default_steam_path = self.controller.query_config(Preferences.DEFAULT)
|
||||
steam = self.controller.query_config(Preferences.STEAM)
|
||||
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
|
||||
self.old_name = name
|
||||
|
||||
self.steam_entry.set_text(steam)
|
||||
self.bm_entry.set_text(bm)
|
||||
p = self.player_box.get_children()[0]
|
||||
if hasattr(p, "set_text"):
|
||||
p.set_text(name)
|
||||
@@ -441,7 +399,6 @@ class Options(Gtk.Box):
|
||||
for field in (
|
||||
[name, self.player_box],
|
||||
[steam, self.steam_box],
|
||||
[bm, self.bm_box],
|
||||
):
|
||||
if field[0] == "":
|
||||
field[1].get_children()[1].set_sensitive(False)
|
||||
@@ -488,4 +445,4 @@ class Options(Gtk.Box):
|
||||
widget.set_visibility(state)
|
||||
|
||||
def grab_content_area(self) -> None:
|
||||
return
|
||||
self.grab_focus()
|
||||
@@ -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)
|
||||
@@ -242,6 +239,15 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
)
|
||||
|
||||
"""Errors"""
|
||||
if len(prereqs.invalid_mods) > 0:
|
||||
pairs = [": ".join(sub) for sub in prereqs.invalid_mods]
|
||||
lines = "\n".join(pairs)
|
||||
msg = (
|
||||
"Server has invalid mods that are not recognized by Steam.\n"
|
||||
"Contact the server owner and include these mod IDs in your report:\n"
|
||||
f"{lines}"
|
||||
)
|
||||
errors.append(msg)
|
||||
if prereqs.binary_missing:
|
||||
errors.append(
|
||||
f"Remote server is running the build '{prereqs.build}', but it is not installed.\n{resync_msg}"
|
||||
@@ -275,6 +281,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 app '{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 +321,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()
|
||||
@@ -327,10 +338,7 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
self.connect_last.hide()
|
||||
|
||||
self._process_warnings(prereqs)
|
||||
if self.tree.is_visible():
|
||||
self.tree.grab_focus()
|
||||
else:
|
||||
self.grab_focus()
|
||||
self.tree.grab_focus()
|
||||
|
||||
def mark_finished(self) -> None:
|
||||
self.mod_count.set_label(preconnect.all_updated)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import logging
|
||||
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
from warnings import deprecated
|
||||
|
||||
from dzgui.const.constants import APP_NAME, SEPARATOR
|
||||
from dzgui.util.keys import is_navkey
|
||||
from dzgui.util.keys import is_ctrl_mask, is_navkey
|
||||
from dzgui.views.mixins.cursor_mixin import CursorMixin
|
||||
|
||||
import gi
|
||||
@@ -85,10 +84,17 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
|
||||
model, rows = sel.get_selected_rows()
|
||||
return [model[row] for row in rows]
|
||||
|
||||
def _on_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None:
|
||||
def _on_keypress(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> bool:
|
||||
from dzgui.views.trees.tree_servers import ServerTreeView
|
||||
|
||||
# NOTE: suppress popup search and delegate binding to server view (cf. request_keyword_focus)
|
||||
if not isinstance(treeview, ServerTreeView):
|
||||
if is_ctrl_mask(event) and event.keyval == Gdk.KEY_f:
|
||||
return True
|
||||
|
||||
if is_navkey(event.keyval):
|
||||
if self.get_model() is None:
|
||||
return
|
||||
return True
|
||||
if self.sel_blocked is False:
|
||||
self.sel_blocked = True
|
||||
self.controller.suppress_signal(
|
||||
@@ -98,7 +104,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
|
||||
True,
|
||||
)
|
||||
self._vim_nav(event)
|
||||
return
|
||||
return False
|
||||
|
||||
def _on_key_release(self, treeview: Gtk.TreeView, event: Gdk.EventKey) -> None:
|
||||
"""
|
||||
@@ -166,7 +172,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
|
||||
(model, pathlist) = sels
|
||||
return (model, pathlist)
|
||||
|
||||
@deprecated("Currently unused")
|
||||
# @deprecated("Currently unused")
|
||||
# def get_mpath(self) -> Optional[Gtk.TreePath]:
|
||||
# (model, pathlist) = self.get_model_and_pathlist()
|
||||
# if len(pathlist) < 1:
|
||||
@@ -188,7 +194,7 @@ class TreeView(CursorMixin, Gtk.TreeView): # type: ignore
|
||||
return True
|
||||
return False
|
||||
|
||||
@deprecated("unused")
|
||||
# @deprecated("unused")
|
||||
# def get_selected_row(self) -> Optional[Gtk.TreeModelRow]:
|
||||
# ind = self.get_selected_row_index()
|
||||
# model = self.get_model()
|
||||
|
||||
@@ -53,7 +53,7 @@ class LogTreeView(ContextMixin, TreeView): # type: ignore
|
||||
model = ModelFactory().new_model_from_logfile(filepath)
|
||||
_filter = model.filter_new()
|
||||
_filter.set_visible_func(self._filter_rows)
|
||||
sortable = Gtk.TreeModelSort(_filter)
|
||||
sortable = Gtk.TreeModelSort.new_with_model(_filter)
|
||||
self.set_model(sortable)
|
||||
_filter.refilter()
|
||||
path = Gtk.TreePath.new_from_indices([0])
|
||||
|
||||
@@ -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,16 +23,19 @@ 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)
|
||||
|
||||
self.set_fixed_height_mode(True)
|
||||
self.set_headers_visible(True)
|
||||
|
||||
self.set_model(None)
|
||||
|
||||
for i, column_title in enumerate(strings.mod_cols):
|
||||
@@ -58,12 +62,40 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore
|
||||
self.connect("button-press-event", self.present_menu)
|
||||
self.connect("key-press-event", self.present_menu)
|
||||
|
||||
self.connect("button-press-event", self.on_row_click)
|
||||
|
||||
def on_row_click(self, widget: Gtk.TreeView, event: Gdk.EventButton) -> bool:
|
||||
if event.state is Gdk.ModifierType.CONTROL_MASK and event.button == 1:
|
||||
path_info = widget.get_path_at_pos(int(event.x), int(event.y))
|
||||
if path_info is None:
|
||||
return False
|
||||
path, column, cellx, celly = path_info
|
||||
model = self.get_model()
|
||||
if model is None:
|
||||
return False
|
||||
if path is None:
|
||||
return False
|
||||
_iter = model.get_iter(path)
|
||||
if self.get_selection().iter_is_selected(_iter):
|
||||
self.get_selection().unselect_iter(_iter)
|
||||
else:
|
||||
self.get_selection().select_iter(_iter)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_mod_man(self) -> ModManager:
|
||||
return self.mod_man
|
||||
|
||||
def load_mods(self) -> None:
|
||||
self.mod_man.load_mods()
|
||||
|
||||
def _on_mods_highlighted(self, emitter: "Emitter") -> None:
|
||||
self.get_selection().unselect_all()
|
||||
|
||||
def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None:
|
||||
if mods < 1:
|
||||
return
|
||||
self.set_headers_visible(True)
|
||||
path = Gtk.TreePath.new_from_indices([0])
|
||||
self.set_cursor(path)
|
||||
|
||||
@@ -106,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,
|
||||
|
||||
+8
-3
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
|
||||
authors = [
|
||||
{name = "aclist"}
|
||||
]
|
||||
version = "7.0.0b9"
|
||||
version = "7.0.0b21"
|
||||
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"
|
||||
@@ -106,9 +107,13 @@ config-settings-package = { pygobject-stubs = { config = "Gtk3,Gdk3,Soup2" } }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"apitest: checks remote endpoints",
|
||||
"config: config file keys/values",
|
||||
"realconfig: depends on actual generated local config file",
|
||||
"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"
|
||||
"webtest: depends on remote endpoint",
|
||||
]
|
||||
@@ -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.
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user