mirror of
https://github.com/aclist/dztui.git
synced 2026-10-25 12:12:31 +01:00
Compare commits
492
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97ee2b7307 | ||
|
|
ff6edb6181 | ||
|
|
4c22263fb5 | ||
|
|
e3d1d62b4d | ||
|
|
1dcfdda657 | ||
|
|
019847dcf9 | ||
|
|
c2cfe2b4d9 | ||
|
|
0359aacd54 | ||
|
|
80e127889e | ||
|
|
da98b1d5b1 | ||
|
|
33869158de | ||
|
|
5cb8c68f5c | ||
|
|
2f92c2a44f | ||
|
|
da48834fa7 | ||
|
|
197c2eba06 | ||
|
|
0131ee6f0e | ||
|
|
b70edebd05 | ||
|
|
ae1c3a19d4 | ||
|
|
7822672cda | ||
|
|
f2a7bde432 | ||
|
|
00b5476aaf | ||
|
|
2a08b9ba82 | ||
|
|
0f8b6fcaad | ||
|
|
95988459d5 | ||
|
|
eed40e6f1a | ||
|
|
07460a1f8f | ||
|
|
a74d576387 | ||
|
|
052f7f0708 | ||
|
|
6feae44887 | ||
|
|
8cdb615796 | ||
|
|
31b6705017 | ||
|
|
ef1e7d17f5 | ||
|
|
3c52072eb5 | ||
|
|
bf69936891 | ||
|
|
fa180b750a | ||
|
|
96d50ffafa | ||
|
|
6c801ffec5 | ||
|
|
5eb70a1eb8 | ||
|
|
a3fff40f8f | ||
|
|
343630d2da | ||
|
|
d63c7ce3ec | ||
|
|
f7ad357e6f | ||
|
|
035a01fe34 | ||
|
|
7dd14804aa | ||
|
|
1cc1ba2864 | ||
|
|
8c4d0aecdc | ||
|
|
616f18452a | ||
|
|
1064f7a9d6 | ||
|
|
b0e85f0e35 | ||
|
|
4eec00e8e7 | ||
|
|
09ef3e446a | ||
|
|
4d02604bcf | ||
|
|
ebffbe39f2 | ||
|
|
61860f6aee | ||
|
|
191776ce74 | ||
|
|
7ae34747e9 | ||
|
|
1555ba2d64 | ||
|
|
4bac804fd7 | ||
|
|
748e1870da | ||
|
|
cd4c6fdfb2 | ||
|
|
1752018331 | ||
|
|
b3b05918b7 | ||
|
|
e6e0a3036d | ||
|
|
8c44a6e255 | ||
|
|
7fc1264b65 | ||
|
|
f748b09ba7 | ||
|
|
01be1247b4 | ||
|
|
27d5c4b4b2 | ||
|
|
045e47037e | ||
|
|
3bc2a54ee4 | ||
|
|
4e22b82703 | ||
|
|
492ed221c4 | ||
|
|
71a61a9201 | ||
|
|
516735defa | ||
|
|
9a3df70914 | ||
|
|
c41e47859f | ||
|
|
11bbe15ecb | ||
|
|
d40512dfa0 | ||
|
|
f07e503b74 | ||
|
|
3caeb823a2 | ||
|
|
6fcd887d56 | ||
|
|
c15b77d470 | ||
|
|
cee1e3b8af | ||
|
|
e8ccdd7cbb | ||
|
|
2629fe254e | ||
|
|
3b15bf58e7 | ||
|
|
e773a4909b | ||
|
|
95380681f9 | ||
|
|
18192a4667 | ||
|
|
f07a2dea5f | ||
|
|
0e58cf514e | ||
|
|
84c6a3868b | ||
|
|
7c0981981f | ||
|
|
e653c61955 | ||
|
|
d4bf9f71f6 | ||
|
|
cb5c233f45 | ||
|
|
ac286f4a72 | ||
|
|
e254821105 | ||
|
|
32b8c5cbc5 | ||
|
|
76ed7f2451 | ||
|
|
f135f6a35d | ||
|
|
eb5d5d5181 | ||
|
|
fef8c69f38 | ||
|
|
32395812a0 | ||
|
|
6d046ee719 | ||
|
|
1ebf253e99 | ||
|
|
ee31dce6e6 | ||
|
|
4aaf7fe195 | ||
|
|
b7e8982f40 | ||
|
|
c3b41ed9b4 | ||
|
|
e42fd26855 | ||
|
|
7a8ccca317 | ||
|
|
72e3054afb | ||
|
|
2f4606ede0 | ||
|
|
c6a75c1e78 | ||
|
|
bad7878c0a | ||
|
|
5014402e39 | ||
|
|
baade5b721 | ||
|
|
3c92108ac3 | ||
|
|
0f6f1c03ec | ||
|
|
39d8607f57 | ||
|
|
64d784f9a0 | ||
|
|
667203b4f9 | ||
|
|
00369837cb | ||
|
|
9bc5964df2 | ||
|
|
b7b83b3ca5 | ||
|
|
d04c20498a | ||
|
|
572ac5d7ce | ||
|
|
b4125c5a51 | ||
|
|
8bae2d5fb5 | ||
|
|
a85c632902 | ||
|
|
74ecc30692 | ||
|
|
33843becae | ||
|
|
07cbf7a36a | ||
|
|
687c5ac168 | ||
|
|
f3fd7ef6ea | ||
|
|
2383f7d2aa | ||
|
|
c839672f85 | ||
|
|
ca234b8dea | ||
|
|
8e3ce72295 | ||
|
|
f02d959ebc | ||
|
|
04e9e5dcf0 | ||
|
|
1b06fcae42 | ||
|
|
9759c39fd7 | ||
|
|
4451eee849 | ||
|
|
3f587f64a0 | ||
|
|
b00523661f | ||
|
|
f24b3574b8 | ||
|
|
fad8d2f43b | ||
|
|
e1e034fffd | ||
|
|
ea2ca684d6 | ||
|
|
2e2d3484c4 | ||
|
|
1254044ef8 | ||
|
|
7d651e9653 | ||
|
|
d9f392f8c9 | ||
|
|
6281ed37b3 | ||
|
|
0bb2ae2266 | ||
|
|
89b4e2ec98 | ||
|
|
b6d3f8d385 | ||
|
|
1b6ebac3a3 | ||
|
|
629c974b9d | ||
|
|
371eaa34ac | ||
|
|
c29346e134 | ||
|
|
79fd96e834 | ||
|
|
cc1948d225 | ||
|
|
e32c7fcacb | ||
|
|
76b3186ed7 | ||
|
|
493818b770 | ||
|
|
e2a6fa0c7e | ||
|
|
0a310f0b70 | ||
|
|
3f1aff7cc6 | ||
|
|
c348a499b0 | ||
|
|
5ff936f266 | ||
|
|
dfeafc3d28 | ||
|
|
96b91fd87c | ||
|
|
71d760dc56 | ||
|
|
aeffce2579 | ||
|
|
93d870b5bf | ||
|
|
63c2b5acd4 | ||
|
|
0d5e190b75 | ||
|
|
e9d064820e | ||
|
|
17b1a75745 | ||
|
|
e7ff025605 | ||
|
|
ec004aad5d | ||
|
|
bd022ea1d8 | ||
|
|
52b18f710f | ||
|
|
9ec3fa09e3 | ||
|
|
cb9ffb5b99 | ||
|
|
37c3937aee | ||
|
|
cf850c8f34 | ||
|
|
1332026c8c | ||
|
|
4eaedd0cdd | ||
|
|
b1f189bff8 | ||
|
|
b153ab875e | ||
|
|
de4e74daa1 | ||
|
|
0dfcc5a387 | ||
|
|
c64631cdca | ||
|
|
97ddbcdf0a | ||
|
|
ddc18e5705 | ||
|
|
6fb83b5c31 | ||
|
|
b5105ca9f9 | ||
|
|
05fab5774e | ||
|
|
203cf0da7f | ||
|
|
7dfdb4209a | ||
|
|
c03aa8f166 | ||
|
|
c34fa2fe03 | ||
|
|
0f4d611345 | ||
|
|
ca1958b0a6 | ||
|
|
c7bd6f635c | ||
|
|
2b394553b3 | ||
|
|
5b83a26f6a | ||
|
|
6ded9a3eec | ||
|
|
1a3154712f | ||
|
|
9f8b760c79 | ||
|
|
0f59d92bc3 | ||
|
|
cca1006efb | ||
|
|
027e6c0781 | ||
|
|
3932a3aed4 | ||
|
|
22c9be36a6 | ||
|
|
a2938c17f5 | ||
|
|
48b0660ebd | ||
|
|
5eb89a172d | ||
|
|
c037cf9c40 | ||
|
|
7cc8076df1 | ||
|
|
b300b614a0 | ||
|
|
0bb8f3206f | ||
|
|
8087f3a707 | ||
|
|
0ebc25b3b4 | ||
|
|
1e073af27a | ||
|
|
250da7a635 | ||
|
|
8098c4b68c | ||
|
|
1e8a423ec3 | ||
|
|
067b77bf92 | ||
|
|
d0ccc3bf4c | ||
|
|
38cc55530f | ||
|
|
5536180fec | ||
|
|
79bbc25714 | ||
|
|
456c6e62e8 | ||
|
|
eeffdb57f3 | ||
|
|
88ad8cf7c2 | ||
|
|
9f9a88d2ef | ||
|
|
f7e0b44379 | ||
|
|
ee33ce6662 | ||
|
|
a421d4c70b | ||
|
|
080b99cda8 | ||
|
|
afca9e5e6a | ||
|
|
a97c17f66b | ||
|
|
fe8bf611e1 | ||
|
|
70ccbf29f3 | ||
|
|
f5d595eccd | ||
|
|
fd7a549dbc | ||
|
|
6c810b28a9 | ||
|
|
1a1145c79c | ||
|
|
510719e3ec | ||
|
|
e06775c3c8 | ||
|
|
f5c71200a8 | ||
|
|
557e221993 | ||
|
|
dd30fa33d1 | ||
|
|
1753a40b94 | ||
|
|
a72d692c68 | ||
|
|
ce1cad6dcf | ||
|
|
9b24928f74 | ||
|
|
87f167bd53 | ||
|
|
4917c0ce2e | ||
|
|
cc485289e3 | ||
|
|
69cba3460d | ||
|
|
dfd6850d6d | ||
|
|
8d1d5bde80 | ||
|
|
533def5061 | ||
|
|
c179e3a9e9 | ||
|
|
2b8e0944a4 | ||
|
|
cd4bc7ff4e | ||
|
|
b209c9912d | ||
|
|
bfb0ffc9ad | ||
|
|
0eb47a0427 | ||
|
|
ea785960d8 | ||
|
|
7102c21f5a | ||
|
|
22294d7125 | ||
|
|
e9ce292368 | ||
|
|
5532e4e267 | ||
|
|
120703e702 | ||
|
|
b31896387a | ||
|
|
e5bb25cd60 | ||
|
|
e59b7e01fd | ||
|
|
7994bf988c | ||
|
|
90b1c875df | ||
|
|
b61e806009 | ||
|
|
b46d0614c9 | ||
|
|
c73864d545 | ||
|
|
314fed559c | ||
|
|
1c346a6c3c | ||
|
|
65e11ddd03 | ||
|
|
a1c0592c7d | ||
|
|
22c82677cb | ||
|
|
c5b41346ca | ||
|
|
c2fc9b2cca | ||
|
|
ba9d6e1d3b | ||
|
|
be6ab2c9cb | ||
|
|
2413dd1f14 | ||
|
|
9d8d37e110 | ||
|
|
fc28d419bf | ||
|
|
095af395f9 | ||
|
|
4ada06d305 | ||
|
|
5a0ab92985 | ||
|
|
f2501871ee | ||
|
|
cf70182733 | ||
|
|
42feeae8d0 | ||
|
|
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 |
No files matched your search
+51
-29
@@ -1,7 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## Added
|
||||
## [7.0.0] Unreleased
|
||||
### Added
|
||||
- Setup wizard
|
||||
- Uninstall wizard
|
||||
- Descriptive details panel to certain error dialogs
|
||||
- Changelog text wrapping and formatting
|
||||
- Changelog ships with source
|
||||
- Documentation ships with source
|
||||
@@ -23,6 +26,7 @@
|
||||
- Set favorite server from tables
|
||||
- Detailed/copyable trace in critical error dialogs
|
||||
- Visual icons
|
||||
- Unset favorite server button
|
||||
- Integrated server notebook
|
||||
- Propagate width changes to all tables
|
||||
- Remember tree position in menus
|
||||
@@ -41,11 +45,22 @@
|
||||
- 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
|
||||
- Update map count (-u flag)
|
||||
- Filter button tooltips
|
||||
- Expand/collapse changelog versions
|
||||
- Support local light/dark mode changes on system theme
|
||||
- Server time calculator
|
||||
|
||||
## Changed
|
||||
### Changed
|
||||
- Conform to PEP 440 versioning for beta versions
|
||||
- Enforce fullscreen mode on Steam Deck
|
||||
- Reduce padding on keys button
|
||||
- Boldface breadcrumbs
|
||||
- Changed some dialog error messages from popups to popovers
|
||||
- Changed how filter buttons are packed in sidebar
|
||||
- Bold labels inside frames
|
||||
- Sidebar buttons do not steal focus
|
||||
- Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method
|
||||
@@ -58,59 +73,66 @@
|
||||
- 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
|
||||
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
|
||||
|
||||
## Dropped
|
||||
- Debug mode
|
||||
### Dropped
|
||||
- Battlemetrics API support
|
||||
- Unused metadata from system log files
|
||||
- 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
|
||||
- In-game time field missing certain ranges of time at night
|
||||
- Wrap long entries in changelog
|
||||
- Certain server descriptions with special characters not rendering correctly
|
||||
- Center server title text on server dialogs
|
||||
- Rare segfaults when changing maps (threading)
|
||||
- Moved dialogs out of threads
|
||||
- History table not being in lock-step with state file
|
||||
- Current keyword filter not being applied when refreshing table
|
||||
|
||||
## Unreleased
|
||||
### Unreleased
|
||||
- Load offline mods
|
||||
- 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
|
||||
@@ -136,7 +158,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
|
||||
@@ -175,7 +197,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
|
||||
@@ -187,50 +209,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,73 @@
|
||||
# Introduction
|
||||
|
||||
Thank you for your interest in DZGUI!
|
||||
|
||||
This guide goes over development conventions and best practices for contributors.
|
||||
If you are a developer, you can skip to the end.
|
||||
|
||||
# Requesting help
|
||||
|
||||
If you encounter a problem with DZGUI, you can submit tickets on the GitHub
|
||||
[issue tracker](https://github.com/aclist/dztui/issues) under the
|
||||
`troubleshooting` tag.
|
||||
|
||||
# How can I help the project?
|
||||
|
||||
There are several ways to help this project.
|
||||
1. Report bugs that you find
|
||||
2. Request features that you would like
|
||||
3. Contribute features and fixes to the codebase
|
||||
4. Contribute documentation to the project
|
||||
|
||||
Before making any contribution, please read the `CODE_OF_CONDUCT.md` and act
|
||||
accordingly.
|
||||
|
||||
## Submitting a ticket
|
||||
|
||||
Navigate to the GitHub [issue tracker](https://github.com/aclist/dztui/issues).
|
||||
From there, follow the onscreen prompts. You will be asked questions such as:
|
||||
|
||||
- What version are you using?
|
||||
- What distribution are you using?
|
||||
- What is the issue that you found?
|
||||
- How can we reproduce the issue?
|
||||
|
||||
You can also attach screenshots, logs, or other data that can help us.
|
||||
|
||||
## Requesting a feature
|
||||
|
||||
You can also request features via the same issue tracker. It is good practice to
|
||||
first search for your idea to see if a similar one has already been posted.
|
||||
If not, open a ticket where you describe your feature and its possible benefits.
|
||||
|
||||
Please note that this is a community project, so it takes time for us to develop
|
||||
features. Putting in a feature request does not mean that it will be implemented,
|
||||
but we will do our best to support as many cool ideas as possible.
|
||||
|
||||
## Contributing code or documentation
|
||||
|
||||
If you would like to assist with an issue on the issue tracker or
|
||||
contribute a new change, please follow the guidelines below.
|
||||
|
||||
Fork this repository and check out the code up to the `dzgui7` (development) branch.
|
||||
|
||||
The following naming conventions apply for PRs:
|
||||
- fix/<your-fix> - patch/hotfix branches
|
||||
- feat/<your-feature> - feature branches
|
||||
- doc/<your-doc-branch> - documentation branches
|
||||
- infra/<your-infra-branch> - infrastructure branches
|
||||
|
||||
Implement your changes and test them locally. If they work, you may
|
||||
open a merge request, and then we review your changes. If everything is OK, it
|
||||
will be merged to `dzgui7`. After sufficient changes are consolidated into a new release,
|
||||
this branch will be tagged to a certain point in time and a binary release published.
|
||||
|
||||
It is recommended to follow
|
||||
[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), as this
|
||||
integrates well with tooling and helps the project review your code.
|
||||
|
||||
Please see DEVELOPERS.md for further details.
|
||||
|
||||
## License applicability to contributions
|
||||
|
||||
Code contributed to this repository becomes subject to the terms of the GPL license defined in the LICENSE file at the project root.
|
||||
@@ -1,15 +1,23 @@
|
||||
## What this is
|
||||
DZGUI is a turnkey browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI).
|
||||
DZGUI is a turnkey server browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI).
|
||||
|
||||
This overcomes certain limitations in the Linux client and helps prepare the game to launch by providing features like:
|
||||
|
||||
- Search for and display global and LAN servers via an interactive table
|
||||
- Supports official, third-party, and modded DayZ servers
|
||||
- Automatically find and prepare mods being requested by servers
|
||||
- Add/delete/manage favorite servers by IP or ID
|
||||
- Quick-connect to favorite/recent servers
|
||||
- Manage local mods and symlinks
|
||||
- Prepare launch options to pass to Steam
|
||||
- Manage and delete local mods
|
||||
- And more
|
||||
|
||||
## Design philosophy
|
||||
|
||||
DZGUI follows a specific set of guidelines, enumerated below:
|
||||
|
||||
- Provide a free and open-source tool under GPL
|
||||
- Do not use undocumented, third-party servers to aggregate data; no central "DZGUI" server
|
||||
- All connections go to Steam and DayZ servers directly; the tool should function in perpetuity without intermediary endpoints
|
||||
- Target Linux desktop and Steam deck specifically; not a cross-platform tool
|
||||
|
||||
## Setup and usage
|
||||
|
||||
@@ -18,19 +26,16 @@ Please refer to the documentation for installation and setup instructions:
|
||||
- [GitHub](https://aclist.github.io/dzgui/index.html)
|
||||
- [Codeberg (Mirror)](https://aclist.codeberg.page)
|
||||
|
||||

|
||||

|
||||
|
||||
## Attribution
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
Steam and the Steam logo are trademarks and/or registered trademarks of Valve Corporation in the U.S. and/or other countries.
|
||||
|
||||
## 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.
|
||||
@@ -8,7 +8,7 @@ runtime environment are built-in.
|
||||
Turnkey installer
|
||||
------------------------
|
||||
|
||||
- Visit the project's `Releases page <https://github.com/aclist/dztui/releases>`_.
|
||||
- Visit the project's `Releases page <https://github.com/aclist/dztui/releases/latest>`_.
|
||||
- Extract the DZGUI tarball from the top of the **Assets** list.
|
||||
- From a terminal, run the command ``./dzgui`` or double click on the extracted file in
|
||||
a file explorer.
|
||||
@@ -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
|
||||
---------------
|
||||
|
||||
+37
-7
@@ -3,17 +3,22 @@ Knowledge Base
|
||||
|
||||
.. _DZG-001:
|
||||
|
||||
DZG-001: Periodically getting dropped from servers, or servers time out in DZGUI
|
||||
DZG-001: Timeouts occur while trying to query a specific server
|
||||
------------------------------------------------------------------------------------
|
||||
DayZ opens a large number of connections while querying/connected to servers.
|
||||
The leading cause of specific servers periodically timing out is local network configuration.
|
||||
|
||||
In addition, packets sent from server responses are expected to be a standard size (see warning below).
|
||||
Many third-party DayZ servers use a server rental/hosting provider with DDoS protection.
|
||||
This can cause responses from servers to originate from a server other than the one originally queried.
|
||||
|
||||
If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers,
|
||||
unresponsiveness, or a timeout.
|
||||
Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation).
|
||||
By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue.
|
||||
|
||||
If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for
|
||||
the server's query port.
|
||||
|
||||
In addition, a uniform packet size is expected. If your router's MTU setting differs from the default of 1,500,
|
||||
it could lead to incoming responses being discarded (see warning below).
|
||||
|
||||
If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers
|
||||
tend to have less headroom than their wired counterparts.
|
||||
|
||||
.. important::
|
||||
Ensure that MTU (maximum tranmission unit) on your network is set to the standard size of 1,500 bytes.
|
||||
@@ -80,3 +85,28 @@ There is some misconception that a Steam Web API key could be used to gain infor
|
||||
A Steam Web API key is the most strict way of getting authentic, reliable, and consistent server information in a zero-trust model.
|
||||
|
||||
You are responsible for the creation, storage, management, and revocation of your Web API key.
|
||||
|
||||
DZG-008: Periodically getting dropped from servers while connected
|
||||
-------------------------------------------------------------------
|
||||
In some cases, DayZ opens a large number of connections while connected to servers.
|
||||
|
||||
If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers,
|
||||
unresponsiveness, or a timeout.
|
||||
|
||||
If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers
|
||||
tend to have less headroom for simultaneous connections than their wired counterparts.
|
||||
|
||||
DZG-009: Floating dialogs appear maximized on tiling window managers
|
||||
-------------------------------------------------------------------
|
||||
|
||||
The main DZGUI window and its child dialogs are expected to be rendered as floating by your window manager.
|
||||
DZGUI sends window manager hints to this effect, but tiling window managers designed to bisect the screen into quadrants (e.g., i3 window manager)
|
||||
may try to always launch applications in fullscreen.
|
||||
|
||||
To resolve this, set specific exclusions or window hints in your WM's configuration file.
|
||||
For example, for i3, add the following to your `XDG_CONFIG_HOME/i3/config` file (defaults to `$HOME/.config/i3/config`):
|
||||
|
||||
.. code:: console
|
||||
|
||||
for_window [instance="DZGUI"] floating enable, move position center
|
||||
for_window [instance="DZGUI - Dialog"] floating enable, move position center
|
||||
+4
-14
@@ -4,7 +4,7 @@ Setup
|
||||
DayZ license
|
||||
-----------------
|
||||
|
||||
Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton
|
||||
Prepare a Steam account with a DayZ license (i.e., own the game). From Steam's right-click menu for the game options, under ``Compatibility``, enable a Proton version ≥ ``6.8`` (or use Proton
|
||||
Experimental for the latest version).
|
||||
|
||||
As of this writing, any recent version of Proton should work, and it is encouraged to use the most recent one.
|
||||
@@ -33,20 +33,10 @@ Once configured, you can insert this key in the app when launching it for the fi
|
||||
|
||||
Battlemetrics API key
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
**This step is optional, but recommended**. Using this key lets you also connect to and query servers by their shorthand ID instead of by IP.
|
||||
.. warning::
|
||||
|
||||
Register for an API key at `BattleMetrics <https://www.battlemetrics.com/account/register?after=%2Fdevelopers>`_ (free).
|
||||
|
||||
From the ``Personal Access Tokens area``, select ``New Token``.
|
||||
|
||||
Give the token any name in the field at the top.
|
||||
|
||||
Leave **all options unchecked** and scroll to the bottom. Select ``Create Token``.
|
||||
|
||||
Once configured, you can insert this key in the app when launching it for the first time (optional), or later on when using the connect/query by ID methods in the app.
|
||||
|
||||
.. tip::
|
||||
Each server has a unique ID. This is the string of numbers at the end of the URL. For example, in the URL ``https://www.battlemetrics.com/servers/dayz/8039514``, the ID is ``8039514``.
|
||||
This feature is no longer supported in DZGUI. Battlemetrics has switched to a paid service.
|
||||
Battlemetrics queries are expected to fail in DZGUI 6. In DZGUI 7, this feature has been removed entirely.
|
||||
|
||||
Next steps
|
||||
--------------
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
Steam integration
|
||||
====================
|
||||
|
||||
.. note::
|
||||
|
||||
In :doc:`DZGUI 7 <dzgui7>`, Steam shortcut creation and cover art setup are handled automatically during setup. You can skip this step unless you are using DZGUI 6.
|
||||
|
||||
DZGUI can be added to Steam as a "non-Steam game" in order to facilitate integration with Steam Deck or desktop environments.
|
||||
|
||||
First, launch Steam in the **Large** (default) view.
|
||||
@@ -74,7 +78,7 @@ The final result will create box art looking like the above.
|
||||
:scale: 100%
|
||||
:alt: Adding a non-Steam game
|
||||
|
||||
Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s
|
||||
Next, right-click the DZGUI entry in your Library and select **Properties** to open the properties dialog. Next to the **Shortcut** field, you will see a small square box which represents the game’s
|
||||
icon. Click this to open a file explorer, navigate to ``$HOME/.local/share/dzgui``, and select ``icon.png``. This will add a small icon to the list view.
|
||||
|
||||
Finally, after you launch DZGUI for the first time, you should quit the application and return to the Library view. Select the **Recent Games** dropdown on the right-hand side.
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+38
-15
@@ -1,12 +1,18 @@
|
||||
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
|
||||
|
||||
@deprecated("Use dzgui.api.steam.unsubscribe()")
|
||||
class WorkshopACF:
|
||||
def __init__(self, file: str) -> None:
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
class ACF:
|
||||
def __init__(self, file: Path) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.dict: dict[str, Any]
|
||||
@@ -15,9 +21,10 @@ class WorkshopACF:
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return self.dict
|
||||
|
||||
def load(self, file: str) -> None:
|
||||
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()
|
||||
@@ -27,6 +34,14 @@ class WorkshopACF:
|
||||
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:
|
||||
@@ -54,6 +69,23 @@ class WorkshopACF:
|
||||
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")
|
||||
@@ -78,18 +110,9 @@ class WorkshopACF:
|
||||
s += line + "\n"
|
||||
return s
|
||||
|
||||
def to_file(self, file: str) -> None:
|
||||
def to_file(self, file: Path) -> None:
|
||||
s = self.unpack(self.dict)
|
||||
with open(file, "w") as f:
|
||||
f.write(s)
|
||||
|
||||
@classmethod
|
||||
def enquote(cls, s: str) -> str:
|
||||
return f'"{s}"'
|
||||
|
||||
@classmethod
|
||||
def dequote(cls, s: str) -> str:
|
||||
return s.rstrip('"').lstrip('"')
|
||||
file.write_text(s)
|
||||
|
||||
def delete(self, modid: int) -> None:
|
||||
del self.dict["AppWorkshop"]["WorkshopItemsInstalled"][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
|
||||
+2
-3
@@ -7,13 +7,12 @@ 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,
|
||||
DAYZ_COMMUNITY_ROOT,
|
||||
LIBRARYFOLDERS_PATH,
|
||||
WORKSHOP_PATH,
|
||||
)
|
||||
|
||||
@@ -40,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
|
||||
|
||||
|
||||
+21
-56
@@ -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 = "<"
|
||||
@@ -258,34 +256,27 @@ 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
|
||||
major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
|
||||
build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF
|
||||
# NOTE: 0-pad revision number
|
||||
revision = str(struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF)
|
||||
offset = 3 - len(revision)
|
||||
padded = "0" * offset + revision
|
||||
return FileVersion(major, minor, build, padded)
|
||||
"""
|
||||
Note: structure differs from standard VS_FIXEDFILEINFO
|
||||
Major: lower 16 bits of first DWORD
|
||||
Minor: upper 16 bits of first DWORD
|
||||
Build: lower 16 bits of third DWORD
|
||||
Revision: upper 16 bits of second DWORD, zero-padded
|
||||
Lower 16 bits of revision DWORD appear unused.
|
||||
"""
|
||||
major_minor = struct.unpack("<I", data.read(4))[0]
|
||||
revision_pair = struct.unpack("<I", data.read(4))[0]
|
||||
build_dword = struct.unpack("<I", data.read(4))[0]
|
||||
|
||||
major = major_minor & 0xFFFF
|
||||
minor = (major_minor >> 16) & 0xFFFF
|
||||
build = build_dword & 0xFFFF
|
||||
revision = (revision_pair >> 16) & 0xFFFF
|
||||
revision = f"{revision:03d}"
|
||||
|
||||
return FileVersion(major, minor, build, revision)
|
||||
|
||||
|
||||
def seek_to_hex(address: str, data: BinaryIO) -> None:
|
||||
@@ -419,37 +410,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
|
||||
|
||||
+29
-40
@@ -11,7 +11,6 @@ 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
|
||||
@@ -55,19 +54,16 @@ class Res:
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Details:
|
||||
data: Union[list, None]
|
||||
data: list[list[str]]
|
||||
name: str
|
||||
description: str
|
||||
success: bool
|
||||
gametime: str
|
||||
day_accel: float
|
||||
night_accel: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Record:
|
||||
# TODO: investigate whether this enum is still being used;
|
||||
# can this be frozen?
|
||||
"""
|
||||
The gameport field is manipulated by the RowType.CONN_BY_IP method
|
||||
"""
|
||||
|
||||
ip: str
|
||||
gameport: int
|
||||
qport: int
|
||||
@@ -284,43 +280,41 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] |
|
||||
info = a2s.info((ip, qport), timeout)
|
||||
return source_info_to_dict(ip, qport, info)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
logger.debug(e)
|
||||
return None
|
||||
|
||||
|
||||
def get_details(record: Record) -> Details:
|
||||
# TODO: consolidate try/except statements
|
||||
def get_details(record: Record) -> Details | None:
|
||||
ip = record.ip
|
||||
qport = record.qport
|
||||
default_str = strings.none_provided
|
||||
|
||||
try:
|
||||
info = a2s.info((ip, qport))
|
||||
except (TimeoutError, a2s.exceptions.BrokenMessageError):
|
||||
return Details(None, default_str, False)
|
||||
try:
|
||||
rules = dayzquery.dayz_rules((ip, qport))
|
||||
except Exception:
|
||||
return Details(None, default_str, False)
|
||||
|
||||
try:
|
||||
keywords = info.keywords.split(",")
|
||||
except AttributeError:
|
||||
return Details(None, default_str, False)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return None
|
||||
|
||||
battleye = strings.disabled
|
||||
if "battleye" in keywords:
|
||||
battleye = strings.enabled
|
||||
|
||||
day_accel = 0.0
|
||||
night_accel = 0.0
|
||||
name = info.server_name
|
||||
day_accel = 1.0
|
||||
night_accel = 1.0
|
||||
gametime = "00:00"
|
||||
for keyword in keywords:
|
||||
if "etm" in keyword:
|
||||
day_accel = float(keyword.lstrip("etm"))
|
||||
day_accel = float(f"{day_accel:g}")
|
||||
if "entm" in keywords:
|
||||
if "entm" in keyword:
|
||||
night_accel = float(keyword.lstrip("entm"))
|
||||
night_accel = float(f"{night_accel:g}")
|
||||
time_reg = r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]$)"
|
||||
if re.match(time_reg, keyword):
|
||||
gametime = keyword
|
||||
|
||||
try:
|
||||
password = info.password_protected
|
||||
@@ -347,10 +341,15 @@ def get_details(record: Record) -> Details:
|
||||
|
||||
try:
|
||||
dlc = rules.dlc_flags
|
||||
if dlc == 0:
|
||||
dlc = strings.none
|
||||
if dlc == 2:
|
||||
dlc = strings.dlc_frostline
|
||||
match dlc:
|
||||
case 0:
|
||||
dlc = strings.dlc_none
|
||||
case 2:
|
||||
dlc = strings.dlc_frostline
|
||||
case 4:
|
||||
dlc = strings.dlc_badlands
|
||||
case _:
|
||||
dlc = strings.unspecified
|
||||
except AttributeError:
|
||||
dlc = strings.unspecified
|
||||
|
||||
@@ -381,7 +380,7 @@ def get_details(record: Record) -> Details:
|
||||
["Version", version],
|
||||
]
|
||||
|
||||
return Details(rows, description, True)
|
||||
return Details(rows, name, description, gametime, day_accel, night_accel)
|
||||
|
||||
|
||||
def ping(ip: str, qport: int) -> int:
|
||||
@@ -478,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,204 @@
|
||||
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, GTK_APP_ID, IMAGES_PATH
|
||||
from dzgui.util.dirs import copy_dzgui_to_xdg_data, make_parents
|
||||
from dzgui.util.strings import unknown
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ShortcutMetadata:
|
||||
appname: str
|
||||
appid: int
|
||||
exe_path: str
|
||||
start_dir: str
|
||||
icon: str
|
||||
|
||||
|
||||
class Shortcuts:
|
||||
def __init__(self, steam_path: Path) -> None:
|
||||
self.user_config_path: Path
|
||||
self.shortcuts_path = self.find_shortcuts_path(steam_path)
|
||||
self._load_shortcuts(self.shortcuts_path)
|
||||
|
||||
@classmethod
|
||||
def gen_bpid(cls, uid: str) -> int:
|
||||
u = uid.encode()
|
||||
encoded = binascii.crc32(u)
|
||||
return encoded | 0x80000000
|
||||
|
||||
@classmethod
|
||||
def gen_signed_appid(cls, appid: int) -> int:
|
||||
return ctypes.c_int(appid).value
|
||||
|
||||
@classmethod
|
||||
def gen_exe_uid(cls, appname: str, exe: Path) -> str:
|
||||
wrapped_exe = f'"{exe}"'
|
||||
return appname + wrapped_exe
|
||||
|
||||
def find_appname_by_unsigned_id(self, appid: int) -> str:
|
||||
# NOTE: bitmask signed int back to 32-bit CRC
|
||||
# varying client versions treat case sensitivity differently
|
||||
try:
|
||||
for key in self.shortcuts["shortcuts"].keys():
|
||||
if self.shortcuts["shortcuts"][key]["appid"] & 0xFFFFFFFF == appid:
|
||||
try:
|
||||
return str(self.shortcuts["shortcuts"][key]["appname"])
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
try:
|
||||
return str(self.shortcuts["shortcuts"][key]["AppName"])
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
return unknown
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
return unknown
|
||||
|
||||
def get_shortcuts(self) -> Any:
|
||||
return self.shortcuts
|
||||
|
||||
def _load_shortcuts(self, path: Path) -> None:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
shortcuts = vdf.binary_load(f)
|
||||
self.shortcuts = shortcuts
|
||||
logger.debug("Loaded shortcuts file")
|
||||
except FileNotFoundError:
|
||||
self.shortcuts = {"shortcuts": {}}
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
raise e
|
||||
|
||||
def find_shortcuts_path(self, steam_path: Path) -> Path:
|
||||
uid = find_user_id_32(steam_path)
|
||||
self.user_config_path = steam_path.joinpath(f"userdata/{uid}/config")
|
||||
return self.user_config_path.joinpath("shortcuts.vdf")
|
||||
|
||||
def add_shortcut(
|
||||
self, appname: str, start_dir: Path, exe_path: Path, icon: Path
|
||||
) -> None:
|
||||
uid = self.gen_exe_uid(appname, exe_path)
|
||||
self.bpid = self.gen_bpid(uid)
|
||||
self.signed_appid = self.gen_signed_appid(self.bpid)
|
||||
|
||||
meta = ShortcutMetadata(
|
||||
appname, self.signed_appid, str(exe_path), str(start_dir), str(icon)
|
||||
)
|
||||
entry = self._create_entry(meta)
|
||||
self._insert_at_last_index(entry)
|
||||
|
||||
def add_grid_images(self, images: Path) -> None:
|
||||
"""
|
||||
{BPID}_hero.png: hero (splash)
|
||||
{BPID}p.ping: portrait boxart in library
|
||||
{BPID}_logo.png: logo overlay on top of hero
|
||||
{BPID}.png: legacy Big Picture/landscape image
|
||||
"""
|
||||
|
||||
grid_path = self.user_config_path.joinpath("grid")
|
||||
for img in ("_hero", "p", "_logo"):
|
||||
filename = str(self.bpid) + img
|
||||
dest = grid_path.joinpath(filename).with_suffix(".png")
|
||||
make_parents(dest)
|
||||
b = images.joinpath(img).with_suffix(".png").read_bytes()
|
||||
dest.write_bytes(b)
|
||||
|
||||
# NOTE: legacy Big Picture header
|
||||
b = images.joinpath("_hero.png").read_bytes()
|
||||
dest = grid_path.joinpath(str(self.bpid)).with_suffix(".png")
|
||||
dest.write_bytes(b)
|
||||
|
||||
def _insert_at_last_index(self, entry: dict[str, Any]) -> None:
|
||||
try:
|
||||
last = list(self.shortcuts["shortcuts"].keys())[-1]
|
||||
n = int(last) + 1
|
||||
except Exception:
|
||||
n = 0
|
||||
self.shortcuts["shortcuts"][str(n)] = entry
|
||||
|
||||
@classmethod
|
||||
def _create_entry(cls, meta: ShortcutMetadata) -> dict[str, Any]:
|
||||
"""
|
||||
https://developer.valvesoftware.com/wiki/Add_Non-Steam_Game
|
||||
https://developer.valvesoftware.com/wiki/Steam_Library_Shortcuts
|
||||
|
||||
Key case is not internally consistent and varies between client versions
|
||||
Most keys use Pascal case, but some do not
|
||||
|
||||
appid: signed int CRC
|
||||
Exe: absolute path to the executable, must be wrapped in literal quotes
|
||||
StartDir: directory the executable starts in, generally the parent
|
||||
"""
|
||||
|
||||
NEW_ENTRY: dict[str, Any] = {}
|
||||
NEW_ENTRY["appid"] = meta.appid
|
||||
NEW_ENTRY["AppName"] = meta.appname
|
||||
NEW_ENTRY["Exe"] = f'"{meta.exe_path}"'
|
||||
NEW_ENTRY["StartDir"] = f"{meta.start_dir}"
|
||||
NEW_ENTRY["icon"] = meta.icon
|
||||
NEW_ENTRY["ShortcutPath"] = ""
|
||||
NEW_ENTRY["LaunchOptions"] = ""
|
||||
NEW_ENTRY["IsHidden"] = 0
|
||||
NEW_ENTRY["AllowDesktopConfig"] = 1
|
||||
NEW_ENTRY["AllowOverlay"] = 1
|
||||
NEW_ENTRY["OpenVR"] = 0
|
||||
NEW_ENTRY["Devkit"] = 0
|
||||
NEW_ENTRY["DevkitGameID"] = ""
|
||||
NEW_ENTRY["DevkitOverrideAppID"] = 0
|
||||
NEW_ENTRY["LastPlayTime"] = 0
|
||||
NEW_ENTRY["FlatpakAppID"] = GTK_APP_ID
|
||||
|
||||
NEW_ENTRY["sortas"] = ""
|
||||
NEW_ENTRY["tags"] = {}
|
||||
return NEW_ENTRY
|
||||
|
||||
def delete_shortcut(self, start_path: Path) -> None:
|
||||
shortcuts = self.shortcuts["shortcuts"]
|
||||
for s in shortcuts:
|
||||
if shortcuts[s]["StartDir"] == str(start_path):
|
||||
del shortcuts[s]
|
||||
break
|
||||
self.save_shortcuts()
|
||||
|
||||
def save_shortcuts(self) -> None:
|
||||
try:
|
||||
backup = self.shortcuts_path.with_suffix(".vdf.bak")
|
||||
shutil.copy(self.shortcuts_path, backup)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
try:
|
||||
with open(self.shortcuts_path, "wb") as f:
|
||||
vdf.binary_dump(self.shortcuts, f)
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
|
||||
|
||||
def add_steam_shortcut(steam_path: Path, exe_path: Path) -> None:
|
||||
try:
|
||||
start_dir = exe_path.parent
|
||||
|
||||
traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH)
|
||||
images = Path(str(traversable))
|
||||
icon = images.joinpath("icon.png")
|
||||
|
||||
shortcuts = Shortcuts(Path(steam_path))
|
||||
shortcuts.add_shortcut(APP_NAME, start_dir, exe_path, icon)
|
||||
shortcuts.save_shortcuts()
|
||||
shortcuts.add_grid_images(images)
|
||||
|
||||
copy_dzgui_to_xdg_data(exe_path)
|
||||
logger.debug("Finished creating Steam shortcut")
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
+285
-66
@@ -1,36 +1,67 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import psutil
|
||||
import re
|
||||
import requests
|
||||
import subprocess
|
||||
from typing import Union
|
||||
import vdf # type: ignore
|
||||
|
||||
from pathlib import Path
|
||||
from requests.exceptions import HTTPError
|
||||
from typing import Any, Generator, Union
|
||||
from warnings import deprecated
|
||||
|
||||
from shlex import shlex
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.init.prereqs import has_steam_client
|
||||
from dzgui.api.acf import ACF
|
||||
from dzgui.init.prereqs import has_any_client
|
||||
from dzgui.const.constants import (
|
||||
APPID_DAYZ,
|
||||
APPID_DAYZ_EXP,
|
||||
APP_NAME,
|
||||
DAYZ_BINARY,
|
||||
DAYZ_LAUNCHER,
|
||||
DEBIAN_STEAM_PATH,
|
||||
DEFAULT_STEAM_PATH,
|
||||
DOWNLOADS_PATH,
|
||||
FLATPAK_STEAM_PATH,
|
||||
LIBRARYFOLDERS_PATH,
|
||||
UBUNTU_STEAM_PATH,
|
||||
REQUEST_TIMEOUT,
|
||||
VDF_PATH,
|
||||
)
|
||||
from dzgui.const.endpoints import SUB_ENDPOINT, STEAM_PUBLISHED_FILES, UNSUB_ENDPOINT
|
||||
from dzgui.const.endpoints import (
|
||||
APP_DETAILS,
|
||||
SUB_ENDPOINT,
|
||||
STEAM_PUBLISHED_FILES,
|
||||
UNSUB_ENDPOINT,
|
||||
)
|
||||
from dzgui.strings import wizard
|
||||
from dzgui.strings.errors import api_validation_error, workshop_endpoint_error
|
||||
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():
|
||||
if has_any_client():
|
||||
HOME = Path.home()
|
||||
env = os.environ.get("XDG_DATA_HOME")
|
||||
if env is not None:
|
||||
@@ -113,6 +144,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"])
|
||||
@@ -121,6 +155,25 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
|
||||
return hashes
|
||||
|
||||
|
||||
def connect_debug(
|
||||
client: str, addr: str, appid: int, name: str, mods: list[str]
|
||||
) -> str:
|
||||
concat = concat_mods(mods)
|
||||
client_args = concat_bash_args(client)
|
||||
params = [
|
||||
"-applaunch",
|
||||
str(appid),
|
||||
f"-connect={addr}",
|
||||
"-nolauncher",
|
||||
"-nosplash",
|
||||
"-skipintro",
|
||||
f"-name={name}",
|
||||
f"-mod={concat}",
|
||||
]
|
||||
client_args.extend(params)
|
||||
return " ".join(client_args)
|
||||
|
||||
|
||||
# TODO: set config to name=user, use official server and no mods,
|
||||
# ensure that formatted string is identical to fixture with same hash
|
||||
def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int:
|
||||
@@ -180,58 +233,47 @@ def launch_offline(
|
||||
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)
|
||||
users = v["users"]
|
||||
# NOTE: beta client: /package/beta
|
||||
last_user = str(next(iter(users)))
|
||||
if len(users) == 1:
|
||||
return last_user
|
||||
last_stamp = int(users[last_user]["timestamp"])
|
||||
for user in users:
|
||||
stamp = int(users[user]["timestamp"])
|
||||
if stamp > last_stamp:
|
||||
last_stamp = stamp
|
||||
last_user = user
|
||||
return last_user
|
||||
except Exception as e:
|
||||
logger.warn(e)
|
||||
logger.debug(e)
|
||||
return None
|
||||
|
||||
|
||||
def vdf2json(path: Path) -> str:
|
||||
def _istr(indent: int, string: str) -> str:
|
||||
return (indent * " ") + string
|
||||
def find_user_id_32(path: Path) -> int:
|
||||
uid = find_user_id(path)
|
||||
if uid is None:
|
||||
raise ValueError("Failed to parse a valid Steam32 ID")
|
||||
return int(uid) & 0xFFFFFFFF
|
||||
|
||||
jbuf = "{\n"
|
||||
indent = 1
|
||||
|
||||
with open(path, "r") as f:
|
||||
st = f.read()
|
||||
lex = shlex(st)
|
||||
class SteamAPIKeyError(Exception):
|
||||
def __init__(self, msg: str | None = None) -> None:
|
||||
super().__init__(msg or api_validation_error)
|
||||
|
||||
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"
|
||||
|
||||
class SteamWorkshopEndpointError(Exception):
|
||||
def __init__(self, msg: str | None = None) -> None:
|
||||
super().__init__(msg or workshop_endpoint_error)
|
||||
|
||||
|
||||
def update_workshop(key: str, mod: int, endpoint: str) -> None:
|
||||
@@ -245,8 +287,19 @@ def update_workshop(key: str, mod: int, endpoint: str) -> None:
|
||||
try:
|
||||
res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
except HTTPError as e:
|
||||
logger.critical(e)
|
||||
match res.status_code:
|
||||
case 403:
|
||||
raise SteamAPIKeyError()
|
||||
case 404:
|
||||
raise SteamWorkshopEndpointError()
|
||||
case _:
|
||||
# TODO: apply redaction filter
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
raise e
|
||||
|
||||
|
||||
def subscribe(key: str, mod: int) -> None:
|
||||
@@ -257,21 +310,187 @@ def unsubscribe(key: str, mod: int) -> None:
|
||||
update_workshop(key, mod, UNSUB_ENDPOINT)
|
||||
|
||||
|
||||
def gen_shortcut() -> None:
|
||||
# TODO:
|
||||
"""
|
||||
during setup, prompt user to select matching user id from loginusers
|
||||
show user account name, select outer steam id
|
||||
steam/userdata/<steamid_32>/config/shortcuts.vdf
|
||||
"""
|
||||
# STEAMID_MAGIC = 76561197960265728
|
||||
# STEAMID_64 - STEAMID_MAGIC = STEAMID32
|
||||
# or get right-most 32 bits
|
||||
# STEAMID_64 & 0xFFFFFFFF
|
||||
pass
|
||||
|
||||
|
||||
@deprecated("Use subscribe()")
|
||||
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, DAYZ_LAUNCHER]
|
||||
for proc in psutil.process_iter():
|
||||
try:
|
||||
procs.append(proc.cmdline())
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
continue
|
||||
return any(s in item for sublist in procs for item in sublist for s in substring)
|
||||
|
||||
|
||||
def get_pending_downloads(steam_path: Path, appid: int) -> Generator[Path, None, None]:
|
||||
root_path = get_app_path(steam_path, appid)
|
||||
downloads_dir = root_path / DOWNLOADS_PATH
|
||||
files = downloads_dir.iterdir()
|
||||
return files
|
||||
|
||||
|
||||
def is_dayz_downloading(steam_path: Path, appid: int) -> bool:
|
||||
files = get_pending_downloads(steam_path, appid)
|
||||
reg = re.compile(rf"^state_(?:{APPID_DAYZ_EXP}|{APPID_DAYZ})_.*\.patch$")
|
||||
pending = [f for f in files if reg.match(f.name)]
|
||||
if len(pending) > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_app_path(folders_path: Path, appid: int) -> Path:
|
||||
app_path = None
|
||||
|
||||
try:
|
||||
with open(folders_path / LIBRARYFOLDERS_PATH) as f:
|
||||
folders = vdf.load(f)
|
||||
except Exception:
|
||||
raise VDFLoadError("Failed to parse libraryfolders")
|
||||
|
||||
for obj in folders["libraryfolders"]:
|
||||
if str(appid) in folders["libraryfolders"][obj]["apps"]:
|
||||
app_path = folders["libraryfolders"][obj]["path"]
|
||||
if Path(app_path).exists():
|
||||
break
|
||||
|
||||
if app_path is None:
|
||||
raise AppNotInstalledError(
|
||||
f"Failed to find a libraryfolder for the appid {appid}"
|
||||
)
|
||||
if Path(app_path).exists() is False:
|
||||
raise AppMovedError(
|
||||
f"The location '{app_path}' pointed to by '{appid}' no longer exists and may have been changed on the disk."
|
||||
)
|
||||
|
||||
return Path(app_path)
|
||||
|
||||
|
||||
def get_app_name(appid: int) -> str | None:
|
||||
payload = {"appids": [appid]}
|
||||
res = requests.get(APP_DETAILS, params=payload)
|
||||
if res.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
return str(res.json()[str(appid)]["data"]["name"])
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
return None
|
||||
+82
-12
@@ -1,9 +1,13 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, STEAM_ICON, ICONS_PATH
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.config.query import lookup
|
||||
from dzgui.config.userprefs import UserPrefs
|
||||
@@ -14,12 +18,14 @@ from dzgui.init.migrate import (
|
||||
migrate_cols_file,
|
||||
)
|
||||
from dzgui.init.flock import lock_acquire
|
||||
from dzgui.init.prereqs import has_steam_client
|
||||
from dzgui.init.prereqs import has_any_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.map_count import test_map_count
|
||||
from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS
|
||||
from dzgui.util.strings import init
|
||||
|
||||
from dzgui.views.base import App
|
||||
@@ -32,24 +38,83 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
# TODO: profile load time
|
||||
def make_parents(path: "Path") -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
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_steam_icon() -> None:
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa
|
||||
try:
|
||||
traversable = resources.files(APP_NAME_LOWER).joinpath(ICONS_PATH)
|
||||
icons = Path(str(traversable))
|
||||
|
||||
theme = Gtk.IconTheme.get_default()
|
||||
paths = theme.get_search_path()
|
||||
theme.set_search_path(paths + [str(icons)])
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
|
||||
|
||||
def load_gui(version: str, is_debug: bool) -> None:
|
||||
lock = lock_acquire() # noqa
|
||||
|
||||
@@ -58,6 +123,8 @@ def load_gui(version: str, is_debug: bool) -> None:
|
||||
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)
|
||||
|
||||
@@ -71,14 +138,14 @@ def load_gui(version: str, is_debug: bool) -> None:
|
||||
del os.environ["GTK_IM_MODULE"]
|
||||
|
||||
if has_new_config(XDG.config) is False:
|
||||
test_map_count()
|
||||
migrate_cols_file(XDG.columns)
|
||||
copy_state_files(xdg_paths["XDG_STATE_HOME"])
|
||||
# TODO: add logging inside wizard
|
||||
SetupWizard(_is_steam_deck, XDG.config)
|
||||
wizard = SetupWizard(_is_steam_deck, XDG)
|
||||
|
||||
# NOTE: implies that setup wizard failed or was closed
|
||||
if has_new_config(XDG.config) is False:
|
||||
return
|
||||
if not wizard.is_setup_complete():
|
||||
return
|
||||
|
||||
setup_logger(XDG.debug)
|
||||
with open(XDG.debug, "w") as f:
|
||||
@@ -88,13 +155,14 @@ def load_gui(version: str, is_debug: bool) -> None:
|
||||
# TODO: sudo escalation dialog
|
||||
# count = get_map_count()
|
||||
# TODO: move into module
|
||||
if has_steam_client() is False:
|
||||
if has_any_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,
|
||||
@@ -106,4 +174,6 @@ def load_gui(version: str, is_debug: bool) -> None:
|
||||
use_miles=use_miles,
|
||||
)
|
||||
print(boot.all_ok)
|
||||
App(prefs)
|
||||
load_steam_icon()
|
||||
app = App(prefs)
|
||||
app.run(None)
|
||||
@@ -42,6 +42,7 @@ def rc2json(file: Path) -> str:
|
||||
|
||||
toggles = ["fullscreen"]
|
||||
deprecated = [
|
||||
"api_key",
|
||||
"staging_dir",
|
||||
"src_path",
|
||||
"steam_path",
|
||||
@@ -66,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()
|
||||
|
||||
@@ -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)
|
||||
@@ -4,8 +4,6 @@ from pathlib import Path
|
||||
from dzgui.util._json import read_json
|
||||
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.const.constants import STEAM_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX
|
||||
|
||||
|
||||
class ConfigFileError(Exception):
|
||||
pass
|
||||
@@ -40,23 +38,5 @@ def get_favorites(path: Path) -> list[str]:
|
||||
return list(conf["ip_list"])
|
||||
|
||||
|
||||
# def is_in_favs(record: str, path: Path) -> bool:
|
||||
# favs = get_favorites(path)
|
||||
# if record in favs:
|
||||
# return True
|
||||
# return False
|
||||
|
||||
|
||||
def enum_to_key(enum: Preferences) -> str:
|
||||
return str(enum.dict["key"])
|
||||
|
||||
|
||||
def get_client_index(client: str) -> int:
|
||||
if client == STEAM_CMD:
|
||||
return 0
|
||||
elif client == FLATPAK_RUN_CMD:
|
||||
return 1
|
||||
elif client == FLATPAK_SANDBOX:
|
||||
return 2
|
||||
else:
|
||||
return 0
|
||||
+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": "",
|
||||
|
||||
@@ -12,8 +12,10 @@ APPNAME_DAYZ = "DayZ"
|
||||
APPNAME_DAYZ_EXP = "DayZ Exp"
|
||||
APPNAME_DAYZ_EXP_HUMAN = "DayZ Experimental"
|
||||
DAYZ_BINARY = "DayZ_x64.exe"
|
||||
DAYZ_LAUNCHER = "DayZLauncher.exe"
|
||||
|
||||
LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf"
|
||||
DOWNLOADS_PATH = "steamapps/downloading"
|
||||
WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ)
|
||||
|
||||
STEAM_CMD = "steam"
|
||||
@@ -29,15 +31,18 @@ BETA_REPO = "testing"
|
||||
APP_NAME = "DZGUI"
|
||||
APP_NAME_LOWER = "dzgui"
|
||||
APP_NAME_ABBR = "dzg"
|
||||
GTK_APP_ID = "io.github.aclist.dzgui"
|
||||
|
||||
HEX_GREEN = "#32CD32"
|
||||
HEX_RED = "#FF0000"
|
||||
HEX_ORANGE = "#FFAC1C"
|
||||
|
||||
# FIXME: normalize icon names
|
||||
# TODO: normalize icon names
|
||||
CALCULATOR = "accessories-calculator-symbolic"
|
||||
CARET_DOWN = "go-down-symbolic"
|
||||
CARET_UP = "go-up-symbolic"
|
||||
CLIPBOARD = "edit-copy-symbolic"
|
||||
CLOSE = "window-close-symbolic"
|
||||
FOLDER = "folder-symbolic"
|
||||
EDIT_DELETE = "edit-delete-symbolic"
|
||||
ERROR = "dialog-error-symbolic"
|
||||
@@ -46,11 +51,13 @@ INPUT_KEYBOARD = "input-keyboard-symbolic"
|
||||
LIST_ADD = "list-add-symbolic"
|
||||
REFRESH_ICON = "view-refresh-symbolic"
|
||||
SEARCH_ICON = "system-search-symbolic"
|
||||
STEAM_ICON = "steam_tray_mono"
|
||||
STEAM_ICON = "steam-tray-symbolic"
|
||||
VIEW_CONCEAL = "view-conceal-symbolic"
|
||||
VIEW_REVEAL = "view-reveal-symbolic"
|
||||
WARNING = "dialog-warning-symbolic"
|
||||
WEB_BROWSER = "web-browser-symbolic"
|
||||
DAY_ICON = "weather-clear-symbolic"
|
||||
NIGHT_ICON = "weather-clear-night-symbolic"
|
||||
|
||||
SEPARATOR = "SEPARATOR"
|
||||
|
||||
@@ -72,11 +79,15 @@ 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"
|
||||
ICONS_PATH = "data/images/gtk"
|
||||
|
||||
HERO_PATH = "data/images/_hero.png"
|
||||
CSS_PATH = "data/app.css"
|
||||
VDF_PATH = "steamapps/libraryfolders.vdf"
|
||||
DEFAULT_STEAM_PATH = ".local/share/Steam"
|
||||
FLATPAK_STEAM_PATH = ".var/app/com.valvesoftware.Steam/data/Steam"
|
||||
FLATPAK_ROOT_PATH = ".var/app/com.valvesoftware.Steam"
|
||||
FLATPAK_STEAM_PATH = FLATPAK_ROOT_PATH + "/data/Steam"
|
||||
UBUNTU_STEAM_PATH = ".steam/steam"
|
||||
DEBIAN_STEAM_PATH = ".steam/debian-installation"
|
||||
|
||||
@@ -87,3 +98,8 @@ TMP_TARBALL = "/tmp/dzgui.tar.gz"
|
||||
TMP_EXE = "/tmp/dzgui/dzgui"
|
||||
|
||||
DAYZ_COMMUNITY_ROOT = "Missions"
|
||||
|
||||
GIO_SETTINGS_INTERFACE = "org.gnome.desktop.interface"
|
||||
GIO_SETTINGS_PROP = "color-scheme"
|
||||
GIO_SETTINGS_KEY = "gtk-application-prefer-dark-theme"
|
||||
GIO_SETTINGS_VAL = "prefer-dark"
|
||||
@@ -5,8 +5,8 @@ STEAM_PUBLISHED_FILES = (
|
||||
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1"
|
||||
SUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Subscribe/v1"
|
||||
UNSUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Unsubscribe/v1"
|
||||
APP_DETAILS = "https://store.steampowered.com/api/appdetails?"
|
||||
|
||||
BM_SERVERS = "https://api.battlemetrics.com/servers?"
|
||||
GITHUB = "https://github.com/aclist"
|
||||
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
|
||||
CODEBERG_RELEASES = "https://codeberg.org/api/v1/repos/aclist/dzgui/releases/latest"
|
||||
@@ -26,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"
|
||||
@@ -61,9 +61,6 @@ class Preferences(EnumWithAttrs):
|
||||
STEAM = {
|
||||
"key": "steam_api",
|
||||
}
|
||||
BM = {
|
||||
"key": "bm_api",
|
||||
}
|
||||
CLIENT = {
|
||||
"key": "client",
|
||||
}
|
||||
@@ -76,12 +73,6 @@ class Preferences(EnumWithAttrs):
|
||||
NAME = {
|
||||
"key": "name",
|
||||
}
|
||||
INSTALL = {
|
||||
"key": "auto_install",
|
||||
}
|
||||
DEBUG = {
|
||||
"key": "debug",
|
||||
}
|
||||
DEFAULT = {
|
||||
"key": "default_steam_path",
|
||||
}
|
||||
@@ -94,9 +85,6 @@ class Preferences(EnumWithAttrs):
|
||||
IP_LIST = {
|
||||
"key": "ip_list",
|
||||
}
|
||||
BRANCH = {
|
||||
"key": "branch",
|
||||
}
|
||||
START_TAB = {
|
||||
"key": "start_tab",
|
||||
}
|
||||
|
||||
@@ -145,6 +145,10 @@ class Emitter(GObject.GObject):
|
||||
def api_change_failed(self) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
|
||||
def api_change_successful(self) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
|
||||
def already_saved_server(self) -> None:
|
||||
pass
|
||||
|
||||
+30
-21
@@ -7,11 +7,12 @@ import dzgui.util._json as JSON # noqa
|
||||
|
||||
|
||||
from dzgui.const.enum import (
|
||||
FilterMode,
|
||||
Preferences,
|
||||
NotebookPage,
|
||||
ButtonType,
|
||||
ContextMenu,
|
||||
FilterMode,
|
||||
NotebookPage,
|
||||
Preferences,
|
||||
ServerTab,
|
||||
)
|
||||
|
||||
from dzgui.config.userprefs import UserPrefs
|
||||
@@ -37,7 +38,6 @@ logger = logging.getLogger(APP_NAME)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.api.servers import Record
|
||||
from dzgui.const.enum import ServerTab
|
||||
from dzgui.managers.connection import Prerequisites
|
||||
from dzgui.managers.filter import FilterManager
|
||||
from dzgui.util.dist import Haversine
|
||||
@@ -88,7 +88,6 @@ class Controller(GObject.GObject):
|
||||
|
||||
# NOTE: suppress requests until entire UI is loaded
|
||||
self.loaded = False
|
||||
self.pending_jobs = 1
|
||||
|
||||
self.exit_event = threading.Event()
|
||||
self.cancel_event = threading.Event()
|
||||
@@ -115,9 +114,6 @@ class Controller(GObject.GObject):
|
||||
def query_config(self, key: Preferences) -> Any:
|
||||
return self.config_man.lookup(key)
|
||||
|
||||
def is_auto_install(self) -> bool:
|
||||
return bool(self.query_config(Preferences.INSTALL))
|
||||
|
||||
def suppress_signal(
|
||||
self, owner: Any, child: Any, func_name: str, state: bool
|
||||
) -> None:
|
||||
@@ -267,6 +263,7 @@ class Controller(GObject.GObject):
|
||||
self.open_page(NotebookPage.LOG)
|
||||
except Exception as e:
|
||||
dialog = ExceptionDialog(self, str(e))
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def select_colorized(self) -> None:
|
||||
@@ -300,10 +297,11 @@ class Controller(GObject.GObject):
|
||||
write_diagnostic(self.prefs.paths.config, file)
|
||||
except Exception as e:
|
||||
dialog = ExceptionDialog(self, str(e))
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def update_api_key(self, key: Preferences, text: str) -> None:
|
||||
self.config_man.update_api_key(key, text)
|
||||
def update_steam_api_key(self, text: str) -> None:
|
||||
self.config_man.update_steam_api_key(text)
|
||||
|
||||
def set_resolution(self, window: "OuterWindow") -> None:
|
||||
self.config_man.set_resolution(window)
|
||||
@@ -402,6 +400,9 @@ class Controller(GObject.GObject):
|
||||
def get_menu(self) -> "MenuTreeView":
|
||||
return self.mediator.menu
|
||||
|
||||
def unset_fav(self) -> None:
|
||||
self.config_man.unset_fav()
|
||||
|
||||
def has_favorites(self) -> bool:
|
||||
favs = self.config_man.get_favorites()
|
||||
if len(favs) < 1:
|
||||
@@ -413,7 +414,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()
|
||||
@@ -437,14 +438,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)
|
||||
@@ -465,6 +460,9 @@ class Controller(GObject.GObject):
|
||||
open_workshop_page(mod, cmd)
|
||||
|
||||
def has_note(self) -> bool:
|
||||
# TODO: get record string, store in memory
|
||||
# get note by record, get is in favs, etc. store as a block
|
||||
# this is only used by context mixin
|
||||
note = self.get_note()
|
||||
if len(note) > 0:
|
||||
return True
|
||||
@@ -479,11 +477,13 @@ class Controller(GObject.GObject):
|
||||
return self.notes_man.get_note(record)
|
||||
|
||||
def add_note(self, note: str) -> None:
|
||||
# TODO: record should be cached upon creation of dialog
|
||||
tv = self.get_active_treeview()
|
||||
record = tv.get_record_string()
|
||||
self.notes_man.add_note(record, note)
|
||||
|
||||
def delete_note(self) -> None:
|
||||
# TODO: record should be cached upon creation of dialog
|
||||
tv = self.get_active_treeview()
|
||||
record = tv.get_record_string()
|
||||
self.notes_man.delete_note(record)
|
||||
@@ -498,6 +498,9 @@ class Controller(GObject.GObject):
|
||||
|
||||
def set_active_keyword(self, keyword: str) -> None:
|
||||
self.get_filter_man().set_active_keyword(keyword)
|
||||
ServerModelManager(self, self.get_active_treeview()).refilter(
|
||||
FilterMode.TOGGLE_ON
|
||||
)
|
||||
|
||||
def get_modtreeview(self) -> "ModTreeView":
|
||||
return self.mediator.modtreeview
|
||||
@@ -523,8 +526,12 @@ class Controller(GObject.GObject):
|
||||
|
||||
def set_start_tab(self) -> None:
|
||||
ind = self.config_man.get_start_tab()
|
||||
logger.info(f"Setting start tab to index {ind}")
|
||||
self.get_servers().notebook.set_current_page(ind)
|
||||
|
||||
def get_debug_args(self) -> str:
|
||||
return self.connection_man.get_debug_args()
|
||||
|
||||
def update_and_load_to_menu(self) -> None:
|
||||
self.connection_man.update_and_connect(menu_only=True)
|
||||
|
||||
@@ -534,8 +541,10 @@ class Controller(GObject.GObject):
|
||||
def update_status(self) -> None:
|
||||
self.mediator.preconnect.mark_finished()
|
||||
|
||||
def get_steam_client_name(self) -> str:
|
||||
return self.mediator.options.get_client_name()
|
||||
def get_client_name(self) -> str:
|
||||
# NOTE: only possible values are "Steam" and "Flatpak"
|
||||
cmd = self.query_config(Preferences.CLIENT)
|
||||
return str(cmd.split()[0].capitalize())
|
||||
|
||||
def present_window(self) -> None:
|
||||
self.mediator.window.present_with_time(Gdk.CURRENT_TIME)
|
||||
|
||||
+51
-29
@@ -1,7 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## Added
|
||||
## [7.0.0] Unreleased
|
||||
### Added
|
||||
- Setup wizard
|
||||
- Uninstall wizard
|
||||
- Descriptive details panel to certain error dialogs
|
||||
- Changelog text wrapping and formatting
|
||||
- Changelog ships with source
|
||||
- Documentation ships with source
|
||||
@@ -23,6 +26,7 @@
|
||||
- Set favorite server from tables
|
||||
- Detailed/copyable trace in critical error dialogs
|
||||
- Visual icons
|
||||
- Unset favorite server button
|
||||
- Integrated server notebook
|
||||
- Propagate width changes to all tables
|
||||
- Remember tree position in menus
|
||||
@@ -41,11 +45,22 @@
|
||||
- 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
|
||||
- Update map count (-u flag)
|
||||
- Filter button tooltips
|
||||
- Expand/collapse changelog versions
|
||||
- Support local light/dark mode changes on system theme
|
||||
- Server time calculator
|
||||
|
||||
## Changed
|
||||
### Changed
|
||||
- Conform to PEP 440 versioning for beta versions
|
||||
- Enforce fullscreen mode on Steam Deck
|
||||
- Reduce padding on keys button
|
||||
- Boldface breadcrumbs
|
||||
- Changed some dialog error messages from popups to popovers
|
||||
- Changed how filter buttons are packed in sidebar
|
||||
- Bold labels inside frames
|
||||
- Sidebar buttons do not steal focus
|
||||
- Copy IP copies IP:queryport only instead of IP:gameport:queryport, mimics syntax needed by add by ip method
|
||||
@@ -58,59 +73,66 @@
|
||||
- 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
|
||||
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
|
||||
|
||||
## Dropped
|
||||
- Debug mode
|
||||
### Dropped
|
||||
- Battlemetrics API support
|
||||
- Unused metadata from system log files
|
||||
- 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
|
||||
- In-game time field missing certain ranges of time at night
|
||||
- Wrap long entries in changelog
|
||||
- Certain server descriptions with special characters not rendering correctly
|
||||
- Center server title text on server dialogs
|
||||
- Rare segfaults when changing maps (threading)
|
||||
- Moved dialogs out of threads
|
||||
- History table not being in lock-step with state file
|
||||
- Current keyword filter not being applied when refreshing table
|
||||
|
||||
## Unreleased
|
||||
### Unreleased
|
||||
- Load offline mods
|
||||
- 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
|
||||
@@ -136,7 +158,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
|
||||
@@ -175,7 +197,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
|
||||
@@ -187,50 +209,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
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
.left-label {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.server-subheading {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.details-heading {
|
||||
font-size: 1.2rem
|
||||
}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M568 320C568 457 456.8 568 319.6 568C205.8 568 110 491.7 80.6 387.6L175.8 426.9C182.2 459 210.7 483.3 244.7 483.3C283.9 483.3 316.6 450.9 314.9 409.8L399.4 349.6C451.5 350.9 495.2 308.7 495.2 256.1C495.2 204.5 453.2 162.6 401.5 162.6C349.8 162.6 307.8 204.6 307.8 256.1L307.8 257.3L248.6 343C233.1 342.1 217.9 346.4 205.1 355.1L72 300.1C82.2 172.4 189.1 72 319.6 72C456.8 72 568 183 568 320zM227.7 448.3L197.2 435.7C202.8 447.3 212.5 456.5 224.4 461.5C251.3 472.7 282.2 459.9 293.4 433.1C298.8 420.1 298.9 405.8 293.5 392.8C288.1 379.8 278 369.6 265 364.2C252.1 358.8 238.3 359 226.1 363.6L257.6 376.6C277.4 384.8 286.8 407.5 278.5 427.3C270.2 447.2 247.5 456.5 227.7 448.3zM401.5 193.8C435.9 193.8 463.8 221.7 463.8 256.1C463.8 290.5 435.9 318.4 401.5 318.4C367.1 318.4 339.2 290.5 339.2 256.1C339.2 221.7 367.1 193.8 401.5 193.8zM401.6 302.8C427.4 302.8 448.4 281.8 448.4 256C448.4 230.2 427.4 209.2 401.6 209.2C375.8 209.2 354.8 230.2 354.8 256C354.8 281.8 375.8 302.8 401.6 302.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
File renamed without changes.
+11
-4
@@ -2,19 +2,26 @@ import logging
|
||||
|
||||
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, APPNAME_DAYZ
|
||||
from dzgui.const.enum import Preferences
|
||||
|
||||
import dzgui.api.pefile as PeFile
|
||||
from dzgui.strings.preconnect import resync
|
||||
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
class VDFSyncError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
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)
|
||||
raise e
|
||||
msg = resync.format(APPNAME_DAYZ)
|
||||
raise VDFSyncError(msg)
|
||||
+3
-4
@@ -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,9 +40,7 @@ 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_libgi_missing() -> None:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import shutil
|
||||
|
||||
from pathlib import Path
|
||||
@@ -7,11 +8,10 @@ from dzgui.config.convert import rc2json
|
||||
from dzgui.util._json import read_json, write_json
|
||||
|
||||
|
||||
def migrate_legacy_conf(config: Path) -> None:
|
||||
def migrate_legacy_conf(config: Path) -> Any:
|
||||
old_conf = Path.home() / LEGACY_CONFIG_PATH
|
||||
j = rc2json(old_conf)
|
||||
config.parent.mkdir(parents=True, exist_ok=True)
|
||||
config.write_text(j)
|
||||
return json.loads(j)
|
||||
|
||||
|
||||
def has_new_config(config: Path) -> bool:
|
||||
|
||||
+20
-7
@@ -7,19 +7,32 @@ from dzgui.const.constants import APP_NAME, FLATPAK_APPID, FLATPAK_CMD, STEAM_CM
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
def has_steam_client() -> bool:
|
||||
if shutil.which(STEAM_CMD):
|
||||
return True
|
||||
if not shutil.which(FLATPAK_CMD):
|
||||
return False
|
||||
def has_flatpak_client() -> bool:
|
||||
try:
|
||||
res = subprocess.run(
|
||||
[FLATPAK_CMD, "list"], capture_output=True, text=True, check=True
|
||||
[FLATPAK_CMD, "list", "--columns=application"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
if FLATPAK_APPID in res.stdout:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
except subprocess.CalledProcessError as e:
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return False
|
||||
|
||||
|
||||
def has_steam_client() -> bool:
|
||||
if shutil.which(STEAM_CMD):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_any_client() -> bool:
|
||||
if shutil.which(STEAM_CMD):
|
||||
return True
|
||||
if not shutil.which(FLATPAK_CMD):
|
||||
return False
|
||||
return has_flatpak_client()
|
||||
+30
-53
@@ -3,66 +3,60 @@ import subprocess
|
||||
import shutil
|
||||
import logging
|
||||
|
||||
from warnings import deprecated
|
||||
|
||||
from dzgui.const.constants import (
|
||||
APP_NAME,
|
||||
DAYZ_BINARY,
|
||||
STEAM_CMD,
|
||||
FLATPAK_APPID,
|
||||
FLATPAK_CMD,
|
||||
FLATPAK_RUN_CMD,
|
||||
FLATPAK_SANDBOX,
|
||||
FLATPAK_ROOT_PATH,
|
||||
FLATPAK_STEAM_PATH,
|
||||
)
|
||||
|
||||
from dzgui.util.format import format_exception
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
# TODO: move to util.proc
|
||||
def is_dayz_running() -> bool:
|
||||
"""Subprocesses spawned from Steam will not show up in regular process tree"""
|
||||
procs = []
|
||||
substring = DAYZ_BINARY
|
||||
for proc in psutil.process_iter():
|
||||
try:
|
||||
procs.append(proc.cmdline())
|
||||
except Exception as e:
|
||||
msg = format_exception(e)
|
||||
logger.warning(msg)
|
||||
continue
|
||||
return any(substring in item for sublist in procs for item in sublist)
|
||||
|
||||
|
||||
def is_steam_running(cmd: str) -> bool:
|
||||
if cmd == STEAM_CMD:
|
||||
if has_cmd(STEAM_CMD) is False:
|
||||
return False
|
||||
return is_running(STEAM_CMD)
|
||||
elif cmd == FLATPAK_RUN_CMD or FLATPAK_SANDBOX:
|
||||
if cmd in (FLATPAK_RUN_CMD, FLATPAK_SANDBOX):
|
||||
return is_flatpak_steam_running()
|
||||
elif cmd == STEAM_CMD:
|
||||
return is_native_steam_running()
|
||||
else:
|
||||
raise TypeError("Not a valid Steam client selection")
|
||||
|
||||
|
||||
# CHORE: test alternate clients
|
||||
# TODO: has_flatpak_steam
|
||||
def is_flatpak_steam_running() -> bool:
|
||||
if has_cmd(FLATPAK_CMD) is False:
|
||||
return False
|
||||
proc = subprocess.check_output([FLATPAK_CMD, "ps"], text=True)
|
||||
proc = subprocess.check_output(
|
||||
[FLATPAK_CMD, "ps", "--columns=application"], text=True
|
||||
)
|
||||
lines = proc.splitlines()
|
||||
if FLATPAK_APPID in lines:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_running(proc: str) -> bool:
|
||||
for process in psutil.process_iter(["pid", "name"]):
|
||||
if process.info["name"] == proc:
|
||||
return True
|
||||
return False
|
||||
def get_steam_procs() -> list[list[str]]:
|
||||
return [
|
||||
p.cmdline()
|
||||
for p in psutil.process_iter(["pid", "name"])
|
||||
if STEAM_CMD in p.info["name"]
|
||||
]
|
||||
|
||||
|
||||
def is_native_steam_running() -> bool:
|
||||
if has_cmd(STEAM_CMD) is False:
|
||||
return False
|
||||
substrings = [FLATPAK_ROOT_PATH, "steam-runtime-launcher-service"]
|
||||
steam_procs = get_steam_procs()
|
||||
native_steam_procs = [
|
||||
proc
|
||||
for proc in steam_procs
|
||||
if not any(sub in item for item in proc for sub in substrings)
|
||||
]
|
||||
return bool(native_steam_procs)
|
||||
|
||||
|
||||
def has_cmd(cmd: str) -> bool:
|
||||
@@ -71,23 +65,6 @@ def has_cmd(cmd: str) -> bool:
|
||||
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
|
||||
)
|
||||
def path_to_steam_cmd(path: str) -> str:
|
||||
cmd = FLATPAK_RUN_CMD if FLATPAK_STEAM_PATH in path else STEAM_CMD
|
||||
return cmd
|
||||
+16
-13
@@ -5,33 +5,36 @@ import warnings
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.init.libgi import test_libgi_missing
|
||||
from dzgui.init.prefix import get_version
|
||||
from dzgui.util.deck import is_steam_deck
|
||||
from dzgui.util.map_count import set_map_count
|
||||
from dzgui.util.strings import flags
|
||||
|
||||
from dzgui.views.dialogs.uninstall import UninstallWizard
|
||||
from dzgui.config.xdg import get_xdg_paths
|
||||
|
||||
parser = argparse.ArgumentParser(description=flags.description)
|
||||
parser.add_argument("-v", "--version", action="store_true", help=flags.version)
|
||||
parser.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall)
|
||||
parser.add_argument("-d", "--debug", action="store_true", help=flags.debug)
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument("-d", "--debug", action="store_true", help=flags.debug)
|
||||
group.add_argument("-m", "--map", action="store_true", help=flags.map_count)
|
||||
group.add_argument("-u", "--uninstall", action="store_true", help=flags.uninstall)
|
||||
group.add_argument("-v", "--version", action="store_true", help=flags.version)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def uninstall() -> None:
|
||||
# TODO: uninstall data files (-u)
|
||||
# -u removes state, log, freedesktop
|
||||
# XDG_SHARE_HOME/dzgui
|
||||
# XDG_STATE_HOME/dzgui
|
||||
# XDG_DATA_HOME/dzgui
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if args.version is True:
|
||||
print(get_version())
|
||||
sys.exit(0)
|
||||
if args.uninstall is True:
|
||||
uninstall()
|
||||
paths = get_xdg_paths()
|
||||
_is_steam_deck = is_steam_deck()
|
||||
UninstallWizard(_is_steam_deck, paths)
|
||||
sys.exit(0)
|
||||
if args.debug is True:
|
||||
warnings.filterwarnings("default", category=DeprecationWarning)
|
||||
if args.map is True:
|
||||
set_map_count()
|
||||
sys.exit(0)
|
||||
|
||||
version = get_version()
|
||||
|
||||
|
||||
+28
-30
@@ -6,24 +6,16 @@ from typing import Any, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import (
|
||||
APP_NAME,
|
||||
FLATPAK_RUN_CMD,
|
||||
FLATPAK_SANDBOX,
|
||||
STEAM_CMD,
|
||||
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
|
||||
from dzgui.strings import dialogs
|
||||
from dzgui.util._json import read_json, write_json
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository.Gtk import main_quit # noqa E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.config.userprefs import UserPrefs
|
||||
from dzgui.controllers.mc import Controller
|
||||
@@ -55,8 +47,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)
|
||||
@@ -95,14 +90,13 @@ class ConfigManager:
|
||||
logger.critical(e)
|
||||
raise e
|
||||
|
||||
@call_on_thread(dialogs.checking_api)
|
||||
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)
|
||||
@call_on_thread(dialogs.checking_api, show_dialog=False)
|
||||
def update_steam_api_key(self, text: str) -> None:
|
||||
res = test_steam_api(text)
|
||||
if res is True:
|
||||
self.update_config(key, text)
|
||||
self.update_config(Preferences.STEAM, text)
|
||||
func = StoredFunc(lambda: self.emitter.emit("api_change_successful"))
|
||||
self.thread_man.set_cleanup_func(func)
|
||||
else:
|
||||
self.thread_man.set_cleanup_func(
|
||||
StoredFunc(lambda: self.emitter.emit("api_change_failed"))
|
||||
@@ -132,15 +126,6 @@ class ConfigManager:
|
||||
def enum_to_key(self, enum: Preferences) -> str:
|
||||
return str(enum.dict["key"])
|
||||
|
||||
def get_client_index(self, client: str) -> int:
|
||||
if client == STEAM_CMD:
|
||||
return 0
|
||||
if client == FLATPAK_RUN_CMD:
|
||||
return 1
|
||||
if client == FLATPAK_SANDBOX:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
def toggle_config(self, key: Preferences) -> None:
|
||||
try:
|
||||
real_key = self.enum_to_key(key)
|
||||
@@ -159,6 +144,13 @@ class ConfigManager:
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def unset_fav(self) -> None:
|
||||
try:
|
||||
self.write_config(Preferences.FAV_LBL, "")
|
||||
self.write_config(Preferences.FAV_SRV, "")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def write_config(self, key: Preferences, value: str) -> None:
|
||||
try:
|
||||
real_key = self.enum_to_key(key)
|
||||
@@ -169,8 +161,8 @@ class ConfigManager:
|
||||
logger.critical(e)
|
||||
trace = traceback.format_exc()
|
||||
dialog = ExceptionDialog(self.controller, trace)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
raise e
|
||||
|
||||
def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None:
|
||||
columns = tv.get_columns()
|
||||
@@ -197,7 +189,10 @@ class ConfigManager:
|
||||
|
||||
logger.info("Normal user exit")
|
||||
if window.props.is_maximized:
|
||||
main_quit()
|
||||
app = window.get_application()
|
||||
if app is None:
|
||||
return
|
||||
app.quit()
|
||||
return
|
||||
|
||||
w, h = window.get_size()
|
||||
@@ -209,7 +204,10 @@ class ConfigManager:
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
|
||||
main_quit()
|
||||
app = window.get_application()
|
||||
if app is None:
|
||||
return
|
||||
app.quit()
|
||||
|
||||
def set_resolution(self, window: "OuterWindow") -> None:
|
||||
if self.prefs.is_game_mode:
|
||||
|
||||
@@ -9,11 +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,
|
||||
get_remote_signatures,
|
||||
connect_debug,
|
||||
is_dayz_downloading,
|
||||
get_app_allows_downloads,
|
||||
get_app_name,
|
||||
get_needs_update,
|
||||
get_remote_signatures,
|
||||
get_running_app,
|
||||
load_to_menu,
|
||||
subscribe,
|
||||
)
|
||||
@@ -32,16 +38,18 @@ from dzgui.const.constants import (
|
||||
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,
|
||||
waiting_for_directories,
|
||||
)
|
||||
from dzgui.strings import kb
|
||||
from dzgui.strings.server_mods import checkmark, resync
|
||||
from dzgui.util.format import format_mib
|
||||
from dzgui.util.format import format_exception, format_mib
|
||||
from dzgui.util.strings import dialog, server_timeout
|
||||
from dzgui.util.symlink import rebuild_symlinks
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
@@ -77,10 +85,13 @@ class Prerequisites:
|
||||
available_space: float
|
||||
passworded: bool
|
||||
dayz_running: bool
|
||||
download_paused: bool
|
||||
steam_proc: SteamProcess
|
||||
mods: list[list[str]]
|
||||
game_mode: bool
|
||||
is_last_server: bool
|
||||
invalid_mods: list[tuple[str, str]]
|
||||
allows_downloads: tuple[bool, str]
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
@@ -97,11 +108,6 @@ class ConnectionManager:
|
||||
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)
|
||||
@@ -165,9 +171,24 @@ class ConnectionManager:
|
||||
free_mib = format_mib(free)
|
||||
|
||||
dayz_running = is_dayz_running()
|
||||
download_paused = is_dayz_downloading(steam_path, self.appid)
|
||||
|
||||
running_app = get_running_app()
|
||||
if running_app is not None:
|
||||
allows_dl = get_app_allows_downloads(steam_path, running_app)
|
||||
running_app_name = get_app_name(running_app)
|
||||
# NOTE: for out-of-range appid, assume NSG.
|
||||
# dzgui.api.steam.get_app_name() will return None
|
||||
if running_app_name is None:
|
||||
s = Shortcuts(steam_path)
|
||||
# NOTE: returns "Unknown" if unparseable
|
||||
running_app_name = s.find_appname_by_unsigned_id(running_app)
|
||||
allows_downloads = (allows_dl, running_app_name)
|
||||
else:
|
||||
allows_downloads = (True, "")
|
||||
|
||||
client_name = self.controller.get_steam_client_name()
|
||||
client = self.controller.query_config(Preferences.CLIENT)
|
||||
client_name = self.controller.get_client_name()
|
||||
running = is_steam_running(client)
|
||||
steam_proc = SteamProcess(client_name, running)
|
||||
self.client = client
|
||||
@@ -176,6 +197,11 @@ class ConnectionManager:
|
||||
|
||||
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,
|
||||
@@ -187,10 +213,13 @@ class ConnectionManager:
|
||||
available_space=free_mib,
|
||||
passworded=info.password_protected,
|
||||
dayz_running=dayz_running,
|
||||
download_paused=download_paused,
|
||||
steam_proc=steam_proc,
|
||||
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)
|
||||
@@ -210,7 +239,7 @@ class ConnectionManager:
|
||||
@call_on_thread(dialog.querying)
|
||||
def query_details(self, record: Servers.Record) -> None:
|
||||
details = Servers.get_details(record)
|
||||
if details.success is False:
|
||||
if details is None:
|
||||
self.thread_man.set_cleanup_func(
|
||||
StoredFunc(self._server_timeout), destroy_first=True
|
||||
)
|
||||
@@ -224,7 +253,7 @@ class ConnectionManager:
|
||||
) -> 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]] = [
|
||||
[
|
||||
@@ -245,6 +274,11 @@ class ConnectionManager:
|
||||
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
|
||||
|
||||
@@ -270,10 +304,24 @@ class ConnectionManager:
|
||||
dialog = ServerDetailsDialog(self.controller, details)
|
||||
dialog.run()
|
||||
|
||||
def _workshop_timeout(self, msg: str) -> None:
|
||||
dialog = ExceptionDialog(self.controller, msg)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def _server_timeout(self) -> None:
|
||||
dialog = ExceptionDialog(self.controller, server_timeout)
|
||||
dialog.set_details_buffer(kb.DZG_006)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def get_debug_args(self) -> str:
|
||||
addr = f"{self.record.ip}:{self.record.gameport}"
|
||||
playername = self.controller.query_config(Preferences.NAME)
|
||||
return connect_debug(
|
||||
self.client, addr, self.appid, playername, self.remote_mod_ids
|
||||
)
|
||||
|
||||
def _connect_steam(self, menu_only: bool) -> None:
|
||||
addr = f"{self.record.ip}:{self.record.gameport}"
|
||||
playername = self.controller.query_config(Preferences.NAME)
|
||||
@@ -312,8 +360,14 @@ class ConnectionManager:
|
||||
for title, mod, stamp, size in self.missing_mods:
|
||||
if self.controller.is_cancel_pending():
|
||||
return
|
||||
subscribe(key, int(mod))
|
||||
time.sleep(API_RATE_LIMIT)
|
||||
try:
|
||||
subscribe(key, int(mod))
|
||||
time.sleep(API_RATE_LIMIT)
|
||||
except Exception as e:
|
||||
msg = format_exception(e)
|
||||
func = StoredFunc(self._workshop_timeout, msg)
|
||||
self.thread_man.set_cleanup_func(func, destroy_first=True)
|
||||
return
|
||||
|
||||
self.thread_man.update_dialog(waiting_for_directories)
|
||||
for title, mod, stamp, size in self.missing_mods:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import gi
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -14,10 +13,6 @@ from dzgui.views.trees.tree_servers import ServerTreeView
|
||||
from dzgui.views.trees.tree_server_mods import ServerModTreeView
|
||||
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402
|
||||
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -21,12 +21,30 @@ class FilterManager:
|
||||
strings.filter_3pp: True,
|
||||
strings.filter_night: True,
|
||||
strings.filter_full: show_full,
|
||||
strings.filter_lowpop: True,
|
||||
strings.filter_nonascii: False,
|
||||
strings.filter_duplicate: False,
|
||||
strings.filter_official: True,
|
||||
strings.filter_unofficial: True,
|
||||
strings.filter_modded: True,
|
||||
strings.filter_lowpop: True,
|
||||
strings.filter_unofficial: True,
|
||||
strings.filter_unmodded: True,
|
||||
strings.filter_duplicate: False,
|
||||
strings.filter_nonascii: False,
|
||||
}
|
||||
|
||||
# TODO: strings
|
||||
self.tooltips = {
|
||||
strings.filter_1pp: "First-person perspective",
|
||||
strings.filter_day: "In-game time between 0600 and 1759",
|
||||
strings.filter_empty: "Servers with no players",
|
||||
strings.filter_3pp: "Third-person perspective",
|
||||
strings.filter_night: "In-game time between 1800 and 0559",
|
||||
strings.filter_full: "Servers with no open slots",
|
||||
strings.filter_official: "Bohemia official servers",
|
||||
strings.filter_nonascii: "Server names using non-standard, complex glyphs",
|
||||
strings.filter_lowpop: "Current population is under 30% of total",
|
||||
strings.filter_unofficial: "Third-party servers",
|
||||
strings.filter_duplicate: "Duplicates of existing servers (usually spoofed)",
|
||||
strings.filter_modded: "Servers with one or more mods",
|
||||
strings.filter_unmodded: "Servers with no mods",
|
||||
}
|
||||
|
||||
self.active_keyword = ""
|
||||
@@ -36,6 +54,9 @@ class FilterManager:
|
||||
self.filters: list
|
||||
self.enabled_filters = dict(self.default_filters)
|
||||
|
||||
def get_tooltips(self) -> dict[str, str]:
|
||||
return self.tooltips
|
||||
|
||||
def set_prior_map(self, name: str) -> None:
|
||||
self.prior_map = name
|
||||
|
||||
@@ -86,6 +107,7 @@ class FilterManager:
|
||||
self.reinit_map_store()
|
||||
maps.sort()
|
||||
for m in maps:
|
||||
# TODO: strings
|
||||
if m == "All maps":
|
||||
continue
|
||||
self.append_map([m])
|
||||
|
||||
@@ -22,9 +22,7 @@ from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManage
|
||||
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
|
||||
|
||||
|
||||
import dzgui.api.pefile as PeFile
|
||||
@@ -60,7 +58,7 @@ class ModManager:
|
||||
|
||||
@call_on_thread(dialogs.fetching_mods)
|
||||
def load_mods(self) -> None:
|
||||
mods = get_delimited_mods(self.path)
|
||||
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))
|
||||
@@ -124,7 +122,11 @@ class ModManager:
|
||||
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))
|
||||
try:
|
||||
unsubscribe(key, int(mod))
|
||||
except Exception as e:
|
||||
logger.critical(e)
|
||||
return
|
||||
|
||||
steam_path = Path(self.path)
|
||||
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
|
||||
@@ -141,7 +143,7 @@ class ModManager:
|
||||
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)
|
||||
|
||||
@@ -213,10 +215,6 @@ class ModManager:
|
||||
func = StoredFunc(self._on_stale_mods_found, stale)
|
||||
self.thread_man.set_cleanup_func(func)
|
||||
|
||||
def _server_timeout(self) -> None:
|
||||
dialog = ExceptionDialog(self.controller, server_timeout)
|
||||
dialog.run()
|
||||
|
||||
def select_colorized(self) -> None:
|
||||
model = self.treeview.get_model()
|
||||
if model is None:
|
||||
|
||||
@@ -5,10 +5,9 @@ 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 launch_offline
|
||||
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.init.proc import is_dayz_running
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.model.model_factory import ModelFactory
|
||||
from dzgui.strings import dialogs
|
||||
@@ -77,7 +76,7 @@ class OfflineManager:
|
||||
window.set_sensitive(True)
|
||||
self.emitter.emit("custom_mods_loaded", store, folder)
|
||||
|
||||
@call_on_thread(dialogs.waiting_for_launch)
|
||||
@call_on_thread(dialogs.waiting_for_launch, show_cancel=True)
|
||||
def launch(
|
||||
self,
|
||||
appid: int,
|
||||
|
||||
@@ -64,4 +64,5 @@ class UpdateManager:
|
||||
|
||||
def _on_update_failure(self, msg: str) -> None:
|
||||
dialog = ExceptionDialog(self.controller, msg)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
@@ -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:
|
||||
|
||||
+12
-12
@@ -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
|
||||
@@ -13,6 +12,9 @@ if TYPE_CHECKING:
|
||||
from dzgui.api.servers import Record
|
||||
from dzgui.model.servers import NewPlayerCount
|
||||
|
||||
DAY_REG = r"([0][6-9]|[1][0-7])"
|
||||
NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])"
|
||||
|
||||
|
||||
class ProxyModelManager:
|
||||
"""
|
||||
@@ -86,8 +88,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)
|
||||
|
||||
@@ -232,17 +234,17 @@ class ProxyModelManager:
|
||||
final.append(row)
|
||||
rows = final
|
||||
case strings.filter_day:
|
||||
reg = r"([0][0-9]|[1][0-6])"
|
||||
rows = [row for row in rows if not re.match(reg, row[3])]
|
||||
rows = [row for row in rows if not re.match(DAY_REG, row[3])]
|
||||
case strings.filter_night:
|
||||
reg = r"([0][0-4]|[1][8]|[2][0-3])"
|
||||
rows = [row for row in rows if not re.match(reg, row[3])]
|
||||
rows = [row for row in rows if not re.match(NIGHT_REG, row[3])]
|
||||
case strings.filter_nonascii:
|
||||
rows = [row for row in rows if row[0].isascii()]
|
||||
case strings.filter_lowpop:
|
||||
rows = [row for row in rows if (row[4] / row[5] * 100) > 30]
|
||||
case strings.filter_modded:
|
||||
rows = [row for row in rows if not row[11]]
|
||||
case strings.filter_unmodded:
|
||||
rows = [row for row in rows if row[11]]
|
||||
return rows
|
||||
|
||||
def filter_toggle_on(self, filters: tuple[str]) -> list:
|
||||
@@ -259,10 +261,6 @@ class ProxyModelManager:
|
||||
def set_cache(self, filters: tuple[str], filtered_rows: list[tuple]) -> None:
|
||||
self.filter_cache[filters] = filtered_rows
|
||||
|
||||
@deprecated("Currently unused")
|
||||
# def convert_model_to_list(self, model: "FastInsertListStore") -> list:
|
||||
# return [[el for el in row] for row in model]
|
||||
|
||||
def set_filtered(self, rows: list | None) -> None:
|
||||
if rows is None:
|
||||
self.filtered = []
|
||||
@@ -298,4 +296,6 @@ class ProxyModelManager:
|
||||
def push(self, data: list[Any]) -> None:
|
||||
self.wipe_cache()
|
||||
self.set_control(data)
|
||||
self.filter(FilterMode.INITIAL)
|
||||
cur_keyword = self.filter_man.get_active_keyword()
|
||||
mode = FilterMode.TOGGLE_ON if len(cur_keyword) > 0 else FilterMode.INITIAL
|
||||
self.filter(mode)
|
||||
+27
-29
@@ -14,14 +14,14 @@ 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.strings import dialogs, kb
|
||||
from dzgui.util.strings import server_timeout, dialog
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa E402
|
||||
from gi.repository import Gtk, GLib # noqa E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.api.servers import A2SInfo, Record
|
||||
@@ -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
|
||||
@@ -397,6 +390,8 @@ class ServerModelManager:
|
||||
def _cleanup_on_success(self) -> None:
|
||||
proxy = self.proxy_man.get_proxy_model()
|
||||
self.tv.set_model(proxy)
|
||||
if self.tv.enum == ServerTab.BROWSER:
|
||||
GLib.idle_add(self.tv.wipe_cache)
|
||||
if self.preserve_on_fail is True:
|
||||
self.proxy_man.wipe_cache()
|
||||
|
||||
@@ -412,6 +407,7 @@ class ServerModelManager:
|
||||
|
||||
if show_dialog:
|
||||
dialog = ExceptionDialog(self.controller, dialogs.load_error_lan)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def _cleanup_on_failure(self, show_dialog: bool = True) -> None:
|
||||
@@ -426,7 +422,9 @@ 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.set_details_buffer(kb.DZG_006)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
|
||||
def _push_data(self, data: list[Any]) -> None:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
heading_local_time = "Local time"
|
||||
heading_server_time = "In-game time"
|
||||
heading_target_time = "Target time"
|
||||
heading_remainder_time = "Remainder until target"
|
||||
heading_adjusted_time = "Adjusted local time"
|
||||
calculate = "Calculate"
|
||||
disclaimer = "Times shown are approximations.\nServer time may differ based on time of year in game."
|
||||
@@ -0,0 +1,2 @@
|
||||
collapse_all = "Collapse all"
|
||||
expand_all = "Expand all"
|
||||
@@ -1,24 +1,22 @@
|
||||
add_button="Add"
|
||||
add_con_heading="Add/connect"
|
||||
from dzgui.const.constants import UDP_PORT
|
||||
|
||||
add_button = "Add"
|
||||
add_con_heading = "Add/connect"
|
||||
add_popover = "This address is already in your Saved Servers"
|
||||
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"
|
||||
)
|
||||
connect_entry_placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)"
|
||||
add_tooltip = "Add to Saved Servers"
|
||||
connect_button = "Connect"
|
||||
connect_tooltip = "Connect to this server"
|
||||
connect_entry_placeholder = f"Enter IP:Query port (e.g., 192.168.1.1:{UDP_PORT})"
|
||||
|
||||
fav_heading="Favorite server"
|
||||
favs_empty="None set. Right click a server and select 'Set favorite' to set."
|
||||
fav_heading = "Favorite server"
|
||||
favs_empty = "None set. Right click a server and select 'Set favorite' to set."
|
||||
|
||||
lan_heading="LAN query port"
|
||||
lan_default_button="Default port (27016)"
|
||||
lan_custom_button="Custom port"
|
||||
lan_scan_button="Scan"
|
||||
lan_heading = "LAN query port"
|
||||
lan_default_button = f"Default port ({UDP_PORT})"
|
||||
lan_custom_button = "Custom port"
|
||||
lan_scan_button = "Scan"
|
||||
lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster"
|
||||
lan_checkbox = "Stop scanning on first hit"
|
||||
lan_placeholder="Enter the query port (1-65535)"
|
||||
lan_entry_tooltip="Specify the port to search for DayZ servers on the local network"
|
||||
lan_scan_tooltip="Scan for servers"
|
||||
lan_entry_placeholder = "Enter the query port (1-65535)"
|
||||
lan_entry_tooltip = "Specify the port to search for DayZ servers on the local network"
|
||||
lan_scan_tooltip = "Scan for servers"
|
||||
@@ -20,3 +20,11 @@ ok = "OK"
|
||||
|
||||
mission_dialog = "Set a mission folder location"
|
||||
custom_mod_dialog = "Set location to a custom mods folder"
|
||||
|
||||
debug_heading = "Debug"
|
||||
debug_secondary = "Launch arguments"
|
||||
|
||||
toggle_calc = "Toggle calculator"
|
||||
|
||||
server_details = "Server details"
|
||||
server_message = "Server message"
|
||||
@@ -4,3 +4,4 @@ api_validation_error = (
|
||||
|
||||
api_popover = "API key validation failed."
|
||||
dayz_not_found = "Could not find a valid DayZ installation."
|
||||
workshop_endpoint_error = "Steam Workshop service unavailable, try again later."
|
||||
@@ -0,0 +1,13 @@
|
||||
DZG_006 = """The leading cause of specific servers periodically timing out is local network configuration.
|
||||
|
||||
Many third-party DayZ servers use a server rental/hosting provider with DDoS protection.
|
||||
|
||||
This can cause responses from servers to originate from a server other than the one originally queried.
|
||||
|
||||
Consumer-grade routers are likely to drop this traffic as invalid due to how they handle NAT (network address translation).
|
||||
|
||||
By contrast, wireless routers and enterprise-grade routers may be less likely to have this issue.
|
||||
|
||||
If you find that a specific server is unresponsive for you when it shouldn't be, add a port forwarding rule to your router's settings for the server's query port.
|
||||
|
||||
In addition, a uniform packet size is expected. If your router's MTU (maximum transmission unit) setting differs from the default of 1500, it could lead to incoming responses being discarded."""
|
||||
@@ -0,0 +1,16 @@
|
||||
exit_msg = (
|
||||
"System map count is not high enough to run DayZ. "
|
||||
"Please exit and run 'dzgui -m' to update map count."
|
||||
)
|
||||
failed_to_parse = (
|
||||
"Failed to parse system map count.\n"
|
||||
"This usually indicates that systemd is not installed."
|
||||
)
|
||||
failed_to_update = (
|
||||
"Failed to update system map count.\n"
|
||||
"Please report the issue upstream and provide the following traceback.\n\n"
|
||||
"{0}"
|
||||
)
|
||||
meets_minimum = "System map count of {0} already meets the minimum."
|
||||
prompt = "Updated map count will be written to the file {0}."
|
||||
user_exit = "User exit"
|
||||
@@ -1,4 +1,7 @@
|
||||
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"
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
developers = "Developers"
|
||||
start_tab = "Start tab"
|
||||
workshop_label = "Subscribed mods"
|
||||
workshop_eventbox = "If you manually subscribed to mods on Steam prior to using DZGUI, you can unsubscribe via this link."
|
||||
steam_combo = "Steam"
|
||||
flatpak_combo = "Flatpak"
|
||||
flatpak_container_combo = "Flatpak (container)"
|
||||
|
||||
steam_placeholder = "Enter your Steam API key"
|
||||
name_placeholder = "Identifies you to other players"
|
||||
|
||||
server_combo = "Server browser"
|
||||
saved_combo = "Saved servers"
|
||||
recent_combo = "Recent"
|
||||
lan_combo = "LAN"
|
||||
|
||||
fullscreen_eventbox = "This option is not available on Steam Deck."
|
||||
save_button = "Save"
|
||||
|
||||
api_failed = "API validation failed"
|
||||
|
||||
flatpak_label = "Flatpak client"
|
||||
flatpak_standard = "Standard"
|
||||
flatpak_container = "Containerized (flatpak-spawn --host)"
|
||||
flatpak_info = (
|
||||
"Containerized mode is used on certain distributions. May have side effects."
|
||||
)
|
||||
@@ -1,9 +1,8 @@
|
||||
update_mods = "Update mods and connect"
|
||||
connect = "Connect"
|
||||
connect_last = "Load to menu screen"
|
||||
connect_last_tooltip = (
|
||||
"Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting."
|
||||
)
|
||||
connect_last_tooltip = "Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting."
|
||||
debug = "Debug"
|
||||
back = "Back"
|
||||
cancel = "Cancel"
|
||||
warnings = "Warnings"
|
||||
@@ -12,5 +11,43 @@ 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."
|
||||
required_space = " Need to download {0} MiB of mod updates."
|
||||
|
||||
placeholder_no_mods = "This server has no mods."
|
||||
placeholder_no_warnings = "No warnings."
|
||||
|
||||
|
||||
# Errors
|
||||
invalid_mods = (
|
||||
"Server has invalid mods that are not recognized by Steam.\n"
|
||||
"Contact the server owner and include these mod IDs in your report:\n"
|
||||
"{0}"
|
||||
)
|
||||
not_enough_space = (
|
||||
"Need to update {0} MiB of mods, but installation path only has {1} MiB."
|
||||
)
|
||||
resync = (
|
||||
"If you recently installed {0} or moved it to a different drive,\n"
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
)
|
||||
steam_not_running = "'{0}' is set as the default Steam client, but it is either not installed or not running."
|
||||
use_desktop_mode = "Use Desktop Mode to download mods on Steam Deck."
|
||||
version_mismatch = "Local client version '{0}' does not match remote version '{1}'."
|
||||
version_missing = "Remote server is running the build '{0}', but it is not installed."
|
||||
no_errors = "No errors."
|
||||
|
||||
|
||||
# Warnings
|
||||
protected_server = (
|
||||
"Protected: you will be prompted for a password when connecting to this server."
|
||||
)
|
||||
dayz_running = "It looks like DayZ is already running in the background. Exit DayZ before connecting."
|
||||
running_app = (
|
||||
"The app '{0}' is currently running in Steam, but background downloads are not enabled.\n"
|
||||
"Either stop the game first, or update your global Steam settings or the game's local settings.\n"
|
||||
"Otherwise, mods may be queued for download but never update."
|
||||
)
|
||||
downloads_paused = (
|
||||
"It looks like DayZ may have a pending update on Steam that was paused.\n"
|
||||
"To enqueue mods for download, unpause any pending DayZ update and allow it to complete."
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
pg1_title = "Choose uninstall targets"
|
||||
pg1_heading = "Choose uninstall targets"
|
||||
pg1_blurb = "Here you can choose which DZGUI configuration/state files to remove. "
|
||||
|
||||
pg2_title = "Uninstall complete"
|
||||
pg2_heading = "Uninstall complete"
|
||||
pg2_blurb = "Finished removing the selected files. See the console for any errors."
|
||||
|
||||
config_label = "Configuration files"
|
||||
config_details = (
|
||||
"User preferences and API key. "
|
||||
"Keep this file if you want to import it again later."
|
||||
)
|
||||
desktop_label = "Desktop shortcut"
|
||||
desktop_details = "Shortcut set on the desktop, if applicable."
|
||||
|
||||
start_menu_label = "Start menu shortcut"
|
||||
start_menu_details = (
|
||||
"Shortcut set under the Games category of the start menu, if applicable."
|
||||
)
|
||||
state_label = "Long-term state files"
|
||||
state_details = (
|
||||
"UI size, filter preferences, column size, server notes, and mod signatures."
|
||||
)
|
||||
steam_shortcut = "Steam shortcut"
|
||||
steam_details = "Shortcut to DZGUI installed within Steam, if applicable. Requires Steam to be restarted to reflect the change."
|
||||
|
||||
|
||||
path_remove_prefix = "The following path will be removed:"
|
||||
not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall."
|
||||
standalone_uninstall = (
|
||||
"It looks like you are running the standalone version of DZGUI.\n"
|
||||
"To finalize the uninstall process, invoke './dzgui self remove',\n"
|
||||
"then remove the 'dzgui' file."
|
||||
)
|
||||
system_uninstall = (
|
||||
"It looks like DZGUI was installed via your system package manager or directly from source code.\n"
|
||||
"To finalize the uninstall process, follow the standard removal procedure for that context."
|
||||
)
|
||||
+20
-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,26 @@ 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. "
|
||||
"Not available if you selected the Flatpak client as your Steam default."
|
||||
),
|
||||
)
|
||||
checkbox_start_menu = (
|
||||
"Add a start menu shortcut",
|
||||
"Depends on local window manager settings. Will work on Steam Deck.",
|
||||
)
|
||||
checkbox_desktop_shortcut = ("Add a desktop shortcut", "Depends on the option above.")
|
||||
|
||||
### Completion page
|
||||
heading_completion = "Setup complete"
|
||||
|
||||
+14
-11
@@ -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,24 +41,26 @@ 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)
|
||||
|
||||
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,15 +70,13 @@ 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}
|
||||
Kernel: {kernel}
|
||||
CPU: {cpu}
|
||||
|
||||
Debug: {debug}
|
||||
Auto-install: {install}
|
||||
Steam path: {steam_redacted}
|
||||
Workshop path: {workshop_redacted}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -84,6 +84,5 @@ class CalcDist:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# TODO: handle failed remote dist
|
||||
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
|
||||
return haversine
|
||||
+1
-9
@@ -2,14 +2,13 @@ import a2s
|
||||
import requests
|
||||
import shutil
|
||||
import subprocess
|
||||
from warnings import deprecated
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from dzgui.api.servers import Record
|
||||
from dzgui.const.constants import REQUEST_TIMEOUT
|
||||
from dzgui.const.endpoints import COORDS_API, IP_ECHO
|
||||
from dzgui.const.endpoints import IP_ECHO
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -127,10 +126,3 @@ def is_valid_port(port: str) -> bool:
|
||||
if not port.isdigit() or int(port) == 0 or int(port[0]) == 0 or int(port) > 65535:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@deprecated("use ips.csv")
|
||||
def get_local_coords(ip: str) -> str:
|
||||
url = COORDS_API + "/" + ip
|
||||
# local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"')
|
||||
return url
|
||||
+59
-16
@@ -1,28 +1,71 @@
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
from pathlib import Path
|
||||
from dzgui.const.constants import VM_FILE, MIN_COUNT
|
||||
from dzgui.const.constants import APP_NAME, VM_FILE, MIN_COUNT
|
||||
from dzgui.util.bash import concat_bash_args
|
||||
from dzgui.strings import map_count
|
||||
from dzgui.views.dialogs.early_alert import EarlyIgnoreDialog
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
|
||||
def is_map_count_valid(count: int | None) -> bool:
|
||||
if count is None:
|
||||
# NOTE: permit if count was unreadable
|
||||
return True
|
||||
return count >= MIN_COUNT
|
||||
|
||||
|
||||
def get_map_count() -> int | None:
|
||||
path = Path(VM_FILE)
|
||||
if path.is_file() is False:
|
||||
try:
|
||||
count = int(path.read_text())
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
return None
|
||||
count = int(path.read_text())
|
||||
return count
|
||||
|
||||
# TODO: unfinished
|
||||
#if count < MIN_COUNT:
|
||||
# print("needs sudo escalation")
|
||||
# # pop prompt
|
||||
# # get response
|
||||
# set_map_count()
|
||||
#return count
|
||||
|
||||
# TODO: unfinished
|
||||
def test_map_count() -> None:
|
||||
count = get_map_count()
|
||||
if is_map_count_valid(count):
|
||||
return
|
||||
msg = map_count.exit_msg
|
||||
EarlyIgnoreDialog(msg)
|
||||
|
||||
|
||||
def set_map_count() -> None:
|
||||
count = get_map_count()
|
||||
if count is None:
|
||||
print(map_count.failed_to_parse)
|
||||
return
|
||||
if is_map_count_valid(count):
|
||||
msg = map_count.meets_minimum.format(count)
|
||||
print(msg)
|
||||
return
|
||||
|
||||
conf = "/etc/sysctl.d/dayz.conf"
|
||||
count = f"vm.max_map_count={MIN_COUNT}"
|
||||
with open(conf, "w") as f:
|
||||
f.write(count)
|
||||
# TODO: use concat_bash_args()
|
||||
subprocess.run(["/usr/bin/sudo", "sysctl", "-p", conf])
|
||||
value = f"vm.max_map_count={MIN_COUNT}"
|
||||
try:
|
||||
msg = map_count.prompt.format(conf)
|
||||
print(msg)
|
||||
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||
tmp = f.name
|
||||
Path(tmp).write_text(value)
|
||||
|
||||
mv_cmd = f"sudo mv {tmp} {conf}"
|
||||
reload_cmd = f"sudo sysctl -p {conf}"
|
||||
for cmd in mv_cmd, reload_cmd:
|
||||
args = concat_bash_args(cmd)
|
||||
subprocess.run([*args])
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
trace = traceback.format_exc()
|
||||
print(map_count.failed_to_update.format(trace))
|
||||
except KeyboardInterrupt:
|
||||
print(map_count.user_exit)
|
||||
sys.exit(0)
|
||||
+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
|
||||
+13
-99
@@ -1,27 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from dzgui.const.constants import SYSTEM_LOG
|
||||
|
||||
# TODO: move to util.format.py
|
||||
|
||||
|
||||
def build_missing(build: str) -> str:
|
||||
msg = (
|
||||
f"This server is running {build}. You can install "
|
||||
f"{build} by searching for it in your Steam library. "
|
||||
f"If you recently installed {build} or moved it to a different drive, "
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def build_path_invalid(build: str) -> str:
|
||||
msg = (
|
||||
f"Steam is reporting that {build} is installed at a non-existent location. "
|
||||
f"If you recently installed {build} or moved it to a different drive, "
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
# General
|
||||
dialog_header = "DZGUI - Dialog"
|
||||
@@ -32,7 +11,6 @@ delimiter = "␞"
|
||||
# GenericDialog
|
||||
main_menu = "Return to main menu"
|
||||
exit_app = "Exit"
|
||||
server_details = "Server details"
|
||||
modlist = "Modlist"
|
||||
input_required = "User input required"
|
||||
confirm = "Confirmation"
|
||||
@@ -80,7 +58,6 @@ browser_cols = [
|
||||
]
|
||||
|
||||
# DetailsDialog
|
||||
server_message = "Server message"
|
||||
workshop = "Enter/double click a row to open in Steam Workshop."
|
||||
|
||||
# LanDialog
|
||||
@@ -100,10 +77,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."
|
||||
)
|
||||
@@ -182,6 +156,7 @@ filter_duplicate = "Duplicate"
|
||||
filter_official = "Official"
|
||||
filter_unofficial = "Unofficial"
|
||||
filter_modded = "Modded"
|
||||
filter_unmodded = "Unmodded"
|
||||
|
||||
# maps
|
||||
all_maps = "All maps"
|
||||
@@ -192,11 +167,12 @@ none_provided = "None provided"
|
||||
disabled = "Disabled"
|
||||
enabled = "Enabled"
|
||||
null = "-"
|
||||
none = "None"
|
||||
unspecified = "not specified"
|
||||
|
||||
# DLC
|
||||
dlc_frostline = "Frostline"
|
||||
dlc_badlands = "Badlands"
|
||||
dlc_none = "None"
|
||||
|
||||
# platform
|
||||
windows = "Windows"
|
||||
@@ -286,11 +262,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 +284,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,8 +306,8 @@ 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"
|
||||
),
|
||||
unsub_selected="Unsubscribe selected",
|
||||
unsub_selected_tooltip="Unsubscribes from selected mods",
|
||||
@@ -378,11 +351,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",
|
||||
@@ -411,7 +381,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",
|
||||
@@ -476,13 +446,15 @@ class Flags:
|
||||
version: str
|
||||
uninstall: str
|
||||
debug: str
|
||||
map_count: str
|
||||
|
||||
|
||||
flags = Flags(
|
||||
description="DayZ server browser and mod manager",
|
||||
version="Print version information",
|
||||
uninstall="Uninstall data files (use prior to 'pip uninstall')",
|
||||
debug="Enables developer debugging features",
|
||||
version="print version information",
|
||||
uninstall="clean up state/config files",
|
||||
debug="enable developer debugging features",
|
||||
map_count="check and update system map count value",
|
||||
)
|
||||
|
||||
|
||||
@@ -534,64 +506,6 @@ server_labels = ServerLabels(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ConnectPanel:
|
||||
connect: str
|
||||
add: str
|
||||
add_con: str
|
||||
placeholder: str
|
||||
entry_tooltip: str
|
||||
add_tooltip: str
|
||||
connect_tooltip: str
|
||||
|
||||
|
||||
connect_panel = ConnectPanel(
|
||||
connect="Connect",
|
||||
add="Add",
|
||||
add_con="Add/connect",
|
||||
placeholder="Enter IP (IP:Query port) or Battlemetrics ID (numerical id)",
|
||||
entry_tooltip=(
|
||||
"- IP: format as IP:Query port\ne.g. 192.168.1.1:27016\n"
|
||||
"- Battlemetrics: numeric server ID\ne.g. 123456"
|
||||
),
|
||||
add_tooltip="Add to Saved Servers",
|
||||
connect_tooltip="Connect to this server",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class FavPanel:
|
||||
heading: str
|
||||
no_fav: str
|
||||
|
||||
|
||||
fav_panel = FavPanel(
|
||||
heading="Favorite server",
|
||||
no_fav="None set. Right click a server and select 'Set favorite' to set.",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class LanPanel:
|
||||
heading: str
|
||||
default_button: str
|
||||
custom_button: str
|
||||
scan_button: str
|
||||
placeholder: str
|
||||
entry_tooltip: str
|
||||
scan_tooltip: str
|
||||
|
||||
|
||||
lan_panel = LanPanel(
|
||||
heading="LAN query port",
|
||||
default_button="Default port (27016)",
|
||||
custom_button="Custom port",
|
||||
scan_button="Scan",
|
||||
placeholder="Enter the query port (1-65535)",
|
||||
entry_tooltip="Specify the port to search for DayZ servers on the local network",
|
||||
scan_tooltip="Scan for servers",
|
||||
)
|
||||
|
||||
distance_suffix = "Distance: calculating..."
|
||||
dialog_error = "ERROR"
|
||||
|
||||
|
||||
+10
-5
@@ -2,7 +2,12 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
|
||||
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT
|
||||
from dzgui.const.constants import (
|
||||
APPID_DAYZ,
|
||||
APPID_DAYZ_EXP,
|
||||
APP_NAME,
|
||||
DAYZ_COMMUNITY_ROOT,
|
||||
)
|
||||
from dzgui.const.enum import Preferences
|
||||
from dzgui.config.query import lookup
|
||||
|
||||
@@ -20,6 +25,7 @@ def expunge_link(file: Path) -> None:
|
||||
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)
|
||||
@@ -63,10 +69,9 @@ def symlink_mission(steam_path: Path, target: str) -> str:
|
||||
stem = path.name
|
||||
parent = path.parent
|
||||
source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT)
|
||||
try:
|
||||
source.symlink_to(parent)
|
||||
except Exception:
|
||||
return ""
|
||||
if source.exists():
|
||||
source.unlink()
|
||||
source.symlink_to(parent)
|
||||
return f"{DAYZ_COMMUNITY_ROOT}/{stem}"
|
||||
|
||||
|
||||
|
||||
+25
-29
@@ -1,19 +1,23 @@
|
||||
import logging
|
||||
import signal
|
||||
import warnings
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING, Literal, Self
|
||||
|
||||
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, STEAM_ICON
|
||||
from dzgui.const.constants import (
|
||||
APP_NAME,
|
||||
APP_NAME_LOWER,
|
||||
GTK_APP_ID,
|
||||
)
|
||||
from dzgui.const.enum import NotebookPage
|
||||
from dzgui.controllers.emitter import Emitter
|
||||
from dzgui.controllers.mc import Controller
|
||||
from dzgui.util import css, strings
|
||||
from dzgui.util import css
|
||||
from dzgui.util.keys import is_ctrl_mask
|
||||
from dzgui.views.components.connect_panel import ConnectPanel
|
||||
from dzgui.views.components.crumbs import Breadcrumbs
|
||||
from dzgui.views.components.right_panel import RightPanel
|
||||
from dzgui.views.components.statusbar import Statusbar
|
||||
from dzgui.views.mixins.colorscheme import ColorAwareApp
|
||||
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
|
||||
|
||||
# TODO: import notebook only and add components there?
|
||||
@@ -41,20 +45,21 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(APP_NAME)
|
||||
|
||||
# TODO: drop
|
||||
# https://bugzilla.gnome.org/show_bug.cgi?id=708676
|
||||
warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
|
||||
|
||||
|
||||
class OuterWindow(Gtk.Window):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER)
|
||||
class OuterWindow(Gtk.ApplicationWindow):
|
||||
def __init__(self, app: Gtk.Application) -> None:
|
||||
super().__init__(
|
||||
application=app, title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER
|
||||
)
|
||||
|
||||
self.hb = AppHeaderBar()
|
||||
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)
|
||||
@@ -66,8 +71,8 @@ class OuterWindow(Gtk.Window):
|
||||
self.show_all()
|
||||
css.load_css()
|
||||
|
||||
MainController.open_page(NotebookPage.SERVERS)
|
||||
MainController.set_start_tab()
|
||||
MainController.open_page(NotebookPage.SERVERS)
|
||||
self.grid.hide_widgets_on_init()
|
||||
|
||||
MainController.loaded = True
|
||||
@@ -276,13 +281,18 @@ class Grid(Gtk.Grid):
|
||||
self.right_panel.refresh_button.set_visible(state)
|
||||
|
||||
|
||||
class App(Gtk.Application):
|
||||
class App(ColorAwareApp, Gtk.Application): # type: ignore
|
||||
def __init__(self, prefs: "UserPrefs") -> None:
|
||||
super().__init__(application_id=GTK_APP_ID)
|
||||
|
||||
GLib.set_prgname(APP_NAME)
|
||||
MainController.set_prefs(prefs)
|
||||
|
||||
self.win = OuterWindow()
|
||||
self._setup_signals()
|
||||
self.connect("activate", self._on_activate)
|
||||
|
||||
def _on_activate(self, app: Self) -> None:
|
||||
self.win = OuterWindow(app)
|
||||
|
||||
accel = Gtk.AccelGroup()
|
||||
accel.connect(
|
||||
@@ -293,9 +303,6 @@ class App(Gtk.Application):
|
||||
)
|
||||
self.win.add_accel_group(accel)
|
||||
|
||||
self._setup_signals()
|
||||
Gtk.main()
|
||||
|
||||
def _setup_signals(self) -> None:
|
||||
SIGNAL_ADD = "signal_add"
|
||||
SIGNAL_ADD_FULL = "signal_add_full"
|
||||
@@ -324,15 +331,4 @@ class App(Gtk.Application):
|
||||
) -> None:
|
||||
self.win.halt_proc_and_quit()
|
||||
|
||||
|
||||
MainController = Controller()
|
||||
|
||||
try:
|
||||
theme = Gtk.IconTheme.get_default()
|
||||
icons = theme.list_icons(None)
|
||||
if STEAM_ICON not in icons:
|
||||
logger.warn(strings.steam_icon_missing)
|
||||
warnings.warn(strings.steam_icon_missing, stacklevel=2)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
warnings.warn(strings.gtk_theme_missing, stacklevel=2)
|
||||
@@ -23,3 +23,11 @@ class HBox(GenericBox):
|
||||
class VBox(GenericBox):
|
||||
def __init__(self, spacing: int = 0) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing)
|
||||
|
||||
|
||||
class ShortHBox(HBox):
|
||||
def __init__(self, widget: Gtk.Widget) -> None:
|
||||
super().__init__(spacing=5)
|
||||
|
||||
self.set_halign(Gtk.Align.START)
|
||||
self.pack_start(widget, expand=False, fill=False, padding=0)
|
||||
@@ -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
|
||||
@@ -6,10 +6,10 @@ from dzgui.strings import buttons
|
||||
from dzgui.util.strings import (
|
||||
alert_button_tooltip,
|
||||
atomic_buttons,
|
||||
connect_panel,
|
||||
)
|
||||
from dzgui.const.constants import (
|
||||
CLIPBOARD,
|
||||
CLOSE,
|
||||
INPUT_KEYBOARD,
|
||||
LIST_ADD,
|
||||
REFRESH_ICON,
|
||||
@@ -17,6 +17,7 @@ from dzgui.const.constants import (
|
||||
WARNING,
|
||||
WEB_BROWSER,
|
||||
)
|
||||
from dzgui.strings import connect_panel
|
||||
|
||||
import gi
|
||||
|
||||
@@ -63,8 +64,17 @@ class IconButton(Gtk.Button):
|
||||
self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end)
|
||||
self.set_image(self.icon)
|
||||
self.set_image_position(position)
|
||||
# self.set_image_position(Gtk.PositionType.RIGHT)
|
||||
self.set_focus_on_click(False)
|
||||
self.set_always_show_image(True)
|
||||
|
||||
def swap_icon(self, icon: str) -> None:
|
||||
start = self.icon.get_margin_start()
|
||||
end = self.icon.get_margin_end()
|
||||
alt_icon = Icon(icon, margin_start=start, margin_end=end)
|
||||
self.set_image(alt_icon)
|
||||
|
||||
def revert_icon(self) -> None:
|
||||
self.set_image(self.icon)
|
||||
|
||||
|
||||
class IconTextButton(IconButton):
|
||||
@@ -96,11 +106,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
|
||||
@@ -108,9 +125,18 @@ 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")
|
||||
|
||||
|
||||
class CloseButton(IconTextButton):
|
||||
def __init__(self, label: str) -> None:
|
||||
super().__init__(icon=CLOSE, label=label)
|
||||
|
||||
# TODO: strings
|
||||
self.set_tooltip_text("Unset this server as favorite")
|
||||
|
||||
|
||||
class WebButton(IconTextButton):
|
||||
def __init__(self, label: str) -> None:
|
||||
super().__init__(icon=WEB_BROWSER, label=label)
|
||||
@@ -196,7 +222,7 @@ class KeysButton(IconTextButton):
|
||||
|
||||
class SteamConnectButton(LargeIconTextButton):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(icon=STEAM_ICON, label=connect_panel.connect)
|
||||
super().__init__(icon=STEAM_ICON, label=connect_panel.connect_button)
|
||||
self.set_tooltip_text(connect_panel.connect_tooltip)
|
||||
|
||||
|
||||
@@ -216,7 +242,7 @@ class SteamWorkshopButton(SteamTextButton):
|
||||
|
||||
class AddButton(IconTextButton):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(icon=LIST_ADD, label=connect_panel.add)
|
||||
super().__init__(icon=LIST_ADD, label=connect_panel.add_button)
|
||||
self.set_tooltip_text(connect_panel.add_tooltip)
|
||||
|
||||
|
||||
@@ -241,3 +267,38 @@ class LoggerAlertsButton(IconTextButton):
|
||||
self.set_halign(Gtk.Align.END)
|
||||
self.set_hexpand(True)
|
||||
self.set_tooltip_text(alert_button_tooltip)
|
||||
|
||||
|
||||
class SpinnerButton(Gtk.Button):
|
||||
def __init__(self, label: str) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.text = label
|
||||
self.label = Gtk.Label(label=label, halign=Gtk.Align.CENTER)
|
||||
|
||||
self.spinner = Gtk.Spinner()
|
||||
self.spinner.set_halign(Gtk.Align.END)
|
||||
self.spinner.set_sensitive(False)
|
||||
|
||||
grid = Gtk.Grid(column_spacing=10)
|
||||
for el in self.label, self.spinner:
|
||||
grid.add(el)
|
||||
|
||||
self.add(grid)
|
||||
|
||||
self.connect("clicked", self._on_button_clicked)
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def _on_map(self, button: Self) -> None:
|
||||
self.spinner.set_visible(False)
|
||||
|
||||
def _on_button_clicked(self, button: Self) -> None:
|
||||
self.start_spinner()
|
||||
|
||||
def stop_spinner(self) -> None:
|
||||
self.spinner.set_visible(False)
|
||||
self.spinner.stop()
|
||||
|
||||
def start_spinner(self) -> None:
|
||||
self.spinner.set_visible(True)
|
||||
self.spinner.start()
|
||||
@@ -1,68 +0,0 @@
|
||||
from dzgui.strings import preconnect
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gdk, Gtk # noqa E402
|
||||
|
||||
|
||||
class ConnectBox(Gtk.Box):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
||||
|
||||
self.back = Gtk.Button(
|
||||
label=preconnect.back, halign=Gtk.Align.END, hexpand=True
|
||||
)
|
||||
|
||||
# TODO: dynamically update this
|
||||
self.ok = Gtk.Button(label="Launch", halign=Gtk.Align.END)
|
||||
|
||||
for button in self.back, self.ok:
|
||||
self.add(button)
|
||||
|
||||
def get_back_button(self) -> Gtk.Button:
|
||||
return self.back
|
||||
|
||||
def get_ok_button(self) -> Gtk.Button:
|
||||
return self.ok
|
||||
|
||||
|
||||
class PreconnectBox(Gtk.Box):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
valign=Gtk.Align.END,
|
||||
vexpand=True,
|
||||
spacing=5,
|
||||
)
|
||||
|
||||
self.button_hbox = ConnectBox()
|
||||
self.ok = self.button_hbox.get_ok_button()
|
||||
self.ok.set_label(preconnect.update_mods)
|
||||
|
||||
self.connect_last = Gtk.Button(
|
||||
label=preconnect.connect_last,
|
||||
halign=Gtk.Align.END,
|
||||
tooltip_text=preconnect.connect_last_tooltip,
|
||||
)
|
||||
self.button_hbox.add(self.connect_last)
|
||||
|
||||
self.raise_window = Gtk.CheckButton(
|
||||
# TODO: strings
|
||||
label="Foreground DZGUI while downloading",
|
||||
halign=Gtk.Align.END,
|
||||
hexpand=True,
|
||||
valign=Gtk.Align.END,
|
||||
visible=False,
|
||||
has_tooltip=True,
|
||||
sensitive=False,
|
||||
tooltip_text="Foreground the DZGUI window after mod downloads are queued",
|
||||
active=True,
|
||||
)
|
||||
self.add(self.button_hbox)
|
||||
self.add(self.raise_window)
|
||||
|
||||
def get_back_button(self) -> Gtk.Button:
|
||||
return self.button_hbox.get_back_button()
|
||||
|
||||
def get_ok_button(self) -> Gtk.Button:
|
||||
return self.ok
|
||||
@@ -6,6 +6,7 @@ from dzgui.strings import connect_panel
|
||||
from dzgui.util.keys import is_ctrl_mask
|
||||
from dzgui.views.components.buttons import (
|
||||
AddButton,
|
||||
CloseButton,
|
||||
CopyIpButton,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -187,17 +188,27 @@ class FavPanel(Gtk.Frame):
|
||||
self.fav_button = SteamConnectButton()
|
||||
self.fav_button.connect("clicked", self._on_connect_clicked)
|
||||
self.copy_button = CopyIpButton(self.controller, self.get_fav_ip)
|
||||
|
||||
# TODO: strings
|
||||
self.unset_button = CloseButton("Unset")
|
||||
self.unset_button.connect("clicked", self._on_unset_clicked)
|
||||
|
||||
if favorite is None:
|
||||
self.toggle_buttons(False)
|
||||
|
||||
# NOTE: disable vscrollbar to prevent layout jumping behavior
|
||||
scrollable_label = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
scrollable_label.add(self.fav_label)
|
||||
self.scrollable_label = Gtk.ScrolledWindow(
|
||||
vscrollbar_policy=Gtk.PolicyType.NEVER, overlay_scrolling=False
|
||||
)
|
||||
self.scrollable_label.add(self.fav_label)
|
||||
|
||||
grid = Gtk.Grid(margin=10, vexpand=False, column_spacing=15, row_spacing=5)
|
||||
grid.attach(scrollable_label, 0, 0, 3, ROWS)
|
||||
grid.attach(self.scrollable_label, 0, 0, 3, ROWS)
|
||||
grid.attach_next_to(
|
||||
self.copy_button, scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS
|
||||
self.unset_button, self.scrollable_label, Gtk.PositionType.RIGHT, COLS, ROWS
|
||||
)
|
||||
grid.attach_next_to(
|
||||
self.copy_button, self.unset_button, Gtk.PositionType.RIGHT, COLS, ROWS
|
||||
)
|
||||
grid.attach_next_to(
|
||||
self.fav_button, self.copy_button, Gtk.PositionType.RIGHT, COLS, ROWS
|
||||
@@ -205,8 +216,16 @@ class FavPanel(Gtk.Frame):
|
||||
|
||||
self.add(grid)
|
||||
|
||||
def _on_unset_clicked(self, button: Gtk.Button) -> None:
|
||||
try:
|
||||
self.controller.unset_fav()
|
||||
except Exception:
|
||||
return
|
||||
self.fav_label.set_text(connect_panel.favs_empty)
|
||||
self.toggle_buttons(False)
|
||||
|
||||
def toggle_buttons(self, state: bool) -> None:
|
||||
for button in self.fav_button, self.copy_button:
|
||||
for button in self.fav_button, self.copy_button, self.unset_button:
|
||||
button.set_sensitive(state)
|
||||
|
||||
def _on_connect_clicked(self, button: Gtk.Button) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any, Callable, TYPE_CHECKING
|
||||
from dzgui.api.servers import validate_ip
|
||||
from dzgui.const.constants import VIEW_CONCEAL, VIEW_REVEAL
|
||||
from dzgui.util.css import add_class, remove_class
|
||||
from dzgui.util.strings import connect_panel, lan_panel
|
||||
from dzgui.strings import connect_panel
|
||||
from dzgui.strings.errors import api_popover
|
||||
|
||||
import gi
|
||||
@@ -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,12 +121,9 @@ class IpEntry(ValidatedEntry):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(
|
||||
controller,
|
||||
func=validate_ip_or_id,
|
||||
placeholder_text=connect_panel.placeholder,
|
||||
tooltip_text=connect_panel.entry_tooltip,
|
||||
func=validate_ip_truthy,
|
||||
placeholder_text=connect_panel.connect_entry_placeholder,
|
||||
)
|
||||
self.set_placeholder_text(connect_panel.placeholder)
|
||||
self.set_tooltip_text(connect_panel.entry_tooltip)
|
||||
|
||||
|
||||
class PortEntry(ValidatedEntry):
|
||||
@@ -137,11 +131,9 @@ class PortEntry(ValidatedEntry):
|
||||
super().__init__(
|
||||
controller,
|
||||
func=validate_port,
|
||||
placeholder_text=connect_panel.placeholder,
|
||||
tooltip_text=connect_panel.entry_tooltip,
|
||||
placeholder_text=connect_panel.lan_entry_placeholder,
|
||||
tooltip_text=connect_panel.lan_entry_tooltip,
|
||||
)
|
||||
self.set_placeholder_text(lan_panel.placeholder)
|
||||
self.set_tooltip_text(lan_panel.entry_tooltip)
|
||||
|
||||
|
||||
# TODO: backport to Options page
|
||||
@@ -162,7 +154,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:
|
||||
|
||||
@@ -9,8 +9,6 @@ from dzgui.const.constants import (
|
||||
NO_PADDING,
|
||||
SEARCH_ICON,
|
||||
)
|
||||
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
|
||||
@@ -29,21 +27,33 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class ButtonGrid(Gtk.Grid):
|
||||
def __init__(self, controller: "Controller", defaults: dict) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
controller: "Controller",
|
||||
defaults: dict[str, str],
|
||||
tooltips: dict[str, str],
|
||||
) -> None:
|
||||
super().__init__(
|
||||
halign=Gtk.Align.CENTER, column_spacing=5, column_homogeneous=True
|
||||
)
|
||||
row = 1
|
||||
col = 0
|
||||
|
||||
self.controller = controller
|
||||
self.emitter = controller.get_emitter()
|
||||
|
||||
self.checks: list[Gtk.CheckButton] = []
|
||||
|
||||
# TODO: use enumerated checks
|
||||
flowbox = Gtk.FlowBox(
|
||||
halign=Gtk.Align.CENTER, min_children_per_line=3, max_children_per_line=3, selection_mode=Gtk.SelectionMode.NONE
|
||||
)
|
||||
|
||||
for check in defaults.keys():
|
||||
checkbox = Gtk.CheckButton(label=check)
|
||||
try:
|
||||
tt = tooltips[check]
|
||||
except Exception as e:
|
||||
logger.debug(e)
|
||||
tt = ""
|
||||
|
||||
checkbox.set_tooltip_text(tt)
|
||||
label = checkbox.get_child()
|
||||
if label is not None:
|
||||
label.set_ellipsize(Pango.EllipsizeMode.END) # type: ignore
|
||||
@@ -51,14 +61,12 @@ class ButtonGrid(Gtk.Grid):
|
||||
if defaults[check]:
|
||||
checkbox.set_active(True)
|
||||
|
||||
col = col + 1
|
||||
if col > 3:
|
||||
row += 1
|
||||
col = 1
|
||||
self.attach(checkbox, col, row, 1, 1)
|
||||
checkbox.connect("toggled", self._on_check_toggled)
|
||||
flowbox.add(checkbox)
|
||||
self.checks.append(checkbox)
|
||||
|
||||
self.add(flowbox)
|
||||
|
||||
def block_toggles(self, state: bool) -> None:
|
||||
for check in self.checks:
|
||||
self.controller.suppress_signal(
|
||||
@@ -138,10 +146,6 @@ class KeywordEntry(Gtk.Entry):
|
||||
self.controller.set_active_keyword(keyword)
|
||||
logger.info(f"User filtered by keyword '{keyword}'")
|
||||
|
||||
ServerModelManager(
|
||||
self.controller, self.controller.get_active_treeview()
|
||||
).refilter(FilterMode.TOGGLE_ON)
|
||||
|
||||
|
||||
class FilterPanel(Gtk.Box):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
@@ -163,11 +167,12 @@ class FilterPanel(Gtk.Box):
|
||||
|
||||
filter_man = self.controller.get_filter_man()
|
||||
defaults = filter_man.get_default_filters()
|
||||
tooltips = filter_man.get_tooltips()
|
||||
self.map_store = filter_man.get_map_store()
|
||||
self.enabled_filters = defaults
|
||||
|
||||
self.keyword_entry = KeywordEntry(self.controller)
|
||||
self.button_grid = ButtonGrid(self.controller, defaults)
|
||||
self.button_grid = ButtonGrid(self.controller, defaults, tooltips)
|
||||
|
||||
# TODO: strings
|
||||
self.filters_label = BoldLabel("Filters")
|
||||
|
||||
@@ -9,3 +9,6 @@ class Icon(Gtk.Image):
|
||||
super().__init__(
|
||||
icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin
|
||||
)
|
||||
|
||||
def set_icon_name(self, name: str) -> None:
|
||||
super().set_from_icon_name(name, Gtk.IconSize.BUTTON)
|
||||
@@ -1,5 +1,11 @@
|
||||
from typing import Self
|
||||
from warnings import deprecated
|
||||
|
||||
from dzgui.const.constants import ERROR, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD
|
||||
from dzgui.init.prereqs import has_steam_client, has_flatpak_client
|
||||
from dzgui.strings import options
|
||||
from dzgui.const.constants import FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD
|
||||
from dzgui.views.components.box import HBox
|
||||
from dzgui.views.components.buttons import Icon
|
||||
|
||||
import gi
|
||||
|
||||
@@ -7,29 +13,71 @@ gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa
|
||||
|
||||
|
||||
@deprecated("Client choice is determined at setup time")
|
||||
class ClientCombo(Gtk.ComboBox):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.client_store = Gtk.ListStore(str, str)
|
||||
clients = (
|
||||
has_steam = has_steam_client()
|
||||
has_flatpak = has_flatpak_client()
|
||||
self.client_store = Gtk.ListStore(str, str, bool)
|
||||
self.clients = (
|
||||
(
|
||||
options.steam_combo,
|
||||
STEAM_CMD,
|
||||
has_steam,
|
||||
),
|
||||
(
|
||||
options.flatpak_combo,
|
||||
FLATPAK_RUN_CMD,
|
||||
has_flatpak,
|
||||
),
|
||||
(
|
||||
options.flatpak_container_combo,
|
||||
FLATPAK_SANDBOX,
|
||||
has_flatpak,
|
||||
),
|
||||
)
|
||||
for client in clients:
|
||||
for client in self.clients:
|
||||
self.client_store.append(client)
|
||||
|
||||
self.set_model(self.client_store)
|
||||
renderer_text = Gtk.CellRendererText()
|
||||
self.pack_start(renderer_text, True)
|
||||
self.add_attribute(renderer_text, "text", 0)
|
||||
self.set_active(0)
|
||||
|
||||
self.set_cell_data_func(self.get_cells()[0], self.data_func)
|
||||
|
||||
def data_func(
|
||||
self,
|
||||
combo: Self,
|
||||
cell: Gtk.CellRendererText,
|
||||
model: Gtk.ListStore,
|
||||
_iter: Gtk.TreeIter,
|
||||
) -> None:
|
||||
sens = model[_iter][2]
|
||||
cell.set_property("sensitive", sens)
|
||||
|
||||
def get_client_human_readable(self, cmd: str) -> str:
|
||||
for client in self.clients:
|
||||
human, command, sens = client
|
||||
if command == cmd:
|
||||
return human
|
||||
raise TypeError("Not a valid Steam client selection")
|
||||
|
||||
|
||||
class ErrorPopover(Gtk.Popover):
|
||||
def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None:
|
||||
super().__init__(relative_to=relative_to, position=position)
|
||||
|
||||
self.hbox = HBox()
|
||||
self.label = Gtk.Label(label="", margin_start=10, margin_end=10)
|
||||
error_icon = Icon(ERROR, margin_start=10)
|
||||
self.hbox.extend([error_icon, self.label])
|
||||
self.add(self.hbox)
|
||||
self.show_all()
|
||||
self.popdown()
|
||||
|
||||
def set_label(self, label: str) -> None:
|
||||
self.label.set_label(label)
|
||||
@@ -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)
|
||||
@@ -4,6 +4,9 @@ from typing import Any, Literal, Self, TYPE_CHECKING
|
||||
# import time
|
||||
from enum import Enum
|
||||
|
||||
|
||||
from dzgui.views.mixins.colorscheme import ColorAwareApp
|
||||
|
||||
# TODO: import dialog titles
|
||||
from dzgui.api.mods import remove_stale_signatures as remove_stale
|
||||
from dzgui.config.ipdb import get_ipdb
|
||||
@@ -37,11 +40,11 @@ class Success(Enum):
|
||||
|
||||
# TODO: strings for "Running", "Failed", etc.
|
||||
# TODO: add margins to tree
|
||||
class BootDialog(Gtk.Dialog):
|
||||
class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
|
||||
def __init__(self, parent: "BootWindow", xdg: "Xdg", version: str) -> None:
|
||||
super().__init__(
|
||||
title=dialog_header,
|
||||
parent=parent,
|
||||
transient_for=parent,
|
||||
modal=True,
|
||||
)
|
||||
|
||||
@@ -99,7 +102,7 @@ class BootDialog(Gtk.Dialog):
|
||||
margin_top=30,
|
||||
margin_bottom=30,
|
||||
)
|
||||
self.error_label = Gtk.Label()
|
||||
self.error_label = Gtk.Label(margin_start=15, margin_end=15)
|
||||
|
||||
# TODO: abstract class
|
||||
self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text())
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import gi
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Literal
|
||||
|
||||
from dzgui.const.constants import DAY_ICON, NIGHT_ICON
|
||||
from dzgui.strings import calc
|
||||
from dzgui.views.components.icon import Icon
|
||||
from dzgui.views.components.labels import BoldLabel
|
||||
from dzgui.views.components.box import VBox, HBox
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, GObject # type: ignore # noqa E402
|
||||
|
||||
DAY_START_TIME = time(hour=6, minute=0, second=0)
|
||||
DAY_END_TIME = time(hour=18, minute=0, second=0)
|
||||
NIGHT_START_TIME = time(hour=18, minute=0, second=0)
|
||||
NIGHT_END_TIME = time(hour=6, minute=0, second=0)
|
||||
EXTENDED_TIME_FORMAT = "%H:%M:%S"
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Time:
|
||||
time: str
|
||||
day_accel: float
|
||||
night_accel: float
|
||||
|
||||
|
||||
class Emitter(GObject.GObject):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, object))
|
||||
def local_time_incremented(self, time_now: datetime, elapsed: timedelta) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
|
||||
def server_time_incremented(self, server_time: datetime) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
|
||||
def calculate_button_clicked(self) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
|
||||
def target_time_changed(self, time: timedelta) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
|
||||
def target_time_reset(self, time: timedelta) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
|
||||
def remaining_time_changed(self, time: datetime) -> None:
|
||||
pass
|
||||
|
||||
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
|
||||
def adjusted_local_time_changed(self, time: datetime) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class LocalClock:
|
||||
def __init__(self, emitter: Emitter) -> None:
|
||||
self.emitter = emitter
|
||||
self.start_time = datetime.now()
|
||||
# NOTE: highest theoretical accel interval is 24 * 64
|
||||
GLib.timeout_add(500, self.check_time)
|
||||
|
||||
def get_time(self) -> str:
|
||||
return datetime.now().time().strftime(EXTENDED_TIME_FORMAT)
|
||||
|
||||
def get_elapsed_time(self) -> timedelta:
|
||||
return datetime.now() - self.start_time
|
||||
|
||||
def check_time(self) -> Literal[True]:
|
||||
time_now = datetime.now()
|
||||
elapsed = datetime.now() - self.start_time
|
||||
self.emitter.emit("local_time_incremented", time_now, elapsed)
|
||||
return True
|
||||
|
||||
|
||||
class ServerClock:
|
||||
def __init__(
|
||||
self, server_time: Time, emitter: Emitter, local_clock: LocalClock
|
||||
) -> None:
|
||||
|
||||
self.local_clock = local_clock
|
||||
self.emitter = emitter
|
||||
|
||||
hour, minute = self.split_time(server_time)
|
||||
self.day_accel = server_time.day_accel
|
||||
self.night_accel = server_time.night_accel
|
||||
|
||||
self.start_time = datetime.combine(datetime.today(), time(hour, minute))
|
||||
self.cur_time = datetime.combine(datetime.today(), time(hour, minute))
|
||||
|
||||
self._last_elapsed = self.local_clock.get_elapsed_time()
|
||||
self.emitter.connect("local_time_incremented", self._on_local_time_incremented)
|
||||
|
||||
@staticmethod
|
||||
def split_time(server_time: Time) -> tuple[int, int]:
|
||||
split = server_time.time.split(":")
|
||||
return int(split[0]), int(split[1])
|
||||
|
||||
def is_day(self) -> bool:
|
||||
if DAY_START_TIME <= self.cur_time.time() <= DAY_END_TIME:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _on_local_time_incremented(
|
||||
self,
|
||||
emitter: Emitter,
|
||||
time: datetime,
|
||||
elapsed: timedelta,
|
||||
) -> Literal[True]:
|
||||
delta = elapsed - self._last_elapsed
|
||||
self._last_elapsed = elapsed
|
||||
factor = self.get_accel_factor(self.cur_time)
|
||||
self.cur_time += delta * factor
|
||||
self.emitter.emit("server_time_incremented", self.cur_time)
|
||||
return True
|
||||
|
||||
def get_accel_factor(self, dt: datetime) -> float:
|
||||
return (
|
||||
self.day_accel
|
||||
if DAY_START_TIME <= dt.time() < DAY_END_TIME
|
||||
else (self.night_accel * self.day_accel)
|
||||
)
|
||||
|
||||
def get_values(self) -> tuple[int, int]:
|
||||
return self.cur_time.hour, self.cur_time.minute
|
||||
|
||||
def get_datetime(self) -> datetime:
|
||||
return self.cur_time
|
||||
|
||||
def get_time(self) -> time:
|
||||
return self.cur_time.time()
|
||||
|
||||
def calc_delta(self, end_time: datetime) -> timedelta:
|
||||
total = 0.0
|
||||
current_time = self.cur_time
|
||||
|
||||
if current_time > end_time:
|
||||
end_time += timedelta(days=1)
|
||||
|
||||
while current_time < end_time:
|
||||
factor = self.get_accel_factor(current_time)
|
||||
day_start = datetime.combine(current_time.date(), DAY_START_TIME)
|
||||
day_end = datetime.combine(current_time.date(), DAY_END_TIME)
|
||||
|
||||
if current_time.time() < DAY_START_TIME:
|
||||
edge = day_start
|
||||
elif current_time.time() < DAY_END_TIME:
|
||||
edge = day_end
|
||||
else:
|
||||
edge = datetime.combine(
|
||||
current_time.date() + timedelta(days=1), DAY_START_TIME
|
||||
)
|
||||
|
||||
chunk_end = min(end_time, edge)
|
||||
total += (chunk_end - current_time).total_seconds() / factor
|
||||
current_time = chunk_end
|
||||
return timedelta(seconds=total)
|
||||
|
||||
|
||||
class RemainderClock:
|
||||
def __init__(self, emitter: Emitter) -> None:
|
||||
self.emitter = emitter
|
||||
self.emitter.connect("target_time_changed", self._on_target_time_changed)
|
||||
self.emitter.connect("target_time_reset", self._on_target_time_reset)
|
||||
self.emitter.connect("local_time_incremented", self._on_local_time_incremented)
|
||||
self.reset_time(True)
|
||||
|
||||
self.previous = timedelta(0)
|
||||
|
||||
def reset_time(self, zero_out: bool = False) -> None:
|
||||
now = datetime.now()
|
||||
second, ms = (0, 0) if zero_out else (now.second, now.microsecond)
|
||||
self.time = datetime.combine(
|
||||
now, time(hour=0, minute=0, second=second, microsecond=ms)
|
||||
)
|
||||
|
||||
def _on_local_time_incremented(
|
||||
self, emitter: Emitter, time: datetime, elapsed: timedelta
|
||||
) -> None:
|
||||
if self.time == datetime.min.time():
|
||||
return
|
||||
|
||||
delta = elapsed - self.previous
|
||||
# NOTE: clamp to midnight
|
||||
self.time = max(
|
||||
self.time - delta, self.time.replace(hour=0, minute=0, second=0)
|
||||
)
|
||||
self.previous = elapsed
|
||||
self.emitter.emit("remaining_time_changed", self.time)
|
||||
|
||||
def _on_target_time_reset(self, emitter: Emitter, time: timedelta) -> None:
|
||||
self.reset_time(True)
|
||||
self.emitter.emit("remaining_time_changed", self.time)
|
||||
# NOTE: restore adjusted time to current time
|
||||
self.emitter.emit("adjusted_local_time_changed", datetime.now())
|
||||
|
||||
def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None:
|
||||
self.reset_time(True)
|
||||
self.time += time
|
||||
self.emitter.emit("remaining_time_changed", self.time)
|
||||
|
||||
def get_time(self) -> str:
|
||||
return self.time.strftime(EXTENDED_TIME_FORMAT)
|
||||
|
||||
|
||||
class AdjustedClock:
|
||||
def __init__(self, emitter: Emitter) -> None:
|
||||
self.emitter = emitter
|
||||
self.emitter.connect("target_time_changed", self._on_target_time_changed)
|
||||
self.start_time = datetime.now()
|
||||
|
||||
def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None:
|
||||
self.start_time = datetime.now()
|
||||
adjusted = self.start_time + time
|
||||
self.emitter.emit("adjusted_local_time_changed", adjusted)
|
||||
|
||||
def get_time(self) -> str:
|
||||
return self.start_time.strftime(EXTENDED_TIME_FORMAT)
|
||||
|
||||
|
||||
class VerticalSpinBox(Gtk.SpinButton):
|
||||
def __init__(self, value: int, upper: int) -> None:
|
||||
super().__init__(
|
||||
adjustment=Gtk.Adjustment(
|
||||
value=value,
|
||||
lower=0,
|
||||
upper=upper,
|
||||
step_increment=1,
|
||||
page_increment=1,
|
||||
page_size=0,
|
||||
),
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
)
|
||||
|
||||
|
||||
class HourSpinBox(VerticalSpinBox):
|
||||
def __init__(self, hour: int) -> None:
|
||||
super().__init__(value=hour, upper=23)
|
||||
|
||||
|
||||
class MinuteSpinBox(VerticalSpinBox):
|
||||
def __init__(self, minute: int) -> None:
|
||||
super().__init__(value=minute, upper=59)
|
||||
|
||||
self.get_adjustment().set_page_increment(10)
|
||||
|
||||
|
||||
class TimePicker(Gtk.Box):
|
||||
def __init__(self, hour: int, minute: int) -> None:
|
||||
super().__init__(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.CENTER,
|
||||
)
|
||||
self.hour_spin = HourSpinBox(hour)
|
||||
self.minute_spin = MinuteSpinBox(minute)
|
||||
self.add(self.hour_spin)
|
||||
self.add(self.minute_spin)
|
||||
|
||||
self.hour_spin.connect("output", self._on_output)
|
||||
self.minute_spin.connect("output", self._on_output)
|
||||
|
||||
def _on_output(self, spin_button: VerticalSpinBox) -> Literal[True]:
|
||||
val = spin_button.get_value()
|
||||
spin_button.set_text(f"{int(val):02d}")
|
||||
# NOTE: indicates that rendering completed
|
||||
return True
|
||||
|
||||
def get_values(self) -> tuple[int, int]:
|
||||
hour = self.get_hour()
|
||||
minute = self.get_minute()
|
||||
return hour, minute
|
||||
|
||||
def get_hour(self) -> int:
|
||||
return int(self.hour_spin.get_adjustment().get_value())
|
||||
|
||||
def get_minute(self) -> int:
|
||||
return int(self.minute_spin.get_adjustment().get_value())
|
||||
|
||||
def get_time(self) -> datetime:
|
||||
"""
|
||||
NOTE: interpolating seconds/microseconds from the current time
|
||||
may prevent abrupt visual changes when setting a new TimePicker time
|
||||
while a clock tick is updating the local remainder label.
|
||||
However, this can introduce false negatives when running tests due to microsecond drift.
|
||||
Ideally, tests should be decoupled from this logic.
|
||||
"""
|
||||
d = datetime.now()
|
||||
return datetime.combine(
|
||||
d,
|
||||
time(
|
||||
hour=self.get_hour(),
|
||||
minute=self.get_minute(),
|
||||
second=0,
|
||||
microsecond=0,
|
||||
),
|
||||
)
|
||||
|
||||
def set_time(self, hour: int, minute: int) -> None:
|
||||
self.hour_spin.get_adjustment().set_value(hour)
|
||||
self.minute_spin.get_adjustment().set_value(minute)
|
||||
|
||||
|
||||
class AdjustedTimeFrame(Gtk.Frame):
|
||||
def __init__(self, emitter: Emitter) -> None:
|
||||
super().__init__(margin_start=50, margin_end=50)
|
||||
|
||||
self.emitter = emitter
|
||||
self.emitter.connect(
|
||||
"adjusted_local_time_changed", self._on_adjusted_time_changed
|
||||
)
|
||||
self.emitter.connect("remaining_time_changed", self._on_remainder_changed)
|
||||
|
||||
self.remainder_clock = RemainderClock(emitter)
|
||||
self.remainder_heading = BoldLabel(calc.heading_remainder_time)
|
||||
self.remaining_time = Gtk.Label(label=self.remainder_clock.get_time())
|
||||
|
||||
self.adjusted_clock = AdjustedClock(emitter)
|
||||
self.adjusted_heading = BoldLabel(calc.heading_adjusted_time)
|
||||
self.adjusted_local_time = Gtk.Label(label=self.adjusted_clock.get_time())
|
||||
|
||||
flowbox = Gtk.FlowBox(
|
||||
row_spacing=20,
|
||||
column_spacing=50,
|
||||
min_children_per_line=2,
|
||||
max_children_per_line=2,
|
||||
margin=15,
|
||||
halign=Gtk.Align.CENTER,
|
||||
selection_mode=Gtk.SelectionMode.NONE,
|
||||
)
|
||||
flowbox.add(self.remainder_heading)
|
||||
flowbox.add(self.adjusted_heading)
|
||||
flowbox.add(self.remaining_time)
|
||||
flowbox.add(self.adjusted_local_time)
|
||||
|
||||
recalc = Gtk.Button(
|
||||
label=calc.calculate, halign=Gtk.Align.CENTER, margin_bottom=20
|
||||
)
|
||||
recalc.connect("clicked", self._on_recalc_clicked)
|
||||
|
||||
vbox = VBox()
|
||||
vbox.extend([flowbox, recalc])
|
||||
self.add(vbox)
|
||||
|
||||
def _on_remainder_changed(self, emitter: Emitter, time: datetime) -> None:
|
||||
strtime = str(time.strftime(EXTENDED_TIME_FORMAT))
|
||||
self.remaining_time.set_text(strtime)
|
||||
|
||||
def _on_adjusted_time_changed(self, emitter: Emitter, time: datetime) -> None:
|
||||
strtime = str(time.strftime(EXTENDED_TIME_FORMAT))
|
||||
self.adjusted_local_time.set_text(strtime)
|
||||
|
||||
def _on_recalc_clicked(self, button: Gtk.Button) -> None:
|
||||
self.emitter.emit("calculate_button_clicked")
|
||||
|
||||
|
||||
class TimePickerFrame(Gtk.Frame):
|
||||
def __init__(self, emitter: Emitter, stime: Time) -> None:
|
||||
super().__init__(margin_start=50, margin_end=50)
|
||||
|
||||
self.emitter = emitter
|
||||
self.emitter.connect("local_time_incremented", self._on_local_clock_increment)
|
||||
self.emitter.connect("server_time_incremented", self._on_server_clock_increment)
|
||||
self.emitter.connect(
|
||||
"calculate_button_clicked", self._on_calculate_button_clicked
|
||||
)
|
||||
|
||||
self.local_clock = LocalClock(self.emitter)
|
||||
local_clock_time = self.local_clock.get_time()
|
||||
|
||||
self.server_time = ServerClock(stime, self.emitter, self.local_clock)
|
||||
hour, minute = self.server_time.get_values()
|
||||
self.picker = TimePicker(hour, minute)
|
||||
|
||||
self.local_timelabel = Gtk.Label(label=local_clock_time)
|
||||
self.server_timelabel = Gtk.Label(label=stime.time, halign=Gtk.Align.CENTER)
|
||||
|
||||
h0 = BoldLabel(calc.heading_local_time)
|
||||
h1 = BoldLabel(calc.heading_server_time)
|
||||
h2 = BoldLabel(calc.heading_target_time)
|
||||
|
||||
self.icon_indicator = Icon(DAY_ICON)
|
||||
self.update_server_icon()
|
||||
|
||||
server_time_box = HBox(10)
|
||||
server_time_box.set_halign(Gtk.Align.CENTER)
|
||||
server_time_box.extend([self.server_timelabel, self.icon_indicator])
|
||||
|
||||
flowbox = Gtk.FlowBox(
|
||||
row_spacing=10,
|
||||
column_spacing=50,
|
||||
min_children_per_line=3,
|
||||
max_children_per_line=3,
|
||||
margin=15,
|
||||
halign=Gtk.Align.CENTER,
|
||||
selection_mode=Gtk.SelectionMode.NONE,
|
||||
)
|
||||
flowbox.add(h0)
|
||||
flowbox.add(h1)
|
||||
flowbox.add(h2)
|
||||
flowbox.add(self.local_timelabel)
|
||||
flowbox.add(server_time_box)
|
||||
flowbox.add(self.picker)
|
||||
|
||||
self.add(flowbox)
|
||||
|
||||
def update_server_icon(self) -> None:
|
||||
icon = DAY_ICON if self.server_time.is_day() else NIGHT_ICON
|
||||
self.icon_indicator.set_icon_name(icon)
|
||||
|
||||
def _on_server_clock_increment(self, emitter: Emitter, time_now: datetime) -> None:
|
||||
t = time_now.time().strftime("%H:%M")
|
||||
self.server_timelabel.set_text(t)
|
||||
self.update_server_icon()
|
||||
|
||||
def _on_local_clock_increment(
|
||||
self, emitter: Emitter, time_now: datetime, elapsed: timedelta
|
||||
) -> None:
|
||||
t = time_now.time().strftime(EXTENDED_TIME_FORMAT)
|
||||
self.local_timelabel.set_text(t)
|
||||
|
||||
def _on_calculate_button_clicked(self, button: Gtk.Button) -> None:
|
||||
if (self.picker.get_values()) == (self.server_time.get_values()):
|
||||
total_time = timedelta(seconds=0)
|
||||
self.emitter.emit("target_time_reset", total_time)
|
||||
else:
|
||||
picker_time = self.picker.get_time()
|
||||
total_time = self.server_time.calc_delta(picker_time)
|
||||
self.emitter.emit("target_time_changed", total_time)
|
||||
|
||||
|
||||
class ServerTimeCalculator(Gtk.ScrolledWindow):
|
||||
def __init__(self, stime: Time) -> None:
|
||||
super().__init__(propagate_natural_width=True)
|
||||
|
||||
self.emitter = Emitter()
|
||||
outerbox = Gtk.Box(
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.START,
|
||||
spacing=20,
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
)
|
||||
time_picker_frame = TimePickerFrame(self.emitter, stime)
|
||||
adjusted_time_frame = AdjustedTimeFrame(self.emitter)
|
||||
disclaimer = Gtk.Label(
|
||||
label=calc.disclaimer,
|
||||
justify=Gtk.Justification.CENTER,
|
||||
)
|
||||
for el in time_picker_frame, adjusted_time_frame, disclaimer:
|
||||
outerbox.add(el)
|
||||
self.add(outerbox)
|
||||
@@ -3,6 +3,7 @@ import sys
|
||||
|
||||
from typing import Self
|
||||
from dzgui.util.strings import dialog_error, dialog_header
|
||||
from dzgui.views.mixins.colorscheme import ColorAwareApp
|
||||
|
||||
import gi
|
||||
|
||||
@@ -10,21 +11,26 @@ gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk # noqa E402
|
||||
|
||||
|
||||
class EarlyAlertDialog(Gtk.MessageDialog):
|
||||
def __init__(self, string: str) -> None:
|
||||
class AbortDialog(ColorAwareApp, Gtk.MessageDialog): # type: ignore
|
||||
def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None:
|
||||
super().__init__(
|
||||
title=dialog_header,
|
||||
text=dialog_error,
|
||||
transient_for=None,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
buttons=buttons,
|
||||
)
|
||||
|
||||
msg = textwrap.fill(string, 50)
|
||||
self.format_secondary_text(msg)
|
||||
|
||||
ma = self.get_message_area()
|
||||
label = ma.get_children()[1] # type: ignore
|
||||
label.set_justify(Gtk.Justification.CENTER)
|
||||
|
||||
aa = self.get_action_area()
|
||||
aa.set_margin_bottom(20)
|
||||
# self.action_area.set_margin_bottom(20)
|
||||
aa.set_layout(Gtk.ButtonBoxStyle.CENTER) # type: ignore
|
||||
|
||||
self.outer = self.get_content_area()
|
||||
self.outer.set_margin_start(30)
|
||||
self.outer.set_margin_end(30)
|
||||
@@ -32,26 +38,30 @@ class EarlyAlertDialog(Gtk.MessageDialog):
|
||||
self.set_default_size(250, 100)
|
||||
|
||||
abort = self.get_widget_for_response(Gtk.ResponseType.OK)
|
||||
ignore = self.get_widget_for_response(Gtk.ResponseType.CANCEL)
|
||||
if abort is not None and hasattr(abort, "set_label"):
|
||||
abort.set_label("Exit")
|
||||
if ignore is not None and hasattr(ignore, "set_label"):
|
||||
ignore.set_label("Ignore")
|
||||
|
||||
self.connect("response", self._on_response)
|
||||
|
||||
self.run()
|
||||
self.destroy()
|
||||
|
||||
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
|
||||
match response:
|
||||
case Gtk.ResponseType.OK:
|
||||
case Gtk.ResponseType.OK | Gtk.ResponseType.DELETE_EVENT:
|
||||
sys.exit(1)
|
||||
case Gtk.ResponseType.CANCEL:
|
||||
return
|
||||
|
||||
|
||||
class EarlyIgnoreDialog(EarlyAlertDialog):
|
||||
class EarlyAlertDialog(AbortDialog):
|
||||
def __init__(self, string: str) -> None:
|
||||
super().__init__(string=string)
|
||||
super().__init__(string=string, buttons=Gtk.ButtonsType.OK)
|
||||
|
||||
# TODO: reverse order
|
||||
self.add_button("Ignore", Gtk.ResponseType.CANCEL)
|
||||
|
||||
# TODO: if exit, sys.exit(1), else pass
|
||||
class EarlyIgnoreDialog(AbortDialog):
|
||||
def __init__(self, string: str) -> None:
|
||||
super().__init__(string=string, buttons=Gtk.ButtonsType.OK_CANCEL)
|
||||
@@ -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)
|
||||
|
||||
+152
-51
@@ -1,6 +1,7 @@
|
||||
from typing import Literal, Self, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL
|
||||
from dzgui.strings import dialogs
|
||||
from dzgui.util import strings
|
||||
from dzgui.views.components.buttons import ClipboardButton
|
||||
|
||||
@@ -107,23 +108,26 @@ class WaitDialog(GenericDialog):
|
||||
self.cur_job = 1
|
||||
|
||||
self.cancel = Gtk.Button(label="Cancel", halign=Gtk.Align.CENTER)
|
||||
self.cancel.connect("clicked", lambda _: self.controller.set_cancel_event())
|
||||
self.cancel.connect("clicked", self._on_cancel_clicked)
|
||||
|
||||
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)
|
||||
@@ -135,6 +139,11 @@ class WaitDialog(GenericDialog):
|
||||
self.prog.set_fraction(fraction)
|
||||
self.cur_job += 1
|
||||
|
||||
def _on_cancel_clicked(self, button: Gtk.Button) -> None:
|
||||
# TODO: strings
|
||||
GLib.idle_add(self.update_text, "Caught cancel signal, cleaning up")
|
||||
self.controller.set_cancel_event()
|
||||
|
||||
def show_cancel(self, state: bool) -> None:
|
||||
self.cancel.set_visible(state)
|
||||
|
||||
@@ -153,6 +162,7 @@ class QuitDialog(GenericDialog):
|
||||
self.add_button(strings.exit_app, Gtk.ResponseType.OK)
|
||||
|
||||
self.connect("response", self._on_response)
|
||||
# TODO: superfluous with the above
|
||||
self.connect("delete-event", self._on_response)
|
||||
|
||||
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
|
||||
@@ -160,58 +170,30 @@ class QuitDialog(GenericDialog):
|
||||
self.controller.save_res_and_quit()
|
||||
|
||||
|
||||
class ExceptionDialog(GenericDialog):
|
||||
"""
|
||||
Error dialog with rich traceback.
|
||||
Usage:
|
||||
try:
|
||||
foo()
|
||||
except Exception:
|
||||
trace = traceback.format_exc()
|
||||
dialog = ExceptionDialog(Controller, trace)
|
||||
dialog.run()
|
||||
"""
|
||||
|
||||
def __init__(self, controller: "Controller", trace: str):
|
||||
class TextBufferDialog(GenericDialog):
|
||||
def __init__(
|
||||
self,
|
||||
controller: "Controller",
|
||||
mtype: Gtk.MessageType,
|
||||
heading: str,
|
||||
secondary: str,
|
||||
text: str,
|
||||
):
|
||||
super().__init__(
|
||||
controller=controller,
|
||||
text=strings.error_heading,
|
||||
mtype=Gtk.MessageType.ERROR,
|
||||
text=heading,
|
||||
mtype=mtype,
|
||||
buttons=Gtk.ButtonsType.NONE,
|
||||
secondary=strings.something_wrong,
|
||||
secondary=secondary,
|
||||
)
|
||||
|
||||
self.trace = trace
|
||||
# NOTE: box expands to end of content area
|
||||
scrollable = Gtk.ScrolledWindow(
|
||||
propagate_natural_height=True, max_content_height=500
|
||||
)
|
||||
box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL)
|
||||
# TODO: wrap/truncate long messages
|
||||
textview = Gtk.TextView(
|
||||
wrap_mode=Gtk.WrapMode.WORD, editable=False, left_margin=10, right_margin=10
|
||||
)
|
||||
textview.set_buffer(Gtk.TextBuffer(text=self.trace))
|
||||
box.pack_start(textview, EXPAND, FILL, 10)
|
||||
scrollable.add(box)
|
||||
|
||||
content = self.get_content_area()
|
||||
content.set_spacing(0)
|
||||
# TODO: padding around top of content area when traceback is long
|
||||
content.add(scrollable)
|
||||
|
||||
copy_button = ClipboardButton(controller, self.get_trace)
|
||||
self.text = text
|
||||
copy_button = ClipboardButton(controller, self.get_text)
|
||||
self.add_action_widget(copy_button, Gtk.ResponseType.NONE)
|
||||
self.add_button("OK", Gtk.ResponseType.OK)
|
||||
|
||||
self.show_all()
|
||||
self.ok = self.get_widget_for_response(Gtk.ResponseType.OK)
|
||||
if self.ok is not None:
|
||||
self.ok.grab_focus()
|
||||
self.connect("response", self._on_response)
|
||||
|
||||
def get_trace(self) -> str:
|
||||
return self.trace
|
||||
def get_text(self) -> str:
|
||||
return self.text
|
||||
|
||||
def _on_response(
|
||||
self, dialog: Self, response: Gtk.ResponseType
|
||||
@@ -227,3 +209,122 @@ class ExceptionDialog(GenericDialog):
|
||||
return None
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
class DebugDialog(TextBufferDialog):
|
||||
def __init__(self, controller: "Controller", debug: str):
|
||||
super().__init__(
|
||||
controller=controller,
|
||||
mtype=Gtk.MessageType.INFO,
|
||||
heading=dialogs.debug_heading,
|
||||
secondary=dialogs.debug_secondary,
|
||||
text=debug,
|
||||
)
|
||||
scrollable = Gtk.ScrolledWindow(
|
||||
propagate_natural_height=False,
|
||||
min_content_height=200,
|
||||
max_content_height=200,
|
||||
margin_bottom=10,
|
||||
)
|
||||
textview = Gtk.TextView(
|
||||
wrap_mode=Gtk.WrapMode.CHAR, editable=False, left_margin=10, right_margin=10
|
||||
)
|
||||
textview.set_buffer(Gtk.TextBuffer(text=debug))
|
||||
scrollable.add(textview)
|
||||
content = self.get_content_area()
|
||||
content.set_spacing(0)
|
||||
content.add(scrollable)
|
||||
|
||||
|
||||
class ExceptionDialog(TextBufferDialog):
|
||||
"""
|
||||
Error dialog with rich traceback.
|
||||
Usage:
|
||||
try:
|
||||
foo()
|
||||
except Exception:
|
||||
trace = traceback.format_exc()
|
||||
dialog = ExceptionDialog(Controller, trace)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
"""
|
||||
|
||||
def __init__(self, controller: "Controller", trace: str):
|
||||
super().__init__(
|
||||
controller=controller,
|
||||
heading=strings.error_heading,
|
||||
secondary=strings.something_wrong,
|
||||
text=trace,
|
||||
mtype=Gtk.MessageType.ERROR,
|
||||
)
|
||||
|
||||
# NOTE: box expands to end of content area
|
||||
scrollable = Gtk.ScrolledWindow(
|
||||
propagate_natural_height=True, max_content_height=500
|
||||
)
|
||||
box = Gtk.Box(hexpand=True, vexpand=True, orientation=Gtk.Orientation.VERTICAL)
|
||||
textview = Gtk.TextView(
|
||||
wrap_mode=Gtk.WrapMode.WORD,
|
||||
editable=False,
|
||||
left_margin=10,
|
||||
right_margin=10,
|
||||
top_margin=15,
|
||||
bottom_margin=10,
|
||||
)
|
||||
textview.set_buffer(Gtk.TextBuffer(text=trace))
|
||||
box.pack_start(textview, EXPAND, FILL, 0)
|
||||
|
||||
self.error_details = Gtk.ScrolledWindow(
|
||||
overlay_scrolling=False,
|
||||
max_content_height=150,
|
||||
propagate_natural_height=False,
|
||||
)
|
||||
details_box = Gtk.Box(
|
||||
hexpand=True,
|
||||
vexpand=True,
|
||||
margin_right=5,
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
)
|
||||
|
||||
self.details_buffer = Gtk.TextBuffer()
|
||||
details_textview = Gtk.TextView(
|
||||
wrap_mode=Gtk.WrapMode.WORD_CHAR,
|
||||
editable=False,
|
||||
left_margin=10,
|
||||
right_margin=10,
|
||||
top_margin=15,
|
||||
)
|
||||
|
||||
details_textview.set_buffer(self.details_buffer)
|
||||
details_box.pack_start(details_textview, EXPAND, FILL, 10)
|
||||
self.error_details.add(details_box)
|
||||
|
||||
self.error_notebook = Gtk.Notebook(show_tabs=False, margin_bottom=15)
|
||||
self.error_notebook.append_page(box, Gtk.Label(label="Error"))
|
||||
self.error_notebook.append_page(self.error_details, Gtk.Label(label="Details"))
|
||||
self.error_notebook.connect("switch-page", self._on_page_changed)
|
||||
scrollable.add(self.error_notebook)
|
||||
|
||||
content = self.get_content_area()
|
||||
content.set_spacing(0)
|
||||
content.add(scrollable)
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def _on_map(self, dialog: Self) -> None:
|
||||
button = self.get_widget_for_response(Gtk.ResponseType.OK)
|
||||
if button is None:
|
||||
return
|
||||
button.grab_focus()
|
||||
|
||||
def _on_page_changed(
|
||||
self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int
|
||||
) -> None:
|
||||
if child == self.error_details:
|
||||
GLib.idle_add(self.error_details.set_propagate_natural_height, True)
|
||||
else:
|
||||
GLib.idle_add(self.error_details.set_propagate_natural_height, False)
|
||||
|
||||
def set_details_buffer(self, text: str) -> None:
|
||||
self.details_buffer.set_text(text)
|
||||
self.error_notebook.set_show_tabs(True)
|
||||
@@ -1,19 +1,21 @@
|
||||
import gi
|
||||
import html
|
||||
from typing import Self, TYPE_CHECKING
|
||||
|
||||
from dzgui.const.constants import EXPAND, FILL
|
||||
from dzgui.const.constants import CALCULATOR
|
||||
from dzgui.const.enum import ContextMenuGroup
|
||||
from dzgui.model.model_factory import ModelFactory
|
||||
from dzgui.util import css
|
||||
from dzgui.util import strings
|
||||
from dzgui.util.format import format_hyperlinks, format_server_mods
|
||||
from dzgui.strings import server_mods
|
||||
from dzgui.strings import server_mods, dialogs
|
||||
from dzgui.views.components.buttons import IconTextButton
|
||||
from dzgui.views.components.box import VBox
|
||||
from dzgui.views.dialogs.calc import Time, ServerTimeCalculator
|
||||
from dzgui.views.dialogs.generic import GenericDialog
|
||||
from dzgui.views.trees.tree_base import TreeView
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa
|
||||
from gi.repository import Gtk, Gdk, Pango # noqa
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dzgui.api.servers import Details
|
||||
@@ -45,12 +47,19 @@ class ServerDialog(GenericDialog):
|
||||
self.connect("response", self._on_response)
|
||||
self.view.connect("key-press-event", self._on_keypress)
|
||||
|
||||
self.scrollable_tree = Gtk.ScrolledWindow()
|
||||
self.scrollable_tree = Gtk.ScrolledWindow(vexpand=True)
|
||||
self.scrollable_tree.add(self.view)
|
||||
self.scrollable_tree.set_size_request(700, 400)
|
||||
self.scrollable_tree.set_size_request(700, 500)
|
||||
|
||||
self.stack = Gtk.Stack()
|
||||
self.content_box = VBox(10)
|
||||
self.content_box.set_valign(Gtk.Align.FILL)
|
||||
self.content_box.add(self.scrollable_tree)
|
||||
|
||||
self.stack.add_named(self.content_box, "METADATA")
|
||||
|
||||
self.content = self.get_content_area()
|
||||
self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0)
|
||||
self.content.pack_start(self.stack, expand=True, fill=True, padding=0)
|
||||
|
||||
def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool:
|
||||
# NOTE: ESC normally unfocuses treeview instead of destroying dialog
|
||||
@@ -65,8 +74,7 @@ class ServerDialog(GenericDialog):
|
||||
|
||||
class ServerDetailsDialog(ServerDialog):
|
||||
def __init__(self, controller: "Controller", details: "Details"):
|
||||
name = controller.get_server_name()
|
||||
super().__init__(controller, strings.server_details, name, menu=None)
|
||||
super().__init__(controller, dialogs.server_details, details.name, menu=None)
|
||||
|
||||
self.store = Gtk.ListStore(str, str, Pango.Weight)
|
||||
self.view.connect("row-activated", self._on_row_activated)
|
||||
@@ -83,14 +91,9 @@ class ServerDetailsDialog(ServerDialog):
|
||||
column.set_sort_column_id(i)
|
||||
column.set_expand(True)
|
||||
|
||||
# NOTE: constrain tree area to logical height of metadata
|
||||
# leaving room for server message
|
||||
self.scrollable_tree.set_size_request(700, 150)
|
||||
|
||||
# TODO: make "Server message" text boldface
|
||||
scrollable_message = Gtk.ScrolledWindow()
|
||||
desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START)
|
||||
css.add_class(desc, "details-heading")
|
||||
scrollable_message = Gtk.ScrolledWindow(vexpand=True)
|
||||
desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START)
|
||||
css.add_class(desc, "server-subheading")
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER)
|
||||
self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True)
|
||||
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
@@ -98,21 +101,41 @@ class ServerDetailsDialog(ServerDialog):
|
||||
for el in desc, sep, self.description:
|
||||
box.add(el)
|
||||
scrollable_message.add(box)
|
||||
|
||||
self.pack(scrollable_message)
|
||||
|
||||
if details.data is None:
|
||||
return
|
||||
self.content_box.add(scrollable_message)
|
||||
|
||||
for row in details.data:
|
||||
self.store.append(row + [Pango.Weight.BOLD])
|
||||
self.view.set_model(self.store)
|
||||
text = details.description
|
||||
text = format_hyperlinks(text)
|
||||
self.description.set_markup(text)
|
||||
self.description.set_markup(html.escape(text))
|
||||
|
||||
calc_button = IconTextButton(CALCULATOR, dialogs.toggle_calc)
|
||||
calc_button.connect("clicked", self._on_calc_pressed)
|
||||
calc_button.set_halign(Gtk.Align.END)
|
||||
calc_button.set_valign(Gtk.Align.START)
|
||||
self.content.pack_start(calc_button, False, False, 0)
|
||||
self.content.reorder_child(calc_button, 2)
|
||||
|
||||
self.scrollable_tree.set_size_request(700, 250)
|
||||
self.calc = ServerTimeCalculator(
|
||||
Time(details.gametime, details.day_accel, details.night_accel)
|
||||
)
|
||||
self.stack.add_named(self.calc, "CALCULATOR")
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
self.show_all()
|
||||
|
||||
def _on_map(self, dialog: Self) -> None:
|
||||
if widget := self.get_widget_for_response(Gtk.ResponseType.OK):
|
||||
widget.grab_focus()
|
||||
|
||||
def _on_calc_pressed(self, button: Gtk.Button) -> None:
|
||||
if self.stack.get_visible_child_name() == "METADATA":
|
||||
self.stack.set_visible_child_name("CALCULATOR")
|
||||
else:
|
||||
self.stack.set_visible_child_name("METADATA")
|
||||
|
||||
def _on_row_activated(
|
||||
self,
|
||||
treeview: Gtk.TreeView,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Self
|
||||
|
||||
from dzgui.api.shortcuts import Shortcuts
|
||||
from dzgui.const.constants import APP_NAME
|
||||
from dzgui.strings import uninstall
|
||||
from dzgui.util._json import read_json
|
||||
from dzgui.util.css import load_css
|
||||
from dzgui.util.format import format_exception
|
||||
from dzgui.views.components.box import HBox
|
||||
from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel
|
||||
from dzgui.views.mixins.colorscheme import ColorAwareApp
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, GLib, Pango # noqa E402
|
||||
|
||||
|
||||
class Assistant(Gtk.Assistant):
|
||||
def __init__(self, is_deck: bool, paths: dict[str, str]):
|
||||
super().__init__()
|
||||
if is_deck:
|
||||
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
|
||||
self.set_default_size(1085, 670)
|
||||
else:
|
||||
self.set_default_size(1500, 900)
|
||||
|
||||
self.set_forward_page_func(self._advance_page)
|
||||
|
||||
pyapp = os.getenv("PYAPP")
|
||||
|
||||
self.page1 = UninstallPage(paths, pyapp)
|
||||
self.page2 = CompletionPage(pyapp)
|
||||
|
||||
self.append_page(self.page1)
|
||||
self.set_page_type(self.page1, Gtk.AssistantPageType.INTRO)
|
||||
self.set_page_title(self.page1, uninstall.pg1_title)
|
||||
self.set_page_complete(self.page1, True)
|
||||
|
||||
self.append_page(self.page2)
|
||||
self.set_page_title(self.page2, uninstall.pg2_title)
|
||||
self.set_page_type(self.page2, Gtk.AssistantPageType.SUMMARY)
|
||||
|
||||
self.connect("cancel", self.destroy_and_quit)
|
||||
self.connect("close", self.destroy_and_quit)
|
||||
self.show_all()
|
||||
load_css()
|
||||
|
||||
def _advance_page(self, index: int) -> int:
|
||||
cur_page = self.get_nth_page(index)
|
||||
if cur_page == self.page1:
|
||||
self.page1.uninstall()
|
||||
return index + 1
|
||||
|
||||
def destroy_and_quit(self, widget: Self) -> None:
|
||||
self.destroy()
|
||||
Gtk.main_quit()
|
||||
|
||||
|
||||
class Window(Gtk.Window):
|
||||
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None:
|
||||
super().__init__(title=APP_NAME, icon_name=APP_NAME)
|
||||
self.assistant = Assistant(is_deck, paths)
|
||||
|
||||
|
||||
class CompletionPage(ScrolledWizardPage):
|
||||
def __init__(self, pyapp: str | None):
|
||||
super().__init__(heading=uninstall.pg2_heading, description=uninstall.pg2_blurb)
|
||||
|
||||
text = (
|
||||
uninstall.standalone_uninstall
|
||||
if pyapp is not None
|
||||
else uninstall.system_uninstall
|
||||
)
|
||||
|
||||
label = Gtk.Label(
|
||||
label=text,
|
||||
wrap_mode=Pango.WrapMode.WORD,
|
||||
margin=10,
|
||||
max_width_chars=80,
|
||||
justify=Gtk.Justification.CENTER,
|
||||
)
|
||||
frame = Gtk.Frame()
|
||||
frame.add(label)
|
||||
self.add_start(frame)
|
||||
|
||||
|
||||
class FileLabel(HBox):
|
||||
def __init__(self, path: Path) -> None:
|
||||
super().__init__(spacing=10)
|
||||
|
||||
label = Gtk.Label(label=uninstall.path_remove_prefix, halign=Gtk.Align.START)
|
||||
textview = Gtk.TextView(
|
||||
editable=False,
|
||||
halign=Gtk.Align.START,
|
||||
left_margin=10,
|
||||
right_margin=10,
|
||||
)
|
||||
textview.set_buffer(Gtk.TextBuffer(text=str(path)))
|
||||
self.extend([label, textview])
|
||||
|
||||
|
||||
class CheckboxWithPath(CheckboxWithLabel):
|
||||
def __init__(
|
||||
self, label: str, details: str, path: Path, active: bool = True
|
||||
) -> None:
|
||||
super().__init__(text=label, blurb_text=details)
|
||||
|
||||
self.conf_path = path
|
||||
self.set_active(active)
|
||||
fl = FileLabel(path)
|
||||
self.indent_below(fl)
|
||||
|
||||
def get_conf_path(self) -> Path:
|
||||
return self.conf_path
|
||||
|
||||
|
||||
class UninstallPage(ScrolledWizardPage):
|
||||
def __init__(self, paths: dict[str, str], pyapp: str | None):
|
||||
super().__init__(heading=uninstall.pg1_heading, description=uninstall.pg1_blurb)
|
||||
|
||||
self.pyapp = pyapp
|
||||
|
||||
config = Path(paths["XDG_CONFIG_HOME"])
|
||||
state = Path(paths["XDG_STATE_HOME"])
|
||||
self.share = Path(paths["XDG_DATA_HOME"])
|
||||
shortcut = self.share.parent.joinpath("applications/dzgui.desktop")
|
||||
desktop = config.parent.parent.joinpath("Desktop/dzgui.desktop")
|
||||
self.steam_path = self.parse_steam_path(config, self.share)
|
||||
|
||||
self.boxes: list[CheckboxWithPath] = []
|
||||
self.checks_area = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
halign=Gtk.Align.CENTER,
|
||||
spacing=20,
|
||||
margin_top=20,
|
||||
margin_bottom=20,
|
||||
)
|
||||
self.config_box = CheckboxWithPath(
|
||||
uninstall.config_label, uninstall.config_details, config, active=False
|
||||
)
|
||||
self.desktop_box = CheckboxWithPath(
|
||||
uninstall.desktop_label, uninstall.desktop_details, desktop
|
||||
)
|
||||
self.start_menu_box = CheckboxWithPath(
|
||||
uninstall.start_menu_label, uninstall.start_menu_details, shortcut
|
||||
)
|
||||
self.state_box = CheckboxWithPath(
|
||||
uninstall.state_label, uninstall.state_details, state
|
||||
)
|
||||
self.steam_box = CheckboxWithLabel(
|
||||
uninstall.steam_shortcut, uninstall.steam_details
|
||||
)
|
||||
|
||||
for box in (
|
||||
self.config_box,
|
||||
self.state_box,
|
||||
self.desktop_box,
|
||||
self.start_menu_box,
|
||||
):
|
||||
self.checks_area.add(box)
|
||||
self.boxes.append(box)
|
||||
|
||||
self.checks_area.add(self.steam_box)
|
||||
|
||||
self.add_start(self.checks_area)
|
||||
self.show_all()
|
||||
|
||||
def parse_steam_path(self, config: Path, steam: Path) -> Path | None:
|
||||
try:
|
||||
file = config.joinpath("config.json")
|
||||
conf = read_json(file)
|
||||
steam = conf["default_steam_path"]
|
||||
self.steam_path = Path(steam)
|
||||
return Path(steam)
|
||||
except Exception as e:
|
||||
print(format_exception(e))
|
||||
return None
|
||||
|
||||
def wipe_conf_file(self, box: CheckboxWithPath) -> None:
|
||||
if box.get_active():
|
||||
path = box.get_conf_path()
|
||||
try:
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
else:
|
||||
path.unlink()
|
||||
print(f"Deleted '{path}'")
|
||||
except Exception as e:
|
||||
print(format_exception(e))
|
||||
|
||||
def wipe_steam_shortcut(self) -> None:
|
||||
if self.steam_path is None:
|
||||
return
|
||||
try:
|
||||
shortcuts = Shortcuts(self.steam_path)
|
||||
shortcuts.delete_shortcut(self.share)
|
||||
shutil.rmtree(self.share)
|
||||
except Exception as e:
|
||||
print(format_exception(e))
|
||||
|
||||
def wipe_pyapp(self) -> None:
|
||||
if self.pyapp is None:
|
||||
return
|
||||
subprocess.run([self.pyapp, "self", "remove"])
|
||||
|
||||
def uninstall(self) -> None:
|
||||
for box in self.boxes:
|
||||
self.wipe_conf_file(box)
|
||||
if self.steam_box.get_active():
|
||||
self.wipe_steam_shortcut()
|
||||
self.wipe_pyapp()
|
||||
pass
|
||||
|
||||
|
||||
class UninstallWizard(ColorAwareApp, Gtk.Application): # type: ignore
|
||||
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None:
|
||||
super().__init__()
|
||||
config = Path(paths["XDG_CONFIG_HOME"])
|
||||
if not config.is_dir() or not config.joinpath("config.json").is_file():
|
||||
print(uninstall.not_installed)
|
||||
return
|
||||
GLib.set_prgname(APP_NAME)
|
||||
self.win = Window(is_deck, paths)
|
||||
Gtk.main()
|
||||
+222
-109
@@ -3,18 +3,22 @@ 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,
|
||||
APP_NAME_LOWER,
|
||||
HERO_PATH,
|
||||
FLATPAK_STEAM_PATH,
|
||||
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.config import freedesktop
|
||||
from dzgui.init.proc import path_to_steam_cmd
|
||||
from dzgui.init.migrate import migrate_legacy_conf
|
||||
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
|
||||
from dzgui.strings import wizard
|
||||
@@ -23,24 +27,34 @@ from dzgui.util.open_links import open_link_by_url
|
||||
from dzgui.util.css import add_class, load_css
|
||||
from dzgui.views.components.buttons import WebButton
|
||||
from dzgui.views.components.entry import APIEntry
|
||||
from dzgui.views.components.misc import ClientCombo
|
||||
from dzgui.views.mixins.colorscheme import ColorAwareApp
|
||||
|
||||
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: "EnumeratedWizardPage") -> None:
|
||||
EMITTER.emit("step_complete")
|
||||
|
||||
|
||||
class DescriptionArea(Gtk.Box):
|
||||
def __init__(self, text: str):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||
@@ -57,10 +71,9 @@ class Progress(Gtk.ProgressBar):
|
||||
|
||||
|
||||
class ScrolledWizardPage(Gtk.ScrolledWindow):
|
||||
def __init__(self, enum: PageNum, heading: str, description: str):
|
||||
super().__init__()
|
||||
def __init__(self, heading: str, description: str):
|
||||
super().__init__(overlay_scrolling=False)
|
||||
|
||||
self.enum = enum
|
||||
self.page_type: Gtk.AssistantPageType
|
||||
self.title = heading
|
||||
self.heading = Heading(heading)
|
||||
@@ -73,7 +86,6 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
|
||||
height=600,
|
||||
preserve_aspect_ratio=True,
|
||||
)
|
||||
image = Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
|
||||
self.box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
@@ -82,21 +94,13 @@ 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)
|
||||
image = Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
self.box.pack_start(image, expand=False, fill=True, padding=0)
|
||||
|
||||
self.add(self.box)
|
||||
self.box.pack_start(self.heading, expand=False, fill=True, padding=0)
|
||||
self.box.pack_start(self.description, expand=False, fill=True, padding=0)
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def get_enum(self) -> PageNum:
|
||||
return self.enum
|
||||
|
||||
def get_progress_bar(self) -> Progress:
|
||||
return self.prog
|
||||
|
||||
def get_page_type(self) -> Gtk.AssistantPageType:
|
||||
return self.page_type
|
||||
|
||||
@@ -115,8 +119,20 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
|
||||
def get_box(self) -> Gtk.Box:
|
||||
return self.box
|
||||
|
||||
def _on_map(self, page: "ScrolledWizardPage") -> None:
|
||||
pass
|
||||
|
||||
class EnumeratedWizardPage(ScrolledWizardPage):
|
||||
def __init__(self, enum: PageNum, heading: str, description: str) -> None:
|
||||
super().__init__(heading=heading, description=description)
|
||||
|
||||
self.enum = enum
|
||||
self.prog = Progress()
|
||||
self.box.pack_end(self.prog, expand=False, fill=False, padding=0)
|
||||
|
||||
def get_enum(self) -> PageNum:
|
||||
return self.enum
|
||||
|
||||
def get_progress_bar(self) -> Progress:
|
||||
return self.prog
|
||||
|
||||
|
||||
class NotificationFrame(Gtk.Frame):
|
||||
@@ -141,8 +157,12 @@ 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):
|
||||
|
||||
class APIValidationPage(EnumeratedWizardPage):
|
||||
def __init__(
|
||||
self, enum: PageNum, heading: str, description: str, link: str, func: Callable
|
||||
) -> None:
|
||||
@@ -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__(
|
||||
@@ -236,7 +239,7 @@ class SteamValidationPage(APIValidationPage):
|
||||
self.thread_man.set_cleanup_func(cleanup)
|
||||
|
||||
|
||||
class IntroductionPage(ScrolledWizardPage):
|
||||
class IntroductionPage(EnumeratedWizardPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.INTRO,
|
||||
@@ -291,7 +294,7 @@ class RadioFrame(Gtk.Frame):
|
||||
return self.button
|
||||
|
||||
|
||||
class ConfigMigrationPage(ScrolledWizardPage):
|
||||
class ConfigMigrationPage(EnumeratedWizardPage):
|
||||
def __init__(self, config: Path) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.HAS_CONFIG,
|
||||
@@ -300,6 +303,7 @@ class ConfigMigrationPage(ScrolledWizardPage):
|
||||
)
|
||||
|
||||
self.migrated = False
|
||||
self.migrated_conf: dict[str, Any] = {}
|
||||
self.config = config
|
||||
self.page_type = Gtk.AssistantPageType.INTRO
|
||||
|
||||
@@ -339,7 +343,7 @@ class ConfigMigrationPage(ScrolledWizardPage):
|
||||
def _on_import_clicked(self, button: Gtk.Button) -> None:
|
||||
self.grid.set_sensitive(False)
|
||||
try:
|
||||
migrate_legacy_conf(self.config)
|
||||
self.migrated_conf = migrate_legacy_conf(self.config)
|
||||
self.migrated = True
|
||||
self.success_box.set_visible(True)
|
||||
except Exception:
|
||||
@@ -350,7 +354,7 @@ class ConfigMigrationPage(ScrolledWizardPage):
|
||||
EMITTER.emit("config", True)
|
||||
|
||||
|
||||
class PreferencesPage(ScrolledWizardPage):
|
||||
class PreferencesPage(EnumeratedWizardPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.USER_PREFS,
|
||||
@@ -381,26 +385,17 @@ class PreferencesPage(ScrolledWizardPage):
|
||||
self.dist_box.add(self.radio_km)
|
||||
self.dist_box.add(self.radio_miles)
|
||||
|
||||
client_label = Gtk.Label(label=wizard.label_client, halign=Gtk.Align.START)
|
||||
self.client_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
self.client_combo = ClientCombo()
|
||||
self.client_box.add(client_label)
|
||||
self.client_box.add(self.client_combo)
|
||||
|
||||
outer_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, spacing=10
|
||||
)
|
||||
for el in self.name_box, self.client_box, self.dist_box:
|
||||
for el in self.name_box, self.dist_box:
|
||||
outer_box.add(el)
|
||||
self.add_start(outer_box)
|
||||
|
||||
def get_prefs(self) -> tuple[str, bool, str]:
|
||||
def get_prefs(self) -> tuple[str, bool]:
|
||||
name = self.name_entry.get_text().strip()
|
||||
use_miles = self.radio_miles.get_active()
|
||||
model = self.client_combo.get_model()
|
||||
ind = self.client_combo.get_active()
|
||||
client = model[ind][1]
|
||||
return name, use_miles, client
|
||||
return name, use_miles
|
||||
|
||||
def _on_entry_changed(self, entry: Gtk.Entry) -> None:
|
||||
text = entry.get_text()
|
||||
@@ -410,7 +405,7 @@ class PreferencesPage(ScrolledWizardPage):
|
||||
EMITTER.emit("step_complete")
|
||||
|
||||
|
||||
class CompletionPage(ScrolledWizardPage):
|
||||
class CompletionPage(EnumeratedWizardPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.FINAL,
|
||||
@@ -423,24 +418,26 @@ class CompletionPage(ScrolledWizardPage):
|
||||
|
||||
|
||||
class Assistant(Gtk.Assistant):
|
||||
def __init__(self, is_deck: bool, config: Path):
|
||||
def __init__(self, is_deck: bool, XDG: "Xdg"):
|
||||
super().__init__()
|
||||
if is_deck:
|
||||
self.fullscreen()
|
||||
# NOTE: deemed to be "safe" dimensions that exclude taskbar size
|
||||
self.set_default_size(1085, 670)
|
||||
else:
|
||||
self.set_default_size(1500, 900)
|
||||
|
||||
self.config_path = config
|
||||
|
||||
self.config_path = XDG.config
|
||||
self.config_values: dict[str, Any] = config_boilerplate
|
||||
|
||||
self.page1 = IntroductionPage()
|
||||
self.page2 = ConfigMigrationPage(config)
|
||||
self.page3 = SteamPathPage()
|
||||
self.page4 = SteamValidationPage()
|
||||
self.page5 = BMValidationPage()
|
||||
self.page6 = PreferencesPage()
|
||||
self.page7 = CompletionPage()
|
||||
self.setup_complete = False
|
||||
|
||||
self.page_intro = IntroductionPage()
|
||||
self.page_migration = ConfigMigrationPage(XDG.config)
|
||||
self.page_paths = SteamPathPage()
|
||||
self.page_api = SteamValidationPage()
|
||||
self.page_prefs = PreferencesPage()
|
||||
self.page_shortcuts = ShortcutCreationPage(XDG.shortcut)
|
||||
self.page_completion = CompletionPage()
|
||||
|
||||
self.set_forward_page_func(self._advance_page)
|
||||
|
||||
@@ -448,21 +445,27 @@ class Assistant(Gtk.Assistant):
|
||||
EMITTER.connect("step_pending", self._mark_page_incomplete)
|
||||
EMITTER.connect("config", self._set_config_state)
|
||||
|
||||
self.pages: dict[EnumeratedWizardPage, int] = {}
|
||||
|
||||
legacy_path = Path.home().joinpath(LEGACY_CONFIG_PATH)
|
||||
self.has_legacy_config = legacy_path.is_file()
|
||||
for page in (
|
||||
self.page1,
|
||||
self.page2,
|
||||
self.page3,
|
||||
self.page4,
|
||||
self.page5,
|
||||
self.page6,
|
||||
self.page7,
|
||||
self.page_intro,
|
||||
self.page_migration,
|
||||
self.page_paths,
|
||||
self.page_api,
|
||||
self.page_prefs,
|
||||
self.page_shortcuts,
|
||||
self.page_completion,
|
||||
):
|
||||
# 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
|
||||
self._add_page(page, page.get_page_type())
|
||||
ind = self._add_page(page, page.get_page_type())
|
||||
self.pages[page] = ind
|
||||
|
||||
self.connect("prepare", self._on_page_prepare)
|
||||
self.connect("cancel", self.destroy_and_quit)
|
||||
@@ -470,35 +473,46 @@ class Assistant(Gtk.Assistant):
|
||||
self.show_all()
|
||||
load_css()
|
||||
|
||||
def write_config(self) -> None:
|
||||
write_json(self.config_values, self.config_path)
|
||||
def write_config(self, config: dict[str, Any]) -> None:
|
||||
write_json(config, self.config_path)
|
||||
|
||||
def get_final_page(self) -> int:
|
||||
return self.pages[self.page_shortcuts]
|
||||
|
||||
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:
|
||||
self.config_values["default_steam_path"] = page.get_path_from_radio()
|
||||
case self.page4:
|
||||
case ConfigMigrationPage():
|
||||
if page.is_migrated():
|
||||
sp = page.migrated_conf["default_steam_path"]
|
||||
self.page_shortcuts.set_steam_path(sp)
|
||||
self.config_values = self.page_migration.migrated_conf
|
||||
return self.get_final_page()
|
||||
case SteamPathPage():
|
||||
path = page.get_path_from_radio()
|
||||
self.config_values["default_steam_path"] = path
|
||||
self.config_values["client"] = path_to_steam_cmd(path)
|
||||
case SteamValidationPage():
|
||||
self.config_values["steam_api"] = page.get_api_key()
|
||||
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 = self.page_prefs.get_prefs()
|
||||
self.config_values["name"] = name
|
||||
self.config_values["use_miles"] = use_miles
|
||||
self.config_values["client"] = client
|
||||
self.write_config()
|
||||
self.page_shortcuts.set_steam_path(
|
||||
self.config_values["default_steam_path"]
|
||||
)
|
||||
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()
|
||||
@@ -517,16 +531,19 @@ class Assistant(Gtk.Assistant):
|
||||
return
|
||||
self.set_page_complete(page, True)
|
||||
|
||||
def _add_page(self, page: ScrolledWizardPage, ptype: Gtk.AssistantPageType) -> None:
|
||||
self.append_page(page)
|
||||
def _add_page(
|
||||
self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType
|
||||
) -> int:
|
||||
ind = self.append_page(page)
|
||||
self.set_page_type(page, ptype)
|
||||
self.set_page_title(page, page.get_title())
|
||||
self.set_page_complete(page, True)
|
||||
return ind
|
||||
|
||||
def _set_config_state(self, emitter: "Emitter", state: bool) -> None:
|
||||
self.config = state
|
||||
|
||||
def _on_page_prepare(self: Self, wizard: Self, page: ScrolledWizardPage) -> None:
|
||||
def _on_page_prepare(self: Self, wizard: Self, page: EnumeratedWizardPage) -> None:
|
||||
page_num = self.get_current_page() + 1
|
||||
total = self.get_n_pages()
|
||||
fraction = page_num / total
|
||||
@@ -535,15 +552,108 @@ 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")
|
||||
|
||||
if page == self.page_completion:
|
||||
self.write_config(self.config_values)
|
||||
self.setup_complete = True
|
||||
|
||||
class SteamPathPage(ScrolledWizardPage):
|
||||
|
||||
class CheckboxWithLabel(Gtk.Box):
|
||||
def __init__(self, text: str, blurb_text: str) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5)
|
||||
|
||||
self.indent = 20
|
||||
self.button = Gtk.CheckButton(label=text)
|
||||
self.button.set_active(True)
|
||||
label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=self.indent)
|
||||
|
||||
wrapped = textwrap.fill(blurb_text, width=100)
|
||||
label.set_markup(f"- {wrapped}")
|
||||
|
||||
for el in self.button, label:
|
||||
self.add(el)
|
||||
|
||||
def indent_below(self, widget: Gtk.Widget) -> None:
|
||||
widget.set_margin_start(self.indent)
|
||||
self.add(widget)
|
||||
|
||||
def get_checkbox(self) -> Gtk.CheckButton:
|
||||
return self.button
|
||||
|
||||
def get_active(self) -> bool:
|
||||
return self.button.get_active()
|
||||
|
||||
def set_active(self, state: bool) -> None:
|
||||
self.button.set_active(state)
|
||||
|
||||
|
||||
class ShortcutCreationPage(OptionalPageMixin, EnumeratedWizardPage): # type: ignore
|
||||
def __init__(self, shortcut: Path) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.SHORTCUTS,
|
||||
heading=wizard.heading_shortcuts,
|
||||
description=wizard.blurb_shortcuts,
|
||||
)
|
||||
|
||||
self.steam_path: Path
|
||||
self.shortcut_path = shortcut
|
||||
self.checks_area = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=20
|
||||
)
|
||||
self.page_type = Gtk.AssistantPageType.INTRO
|
||||
|
||||
label, blurb = wizard.checkbox_steam_shortcut
|
||||
self.steam_checkbox = CheckboxWithLabel(label, blurb)
|
||||
|
||||
label, blurb = wizard.checkbox_start_menu
|
||||
self.start_menu_checkbox = CheckboxWithLabel(label, blurb)
|
||||
cb = self.start_menu_checkbox.get_checkbox()
|
||||
cb.connect("toggled", self._on_start_menu_toggled)
|
||||
|
||||
label, blurb = wizard.checkbox_desktop_shortcut
|
||||
self.desktop_checkbox = CheckboxWithLabel(label, blurb)
|
||||
|
||||
for el in (
|
||||
self.steam_checkbox,
|
||||
self.start_menu_checkbox,
|
||||
self.desktop_checkbox,
|
||||
):
|
||||
self.checks_area.add(el)
|
||||
|
||||
self.add_start(self.checks_area)
|
||||
self.show_all()
|
||||
self.connect("map", self._on_map)
|
||||
self.connect("map", self._toggle_checkbox)
|
||||
|
||||
def _toggle_checkbox(self, page: "EnumeratedWizardPage") -> None:
|
||||
if FLATPAK_STEAM_PATH in str(self.steam_path):
|
||||
self.steam_checkbox.set_active(False)
|
||||
self.steam_checkbox.set_sensitive(False)
|
||||
|
||||
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(EnumeratedWizardPage):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
enum=PageNum.USER_PREFS,
|
||||
@@ -598,18 +708,21 @@ class SteamPathPage(ScrolledWizardPage):
|
||||
self.err_box.set_visible(False)
|
||||
|
||||
|
||||
class SetupWizard(Gtk.Application):
|
||||
def __init__(self, is_deck: bool, config: Path) -> None:
|
||||
class SetupWizard(ColorAwareApp, Gtk.Application): # type: ignore
|
||||
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):
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import Any
|
||||
|
||||
from dzgui.const.constants import (
|
||||
GIO_SETTINGS_KEY,
|
||||
GIO_SETTINGS_INTERFACE,
|
||||
GIO_SETTINGS_PROP,
|
||||
GIO_SETTINGS_VAL,
|
||||
)
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "3.0")
|
||||
from gi.repository import Gtk, Gio # noqa
|
||||
|
||||
|
||||
class ColorAwareApp:
|
||||
"""
|
||||
Polls the local theme's color scheme settings.
|
||||
Intended to be consumed by Gtk.Applications (or ephemeral widgets like EarlyAlertDialogs)
|
||||
on initialization, prior to being realized. Applying scheme changes too late in the chain
|
||||
may cause a blinking effect.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.interface_settings = Gio.Settings.new(GIO_SETTINGS_INTERFACE)
|
||||
self.interface_settings.connect(
|
||||
"changed::color-scheme", self._on_color_scheme_changed
|
||||
)
|
||||
self.default_settings = Gtk.Settings.get_default()
|
||||
self._on_color_scheme_changed(self.interface_settings, GIO_SETTINGS_PROP)
|
||||
|
||||
def _on_color_scheme_changed(self, settings: Gio.Settings, prop: str) -> None:
|
||||
state = self.interface_settings.get_string(prop) == GIO_SETTINGS_VAL
|
||||
if self.default_settings is None:
|
||||
return
|
||||
self.default_settings.set_property(GIO_SETTINGS_KEY, state)
|
||||
@@ -104,20 +104,11 @@ class ContextMixin(TreeView):
|
||||
return False
|
||||
menu = self.context_menu
|
||||
children = menu.get_children()
|
||||
sel = menu.get_selected_item()
|
||||
for i, child in enumerate(children):
|
||||
if sel is child:
|
||||
ind = i
|
||||
break
|
||||
|
||||
match event.keyval:
|
||||
case Gdk.KEY_j:
|
||||
if ind + 1 > len(children) - 1:
|
||||
return False
|
||||
menu.emit("move-current", Gtk.MenuDirectionType.NEXT)
|
||||
case Gdk.KEY_k:
|
||||
if ind - 1 < 0:
|
||||
return False
|
||||
menu.emit("move-current", Gtk.MenuDirectionType.PREV)
|
||||
case Gdk.KEY_g:
|
||||
menu.select_item(children[0])
|
||||
|
||||
@@ -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,9 +1,12 @@
|
||||
import logging
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from importlib import resources
|
||||
|
||||
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, CHANGELOG_PATH
|
||||
from dzgui.strings import changelog as clog
|
||||
from dzgui.util.strings import missing_changelog
|
||||
from dzgui.util.format import format_pango
|
||||
from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin
|
||||
@@ -30,17 +33,100 @@ class Changelog(HelpMenuMixin, ScrollableMixin, Gtk.ScrolledWindow): # type: ig
|
||||
logger.critical(e)
|
||||
changelog = missing_changelog
|
||||
|
||||
# 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)
|
||||
|
||||
expand_all = Gtk.Button(
|
||||
label=clog.expand_all, halign=Gtk.Align.START, margin_start=20
|
||||
)
|
||||
expand_all.connect("clicked", self._on_expand_all_clicked)
|
||||
self.box.add(expand_all)
|
||||
|
||||
self.expanded = False
|
||||
self.expanders: list[Gtk.Expander] = []
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
self.connect("key-press-event", self._on_esc_keypress)
|
||||
|
||||
changes = self._parse(changelog)
|
||||
self._generate_nodes(changes)
|
||||
|
||||
self.show_all()
|
||||
|
||||
def _on_expand_all_clicked(self, button: Gtk.Button) -> None:
|
||||
self.expanded = not self.expanded
|
||||
for expander in self.expanders:
|
||||
# NOTE: simply setting set_expanded() does not trigger activate() signal,
|
||||
# so margins are not applied
|
||||
if expander.get_expanded() == self.expanded:
|
||||
continue
|
||||
expander.activate()
|
||||
label = clog.collapse_all if self.expanded else clog.expand_all
|
||||
button.set_label(label)
|
||||
|
||||
def grab_content_area(self) -> None:
|
||||
self.grab_focus()
|
||||
|
||||
def _parse_version(self, version: str) -> tuple[str, str]:
|
||||
pattern = r"(^##\s\[)(.+)(\] )(.+$)"
|
||||
match = re.match(pattern, version)
|
||||
if match:
|
||||
version = match.group(2)
|
||||
date = match.group(4)
|
||||
return version, date
|
||||
raise ValueError("Changelog parse error")
|
||||
|
||||
def _generate_nodes(self, releases: list[tuple[str, list[str]]]) -> None:
|
||||
for header, changes in releases:
|
||||
for ind in (0, -1):
|
||||
if changes[ind] == "":
|
||||
del changes[ind]
|
||||
text = "\n".join(changes)
|
||||
formatted = format_pango(text)
|
||||
|
||||
container = Gtk.Box(valign=Gtk.Align.START, halign=Gtk.Align.START)
|
||||
label = Gtk.Label()
|
||||
label.set_markup(formatted)
|
||||
container.add(label)
|
||||
|
||||
version, date = self._parse_version(header)
|
||||
button_text = f"{version} ({date})"
|
||||
button = Gtk.Button(label=button_text, margin_start=5)
|
||||
|
||||
expander = Gtk.Expander()
|
||||
expander.set_label_widget(button)
|
||||
expander.add(container)
|
||||
expander.connect("activate", self._on_expand, container)
|
||||
self.box.add(expander)
|
||||
self.expanders.append(expander)
|
||||
|
||||
def _on_expand(self, expander: Gtk.Box, container: Gtk.Box) -> None:
|
||||
"""
|
||||
Wayland/some themes do not expect widgets to have a size allocation until expanded.
|
||||
Trying to set margins a priori may cause negative allocation,
|
||||
as the margins are subtracted from zero. In effect, the widget is not realized
|
||||
until it is expanded for the first time.
|
||||
"""
|
||||
container.set_margin_start(15)
|
||||
container.set_margin_top(15)
|
||||
container.set_margin_bottom(15)
|
||||
|
||||
def _parse(self, changelog: str) -> list[tuple[str, list[str]]]:
|
||||
|
||||
release = ""
|
||||
releases: list[tuple[str, list[str]]] = []
|
||||
release_notes: list[str] = []
|
||||
for line in changelog.splitlines():
|
||||
if line.startswith("# "):
|
||||
continue
|
||||
if line.startswith("## "):
|
||||
if release != "":
|
||||
releases.append((release, release_notes))
|
||||
release_notes = []
|
||||
release = ""
|
||||
release = line
|
||||
continue
|
||||
release_notes.append(textwrap.fill(line.rstrip(), width=120))
|
||||
return releases
|
||||
@@ -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()
|
||||
@@ -9,7 +9,6 @@ from dzgui.const.constants import (
|
||||
APPNAME_DAYZ,
|
||||
APPNAME_DAYZ_EXP_HUMAN,
|
||||
EDIT_DELETE,
|
||||
ERROR,
|
||||
FOLDER,
|
||||
WARNING,
|
||||
)
|
||||
@@ -21,6 +20,7 @@ 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.components.misc import ErrorPopover
|
||||
from dzgui.views.trees.tree_mods import OfflineModTreeView
|
||||
|
||||
|
||||
@@ -41,26 +41,27 @@ class FolderError(Enum):
|
||||
FOLDER_CHANGED = 3
|
||||
|
||||
|
||||
class PageHeading(Gtk.Label):
|
||||
def __init__(self, label: str) -> None:
|
||||
super().__init__(label=label, halign=Gtk.Align.CENTER)
|
||||
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)
|
||||
class OfflineErrorPopover(ErrorPopover):
|
||||
def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None:
|
||||
super().__init__(relative_to=relative_to, position=position)
|
||||
|
||||
self.hbox = HBox()
|
||||
self.label = Gtk.Label(label="", margin_start=10, margin_end=10)
|
||||
error_icon = Icon(ERROR, margin_start=10)
|
||||
self.hbox.extend([error_icon, self.label])
|
||||
self.add(self.hbox)
|
||||
self.show_all()
|
||||
self.popdown()
|
||||
|
||||
def set_label(self, error: FolderError, msg: str) -> None:
|
||||
def set_error_label(self, error: FolderError, msg: str) -> None:
|
||||
match error:
|
||||
case FolderError.NO_VALID_MODS:
|
||||
prefix = offline.no_mods
|
||||
@@ -106,13 +107,10 @@ class FolderHBox(HBox):
|
||||
[self.eb, self.button, self.spinner, self.scrolled_label, self.unset_button]
|
||||
)
|
||||
|
||||
self.pop = ErrorPopover()
|
||||
self.pop.set_relative_to(self.button)
|
||||
self.pop = OfflineErrorPopover(self.button, Gtk.PositionType.RIGHT)
|
||||
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 = OfflineErrorPopover(self.scrolled_label, Gtk.PositionType.BOTTOM)
|
||||
self.sidepop.connect("unmap", lambda _: self.grab_focus())
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
@@ -161,13 +159,13 @@ class FolderHBox(HBox):
|
||||
|
||||
def present_error(self, error: FolderError, msg: str) -> None:
|
||||
if error == FolderError.FOLDER_CHANGED:
|
||||
self.sidepop.set_label(error, msg)
|
||||
self.sidepop.set_error_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.set_error_label(error, msg)
|
||||
self.pop.popup()
|
||||
|
||||
|
||||
@@ -434,7 +432,8 @@ class OfflineLoader(Gtk.Box):
|
||||
self.emitter = controller.get_emitter()
|
||||
self.offline_man = OfflineManager(controller)
|
||||
|
||||
self.add(PageHeading(offline.heading))
|
||||
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)
|
||||
@@ -457,7 +456,6 @@ class OfflineLoader(Gtk.Box):
|
||||
]
|
||||
)
|
||||
|
||||
# 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)
|
||||
@@ -511,3 +509,6 @@ class OfflineLoader(Gtk.Box):
|
||||
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()
|
||||
+276
-261
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Callable, TYPE_CHECKING
|
||||
|
||||
from dzgui.api import pefile as PeFile
|
||||
|
||||
@@ -9,22 +9,28 @@ from dzgui.const.constants import (
|
||||
APPID_DAYZ_EXP,
|
||||
APPNAME_DAYZ,
|
||||
APPNAME_DAYZ_EXP_HUMAN,
|
||||
FLATPAK_RUN_CMD,
|
||||
FLATPAK_SANDBOX,
|
||||
FLATPAK_STEAM_PATH,
|
||||
NO_EXPAND,
|
||||
NO_FILL,
|
||||
NO_PADDING,
|
||||
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.strings import options
|
||||
from dzgui.util import strings, css, open_links
|
||||
|
||||
from dzgui.views.components.box import ShortHBox, VBox
|
||||
from dzgui.views.components.eventbox import InfoEventBox
|
||||
from dzgui.views.components.labels import LeftLabel
|
||||
from dzgui.views.components.buttons import WebButton
|
||||
from dzgui.views.components.buttons import SpinnerButton, WebButton
|
||||
from dzgui.views.components.frame import HeadingFrame
|
||||
from dzgui.views.components.misc import ClientCombo
|
||||
from dzgui.views.components.misc import ErrorPopover
|
||||
from dzgui.views.dialogs.generic import ExceptionDialog
|
||||
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
|
||||
|
||||
|
||||
import gi
|
||||
@@ -37,74 +43,271 @@ if TYPE_CHECKING:
|
||||
from dzgui.controllers.emitter import Emitter
|
||||
|
||||
|
||||
class ShortHBox(Gtk.Box):
|
||||
def __init__(self, widget: Gtk.Widget) -> None:
|
||||
class SubmitField(Gtk.Box):
|
||||
def __init__(
|
||||
self,
|
||||
controller: "Controller",
|
||||
placeholder: str,
|
||||
context: Preferences,
|
||||
slow: bool = False,
|
||||
private: bool = True,
|
||||
) -> None:
|
||||
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
|
||||
self.callback: Callable
|
||||
self.controller = controller
|
||||
self.context = context
|
||||
|
||||
self.old_text: str
|
||||
self.placeholder = placeholder
|
||||
|
||||
self.entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True)
|
||||
|
||||
# TODO: audit for all applicable callbacks
|
||||
if private:
|
||||
self.entry.set_icon_from_icon_name(
|
||||
Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL
|
||||
)
|
||||
self.entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
|
||||
self.entry.connect("icon-release", self._on_icon_release)
|
||||
self.entry.set_visibility(False)
|
||||
|
||||
self.button: SpinnerButton | Gtk.Button
|
||||
if slow:
|
||||
self.button = SpinnerButton(label=options.save_button)
|
||||
else:
|
||||
self.button = Gtk.Button(label=options.save_button)
|
||||
|
||||
self.button.connect("clicked", self._on_save_clicked)
|
||||
self.entry.connect("changed", self._on_text_typed)
|
||||
self.entry.connect("activate", self._on_field_activated)
|
||||
self.entry.get_property("buffer").connect("deleted-text", self._on_text_deleted)
|
||||
|
||||
self.add(self.entry)
|
||||
self.add(self.button)
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
self.old_text = text
|
||||
self.entry.set_text(text)
|
||||
if len(text) == 0:
|
||||
self.button.set_sensitive(False)
|
||||
|
||||
def _is_valid_text(self, text: str) -> bool:
|
||||
if text.isspace():
|
||||
return False
|
||||
if len(text) == 0:
|
||||
return False
|
||||
|
||||
if text == self.old_text:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _on_text_deleted(
|
||||
self,
|
||||
buffer: Gtk.EntryBuffer,
|
||||
position: int,
|
||||
chars: int,
|
||||
) -> None:
|
||||
|
||||
text = buffer.get_text()
|
||||
state = self._is_valid_text(text)
|
||||
self.button.set_sensitive(state)
|
||||
|
||||
def _on_text_typed(
|
||||
self,
|
||||
entry: Gtk.Entry,
|
||||
) -> None:
|
||||
|
||||
text = entry.get_text()
|
||||
state = self._is_valid_text(text)
|
||||
self.button.set_sensitive(state)
|
||||
|
||||
def _on_icon_release(
|
||||
self,
|
||||
widget: Gtk.Entry,
|
||||
icon_pos: Gtk.EntryIconPosition,
|
||||
event: Gdk.Event,
|
||||
) -> None:
|
||||
visible = widget.get_visibility()
|
||||
if visible:
|
||||
icon, state = VIEW_REVEAL, False
|
||||
else:
|
||||
icon, state = VIEW_CONCEAL, True
|
||||
widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
|
||||
widget.set_visibility(state)
|
||||
|
||||
def _on_field_activated(self, entry: Gtk.Entry) -> None:
|
||||
text = entry.get_text()
|
||||
if not self._is_valid_text(text):
|
||||
return
|
||||
self.save_option()
|
||||
|
||||
def set_callback(self, callback: Callable) -> None:
|
||||
self.callback = callback
|
||||
|
||||
def save_option(self) -> None:
|
||||
self.callback()
|
||||
self.button.set_sensitive(False)
|
||||
|
||||
def _on_save_clicked(self, button: Gtk.Button) -> None:
|
||||
self.save_option()
|
||||
|
||||
|
||||
class SteamSubmitField(SubmitField):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(
|
||||
controller,
|
||||
options.steam_placeholder,
|
||||
Preferences.STEAM,
|
||||
slow=True,
|
||||
private=True,
|
||||
)
|
||||
|
||||
self.pop = ErrorPopover(
|
||||
position=Gtk.PositionType.BOTTOM, relative_to=self.entry
|
||||
)
|
||||
self.pop.set_label(options.api_failed)
|
||||
self.pop.show_all()
|
||||
self.pop.popdown()
|
||||
|
||||
emitter = controller.get_emitter()
|
||||
emitter.connect("api_change_failed", self._on_api_failure)
|
||||
emitter.connect("api_change_successful", self._on_api_success)
|
||||
|
||||
self.set_callback(self.save_setting)
|
||||
|
||||
def save_setting(self) -> None:
|
||||
self.entry.set_sensitive(False)
|
||||
text = "".join(self.entry.get_text().split())
|
||||
self.controller.update_steam_api_key(text)
|
||||
|
||||
def _on_api_success(self, emitter: "Emitter") -> None:
|
||||
self.old_text = self.entry.get_text()
|
||||
if isinstance(self.button, SpinnerButton):
|
||||
self.button.stop_spinner()
|
||||
self.entry.set_sensitive(True)
|
||||
|
||||
def _on_api_failure(self, emitter: "Emitter") -> None:
|
||||
self.pop.popup()
|
||||
if isinstance(self.button, SpinnerButton):
|
||||
self.button.stop_spinner()
|
||||
self.entry.set_sensitive(True)
|
||||
|
||||
def block_text_entry(self) -> None:
|
||||
self.entry.set_position(-1)
|
||||
self.entry.set_can_focus(False)
|
||||
|
||||
def unblock_text_entry(self) -> None:
|
||||
self.entry.set_can_focus(True)
|
||||
|
||||
|
||||
class NameSubmitField(SubmitField):
|
||||
def __init__(self, controller: "Controller") -> None:
|
||||
super().__init__(
|
||||
controller,
|
||||
options.name_placeholder,
|
||||
Preferences.NAME,
|
||||
private=False,
|
||||
)
|
||||
self.entry.set_width_chars(30) # type: ignore
|
||||
self.set_callback(self.save_player_name)
|
||||
|
||||
def save_player_name(self) -> None:
|
||||
value = self.entry.get_text().strip()
|
||||
self.old_text = value
|
||||
self.controller.update_config(self.context, value)
|
||||
|
||||
|
||||
class ToggleField(Gtk.Box):
|
||||
def __init__(
|
||||
self,
|
||||
controller: "Controller",
|
||||
first_option: str,
|
||||
second_option: str,
|
||||
context: Preferences,
|
||||
) -> None:
|
||||
super().__init__(spacing=5, halign=Gtk.Align.START)
|
||||
|
||||
self.pack_start(widget, NO_EXPAND, NO_FILL, NO_PADDING)
|
||||
self.controller = controller
|
||||
self.context = context
|
||||
self.radio1 = Gtk.RadioButton.new_with_label(None, first_option)
|
||||
self.radio2 = Gtk.RadioButton.new_from_widget(self.radio1)
|
||||
self.radio2.set_label(second_option)
|
||||
self.pack_start(self.radio1, NO_EXPAND, NO_FILL, NO_PADDING)
|
||||
self.pack_start(self.radio2, NO_EXPAND, NO_FILL, NO_PADDING)
|
||||
|
||||
def set_suboption_active(self, state: bool) -> None:
|
||||
# NOTE: defer connection of signal until after state is set
|
||||
self.radio2.set_active(state)
|
||||
self.radio1.connect("toggled", self._on_radio_toggled, self.context)
|
||||
|
||||
def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None:
|
||||
self.controller.toggle_config(context)
|
||||
|
||||
def set_sensitive(self, state: bool) -> None:
|
||||
for el in self.radio1, self.radio2:
|
||||
el.set_sensitive(state)
|
||||
|
||||
|
||||
class Options(Gtk.Box):
|
||||
class FlatpakToggleField(ToggleField):
|
||||
def __init__(
|
||||
self,
|
||||
controller: "Controller",
|
||||
) -> None:
|
||||
super().__init__(
|
||||
controller,
|
||||
first_option=options.flatpak_standard,
|
||||
second_option=options.flatpak_container,
|
||||
context=Preferences.CLIENT,
|
||||
)
|
||||
self.add(InfoEventBox(options.flatpak_info, self.controller))
|
||||
self.controller = controller
|
||||
self.radio1.connect("toggled", self._on_radio_toggled, Preferences.CLIENT)
|
||||
|
||||
def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None:
|
||||
cmd = FLATPAK_RUN_CMD if self.radio1.get_active() else FLATPAK_SANDBOX
|
||||
print(context)
|
||||
self.controller.update_config(context, cmd)
|
||||
|
||||
|
||||
class Options(ScrollableMixin, Gtk.ScrolledWindow): # type: ignore
|
||||
def __init__(self, controller: "Controller"):
|
||||
super().__init__(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
margin_start=10,
|
||||
margin_end=10,
|
||||
)
|
||||
|
||||
self.controller = controller
|
||||
self.controller.register_widget("options", self)
|
||||
emitter = controller.get_emitter()
|
||||
emitter.connect("api_change_failed", self._on_api_change_failed)
|
||||
|
||||
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.pop: Gtk.Popover
|
||||
|
||||
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
|
||||
)
|
||||
self.steam_box = SteamSubmitField(controller)
|
||||
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(
|
||||
strings.options.name_placeholder, Preferences.NAME
|
||||
)
|
||||
self.player_box = NameSubmitField(controller)
|
||||
self.player_box.set_halign(Gtk.Align.START)
|
||||
# TODO: make submit field a standalone class
|
||||
self.player_box.get_children()[0].set_width_chars(30) # type: ignore
|
||||
|
||||
self.fullscreen_toggle = self.make_binary_radio(
|
||||
self.flatpak_toggle = FlatpakToggleField(self.controller)
|
||||
self.fullscreen_toggle = ToggleField(
|
||||
self.controller,
|
||||
strings.options.last_used,
|
||||
strings.options.always_fs,
|
||||
Preferences.WINDOW,
|
||||
)
|
||||
|
||||
self.client_combo = ClientCombo()
|
||||
self.client_combo.connect("changed", self._on_client_changed)
|
||||
|
||||
client_hbox = ShortHBox(self.client_combo)
|
||||
|
||||
self.distance_toggle = self.make_binary_radio(
|
||||
strings.options.km, strings.options.mi, Preferences.DIST
|
||||
self.distance_toggle = ToggleField(
|
||||
self.controller, strings.options.km, strings.options.mi, Preferences.DIST
|
||||
)
|
||||
|
||||
combo_store = Gtk.ListStore(str, object)
|
||||
@@ -126,12 +329,17 @@ class Options(Gtk.Box):
|
||||
self.start_tab_combo.connect("changed", self._on_start_tab_changed)
|
||||
|
||||
pref_rows = [
|
||||
[LeftLabel(strings.options.client), client_hbox],
|
||||
[LeftLabel(strings.options.window_size), self.fullscreen_toggle],
|
||||
[LeftLabel(strings.options.distance), self.distance_toggle],
|
||||
[LeftLabel(options.start_tab), start_tab_hbox],
|
||||
[LeftLabel(strings.options.name), self.player_box],
|
||||
]
|
||||
default = self.controller.query_config(Preferences.DEFAULT)
|
||||
if FLATPAK_STEAM_PATH in default:
|
||||
pref_rows.insert(
|
||||
0,
|
||||
[LeftLabel(options.flatpak_label), self.flatpak_toggle],
|
||||
)
|
||||
|
||||
self.dayz_version_label = Gtk.Label(label=strings.null)
|
||||
self.dayz_exp_version_label = Gtk.Label(label=strings.null)
|
||||
@@ -144,18 +352,6 @@ 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)
|
||||
version_grid = self._make_grid(version_rows)
|
||||
@@ -176,7 +372,7 @@ class Options(Gtk.Box):
|
||||
grid.attach(developers, 1, 0, self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT)
|
||||
|
||||
for pair in [
|
||||
(api_box, strings.options.api_keys),
|
||||
(api_box, strings.options.api_key),
|
||||
(prefs_grid, strings.options.prefs),
|
||||
(version_grid, strings.options.version),
|
||||
]:
|
||||
@@ -185,23 +381,22 @@ class Options(Gtk.Box):
|
||||
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")
|
||||
|
||||
def get_client_name(self) -> str:
|
||||
model = self.client_combo.get_model()
|
||||
ind = self.client_combo.get_active()
|
||||
return str(model[ind][0])
|
||||
box = VBox()
|
||||
box.add(label)
|
||||
box.add(grid)
|
||||
self.add(box)
|
||||
|
||||
self.connect("key-press-event", self._on_keypress)
|
||||
|
||||
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_box.block_text_entry()
|
||||
|
||||
def unblock_text_entry(self) -> None:
|
||||
for entry in self.steam_entry, self.bm_entry:
|
||||
entry.set_can_focus(True)
|
||||
self.steam_box.unblock_text_entry()
|
||||
|
||||
def _on_developers_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.show_developers_page()
|
||||
@@ -209,48 +404,6 @@ class Options(Gtk.Box):
|
||||
def _on_link_button_clicked(self, button: Gtk.Button, url: str) -> None:
|
||||
open_links.open_link_by_url(url)
|
||||
|
||||
def _make_submit_field(
|
||||
self,
|
||||
placeholder: str,
|
||||
context: Preferences,
|
||||
private: bool = False,
|
||||
) -> Gtk.Box:
|
||||
|
||||
entry = Gtk.Entry(placeholder_text=placeholder, hexpand=True)
|
||||
button = Gtk.Button(label="Save")
|
||||
|
||||
button.connect("clicked", self._on_save_clicked, entry, context)
|
||||
entry.connect("insert-text", self._on_text_typed, context, button)
|
||||
entry.connect("activate", self._on_field_activated, context, button)
|
||||
entry.get_property("buffer").connect(
|
||||
"deleted-text", self._on_text_deleted, context, button
|
||||
)
|
||||
|
||||
if private:
|
||||
entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL)
|
||||
entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
|
||||
entry.connect("icon-release", self._on_icon_release)
|
||||
entry.set_visibility(False)
|
||||
|
||||
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)
|
||||
box.add(button)
|
||||
|
||||
return box
|
||||
|
||||
def _on_field_activated(
|
||||
self, entry: Gtk.Entry, context: Preferences, button: Gtk.Button
|
||||
) -> None:
|
||||
text = entry.get_text()
|
||||
if not self._is_valid_text(text, context):
|
||||
return
|
||||
self._on_save_clicked(button, entry, context)
|
||||
|
||||
def _make_grid(self, rows: list) -> Gtk.Grid:
|
||||
grid = Gtk.Grid(
|
||||
orientation=Gtk.Orientation.VERTICAL,
|
||||
@@ -270,37 +423,6 @@ class Options(Gtk.Box):
|
||||
row += 1
|
||||
return grid
|
||||
|
||||
def _on_save_clicked(
|
||||
self, button: Gtk.Button, entry: Gtk.Entry, enum: Preferences
|
||||
) -> None:
|
||||
old_text = self.controller.query_config(enum)
|
||||
self.old_text = old_text
|
||||
self.old_entry = entry
|
||||
|
||||
button.set_sensitive(False)
|
||||
match enum:
|
||||
case Preferences.NAME:
|
||||
value = entry.get_text().strip()
|
||||
self.controller.update_config(enum, value)
|
||||
case Preferences.BM | 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)
|
||||
dialog = ExceptionDialog(self.controller, errors.api_validation_error)
|
||||
dialog.run()
|
||||
|
||||
def restore_api_text(self, text: str, entry: Gtk.Entry) -> None:
|
||||
entry.set_text(text)
|
||||
|
||||
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()
|
||||
if _iter is None:
|
||||
@@ -309,88 +431,12 @@ class Options(Gtk.Box):
|
||||
index = enum.value
|
||||
self.controller.update_config(Preferences.START_TAB, index)
|
||||
|
||||
def _on_client_changed(self, combo: Gtk.ComboBoxText) -> None:
|
||||
_iter = combo.get_active_iter()
|
||||
if _iter is None:
|
||||
raise ValueError(f"No active iterator set on {combo}")
|
||||
real_cmd = combo.get_model()[_iter][1]
|
||||
self.controller.update_config(Preferences.CLIENT, real_cmd)
|
||||
|
||||
def _on_radio_toggled(self, button: Gtk.RadioButton, context: Preferences) -> None:
|
||||
try:
|
||||
self.controller.toggle_config(context)
|
||||
except Exception:
|
||||
button.handler_block_by_func(self._on_radio_toggled)
|
||||
self.populate_settings()
|
||||
button.handler_unblock_by_func(self._on_radio_toggled)
|
||||
|
||||
def _is_valid_text(self, text: str, context: Preferences) -> bool:
|
||||
if text.isspace():
|
||||
return False
|
||||
if len(text) == 0:
|
||||
return False
|
||||
|
||||
match context:
|
||||
case Preferences.NAME:
|
||||
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
|
||||
|
||||
def _on_text_deleted(
|
||||
self,
|
||||
buffer: Gtk.EntryBuffer,
|
||||
position: int,
|
||||
chars: int,
|
||||
context: Preferences,
|
||||
button: Gtk.Button,
|
||||
) -> None:
|
||||
|
||||
text = buffer.get_text()
|
||||
state = self._is_valid_text(text, context)
|
||||
button.set_sensitive(state)
|
||||
|
||||
def _on_text_typed(
|
||||
self,
|
||||
entry: Gtk.Entry,
|
||||
text: str,
|
||||
length: int,
|
||||
pos: int,
|
||||
context: Preferences,
|
||||
button: Gtk.Button,
|
||||
) -> None:
|
||||
|
||||
buffer = entry.get_property("buffer")
|
||||
text = buffer.get_text() + text
|
||||
state = self._is_valid_text(text, context)
|
||||
button.set_sensitive(state)
|
||||
|
||||
def make_binary_radio(
|
||||
self,
|
||||
first_option: str,
|
||||
second_option: str,
|
||||
context: Preferences,
|
||||
) -> Gtk.Box:
|
||||
|
||||
hbox = Gtk.Box(spacing=5, halign=Gtk.Align.START)
|
||||
radio1 = Gtk.RadioButton.new_with_label(None, first_option)
|
||||
radio2 = Gtk.RadioButton.new_from_widget(radio1)
|
||||
radio2.set_label(second_option)
|
||||
radio1.connect("toggled", self._on_radio_toggled, context)
|
||||
hbox.pack_start(radio1, NO_EXPAND, NO_FILL, NO_PADDING)
|
||||
hbox.pack_start(radio2, NO_EXPAND, NO_FILL, NO_PADDING)
|
||||
|
||||
return hbox
|
||||
|
||||
def populate_settings(self) -> None:
|
||||
prefs = self.controller.get_prefs()
|
||||
# NOTE: re-check in case file was removed by user between runs
|
||||
if prefs.paths.config.is_file() is False:
|
||||
dialog = ExceptionDialog(self.controller, strings.config_not_found)
|
||||
dialog.show_all()
|
||||
dialog.run()
|
||||
raise OSError(f"Config file '{prefs.paths.config}' not found")
|
||||
|
||||
@@ -400,37 +446,24 @@ 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)
|
||||
|
||||
self.old_steam = steam
|
||||
self.old_bm = bm
|
||||
self.old_name = name
|
||||
self.steam_box.set_text(steam)
|
||||
self.player_box.set_text(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)
|
||||
fs = config["fullscreen"]
|
||||
miles = config["use_miles"]
|
||||
|
||||
# NOTE: suppress toggle signal until radios are built
|
||||
self._suppress_toggles(True)
|
||||
for el, conf_state in [
|
||||
(self.fullscreen_toggle, config["fullscreen"]),
|
||||
(self.distance_toggle, config["use_miles"]),
|
||||
]:
|
||||
el.get_children()[conf_state].set_active(True)
|
||||
self._suppress_toggles(False)
|
||||
if prefs.is_steam_deck:
|
||||
self.fullscreen_toggle.set_sensitive(False)
|
||||
eb = InfoEventBox(options.fullscreen_eventbox, self.controller)
|
||||
self.fullscreen_toggle.add(eb)
|
||||
|
||||
# NOTE: disable buttons if no text is set
|
||||
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)
|
||||
fs = True
|
||||
|
||||
self.fullscreen_toggle.set_suboption_active(fs)
|
||||
self.distance_toggle.set_suboption_active(miles)
|
||||
|
||||
dayz_version = PeFile.get_pretty_version(steam_path, APPID_DAYZ)
|
||||
if dayz_version is None:
|
||||
@@ -443,10 +476,6 @@ class Options(Gtk.Box):
|
||||
self.dayz_version_label.set_text(dayz_version)
|
||||
self.dayz_exp_version_label.set_text(dayz_exp_version)
|
||||
|
||||
# TODO: bicolumn list store with no cell renderer on index 1, use raw command names
|
||||
active_combo = query.get_client_index(config["client"])
|
||||
self.client_combo.set_active(active_combo)
|
||||
|
||||
start_tab = self.controller.query_config(Preferences.START_TAB)
|
||||
self.start_tab_combo.set_active(start_tab)
|
||||
|
||||
@@ -459,19 +488,5 @@ class Options(Gtk.Box):
|
||||
self, toggle.get_children()[0], "_on_radio_toggled", state
|
||||
)
|
||||
|
||||
def _on_icon_release(
|
||||
self,
|
||||
widget: Gtk.Entry,
|
||||
icon_pos: Gtk.EntryIconPosition,
|
||||
event: Gdk.Event,
|
||||
) -> None:
|
||||
visible = widget.get_visibility()
|
||||
if visible:
|
||||
icon, state = VIEW_REVEAL, False
|
||||
else:
|
||||
icon, state = VIEW_CONCEAL, True
|
||||
widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
|
||||
widget.set_visibility(state)
|
||||
|
||||
def grab_content_area(self) -> None:
|
||||
return
|
||||
self.grab_focus()
|
||||
@@ -11,6 +11,7 @@ from dzgui.util.keys import is_ctrl_mask
|
||||
from dzgui.util.localize import number
|
||||
from dzgui.strings.server_mods import checkmark
|
||||
from dzgui.strings import preconnect
|
||||
from dzgui.views.dialogs.generic import DebugDialog
|
||||
from dzgui.views.components.frame import HeadingFrame
|
||||
from dzgui.views.trees.tree_server_mods import ServerModTreeView
|
||||
|
||||
@@ -120,6 +121,12 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
spacing=5,
|
||||
)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
||||
|
||||
# NOTE: -d flag
|
||||
if self.controller.get_prefs().is_debug:
|
||||
debug = Gtk.Button(label=preconnect.debug, halign=Gtk.Align.START)
|
||||
debug.connect("clicked", self._on_debug_clicked)
|
||||
box.add(debug)
|
||||
for button in self.back, self.ok, self.connect_last:
|
||||
box.add(button)
|
||||
self.button_box.add(box)
|
||||
@@ -150,8 +157,7 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
self.tree_box.add(self.scrolled)
|
||||
self.tree_box.add(self.progress_box)
|
||||
|
||||
# TODO: strings
|
||||
self.mods_placeholder = Placeholder("This server has no mods.")
|
||||
self.mods_placeholder = Placeholder(preconnect.placeholder_no_mods)
|
||||
self.tree_box.add(self.mods_placeholder)
|
||||
|
||||
self.tree_frame = HeadingFrame.new_with_widget_and_label(
|
||||
@@ -161,8 +167,7 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
# TODO: abstract into components
|
||||
self.warning_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.warning_tree = MaskedTree(WARNING)
|
||||
# TODO: strings
|
||||
self.warning_placeholder = Placeholder("No warnings.")
|
||||
self.warning_placeholder = Placeholder(preconnect.placeholder_no_warnings)
|
||||
self.warning_box.add(self.warning_tree)
|
||||
self.warning_box.add(self.warning_placeholder)
|
||||
self.warning_frame = HeadingFrame.new_with_widget_and_label(
|
||||
@@ -171,8 +176,7 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
|
||||
self.error_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.error_tree = MaskedTree(ERROR)
|
||||
# TODO: strings
|
||||
self.error_placeholder = Placeholder("No errors.")
|
||||
self.error_placeholder = Placeholder(preconnect.no_errors)
|
||||
self.error_box.add(self.error_tree)
|
||||
self.error_box.add(self.error_placeholder)
|
||||
self.error_frame = HeadingFrame.new_with_widget_and_label(
|
||||
@@ -223,6 +227,12 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
def _on_connect_last_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.update_and_load_to_menu()
|
||||
|
||||
def _on_debug_clicked(self, button: Gtk.Button) -> None:
|
||||
args = self.controller.get_debug_args()
|
||||
d = DebugDialog(self.controller, args)
|
||||
d.show_all()
|
||||
d.run()
|
||||
|
||||
def _on_ok_clicked(self, button: Gtk.Button) -> None:
|
||||
self.controller.update_and_connect()
|
||||
|
||||
@@ -233,44 +243,49 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
warnings: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
resync_msg = (
|
||||
f"If you recently installed {prereqs.build} or moved it to a different drive,\n"
|
||||
"restart Steam to allow these changes to synchronize, then try again."
|
||||
)
|
||||
resync_msg = preconnect.resync.format(prereqs.build)
|
||||
|
||||
"""Errors"""
|
||||
if len(prereqs.invalid_mods) > 0:
|
||||
pairs = [": ".join(sub) for sub in prereqs.invalid_mods]
|
||||
lines = "\n".join(pairs)
|
||||
msg = preconnect.invalid_mods.format(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}"
|
||||
)
|
||||
msg = preconnect.version_missing.format(prereqs.build)
|
||||
msg += f"\n{resync_msg}"
|
||||
errors.append(msg)
|
||||
elif prereqs.local_version != prereqs.remote_version:
|
||||
errors.append(
|
||||
f"Local client version '{prereqs.local_version}' does not match remote version '{prereqs.remote_version}'.\n{resync_msg}"
|
||||
msg = preconnect.version_mismatch.format(
|
||||
prereqs.local_version, prereqs.remote_version
|
||||
)
|
||||
msg += f"\n{resync_msg}"
|
||||
errors.append(msg)
|
||||
if prereqs.required_space > prereqs.available_space:
|
||||
required_pretty = number(prereqs.required_space)
|
||||
available_pretty = number(prereqs.available_space)
|
||||
errors.append(
|
||||
f"Need to update {required_pretty} MiB of mods, but installation path only has {available_pretty} MiB."
|
||||
)
|
||||
msg = preconnect.not_enough_space.format(required_pretty, available_pretty)
|
||||
errors.append(msg)
|
||||
if len(prereqs.mods) > 0 and prereqs.game_mode:
|
||||
errors.append("Use Desktop Mode to download mods on Steam Deck")
|
||||
errors.append(preconnect.use_desktop_mode)
|
||||
|
||||
if prereqs.steam_proc.is_running is False:
|
||||
client = prereqs.steam_proc.name
|
||||
errors.append(
|
||||
f"'{client}' is set as the default Steam client, but it is either not installed or not running."
|
||||
)
|
||||
msg = preconnect.steam_not_running.format(client)
|
||||
errors.append(msg)
|
||||
|
||||
"""Warnings"""
|
||||
if prereqs.passworded:
|
||||
warnings.append(
|
||||
"Protected: you will be prompted for a password when connecting to this server."
|
||||
)
|
||||
warnings.append(preconnect.protected_server)
|
||||
if prereqs.dayz_running is True:
|
||||
warnings.append(
|
||||
"It looks like DayZ is already running in the background. Exit DayZ before connecting."
|
||||
)
|
||||
warnings.append(preconnect.dayz_running)
|
||||
if len(prereqs.mods) > 0 and prereqs.download_paused:
|
||||
warnings.append(preconnect.downloads_paused)
|
||||
|
||||
allows_dl, running_app = prereqs.allows_downloads
|
||||
if len(prereqs.mods) > 0 and allows_dl is False:
|
||||
msg = preconnect.running_app.format(running_app)
|
||||
warnings.append(msg)
|
||||
|
||||
self.add_warnings(warnings)
|
||||
self.add_errors(errors)
|
||||
@@ -308,7 +323,7 @@ class PreConnectionAssistant(Gtk.Box):
|
||||
|
||||
if prereqs.required_space != 0:
|
||||
pretty = number(prereqs.required_space)
|
||||
suffix = f" Need to download {pretty} MiB of mod updates."
|
||||
suffix = preconnect.required_space.format(pretty)
|
||||
prefix = preconnect.total_mods
|
||||
self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}")
|
||||
else:
|
||||
|
||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user