Compare commits

...
Author SHA1 Message Date
aclist 7ae5880890 chore: update union
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-11-11 03:36:14 +09:00
aclist afbced228d Merge branch 'dzgui7' into change/enum-dataclasses 2026-11-11 03:33:44 +09:00
aclist f5d7bf713d Merge pull request #538 from aclist/fix/clipboard-button-controller
fix: clipboard button controller
2026-11-11 03:32:27 +09:00
aclist c1630adee1 chore: update return value 2026-11-11 03:31:17 +09:00
aclist 88f6b84bea fix: restore dropped typehint 2026-11-11 03:29:23 +09:00
aclist 134dcb6f0b fix: unused param blocking boot 2026-11-11 03:27:12 +09:00
aclist cc0354c881 chore: drop unused imports 2026-11-11 03:21:31 +09:00
aclist f2d21be347 Merge pull request #534 from aclist/chore/strict-typehinting
chore: clear strict typehinting errors
2026-11-11 03:19:29 +09:00
aclist f65fa153cc Merge branch 'chore/strict-typehinting' of github.com:aclist/dzgui into chore/strict-typehinting
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-11-11 03:18:50 +09:00
aclist 8aa7328d8f chore: drop unused import 2026-11-11 03:18:40 +09:00
aclist bb04854cd7 chore: add keybinding to strings 2026-11-11 03:17:29 +09:00
aclist 5014b2a467 Merge branch 'dzgui7' into chore/strict-typehinting 2026-11-11 03:15:26 +09:00
aclist d020295aa3 Merge pull request #537 from aclist/fix/cannot-acquire-lock
fix: cannot acquire lock
2026-11-11 03:12:31 +09:00
aclist 1879347831 chore: add typehints 2026-11-11 03:09:39 +09:00
aclist 5a1c098af7 fix: acquire lock prior to calling build_gui() 2026-11-11 03:09:14 +09:00
aclist 3d346dfb07 Merge pull request #531 from aclist/chore/update-strings-pass-1
chore: update strings pass 1
2026-11-11 03:06:43 +09:00
aclist cb03db6032 Merge branch 'dzgui7' into chore/strict-typehinting 2026-11-11 02:47:40 +09:00
aclist c69022f74c Merge pull request #532 from aclist/drop/no-e402
chore: drop unused Gtk imports
2026-11-11 02:44:40 +09:00
aclist 7f0bccad64 Merge pull request #528 from aclist/drop/abstract-constructor-params
drop: abstracted constructor params
2026-11-11 02:43:30 +09:00
aclist d6b5c7bbb4 chore: drop unused imports
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-11-11 01:45:00 +09:00
aclist f0a07cbbba Merge branch 'dzgui7' into drop/abstract-constructor-params 2026-11-11 01:41:58 +09:00
aclist 30fa762780 fix: update bool 2026-11-11 01:35:12 +09:00
aclist 807a530550 Merge pull request #529 from aclist/change/normalize-icon-names
change: normalize icon names
2026-11-11 01:31:08 +09:00
aclist f092cfdf4a Merge pull request #530 from aclist/change/normalize-endpoint-names
change: normalize endpoint names
2026-11-11 01:22:59 +09:00
aclist d5c597ab03 Merge pull request #527 from aclist/docs/history-file-2
docs: add history file
2026-11-11 01:15:33 +09:00
aclist 06c85fdfd8 chore: add remarks about release structure
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-11-11 01:12:51 +09:00
aclist 0c4026daf8 chore: clear typehinting errors 2026-11-10 10:02:41 +09:00
aclist e03c6905c6 chore: clear typehinting errors 2026-11-10 06:30:12 +09:00
aclist 8fb3462f35 chore: add placeholder for row struct 2026-11-10 05:58:22 +09:00
aclist bfe9b9d147 chore: clear typehinting errors 2026-11-10 05:58:02 +09:00
aclist 9f36569fd4 drop: LoggerAlertsButton 2026-11-10 05:10:56 +09:00
aclist da18b41136 chore: drop Soup2 stubs 2026-11-10 05:09:02 +09:00
aclist f092207c61 chore: clear strict typehinting errors 2026-11-10 04:37:51 +09:00
aclist c94e4e73db chore: add strings file 2026-11-10 04:34:38 +09:00
aclist 8da264686a chore: clear typehinting errors 2026-11-10 04:33:54 +09:00
aclist 598973104c change: reference dataclass values in enum 2026-11-10 04:30:40 +09:00
aclist 15d4b71350 Merge pull request #533 from aclist/docs/more-attribution
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
chore: add remarks to README
2026-11-10 03:08:12 +09:00
aclist 16d3df618a chore: add remarks to README 2026-11-10 03:07:30 +09:00
aclist 4e5fadf928 chore: drop old comments 2026-11-10 03:04:44 +09:00
aclist 3dc37561be chore: update pyproject.toml 2026-11-10 03:02:35 +09:00
aclist 13786591b3 chore: restore exclusion for GLibUnix 2026-11-10 03:02:06 +09:00
aclist 9eb1342877 drop: unused imports script 2026-11-10 03:00:35 +09:00
aclist 3f29484895 chore: drop unused Gtk imports 2026-11-10 02:59:22 +09:00
aclist 22dd8fc67f Merge pull request #525 from aclist/change/get-git-root
change: get git root
2026-11-10 02:44:51 +09:00
aclist fa2f86b03e chore: abstract string literals 2026-11-10 02:41:24 +09:00
aclist c8fe6ec056 chore: update strings files 2026-11-10 02:37:22 +09:00
aclist 6901e5456e drop: unused dataclass 2026-11-10 02:36:18 +09:00
aclist 49298d9943 change: normalize endpoint names 2026-11-09 23:51:19 +09:00
aclist 72e3d95808 change: normalize icon names 2026-11-09 23:48:10 +09:00
aclist a42c9d4389 drop: abstracted constructor params 2026-11-09 23:28:50 +09:00
aclist cc708c64b2 docs: add history file 2026-11-09 23:19:32 +09:00
aclist 03fa707305 chore: remove unused imports 2026-11-09 11:04:56 +09:00
aclist 2479cbd41b change: parse repository root 2026-11-09 11:03:50 +09:00
aclist 370edbe01b Merge pull request #522 from aclist/prerelease/7.0.0b30
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
Prerelease/7.0.0b30
2026-11-06 23:39:31 +09:00
aclist 0c41654645 feat: add screenshot dumper
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-11-06 19:18:35 +09:00
aclist 903f8fc31d chore: update example.png 2026-11-05 22:19:19 +09:00
aclist 6b583f68d6 chore: bump version 2026-11-05 22:04:05 +09:00
aclist 3b3ccd0de6 chore: update changelog 2026-11-05 22:03:31 +09:00
aclist 70aacaa866 chore: update debounce comments 2026-11-05 22:03:03 +09:00
aclist 7582ab4d3b Merge pull request #513 from aclist/feat/sqlite
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
feat: sqlite
2026-11-05 21:56:27 +09:00
aclist 225a2a148e Merge pull request #511 from aclist/feat/validate-mod
feat: validate mod
2026-11-05 21:38:44 +09:00
aclist 9117655ebd Merge pull request #518 from aclist/fix/catch-record-timeout
fix: handle query timeout on add/remove actions
2026-11-05 21:30:29 +09:00
aclist fdf92dca40 Merge pull request #521 from aclist/feat/last-updated
feat: last updated (patch)
2026-11-05 21:27:57 +09:00
aclist a97766855d chore: dclear typehinting errors
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-11-05 21:27:07 +09:00
aclist 3097701e98 change: drop explicit import 2026-11-05 21:26:37 +09:00
aclist 895e08d228 fix: error handling 2026-11-05 21:26:20 +09:00
aclist 08a6f698a5 Merge pull request #514 from aclist/feat/last-updated
feat: last updated timestamp
2026-11-05 21:21:41 +09:00
aclist 78be8c93b7 Merge pull request #516 from aclist/chore/test-list-store
chore: add tests
2026-11-05 21:17:05 +09:00
aclist 8a2889d15d Merge pull request #515 from aclist/fix/insensitive-filters
fix: prevent toggling when panel is insensitive
2026-11-05 21:16:19 +09:00
aclist 1a33f71716 Merge pull request #520 from aclist/feat/unmanned-screenshot
feat: unmanned screenshot
2026-11-05 21:15:02 +09:00
aclist ae13dc9a89 chore: add newline 2026-11-05 21:14:27 +09:00
aclist d5706f969c change: use base class type 2026-11-05 21:09:42 +09:00
aclist 790e34b20c chore: clear typehinting errors 2026-11-05 21:09:10 +09:00
aclist 35230ae03f feat: capture composited screenshot 2026-11-05 21:08:52 +09:00
aclist 596d3a59ac fix: handle query timeout on add/remove actions 2026-11-04 17:50:45 +09:00
aclist e7cef215c6 drop: resolved comment
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-11-02 12:19:10 +09:00
aclist 12daf041ff chore: add tests 2026-11-02 12:17:50 +09:00
aclist 90ef6cbdbb chore: add tests 2026-11-02 12:17:04 +09:00
aclist 2cc26b6573 fix: prevent toggling when panel is insensitive 2026-11-01 23:47:06 +09:00
aclist 0ec1c157f4 chore: clear typehinting errors 2026-11-01 23:45:01 +09:00
aclist 0cf6439538 chore: remove unused imports 2026-11-01 23:43:12 +09:00
aclist e3c1ef59ee feat: get last updated timestamp 2026-11-01 23:42:06 +09:00
aclist 78144e9127 chore: update tests 2026-10-31 00:26:51 +09:00
aclist 33b8dfba70 change: simplify logic 2026-10-31 00:26:27 +09:00
aclist abf0060cac feat: import ipaddress 2026-10-31 00:26:08 +09:00
aclist 95daa9163b change: update paths 2026-10-31 00:25:32 +09:00
aclist ff068e5396 feat: marshal geo DB 2026-10-31 00:23:27 +09:00
aclist 80680b86b0 chore: update test decorator 2026-10-28 15:46:00 +09:00
aclist a4ac357c8f feat: validate atomic mods 2026-10-28 15:44:30 +09:00
aclist 728b8b8242 Merge pull request #509 from aclist/fix/marks-on-fixtures
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: do not apply marks to fixtures
2026-10-26 20:07:47 +09:00
aclist 3b51392d39 fix: do not apply marks to fixtures 2026-10-26 20:06:56 +09:00
aclist 159e2da355 Merge pull request #508 from aclist/prerelease/7.0.0b29-patch-4
chore: update test
2026-10-26 19:42:25 +09:00
aclist b83a333cea chore: update test 2026-10-26 19:42:03 +09:00
aclist 46117ecdda Merge pull request #507 from aclist/prerelease/7.0.0b29-patch-3
fix: add path out of loop
2026-10-26 19:25:32 +09:00
aclist 30119fec46 fix: add path out of loop 2026-10-26 19:24:42 +09:00
aclist ac01818549 Merge pull request #506 from aclist/prerelease/7.0.0b29-patch-2
fix: add /tmp to wizard
2026-10-26 19:21:40 +09:00
aclist d486712a4c fix: add /tmp to wizard 2026-10-26 19:20:17 +09:00
aclist d116702145 Merge pull request #505 from aclist/prerelease/7.0.0b29-patch-1
Prerelease/7.0.0b29 patch 1
2026-10-26 19:11:15 +09:00
aclist 5bee3614bd fix: whitelist /tmp 2026-10-26 19:09:50 +09:00
aclist c73b07991f change: abstract lock file path 2026-10-26 19:09:32 +09:00
aclist 199cd07982 Merge pull request #504 from aclist/prerelease/7.0.0b29
chore: bump version
2026-10-26 18:05:52 +09:00
aclist e3d62c6749 chore: bump version 2026-10-26 17:59:18 +09:00
aclist 5134bf02fb Merge pull request #503 from aclist/fix/flatpak-fixes
fix: flatpak fixes
2026-10-26 17:22:58 +09:00
aclist f97be9352d fix: typo 2026-10-26 17:21:10 +09:00
aclist b092a809f4 fix: capitalization 2026-10-26 17:20:28 +09:00
aclist 2cc7710807 fix: handle sandboxed mode 2026-10-26 17:20:14 +09:00
aclist 5fb10d9758 change: suppress missing shortcuts message 2026-10-26 17:19:56 +09:00
aclist 100cb43afc Merge pull request #502 from aclist/fix/check-granular-client
fix: check granular client
2026-10-26 16:15:47 +09:00
aclist 7e2a585812 chore: drop unused imports 2026-10-26 16:14:57 +09:00
aclist c1181435b3 chore: formatting 2026-10-26 16:13:56 +09:00
aclist 22a7814ce4 fix: check actual client command 2026-10-26 16:13:42 +09:00
aclist b030da5c65 Merge pull request #498 from aclist/fix/refresh-on-timeout
fix: refresh on timeout
2026-10-26 15:56:51 +09:00
aclist 3c0acdd068 Merge pull request #501 from aclist/fix/symbolic-svg
fix: symbolic svg
2026-10-26 15:56:16 +09:00
aclist 25ae2052e4 Merge branch 'dzgui7' into fix/symbolic-svg
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-10-26 15:55:37 +09:00
aclist 1b1777117f Merge pull request #497 from aclist/feat/dynamic-flatpak-toggle
feat: dynamic flatpak toggle
2026-10-26 15:51:18 +09:00
aclist 3e6bd95285 change: require explicit Steam launch command
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-10-26 15:47:45 +09:00
aclist f4be9a31e9 change: check actual client preference
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-10-25 15:56:06 +09:00
aclist a6282e4401 chore: clean up test blockage
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-10-24 10:58:21 +09:00
aclist a9ae5f59b5 chore: add flatpak tests 2026-10-24 09:21:50 +09:00
aclist cdec81071d feat: show notice on override
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-10-23 15:39:56 +09:00
aclist 97b5c0e6ee fix: split arguments 2026-10-23 15:19:37 +09:00
aclist 67c19fb90d feat: add SimpleTree 2026-10-23 15:11:05 +09:00
aclist d9d3f747bb feat: add override module 2026-10-23 15:10:24 +09:00
aclist 1440871819 chore: update strings 2026-10-23 14:14:44 +09:00
aclist 761988d8c2 feat: add override module 2026-10-23 14:14:30 +09:00
aclist 97ee2b7307 chore: update attribution 2026-10-23 13:07:38 +09:00
aclist ff6edb6181 fix: light mode symbolic icon 2026-10-23 13:05:20 +09:00
aclist 4c22263fb5 Merge branch 'dzgui7' of github.com:aclist/dztui into dzgui7 2026-10-23 13:04:09 +09:00
aclist ab5637ae3b Merge branch 'dzgui7' into feat/dynamic-flatpak-toggle 2026-10-23 12:35:53 +09:00
aclist 682532a867 chore: remove erroneous debug string 2026-10-23 12:34:04 +09:00
aclist d577ba0970 change: remove Flatpak option if binary is not installed 2026-10-23 12:20:10 +09:00
aclist c2993ad289 change: revert prior VDF change, no observable effect on D-Bus 2026-10-23 12:11:19 +09:00
aclist edb87893f9 chore: clear typehinting errors 2026-10-23 11:46:05 +09:00
aclist 519e6c354d chore: patch VDF changes from main 2026-10-23 11:42:08 +09:00
aclist e3d1d62b4d Merge pull request #499 from aclist/fix/vdf-missing
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: missing shortcuts file
2026-10-23 11:38:48 +09:00
aclist 1dcfdda657 change: allow D-Bus registration when sandboxed 2026-10-23 11:36:13 +09:00
aclist 019847dcf9 fix: handle missing grid directory 2026-10-23 11:35:13 +09:00
aclist c2cfe2b4d9 fix: handle missing shortcut file 2026-10-23 11:34:52 +09:00
aclist 01b3babdf5 feat: FlatpakOverridePage WIP 2026-10-23 11:23:28 +09:00
aclist 141f96fb9f fix: allow using refresh button after timeout events (#484) 2026-10-22 15:59:05 +09:00
aclist e286f02342 chore: add flatpak toggle test 2026-10-22 12:36:23 +09:00
aclist 9f3c39fdd6 chore: drop unused print statement 2026-10-22 12:35:56 +09:00
aclist 0359aacd54 chore: clear typehinting errors 2026-10-22 12:09:40 +09:00
aclist 80e127889e chore: drop unused import 2026-10-22 12:09:07 +09:00
aclist da98b1d5b1 feat: toggle flatpak client dynamically 2026-10-22 12:08:44 +09:00
aclist 33869158de change: explicitly disable NSG creation on Flatpak 2026-10-22 12:07:59 +09:00
aclist 5cb8c68f5c Merge pull request #496 from aclist/fix/always-show-icon
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: always show icon
2026-10-22 10:16:44 +09:00
aclist 2f92c2a44f chore: clear typehinting errors 2026-10-22 10:12:38 +09:00
aclist da48834fa7 feat: load Steam icon into search paths 2026-10-22 10:11:55 +09:00
aclist 197c2eba06 fix: always show icon 2026-10-22 10:11:13 +09:00
aclist 0131ee6f0e Merge pull request #486 from aclist/change/flatpak-sens
change: set flatpak option sensitivity dynamically
2026-10-22 08:20:09 +09:00
aclist b70edebd05 Merge branch 'dzgui7' into change/flatpak-sens
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
2026-10-21 16:44:56 +09:00
aclist ae1c3a19d4 chore: update tests 2026-10-21 16:35:01 +09:00
aclist 7822672cda chore: clear typehinting errors 2026-10-21 16:33:17 +09:00
aclist f2a7bde432 fix: collision with steam-runtime-launcher-service 2026-10-21 16:32:38 +09:00
aclist 00b5476aaf change: treat steam clients as separate processes 2026-10-21 14:51:56 +09:00
aclist 2a08b9ba82 chore: clear typehinting errors 2026-10-21 13:04:12 +09:00
aclist 0f8b6fcaad change: drop client combo from wizard 2026-10-21 13:03:30 +09:00
aclist 95988459d5 chore: drop unused import 2026-10-21 13:00:06 +09:00
aclist eed40e6f1a change: return client name from controller 2026-10-21 12:59:54 +09:00
aclist 07460a1f8f chore: clear typehinting errors 2026-10-21 12:44:41 +09:00
aclist a74d576387 change: interpolate client name from full command 2026-10-21 12:43:20 +09:00
aclist 052f7f0708 chore: remove client combo from options page 2026-10-21 12:42:50 +09:00
aclist 6feae44887 chore: add import 2026-10-21 12:41:10 +09:00
aclist 8cdb615796 chore: drop unused imports 2026-10-21 12:40:56 +09:00
aclist 31b6705017 change: parse client from path 2026-10-21 12:40:03 +09:00
aclist ef1e7d17f5 chore: drop unused functions 2026-10-21 12:39:23 +09:00
aclist 3c52072eb5 Merge pull request #493 from aclist/feat/paused-downloads
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
feat: paused downloads
2026-10-21 10:26:51 +09:00
aclist bf69936891 Merge pull request #495 from aclist/fix/vdf-timestamp
fix: support changes to VDF timestamp
2026-10-21 10:22:35 +09:00
aclist fa180b750a Merge pull request #494 from aclist/change/unmodded-filter
change: separate modded into unmodded
2026-10-21 10:20:46 +09:00
aclist 96d50ffafa chore: resolve pytest errors 2026-10-18 07:44:31 +09:00
aclist 6c801ffec5 chore: update tests 2026-10-18 07:44:14 +09:00
aclist 5eb70a1eb8 fix: support changes to VDF format 2026-10-18 07:35:17 +09:00
aclist a3fff40f8f change: separate modded into unmodded 2026-10-18 01:54:59 +09:00
aclist 343630d2da chore: update placeholder appid 2026-10-18 01:52:43 +09:00
aclist d63c7ce3ec chore: add test 2026-10-18 01:50:10 +09:00
aclist f7ad357e6f feat: print paused download warning on preconnect 2026-10-18 01:49:14 +09:00
aclist 035a01fe34 feat: check for pending patches 2026-10-18 01:48:42 +09:00
aclist 7dd14804aa Merge pull request #491 from aclist/fix/dword-parse
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: dword parse
2026-10-16 00:09:26 +09:00
aclist 1cc1ba2864 chore: bump version 2026-10-16 00:06:54 +09:00
aclist 8c4d0aecdc fix: parse alternate VS_FIXEDFILEINFO structure 2026-10-16 00:06:37 +09:00
aclist 616f18452a Merge pull request #489 from aclist/fix/badlands
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: badlands
2026-10-15 19:26:43 +09:00
aclist 1064f7a9d6 chore: clear typehinting errors 2026-10-15 19:23:42 +09:00
aclist b0e85f0e35 fix: update Badlands flag 2026-10-15 19:22:44 +09:00
aclist 4eec00e8e7 chore: trigger checks 2026-10-15 00:36:09 +09:00
aclist 09ef3e446a change: set flatpak option sensitivity dynamically 2026-10-15 00:35:02 +09:00
aclist 4d02604bcf Merge pull request #485 from aclist/fix/gint-warning
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: use more stable GObject signal
2026-10-15 00:32:28 +09:00
aclist ebffbe39f2 fix: use more stable GObject signal 2026-10-15 00:23:11 +09:00
aclist 61860f6aee Merge pull request #482 from aclist/fix/duplicate-warnings
Fix/duplicate warnings
2026-10-14 23:12:18 +09:00
aclist 191776ce74 fix: use correct logger method 2026-10-14 23:11:29 +09:00
aclist 7ae34747e9 fix: add missing dummy method to test 2026-10-14 23:07:22 +09:00
aclist 1555ba2d64 chore: drop no longer relevant filter 2026-10-14 23:07:00 +09:00
aclist 4bac804fd7 fix: duplicate warnings (#480) 2026-10-14 23:06:41 +09:00
aclist 748e1870da Merge pull request #479 from sylvaingirardbe/fix/478-flatpak-steam-detection
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: detect running Flatpak Steam correctly
2026-10-14 23:03:43 +09:00
aclist cd4c6fdfb2 Merge pull request #481 from aclist/fix/workshop-403
fix: handle HTTPErrors from workshop (#477)
2026-10-14 23:01:48 +09:00
aclist 1752018331 fix: handle HTTPErrors from workshop (#477) 2026-10-14 22:53:02 +09:00
aclist b3b05918b7 chore: add test case 2026-10-09 06:45:59 +09:00
aclist e6e0a3036d fix: drop unused assignment 2026-10-09 04:03:36 +09:00
aclist 8c44a6e255 fix: user-facing Steam client name not available until Options populated
The old method was checking the active ComboBox string after the Options
page was initialized, but before it was actually populated with the real
setting. This caused it to always return the string 'Steam' instead of
the actual client name.
2026-10-09 04:03:17 +09:00
aclist 7fc1264b65 fix: false positive when Flatpak client is running
The process ultimately spawned from the Flatpak run command has the same
process name as the one expected for STEAM_CMD (essentially just the
Steam client wrapped in a Flatpak), so the old logic would return True
for is_steam_running() if the Flatpak version had already been launched.
To prevent unintended collisions and isolate launch parameters,
STEAM_CMD and FLATPAK_RUN_CMD/FLATPAK_SANDBOX should be
mutually exclusive.
2026-10-09 04:02:58 +09:00
Sylvain Girard f748b09ba7 fix: detect running Flatpak Steam correctly
Request only application IDs from flatpak ps so running Steam instances match correctly. Fix client selection validation and cover detection with regression tests.

Refs aclist/dztui#478.
2026-10-08 15:58:51 +02:00
aclist 01be1247b4 Merge pull request #476 from aclist/fix/missing-mission
Mirror to Codeberg / mirror-to-codeberg (push) Canceled after 0s
fix: missing mission
2026-09-24 13:48:24 +09:00
aclist 27d5c4b4b2 Merge branch 'dzgui7' into fix/missing-mission 2026-09-24 13:39:56 +09:00
aclist 045e47037e chore: bump version 2026-09-24 13:39:12 +09:00
aclist 3bc2a54ee4 fix: mission param returning empty 2026-09-24 13:38:16 +09:00
aclist 4e22b82703 change: more explicitchange: more explicit status message 2026-09-24 13:37:56 +09:00
aclist 492ed221c4 change: add padding to error area 2026-09-24 13:37:31 +09:00
aclist 71a61a9201 drop: old comment 2026-09-24 13:37:16 +09:00
aclist 516735defa chore: add string 2026-09-24 13:36:58 +09:00
aclist 9a3df70914 chore: format strings 2026-09-24 13:36:25 +09:00
aclist c41e47859f change: more explicit error message 2026-09-24 13:35:59 +09:00
aclist 11bbe15ecb chore: change comment flag 2026-09-24 13:35:35 +09:00
aclist d40512dfa0 Merge pull request #474 from aclist/prerelease/7.0.0b24
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
chore: bump version
2026-09-09 17:13:41 +09:00
aclist f07e503b74 chore: bump version 2026-09-09 17:13:07 +09:00
aclist 3caeb823a2 Merge pull request #473 from aclist/feat/server-time-calc
feat: server time calc
2026-09-09 17:12:01 +09:00
aclist 6fcd887d56 Merge pull request #472 from aclist/fix/refresh-ping
fix: refresh ping column on filter action
2026-09-09 17:07:34 +09:00
aclist c15b77d470 chore: add comments regarding time interpolation
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-09-08 05:07:41 +09:00
aclist cee1e3b8af chore: move comment 2026-09-08 02:58:39 +09:00
aclist e8ccdd7cbb fix: local clock drives virtual clock 2026-09-08 02:56:24 +09:00
aclist 2629fe254e fix: superclass type collision 2026-09-08 02:29:04 +09:00
aclist 3b15bf58e7 feat: add time of day icon 2026-09-08 02:28:04 +09:00
aclist e773a4909b fix: restore adjusted time to current time on reset 2026-09-08 01:02:23 +09:00
aclist 95380681f9 chore: update test to new Details dataclass format 2026-09-08 00:51:26 +09:00
aclist 18192a4667 chore: remove debug mark 2026-09-08 00:46:22 +09:00
aclist f07a2dea5f chore: add calculator reference test 2026-09-08 00:45:30 +09:00
aclist 0e58cf514e fix: date traversal logic 2026-09-08 00:45:07 +09:00
aclist 84c6a3868b change: normalize time boundaries 2026-09-08 00:44:32 +09:00
aclist 7c0981981f chore: drop unused import 2026-09-08 00:43:48 +09:00
aclist e653c61955 chore: remove debug mark 2026-09-08 00:43:27 +09:00
aclist d4bf9f71f6 chore: clear typehinting errors 2026-09-04 19:28:12 +09:00
aclist cb5c233f45 change: simplify calculation logic 2026-09-04 19:25:37 +09:00
aclist ac286f4a72 chore: add metadata test
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-09-04 04:00:34 +09:00
aclist e254821105 change: update time range assertions 2026-09-04 01:03:22 +09:00
aclist 32b8c5cbc5 chore: remove resolved comments 2026-09-04 00:40:25 +09:00
aclist 76ed7f2451 change: use greater precision only when setting target time 2026-09-04 00:38:37 +09:00
aclist f135f6a35d drop: GenericAdjustedClock 2026-09-03 23:26:45 +09:00
aclist eb5d5d5181 fix: reset time on subsequent invocations 2026-09-03 23:26:01 +09:00
aclist fef8c69f38 change: synchronize local and remainder clocks 2026-09-03 23:21:11 +09:00
aclist 32395812a0 change: support new dataclass format 2026-09-03 20:11:02 +09:00
aclist 6d046ee719 change: restructure Details dataclass 2026-09-03 20:10:50 +09:00
aclist 1ebf253e99 chore: apply linter changes 2026-09-03 20:10:20 +09:00
aclist ee31dce6e6 chore: add icon constant 2026-09-03 20:09:51 +09:00
aclist 4aaf7fe195 chore: update strings files 2026-09-03 20:08:32 +09:00
aclist b7e8982f40 feat: pack calculator in Gtk.Stack 2026-09-03 20:08:03 +09:00
aclist c3b41ed9b4 feat: add server time calculator 2026-09-03 20:07:30 +09:00
aclist e42fd26855 fix: enquote typehint 2026-09-02 01:36:48 +09:00
aclist 7a8ccca317 chore: clear typehinting errors 2026-09-02 00:48:25 +09:00
aclist 72e3054afb fix: wipe cache and redraw when filtering ServerTab.BROWSER 2026-09-02 00:45:44 +09:00
aclist 2f4606ede0 fix: focus OK button when dialog opens 2026-09-02 00:43:37 +09:00
aclist c6a75c1e78 chore: move refilter() call to controller 2026-09-02 00:43:13 +09:00
aclist bad7878c0a drop: remove old comments 2026-09-02 00:42:15 +09:00
aclist 5014402e39 Merge pull request #470 from aclist/docs/update-readme
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
docs: update readme with missing details from dzgui6
2026-09-01 15:37:29 +09:00
aclist baade5b721 docs: update readme with missing details from dzgui6 2026-09-01 15:36:53 +09:00
aclist 3c92108ac3 Merge pull request #468 from aclist/fix/wizard-config-exit
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
fix: write config later in install wizard
2026-08-29 16:21:57 +09:00
aclist 0f6f1c03ec chore: update changelog
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-08-29 15:55:36 +09:00
aclist 39d8607f57 chore: drop unused code 2026-08-29 15:55:24 +09:00
aclist 64d784f9a0 fix: apply keyword filter when refreshing (#469) 2026-08-29 15:55:02 +09:00
aclist 667203b4f9 chore: clear typehinting errors
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-08-28 17:36:23 +09:00
aclist 00369837cb fix: support multiple user ids 2026-08-28 17:35:29 +09:00
aclist 9bc5964df2 chore: add XDG path conversion test 2026-08-28 16:58:44 +09:00
aclist b7b83b3ca5 chore: drop unused imports 2026-08-28 16:35:34 +09:00
aclist d04c20498a change: write config only on last page 2026-08-28 16:35:08 +09:00
aclist 572ac5d7ce chore: drop unused code 2026-08-28 16:07:06 +09:00
aclist b4125c5a51 change: change log level 2026-08-28 16:01:09 +09:00
aclist 8bae2d5fb5 chore: add log output when setting start tab 2026-08-28 16:00:14 +09:00
aclist a85c632902 change: namespace dlc strings 2026-08-28 15:52:15 +09:00
aclist 74ecc30692 feat: add badlands string support 2026-08-28 15:49:50 +09:00
aclist 33843becae chore: bump version 2026-08-28 15:49:19 +09:00
aclist 07cbf7a36a change: always show shortcuts page
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
The expected shortcut path is already parsed by dzgui.config.xdg, which
invokes is_writeable(); it will use a writeable fallback path for
XDG_DATA_HOME.  There is no need for a conditional check here. Even if
the launcher itself is installed to a non-writeable system location, the
shortcut can still copy from that.
2026-08-26 16:55:25 +09:00
aclist 687c5ac168 chore: add comments 2026-08-25 20:09:56 +09:00
aclist f3fd7ef6ea change: revert prior changes 2026-08-25 20:09:02 +09:00
aclist 2383f7d2aa change: simplify method 2026-08-25 20:03:42 +09:00
aclist c839672f85 chore: clear typehinting errors 2026-08-25 20:03:32 +09:00
aclist ca234b8dea drop: unused imports 2026-08-25 19:58:37 +09:00
aclist 8e3ce72295 change: do not store pages linearly 2026-08-25 19:57:52 +09:00
aclist f02d959ebc change: write config later 2026-08-25 19:57:07 +09:00
aclist 04e9e5dcf0 Merge pull request #467 from aclist/change/baseline-cpu-arch
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
change: target baseline cpu architecture
2026-08-25 16:21:36 +09:00
aclist 1b06fcae42 change: target baseline cpu architecture 2026-08-25 16:20:48 +09:00
aclist 9759c39fd7 Merge pull request #466 from aclist/feat/color-aware-app
feat: color scheme aware app
2026-08-23 20:22:58 +09:00
aclist 4451eee849 docs: update changelog 2026-08-23 20:22:10 +09:00
aclist 3f587f64a0 chore: drop extraneous newline 2026-08-23 19:33:33 +09:00
aclist b00523661f chore: update exclusions 2026-08-23 19:30:10 +09:00
aclist f24b3574b8 chore: clear typehinting errors 2026-08-23 19:29:51 +09:00
aclist fad8d2f43b feat: add ColorAwareApp mixin (resolves #465) 2026-08-23 19:28:42 +09:00
aclist e1e034fffd chore: initialize and run app 2026-08-23 19:26:41 +09:00
aclist ea2ca684d6 chore: restructure as Gtk.Application and drop Gtk.main loop 2026-08-23 19:26:16 +09:00
aclist 2e2d3484c4 chore: drop unused imports 2026-08-23 19:24:06 +09:00
aclist 1254044ef8 chore: drop Gtk.main_quit call 2026-08-23 19:23:39 +09:00
aclist 7d651e9653 feat: add GIO property constants 2026-08-23 19:22:28 +09:00
146 changed files with 3259 additions and 1190 deletions

No files matched your search

+10
View File
@@ -51,6 +51,12 @@
- Update map count (-u flag) - Update map count (-u flag)
- Filter button tooltips - Filter button tooltips
- Expand/collapse changelog versions - Expand/collapse changelog versions
- Support local light/dark mode changes on system theme
- Server time calculator
- Add "Unmodded" filter to servers
- List timestamp of last DayZ client update
- Validate mods via Mod page to pull in updates
- Support for Nasdara (Badlands) DLC
### Changed ### Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
@@ -72,6 +78,7 @@
- Enable LAN page Empty/Full filters on first run of app - Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client - Propagate subscribed mods to Steam client
- Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow - Make j/k keys in context menus follow same wraparound logic as down arrow/up arrow
- Use SQLite DB for geolocation records
### Dropped ### Dropped
- Battlemetrics API support - Battlemetrics API support
@@ -89,6 +96,9 @@
- Rare segfaults when changing maps (threading) - Rare segfaults when changing maps (threading)
- Moved dialogs out of threads - Moved dialogs out of threads
- History table not being in lock-step with state file - History table not being in lock-step with state file
- Current keyword filter not being applied when refreshing table
- Steam Flatpak cannot run DZGUI as a non-Steam game
- Collisions between native Steam and Steam Flatpak clients
### Unreleased ### Unreleased
- Load offline mods - Load offline mods
+124
View File
@@ -0,0 +1,124 @@
# History of the project
This file collects notable milestones and events related to DZGUI, mainly around DZGUI as a framework and the history of development.
A chronological summary of features can be found in CHANGELOG.md.
## 2021
### 2021-12-03: DayZ adds support for Battleye over Proton
DayZ was among a handful of games with whitelisted anti-cheat support under Valve's emerging WINE-based Proton compatibility layer.
While adding anti-cheat compatibility at a technical level, this change did not actually allow the client to connect to servers running complex modlists.
Development of the precursor to DZGUI began at around this time.
### 2021-12-12: initial version of DZTUI is created
DZTUI, a Bash script and terminal user interface, was the precursor to DZGUI.
This was an initial attempt at bootstrapping the Steam client to handle mods correctly when launching DayZ.
Due to user interest, features continued to be added. This early tool is archived under the 'dztui' branch.
## 2022
### 2022-05-09: addition of zenity GUI and project name change (DZGUI)
With the introduction of the Steam Deck, more users wanted a graphical user interface
to interact with DZTUI. Being that DZTUI was purely a Bash script, a method of
drawing menus was needed, so the "zenity" package was used to create prefabricated dialogs.
While this met the minimum requirement to show prompts and selectable options, no rich customization was possible.
The introduction of zenity as a dependency marked the shift from "DZTUI" to "DZGUI".
Throughout 2022, the limits of zenity for DZGUI's purposes were tested, particularly in the context of SteamOS.
This led to the discovery of a number of bugs in zenity, GTK, and SteamOS, which were reported up to their respective channels.
While this was improving the SteamOS user experience, it started to become clear that a fully custom UI was needed.
This barebones version of DZGUI sufficed for some time, with the Bash script and some standalone Python modules doing the heavy lifting,
and this period served to surface a number of bugs and interesting interactions with the Steam client.
## 2023
### 2023-11-23: Python 3.10 dependency
Prior to this time, DZGUI open-endedly allowed you to use any Python version for its modules, without any significant handling for
the idiosyncrasies of different versions. A minimum requirement of Python 3.10 was thus instituted to normalize expected behavior.
Throughout 2023, various features related to server browsing were added, such as filters and more granular search parameters.
There were still a lot of unknowns at this time, so things were being resolved through trial and error.
## 2024
### 2024-01-31: DZGUI 5 API introduces GTK UI
Using zenity as a simple GUI had exposed some bugs and limitations in that package, particularly when trying to push huge lists of server records
into tables and sorting them in a performant manner.
Around this time it became clear that a more robust UI framework was needed. While keeping the original Bash script and Python modules for
background processing, PyGObject (GTK) was added to the project in order to ship a custom UI that could be designed from the ground up.
However, it should be noted that at this point, the DZGUI logic itself was still a (increasingly large) Bash script to which a GTK UI had been stitched on.
These were totally separate components, with UI actions on the GTK side triggering calls to Bash functions in an overly complex helper file.
The UI was basically a thin client around the old Bash logic, but this was still a far cry from an integrated application with proper signals and dedicated components.
Effectively, DZGUI 5 through 6 were a stepping stone that set the groundwork to go from DZGUI 4 (pure Bash script) to
DZGUI 7 (standalone Python application). Today, little of DZGUI 5's original logic remains outside of the basic UI grid.
### 2024-10-15: Frostline DLC released, widespread parsing problems
DayZ's first DLC introduced a new format for server responses that packed extra metadata into the A2S_RULES format.
Upon its release, all third-party DayZ launchers and server listing web sites were unable to parse the mod information being returned from Frostline servers.
For a period of two weeks, this remained unsolved and various attempts were made to understand the updated specification.
### 2024-10-30: DZGUI hotfix adds support for Frostline servers
DZGUI was the first third-party tool to fix parsing for Frostline servers and restore functionality.
This was a small milestone, because it meant DZGUI was evolving from a "dumb" launcher to a featureful tool
touching upon and improving all aspects of the end-to-end DayZ experience.
Particularly around this time, the sheer diversity of needs of modders, server operators, and players started to become clear.
Users had highly specific and diverse use cases for setting up their game environment, and requested a number of features to support that on
the DZGUI side.
## 2025
### 2025-01-17: DZGUI 6 API
With DZGUI 6, the difficult decision to enforce a Python 3.13 dependency was made.
While the previous dependency was Python 3.10+, it was deemed better to develop against and target identical versions.
However, due to the vagaries of managing Python environments on end-user systems, this change added unnecessary complexity and frustration.
Users had to properly set up virtual environments, install the relevant dependencies, and create a wrapper script to run this version, whereas
previous versions had essentially been point and click.
### 2025-10-23: DZGUI 7 API and the "Ship of Theseus"
It had become clear that the situation above was untenable and the legacy Bash script was unmaintainable given the project's enlarged scope.
The lack of a robust testing or typehinting framework made it difficult to diagnose rare bugs.
Shipping a standalone, turnkey application with zero or minimal dependencies was the most urgent priority.
DZGUI was still carrying around small bits and pieces of the old Bash script logic, so the decision was made to
refactor the application into a pure Python + GTK tool. This would effectively be a total rewrite of all logic,
only preserving the most minimal skeleton of the original UI layout as a reference.
Over the course of a year, a wholesale "Ship of Theseus" rewrite took place.
Nearly every component of DZGUI 6, both API and UI side, was taken apart and replaced.
A soft alpha and beta phase were used to iterate on this concept further. DZGUI 7 was increasingly recommended to users
as the preferred choice. This allowed for keeping the established userbase on the (relatively) stable DZGUI 6 spec, while building up
the new spec and making it available to more users as features were elaborated.
Through late 2025, several LLM-generated applications that recycled significant parts of DZGUI's code without the applicable GPL license were found.
The maintainers of those projects were advised to excise the offending segments or apply the necessary GPL licenses.
## 2026
### 2026-05-17: Switch to standalone releases
The DZGUI 7 beta starts shipping standalone glibc builds with an integrated setup wizard and installer.
DZGUI 6 had relied on "over the air" updates delivered via either a "stable" or "testing" branch
(archived respectively under the "dzgui" and "testing" branches). Effectively, the script would bootstrap itself and
fetch raw files directly from the repository. This was prone to problems because updates were monolithic, not giving
an easy escape hatch if the user needed to rollback in the event of a regression.
This was changed to atomic, tagged releases. In addition to updating directly through DZGUI, these prebuilt releases can be
downloaded manually as needed, and rollbacks are available for all prior versions under the DZGUI 7 specification.
### 2026-10-27: 500 tickets closed
The milestone of 500 tickets/PRs being closed was reached.
2026 was by far DZGUI's most intensive year by commits, perhaps suggesting the scope of the rewrite:
- 2021: 16
- 2022: 434
- 2023: 322
- 2024: 347
- 2025: 426
- 2026: 1300
DZGUI 7 is scheduled to release as a stable version within the year.
+17 -5
View File
@@ -1,15 +1,23 @@
## What this is ## 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: This overcomes certain limitations in the Linux client and helps prepare the game to launch by providing features like:
- Search for and display global and LAN servers via an interactive table - Search for and display global and LAN servers via an interactive table
- Supports official, third-party, and modded DayZ servers - Supports official, third-party, and modded DayZ servers
- Automatically find and prepare mods being requested by servers - Automatically find and prepare mods being requested by servers
- Add/delete/manage favorite servers by IP or ID
- Quick-connect to favorite/recent servers - Quick-connect to favorite/recent servers
- Manage local mods and symlinks - Manage and delete local mods
- Prepare launch options to pass to Steam - 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 ## Setup and usage
@@ -18,7 +26,7 @@ Please refer to the documentation for installation and setup instructions:
- [GitHub](https://aclist.github.io/dzgui/index.html) - [GitHub](https://aclist.github.io/dzgui/index.html)
- [Codeberg (Mirror)](https://aclist.codeberg.page) - [Codeberg (Mirror)](https://aclist.codeberg.page)
![A screenshot of DZGUI](/images/example.png) ![A screenshot of DZGUI](/dzgui/data/images/example.png)
## Attribution ## Attribution
@@ -27,6 +35,10 @@ Geolocation records from [DB-IP](https://db-ip.com) under [CC 4.0 license](https
Executable versions of DZGUI published as release binaries ship with various runtime dependencies and the Python interpreter built in. 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. Users wishing to review the licenses to these components can inspect the `LICENSE` file located in the release tarball.
Steam and the Steam logo are trademarks and/or registered trademarks of Valve Corporation in the U.S. and/or other countries.
DayZ is a trademark and brand of Bohemia Interactive a.s.
## Disclaimer ## Disclaimer
DZGUI is beta-quality software and is provided as-is. DZGUI is beta-quality software and is provided as-is.
+30 -4
View File
@@ -1,15 +1,18 @@
import logging import logging
import re import re
from collections.abc import Iterator from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, TYPE_CHECKING
from warnings import deprecated from warnings import deprecated
from dzgui.const.constants import APP_NAME from dzgui.const.constants import APP_NAME
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from collections.abc import Iterator
class ACF: class ACF:
def __init__(self, file: Path) -> None: def __init__(self, file: Path) -> None:
@@ -42,7 +45,7 @@ class ACF:
def dequote(cls, s: str) -> str: def dequote(cls, s: str) -> str:
return s.rstrip('"').lstrip('"') return s.rstrip('"').lstrip('"')
def parse(self, lines: Iterator[list[str]]) -> dict[str, str]: def parse(self, lines: "Iterator[list[str]]") -> dict[str, str]:
acf: dict[str, Any] = {} acf: dict[str, Any] = {}
try: try:
while True: while True:
@@ -69,6 +72,28 @@ class ACF:
except StopIteration: except StopIteration:
return acf return acf
def search_key(self, key: str) -> str:
return str(self.dict["AppState"][key])
def date_to_str(self, epoch: int) -> str | None:
if epoch == 0:
return None
return datetime.fromtimestamp(epoch).isoformat(" ")
def get_date_by_key(self, key: str) -> str | None:
try:
stamp = int(self.search_key(key))
return self.date_to_str(stamp)
except Exception as e:
logger.critical(e)
return None
def get_last_updated(self) -> str | None:
return self.get_date_by_key("LastUpdated")
def get_last_played(self) -> str | None:
return self.get_date_by_key("LastPlayed")
def get_allows_downloads(self) -> int | None: def get_allows_downloads(self) -> int | None:
try: try:
# TODO: consider coercing strs to ints on initial import # TODO: consider coercing strs to ints on initial import
@@ -86,7 +111,8 @@ class WorkshopACF(ACF):
def __init__(self, file: Path) -> None: def __init__(self, file: Path) -> None:
super().__init__(file) super().__init__(file)
def unpack(self, d: dict, lines: list[Any] = []) -> str: def unpack(self, d: dict[str, Any], lines: list[Any] = []) -> str:
self.indent: int
t1 = "AppWorkshop" t1 = "AppWorkshop"
t2 = ("WorkshopItemsInstalled", "WorkshopItemDetails") t2 = ("WorkshopItemsInstalled", "WorkshopItemDetails")
for k, v in d.items(): for k, v in d.items():
+1 -13
View File
@@ -24,14 +24,6 @@ from typing import Any
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
@dataclass
class ModMeta:
protocol: str
published_id: str
name: str
timestamp: str
def get_local_mod_ids(steam_path: Path) -> list[int]: def get_local_mod_ids(steam_path: Path) -> list[int]:
workshop_path = get_local_mod_path(steam_path) workshop_path = get_local_mod_path(steam_path)
mods = get_local_mods(workshop_path) mods = get_local_mods(workshop_path)
@@ -114,10 +106,6 @@ def get_delimited_mods(steam_path: Path) -> list[Any]:
return parse_mods(mods) return parse_mods(mods)
def get_missing_mods(local: list, remote: list) -> list:
return [mod for mod in remote if mod not in local]
def _hash(uid: str, use_custom: bool = False) -> str: def _hash(uid: str, use_custom: bool = False) -> str:
md5 = hashlib.md5() md5 = hashlib.md5()
md5.update(uid.encode("ascii")) md5.update(uid.encode("ascii"))
@@ -152,7 +140,7 @@ def remove_stale_signatures(
def find_stale_mods(config: Path) -> list[int]: def find_stale_mods(config: Path) -> list[int]:
def push_record(rec: str) -> list: def push_record(rec: str) -> list[int]:
record = fqip_to_record(rec) record = fqip_to_record(rec)
if record is None: if record is None:
return [] return []
+20 -8
View File
@@ -255,16 +255,28 @@ class PeFileError(Exception):
pass pass
def parse_version_number(data: BinaryIO) -> FileVersion: def parse_version_number(data: BinaryIO) -> FileVersion:
# https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo # https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo
minor = struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF """
major = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF Note: structure differs from standard VS_FIXEDFILEINFO
build = struct.unpack("<L", data.read(4))[0] >> 0 & 0xFFFF Major: lower 16 bits of first DWORD
# NOTE: 0-pad revision number Minor: upper 16 bits of first DWORD
revision = str(struct.unpack("<L", data.read(4))[0] >> 16 & 0xFFFF) Build: lower 16 bits of third DWORD
offset = 3 - len(revision) Revision: upper 16 bits of second DWORD, zero-padded
padded = "0" * offset + revision Lower 16 bits of revision DWORD appear unused.
return FileVersion(major, minor, build, padded) """
major_minor = struct.unpack("<I", data.read(4))[0]
revision_pair = struct.unpack("<I", data.read(4))[0]
build_dword = struct.unpack("<I", data.read(4))[0]
major = major_minor & 0xFFFF
minor = (major_minor >> 16) & 0xFFFF
build = build_dword & 0xFFFF
revision = (revision_pair >> 16) & 0xFFFF
revision = f"{revision:03d}"
return FileVersion(major, minor, build, revision)
def seek_to_hex(address: str, data: BinaryIO) -> None: def seek_to_hex(address: str, data: BinaryIO) -> None:
+35 -33
View File
@@ -54,19 +54,16 @@ class Res:
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class Details: class Details:
data: Union[list, None] data: list[list[str]]
name: str
description: str description: str
success: bool gametime: str
day_accel: float
night_accel: float
@dataclass(slots=True) @dataclass(slots=True, frozen=True)
class Record: class Record:
# TODO: investigate whether this enum is still being used;
# can this be frozen?
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str ip: str
gameport: int gameport: int
qport: int qport: int
@@ -114,7 +111,7 @@ def get_netmask() -> str:
# TODO: rename # TODO: rename
def test_ip(suffix: int, port: int, event: threading.Event) -> dict | None: def test_ip(suffix: int, port: int, event: threading.Event) -> dict[str, Any] | None:
if event.is_set(): if event.is_set():
return None return None
netmask = get_netmask() netmask = get_netmask()
@@ -142,7 +139,9 @@ def sanitize(name: str) -> str:
return name return name
def parse_json(json: list) -> list: def parse_json(
json: list[dict[str, Any]],
) -> list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]]:
""" """
Server metadata is underspecified and server operators Server metadata is underspecified and server operators
tend to insert random garbage in the headers. In case tend to insert random garbage in the headers. In case
@@ -283,43 +282,41 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] |
info = a2s.info((ip, qport), timeout) info = a2s.info((ip, qport), timeout)
return source_info_to_dict(ip, qport, info) return source_info_to_dict(ip, qport, info)
except Exception as e: except Exception as e:
logger.warning(e) logger.debug(e)
return None return None
def get_details(record: Record) -> Details: def get_details(record: Record) -> Details | None:
# TODO: consolidate try/except statements
ip = record.ip ip = record.ip
qport = record.qport qport = record.qport
default_str = strings.none_provided default_str = strings.none_provided
try: try:
info = a2s.info((ip, qport)) info = a2s.info((ip, qport))
except (TimeoutError, a2s.exceptions.BrokenMessageError):
return Details(None, default_str, False)
try:
rules = dayzquery.dayz_rules((ip, qport)) rules = dayzquery.dayz_rules((ip, qport))
except Exception:
return Details(None, default_str, False)
try:
keywords = info.keywords.split(",") keywords = info.keywords.split(",")
except AttributeError: except Exception as e:
return Details(None, default_str, False) logger.critical(e)
return None
battleye = strings.disabled battleye = strings.disabled
if "battleye" in keywords: if "battleye" in keywords:
battleye = strings.enabled battleye = strings.enabled
day_accel = 0.0 name = info.server_name
night_accel = 0.0 day_accel = 1.0
night_accel = 1.0
gametime = "00:00"
for keyword in keywords: for keyword in keywords:
if "etm" in keyword: if "etm" in keyword:
day_accel = float(keyword.lstrip("etm")) day_accel = float(keyword.lstrip("etm"))
day_accel = float(f"{day_accel:g}") day_accel = float(f"{day_accel:g}")
if "entm" in keywords: if "entm" in keyword:
night_accel = float(keyword.lstrip("entm")) night_accel = float(keyword.lstrip("entm"))
night_accel = float(f"{night_accel:g}") night_accel = float(f"{night_accel:g}")
time_reg = r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]$)"
if re.match(time_reg, keyword):
gametime = keyword
try: try:
password = info.password_protected password = info.password_protected
@@ -346,10 +343,15 @@ def get_details(record: Record) -> Details:
try: try:
dlc = rules.dlc_flags dlc = rules.dlc_flags
if dlc == 0: match dlc:
dlc = strings.none case 0:
if dlc == 2: dlc = strings.dlc_none
dlc = strings.dlc_frostline case 2:
dlc = strings.dlc_frostline
case 4:
dlc = strings.dlc_badlands
case _:
dlc = strings.unspecified
except AttributeError: except AttributeError:
dlc = strings.unspecified dlc = strings.unspecified
@@ -380,7 +382,7 @@ def get_details(record: Record) -> Details:
["Version", version], ["Version", version],
] ]
return Details(rows, description, True) return Details(rows, name, description, gametime, day_accel, night_accel)
def ping(ip: str, qport: int) -> int: def ping(ip: str, qport: int) -> int:
@@ -507,14 +509,14 @@ def record_to_fqip(record: Record) -> str:
return f"{record.ip}:{record.gameport}:{record.qport}" return f"{record.ip}:{record.gameport}:{record.qport}"
def response_to_fqip(res: dict) -> str: def response_to_fqip(res: dict[str, Any]) -> str:
ip = res["addr"].split(":")[0] ip = res["addr"].split(":")[0]
gameport = res["gameport"] gameport = res["gameport"]
qport = res["addr"].split(":")[1] qport = res["addr"].split(":")[1]
return f"{ip}:{gameport}:{qport}" return f"{ip}:{gameport}:{qport}"
def response_to_record(res: dict) -> Record: def response_to_record(res: dict[str, Any]) -> Record:
ip = res["addr"].split(":")[0] ip = res["addr"].split(":")[0]
gameport = res["gameport"] gameport = res["gameport"]
qport = res["addr"].split(":")[1] qport = res["addr"].split(":")[1]
+11 -3
View File
@@ -11,7 +11,7 @@ from typing import Any
from dzgui.api.steam import find_user_id_32 from dzgui.api.steam import find_user_id_32
from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH
from dzgui.util.dirs import copy_dzgui_to_xdg_data from dzgui.util.dirs import copy_dzgui_to_xdg_data, make_parents
from dzgui.util.strings import unknown from dzgui.util.strings import unknown
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
@@ -75,6 +75,8 @@ class Shortcuts:
shortcuts = vdf.binary_load(f) shortcuts = vdf.binary_load(f)
self.shortcuts = shortcuts self.shortcuts = shortcuts
logger.debug("Loaded shortcuts file") logger.debug("Loaded shortcuts file")
except FileNotFoundError:
self.shortcuts = {"shortcuts": {}}
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
raise e raise e
@@ -109,6 +111,7 @@ class Shortcuts:
for img in ("_hero", "p", "_logo"): for img in ("_hero", "p", "_logo"):
filename = str(self.bpid) + img filename = str(self.bpid) + img
dest = grid_path.joinpath(filename).with_suffix(".png") dest = grid_path.joinpath(filename).with_suffix(".png")
make_parents(dest)
b = images.joinpath(img).with_suffix(".png").read_bytes() b = images.joinpath(img).with_suffix(".png").read_bytes()
dest.write_bytes(b) dest.write_bytes(b)
@@ -156,6 +159,7 @@ class Shortcuts:
NEW_ENTRY["DevkitOverrideAppID"] = 0 NEW_ENTRY["DevkitOverrideAppID"] = 0
NEW_ENTRY["LastPlayTime"] = 0 NEW_ENTRY["LastPlayTime"] = 0
NEW_ENTRY["FlatpakAppID"] = "" NEW_ENTRY["FlatpakAppID"] = ""
NEW_ENTRY["sortas"] = "" NEW_ENTRY["sortas"] = ""
NEW_ENTRY["tags"] = {} NEW_ENTRY["tags"] = {}
return NEW_ENTRY return NEW_ENTRY
@@ -169,9 +173,13 @@ class Shortcuts:
self.save_shortcuts() self.save_shortcuts()
def save_shortcuts(self) -> None: def save_shortcuts(self) -> None:
if self.shortcuts_path.is_file():
try:
backup = self.shortcuts_path.with_suffix(".vdf.bak")
shutil.copy(self.shortcuts_path, backup)
except Exception as e:
logger.critical(e)
try: try:
backup = self.shortcuts_path.with_suffix(".vdf.bak")
shutil.copy(self.shortcuts_path, backup)
with open(self.shortcuts_path, "wb") as f: with open(self.shortcuts_path, "wb") as f:
vdf.binary_dump(self.shortcuts, f) vdf.binary_dump(self.shortcuts, f)
except Exception as e: except Exception as e:
+103 -22
View File
@@ -1,16 +1,20 @@
import logging import logging
import os import os
import psutil import psutil
import re
import requests import requests
import subprocess import subprocess
import vdf # type: ignore import vdf # type: ignore
from pathlib import Path from pathlib import Path
from typing import Any, Union from requests.exceptions import HTTPError
from typing import Any, Generator, Union
from warnings import deprecated from warnings import deprecated
from dzgui.api.acf import ACF from dzgui.api.acf import ACF
from dzgui.init.prereqs import has_steam_client from dzgui.const.endpoints import VALIDATE
from dzgui.init.prereqs import has_any_client
from dzgui.init.prereqs import has_flatpak_client, has_steam_client
from dzgui.const.constants import ( from dzgui.const.constants import (
APPID_DAYZ, APPID_DAYZ,
APPID_DAYZ_EXP, APPID_DAYZ_EXP,
@@ -19,19 +23,21 @@ from dzgui.const.constants import (
DAYZ_LAUNCHER, DAYZ_LAUNCHER,
DEBIAN_STEAM_PATH, DEBIAN_STEAM_PATH,
DEFAULT_STEAM_PATH, DEFAULT_STEAM_PATH,
DOWNLOADS_PATH,
FLATPAK_STEAM_PATH, FLATPAK_STEAM_PATH,
LIBRARYFOLDERS_PATH, LIBRARYFOLDERS_PATH,
UBUNTU_STEAM_PATH,
REQUEST_TIMEOUT, REQUEST_TIMEOUT,
UBUNTU_STEAM_PATH,
VDF_PATH, VDF_PATH,
) )
from dzgui.const.endpoints import ( from dzgui.const.endpoints import (
APP_DETAILS, STEAM_APP_DETAILS,
SUB_ENDPOINT,
STEAM_PUBLISHED_FILES, STEAM_PUBLISHED_FILES,
UNSUB_ENDPOINT, STEAM_SUB,
STEAM_UNSUB,
) )
from dzgui.strings import wizard from dzgui.strings import wizard
from dzgui.strings.errors import api_validation_error, workshop_endpoint_error
from dzgui.util.bash import concat_bash_args from dzgui.util.bash import concat_bash_args
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
@@ -57,7 +63,7 @@ class VDFLoadError(Exception):
def get_steam_paths() -> list[tuple[Path, str]]: def get_steam_paths() -> list[tuple[Path, str]]:
paths = [] paths = []
if has_steam_client(): if has_any_client():
HOME = Path.home() HOME = Path.home()
env = os.environ.get("XDG_DATA_HOME") env = os.environ.get("XDG_DATA_HOME")
if env is not None: if env is not None:
@@ -78,6 +84,10 @@ def get_steam_paths() -> list[tuple[Path, str]]:
} }
for path in DEFAULT_PATH, FLATPAK_PATH, UBUNTU_PATH, DEBIAN_PATH: for path in DEFAULT_PATH, FLATPAK_PATH, UBUNTU_PATH, DEBIAN_PATH:
if path is FLATPAK_PATH and has_flatpak_client() is False:
continue
if path is not FLATPAK_PATH and has_steam_client() is False:
continue
if path.joinpath(VDF_PATH).is_file(): if path.joinpath(VDF_PATH).is_file():
paths.append((path, human[path])) paths.append((path, human[path]))
return paths return paths
@@ -151,7 +161,9 @@ def get_remote_signatures(mods: list[str]) -> list[tuple[str, str, int, int]]:
return hashes return hashes
def connect_debug(client: str, addr: str, appid: int, name: str, mods: list[str]) -> str: def connect_debug(
client: str, addr: str, appid: int, name: str, mods: list[str]
) -> str:
concat = concat_mods(mods) concat = concat_mods(mods)
client_args = concat_bash_args(client) client_args = concat_bash_args(client)
params = [ params = [
@@ -167,6 +179,7 @@ def connect_debug(client: str, addr: str, appid: int, name: str, mods: list[str]
client_args.extend(params) client_args.extend(params)
return " ".join(client_args) return " ".join(client_args)
# TODO: set config to name=user, use official server and no mods, # TODO: set config to name=user, use official server and no mods,
# ensure that formatted string is identical to fixture with same hash # ensure that formatted string is identical to fixture with same hash
def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int: def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int:
@@ -191,6 +204,7 @@ def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int:
"""Loads to the menu screen with the selected mods; used for AFK/pre-joining""" """Loads to the menu screen with the selected mods; used for AFK/pre-joining"""
concat = concat_mods(mods) concat = concat_mods(mods)
client_args = concat_bash_args(client) client_args = concat_bash_args(client)
logger.debug(f"Setting client arguments to: {client_args}")
params = [ params = [
"-applaunch", "-applaunch",
str(appid), str(appid),
@@ -235,14 +249,18 @@ def find_user_id(path: Path) -> str | None:
try: try:
with open(resolved_path, "r") as f: with open(resolved_path, "r") as f:
v = vdf.load(f) v = vdf.load(f)
# NOTE: beta client users = v["users"]
# /package/beta # NOTE: beta client: /package/beta
if len(v["users"]) == 1: last_user = str(next(iter(users)))
return str(list(v["users"].keys())[0]) if len(users) == 1:
for user in v["users"]: return last_user
if v["users"][user]["MostRecent"] == "1": last_stamp = int(users[last_user]["timestamp"])
return str(user) for user in users:
return None stamp = int(users[user]["timestamp"])
if stamp > last_stamp:
last_stamp = stamp
last_user = user
return last_user
except Exception as e: except Exception as e:
logger.debug(e) logger.debug(e)
return None return None
@@ -255,6 +273,16 @@ def find_user_id_32(path: Path) -> int:
return int(uid) & 0xFFFFFFFF return int(uid) & 0xFFFFFFFF
class SteamAPIKeyError(Exception):
def __init__(self, msg: str | None = None) -> None:
super().__init__(msg or api_validation_error)
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: def update_workshop(key: str, mod: int, endpoint: str) -> None:
payload: dict[str, Union[int, str]] = { payload: dict[str, Union[int, str]] = {
"publishedfileid": mod, "publishedfileid": mod,
@@ -266,22 +294,32 @@ def update_workshop(key: str, mod: int, endpoint: str) -> None:
try: try:
res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT) res = requests.post(endpoint, params=payload, timeout=REQUEST_TIMEOUT)
res.raise_for_status() res.raise_for_status()
except HTTPError as e:
logger.critical(e)
match res.status_code:
case 403:
raise SteamAPIKeyError()
case 404:
raise SteamWorkshopEndpointError()
case _:
# TODO: apply redaction filter
raise e
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
raise e
def subscribe(key: str, mod: int) -> None: def subscribe(key: str, mod: int) -> None:
update_workshop(key, mod, SUB_ENDPOINT) update_workshop(key, mod, STEAM_SUB)
def unsubscribe(key: str, mod: int) -> None: def unsubscribe(key: str, mod: int) -> None:
update_workshop(key, mod, UNSUB_ENDPOINT) update_workshop(key, mod, STEAM_UNSUB)
@deprecated("Use subscribe()") def validate_mod(client: str, mod: str) -> None:
def enqueue_mod(client: str, mod: str, appid: int) -> None:
client_args = concat_bash_args(client) client_args = concat_bash_args(client)
subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod]) subprocess.Popen([*client_args, "+workshop_download_item", str(APPID_DAYZ), mod])
@deprecated("Cf. https://github.com/ValveSoftware/steam-for-linux/issues/9672") @deprecated("Cf. https://github.com/ValveSoftware/steam-for-linux/issues/9672")
@@ -351,6 +389,26 @@ def get_running_app() -> int | None:
return None return None
def get_last_updated(path: Path, appid: int) -> str | None:
root_path = get_app_path(path, appid)
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
try:
return ACF(acf).get_last_updated()
except Exception as e:
logger.critical(e)
return None
def get_last_played(path: Path, appid: int) -> str | None:
root_path = get_app_path(path, appid)
acf = root_path.joinpath(f"steamapps/appmanifest_{appid}.acf")
try:
return ACF(acf).get_last_played()
except Exception as e:
logger.critical(e)
return None
def get_app_allows_downloads(path: Path, appid: int) -> bool: def get_app_allows_downloads(path: Path, appid: int) -> bool:
try: try:
root_path = get_app_path(path, appid) root_path = get_app_path(path, appid)
@@ -409,6 +467,22 @@ def is_dayz_running() -> bool:
return any(s in item for sublist in procs for item in sublist for s in substring) 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: def get_app_path(folders_path: Path, appid: int) -> Path:
app_path = None app_path = None
@@ -438,7 +512,7 @@ def get_app_path(folders_path: Path, appid: int) -> Path:
def get_app_name(appid: int) -> str | None: def get_app_name(appid: int) -> str | None:
payload = {"appids": [appid]} payload = {"appids": [appid]}
res = requests.get(APP_DETAILS, params=payload) res = requests.get(STEAM_APP_DETAILS, params=payload)
if res.status_code != 200: if res.status_code != 200:
return None return None
try: try:
@@ -446,3 +520,10 @@ def get_app_name(appid: int) -> str | None:
except Exception as e: except Exception as e:
logger.debug(e) logger.debug(e)
return None return None
@deprecated("Currently unused")
def validate_dayz(client: str) -> None:
client_args = concat_bash_args(client)
uri = VALIDATE + str(APPID_DAYZ)
subprocess.Popen([*client_args, uri])
+38 -12
View File
@@ -1,10 +1,18 @@
import logging import logging
import os import os
import shutil import shutil
from importlib import resources
from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME from dzgui.const.constants import (
APP_NAME,
APP_NAME_LOWER,
FLATPAK_ROOT_PATH,
ICONS_PATH,
)
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.config.query import lookup from dzgui.config.query import lookup
from dzgui.config.userprefs import UserPrefs from dzgui.config.userprefs import UserPrefs
@@ -14,8 +22,7 @@ from dzgui.init.migrate import (
copy_state_files, copy_state_files,
migrate_cols_file, migrate_cols_file,
) )
from dzgui.init.flock import lock_acquire from dzgui.init.prereqs import has_flatpak_client, has_steam_client
from dzgui.init.prereqs import has_steam_client
from dzgui.strings import boot from dzgui.strings import boot
from dzgui.util.deck import is_steam_deck, is_game_mode from dzgui.util.deck import is_steam_deck, is_game_mode
@@ -96,9 +103,24 @@ def copy_bare_configs(config: "Path", resolution: "Path") -> tuple[bool, bool]:
return (config_changed, state_changed) return (config_changed, state_changed)
def load_gui(version: str, is_debug: bool) -> None: def load_steam_icon() -> None:
lock = lock_acquire() # noqa 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 build_gui(version: str, is_debug: bool) -> App | None:
set_locale() set_locale()
# NOTE: consider aborting this check if steam deck # NOTE: consider aborting this check if steam deck
xdg_paths = get_xdg_paths() xdg_paths = get_xdg_paths()
@@ -123,26 +145,28 @@ def load_gui(version: str, is_debug: bool) -> None:
migrate_cols_file(XDG.columns) migrate_cols_file(XDG.columns)
copy_state_files(xdg_paths["XDG_STATE_HOME"]) copy_state_files(xdg_paths["XDG_STATE_HOME"])
# TODO: add logging inside wizard # TODO: add logging inside wizard
wizard = SetupWizard(_is_steam_deck, XDG) wizard = SetupWizard(_is_steam_deck, XDG, xdg_paths)
if not wizard.is_setup_complete(): if not wizard.is_setup_complete():
return return None
setup_logger(XDG.debug) setup_logger(XDG.debug)
with open(XDG.debug, "w") as f: with open(XDG.debug, "w") as f:
f.truncate(0) f.truncate(0)
if _is_steam_deck is False: if _is_steam_deck is False:
# TODO: sudo escalation dialog if FLATPAK_ROOT_PATH in lookup(XDG.config, Preferences.DEFAULT):
# count = get_map_count() has_client = has_flatpak_client()
# TODO: move into module else:
if has_steam_client() is False: has_client = has_steam_client()
if has_client is False:
EarlyAlertDialog(init.requires_steam) EarlyAlertDialog(init.requires_steam)
bootwin = BootWindow(XDG, version) bootwin = BootWindow(XDG, version)
local_coords, latest_release = bootwin.get_results() local_coords, latest_release = bootwin.get_results()
use_miles = lookup(XDG.config, Preferences.DIST) use_miles = lookup(XDG.config, Preferences.DIST)
prefs = UserPrefs( prefs = UserPrefs(
is_steam_deck=_is_steam_deck, is_steam_deck=_is_steam_deck,
is_game_mode=_is_game_mode, is_game_mode=_is_game_mode,
@@ -154,4 +178,6 @@ def load_gui(version: str, is_debug: bool) -> None:
use_miles=use_miles, use_miles=use_miles,
) )
print(boot.all_ok) print(boot.all_ok)
App(prefs) load_steam_icon()
app = App(prefs)
return app
+102 -3
View File
@@ -3,6 +3,9 @@ import logging
import re import re
import requests import requests
import shutil import shutil
import socket
import sqlite3
import time
from pathlib import Path from pathlib import Path
@@ -22,15 +25,16 @@ def find_download_url(url: str) -> str | None:
return str(urls[0]) return str(urls[0])
def get_ipdb(ips_path: Path) -> None: def get_ipdb(db_path: Path) -> None:
try: try:
url = find_download_url(DB_IP) url = find_download_url(DB_IP)
if url is None: if url is None:
return return
ips_path = db_path.with_suffix(".csv")
month_file = db_path.parent / ".month"
date = find_date(url) date = find_date(url)
month_file = ips_path.parent / ".month" if db_path.exists() and month_file.exists():
if ips_path.exists() and month_file.exists():
old_date = month_file.read_text().rstrip("\n") old_date = month_file.read_text().rstrip("\n")
if old_date == date: if old_date == date:
logger.info(f"IP DB date matches: {date}") logger.info(f"IP DB date matches: {date}")
@@ -45,6 +49,9 @@ def get_ipdb(ips_path: Path) -> None:
logger.info("Stripping IPv6 records") logger.info("Stripping IPv6 records")
strip_ipv6(ips_path) strip_ipv6(ips_path)
create_table(db_path, ips_path)
ips_path.unlink()
with open(month_file, "w") as f: with open(month_file, "w") as f:
f.write(date) f.write(date)
logger.info(f"Wrote {date} to {month_file}") logger.info(f"Wrote {date} to {month_file}")
@@ -108,3 +115,95 @@ def strip_ipv6(path: Path) -> None:
out.write(merged) out.write(merged)
path.unlink() path.unlink()
alt_path.rename(path) alt_path.rename(path)
def ip_to_int(ip: str) -> int:
return int.from_bytes(socket.inet_aton(ip), "big")
def marshal_rows(ip_file: Path) -> list[tuple[int, int, str, str]]:
YIELD = 0.003
SLEEP = 0.001
with open(ip_file, "r") as f:
rows: list[tuple[int, int, str, str]] = []
# NOTE: yield to GIL periodically to reduce load on spinner
last_yield = time.monotonic()
for row in f:
start, end, lat, lon = row.rstrip("\n").split(",")
int_start = ip_to_int(start)
int_end = ip_to_int(end)
rows.append((int_start, int_end, lat, lon))
now = time.monotonic()
if (now - last_yield) >= YIELD:
time.sleep(SLEEP)
last_yield = time.monotonic()
return rows
def create_table(db_file: Path, ip_file: Path) -> None:
create = """
CREATE TABLE IF NOT EXISTS ips (
start_range INTEGER PRIMARY KEY,
end_range INTEGER,
latitude REAL,
longitude REAL
) WITHOUT ROWID;
"""
insert = """
INSERT INTO ips
(start_range, end_range, latitude, longitude)
values(?, ?, ?, ?)
"""
if db_file.exists():
db_file.unlink()
conn = sqlite3.connect(":memory:")
c = conn.cursor()
try:
c.execute(create)
rows = marshal_rows(ip_file)
c.executemany(insert, rows)
logger.info(f"Inserted {len(rows)} IP records into database")
conn.commit()
with sqlite3.connect(db_file) as dest:
conn.backup(dest)
logger.info(f"Serialized IP records to database: '{db_file}'")
conn.close()
except Exception as e:
logger.critical(e)
def read_from_table(db_path: Path, ip: str) -> tuple[float, float]:
if not db_path.exists():
raise FileNotFoundError(f"No file found at the location '{db_path}")
conn = sqlite3.connect(db_path)
c = conn.cursor()
int_ip = ip_to_int(ip)
query = """
SELECT end_range, latitude, longitude
FROM ips
WHERE start_range <= ?
ORDER BY start_range DESC
LIMIT 1
"""
index = """
CREATE INDEX IF NOT EXISTS start_index
ON ips(start_range);
"""
try:
c.execute(index)
conn.commit()
c.execute(query, (int_ip,))
row = c.fetchone()
end, lat, lon = row
conn.close()
return lat, lon
except Exception as e:
logger.critical(e)
raise e
+1 -20
View File
@@ -4,7 +4,6 @@ from pathlib import Path
from dzgui.util._json import read_json from dzgui.util._json import read_json
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.const.constants import STEAM_CMD, FLATPAK_RUN_CMD, FLATPAK_SANDBOX
class ConfigFileError(Exception): class ConfigFileError(Exception):
@@ -40,23 +39,5 @@ def get_favorites(path: Path) -> list[str]:
return list(conf["ip_list"]) return list(conf["ip_list"])
# def is_in_favs(record: str, path: Path) -> bool:
# favs = get_favorites(path)
# if record in favs:
# return True
# return False
def enum_to_key(enum: Preferences) -> str: def enum_to_key(enum: Preferences) -> str:
return str(enum.dict["key"]) return str(enum.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
+15 -7
View File
@@ -3,7 +3,13 @@ import os
from pathlib import Path from pathlib import Path
from dataclasses import dataclass from dataclasses import dataclass
from dzgui.const.constants import APP_NAME_LOWER, DEBUG_LOG, SYSTEM_LOG from dzgui.const.constants import (
APP_NAME_LOWER,
DEBUG_LOG,
FLATPAK_ROOT_PATH,
SYSTEM_LOG,
)
from dzgui.init.proc import is_sandboxed
@dataclass @dataclass
@@ -16,7 +22,7 @@ class Xdg:
version: Path version: Path
system: Path system: Path
debug: Path debug: Path
ips: Path db: Path
filters: Path filters: Path
shortcut: Path shortcut: Path
@@ -45,7 +51,7 @@ def is_writeable(path_str: str) -> bool:
return True return True
def get_xdg_paths() -> dict: def get_xdg_paths() -> dict[str, Path]:
home = Path.home() home = Path.home()
xdg_paths = { xdg_paths = {
"XDG_CONFIG_HOME": home / ".config", "XDG_CONFIG_HOME": home / ".config",
@@ -53,10 +59,12 @@ def get_xdg_paths() -> dict:
"XDG_DATA_HOME": home / ".local" / "share", "XDG_DATA_HOME": home / ".local" / "share",
"XDG_CACHE_HOME": home / ".cache", "XDG_CACHE_HOME": home / ".cache",
} }
resolved_paths = {} resolved_paths = {}
for path in xdg_paths: for path in xdg_paths:
real_path = os.environ.get(path) real_path = os.environ.get(path)
if real_path is not None and is_sandboxed():
search_string = FLATPAK_ROOT_PATH + "/"
real_path = real_path.replace(search_string, "")
if real_path is not None and is_writeable(real_path): if real_path is not None and is_writeable(real_path):
new_path = Path(real_path) new_path = Path(real_path)
else: else:
@@ -65,7 +73,7 @@ def get_xdg_paths() -> dict:
return resolved_paths return resolved_paths
def parse_filepaths(xdg: dict) -> Xdg: def parse_filepaths(xdg: dict[str, Path]) -> Xdg:
config = xdg["XDG_CONFIG_HOME"] config = xdg["XDG_CONFIG_HOME"]
state = xdg["XDG_STATE_HOME"] state = xdg["XDG_STATE_HOME"]
share = xdg["XDG_DATA_HOME"] share = xdg["XDG_DATA_HOME"]
@@ -80,7 +88,7 @@ def parse_filepaths(xdg: dict) -> Xdg:
resolution = state / "dzg.res.json" resolution = state / "dzg.res.json"
history = state / "dzg.history" history = state / "dzg.history"
versions = state / "dzg.versions" versions = state / "dzg.versions"
ips = state / "ips.csv" db = state / "ips.db"
filters = state / "dzg.filters.json" filters = state / "dzg.filters.json"
shortcut = share / APP_NAME_LOWER shortcut = share / APP_NAME_LOWER
@@ -94,7 +102,7 @@ def parse_filepaths(xdg: dict) -> Xdg:
versions, versions,
system, system,
debug, debug,
ips, db,
filters, filters,
shortcut, shortcut,
) )
+11 -27
View File
@@ -15,6 +15,7 @@ DAYZ_BINARY = "DayZ_x64.exe"
DAYZ_LAUNCHER = "DayZLauncher.exe" DAYZ_LAUNCHER = "DayZLauncher.exe"
LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf" LIBRARYFOLDERS_PATH = "steamapps/libraryfolders.vdf"
DOWNLOADS_PATH = "steamapps/downloading"
WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ) WORKSHOP_PATH = "steamapps/workshop/content/" + str(APPID_DAYZ)
STEAM_CMD = "steam" STEAM_CMD = "steam"
@@ -30,39 +31,14 @@ BETA_REPO = "testing"
APP_NAME = "DZGUI" APP_NAME = "DZGUI"
APP_NAME_LOWER = "dzgui" APP_NAME_LOWER = "dzgui"
APP_NAME_ABBR = "dzg" APP_NAME_ABBR = "dzg"
GTK_APP_ID = "io.github.aclist.dzgui"
HEX_GREEN = "#32CD32" HEX_GREEN = "#32CD32"
HEX_RED = "#FF0000" HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C" HEX_ORANGE = "#FFAC1C"
# FIXME: normalize icon names
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"
HELP_BUBBLE = "help-about-symbolic"
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"
VIEW_CONCEAL = "view-conceal-symbolic"
VIEW_REVEAL = "view-reveal-symbolic"
WARNING = "dialog-warning-symbolic"
WEB_BROWSER = "web-browser-symbolic"
SEPARATOR = "SEPARATOR" SEPARATOR = "SEPARATOR"
# TODO: drop
NO_PADDING = 0
NO_EXPAND = False
NO_FILL = False
EXPAND = True
FILL = True
SCROLL_INCREMENT = 50 SCROLL_INCREMENT = 50
WINDOW_DEFAULT_X = 1400 WINDOW_DEFAULT_X = 1400
WINDOW_DEFAULT_Y = 800 WINDOW_DEFAULT_Y = 800
@@ -75,12 +51,14 @@ DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG"
SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG" SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG"
CHANGELOG_PATH = "data/CHANGELOG.md" CHANGELOG_PATH = "data/CHANGELOG.md"
IMAGES_PATH = "data/images" IMAGES_PATH = "data/images"
ICONS_PATH = "data/images/gtk"
HERO_PATH = "data/images/_hero.png" HERO_PATH = "data/images/_hero.png"
CSS_PATH = "data/app.css" CSS_PATH = "data/app.css"
VDF_PATH = "steamapps/libraryfolders.vdf" VDF_PATH = "steamapps/libraryfolders.vdf"
DEFAULT_STEAM_PATH = ".local/share/Steam" DEFAULT_STEAM_PATH = ".local/share/Steam"
FLATPAK_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" UBUNTU_STEAM_PATH = ".steam/steam"
DEBIAN_STEAM_PATH = ".steam/debian-installation" DEBIAN_STEAM_PATH = ".steam/debian-installation"
@@ -89,5 +67,11 @@ LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG")
TMP_PATH = "/tmp" TMP_PATH = "/tmp"
TMP_TARBALL = "/tmp/dzgui.tar.gz" TMP_TARBALL = "/tmp/dzgui.tar.gz"
TMP_EXE = "/tmp/dzgui/dzgui" TMP_EXE = "/tmp/dzgui/dzgui"
LOCK_FILE = "/tmp/dzgui.flock"
DAYZ_COMMUNITY_ROOT = "Missions" DAYZ_COMMUNITY_ROOT = "Missions"
GIO_SETTINGS_INTERFACE = "org.gnome.desktop.interface"
GIO_SETTINGS_PROP = "color-scheme"
GIO_SETTINGS_KEY = "gtk-application-prefer-dark-theme"
GIO_SETTINGS_VAL = "prefer-dark"
+4 -3
View File
@@ -3,9 +3,9 @@ STEAM_PUBLISHED_FILES = (
"https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1" "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1"
) )
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1" STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1"
SUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Subscribe/v1" STEAM_SUB = "https://api.steampowered.com/IPublishedFileService/Subscribe/v1"
UNSUB_ENDPOINT = "https://api.steampowered.com/IPublishedFileService/Unsubscribe/v1" STEAM_UNSUB = "https://api.steampowered.com/IPublishedFileService/Unsubscribe/v1"
APP_DETAILS = "https://store.steampowered.com/api/appdetails?" STEAM_APP_DETAILS = "https://store.steampowered.com/api/appdetails?"
GITHUB = "https://github.com/aclist" GITHUB = "https://github.com/aclist"
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest" GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
@@ -18,6 +18,7 @@ COORDS_API = "http://ip-api.com/json"
# steam URIs # steam URIs
WORKSHOP = "steam://url/CommunityFilePage/" WORKSHOP = "steam://url/CommunityFilePage/"
OPEN_URL = "steam://openurl/" OPEN_URL = "steam://openurl/"
VALIDATE = "steam://validate/"
# user facing # user facing
GITHUB_PAGES = "https://aclist.github.io/dzgui/index" GITHUB_PAGES = "https://aclist.github.io/dzgui/index"
+224 -201
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Any, Self
from dzgui.util import strings from dzgui.util import strings
from dzgui.strings import rows
class Port(Enum): class Port(Enum):
@@ -46,124 +48,61 @@ class FilterMode(Enum):
TOGGLE_ON = 3 TOGGLE_ON = 3
class EnumWithAttrs(Enum): @dataclass(slots=True, frozen=True)
def __new__(cls, *args: Any, **kwargs: Any) -> Self: class ModButtonMeta:
value = len(cls.__members__) + 1 label: str
obj = object.__new__(cls) tooltip: str
obj._value_ = value
return obj
def __init__(self, d: dict):
self.dict = d
class Preferences(EnumWithAttrs): @dataclass(slots=True, frozen=True)
STEAM = { class ButtonTypeMeta:
"key": "steam_api", label: str
} tooltip: str
CLIENT = { opens: NotebookPage | None
"key": "client",
}
WINDOW = {
"key": "fullscreen",
}
DIST = {
"key": "use_miles",
}
NAME = {
"key": "name",
}
DEFAULT = {
"key": "default_steam_path",
}
FAV_LBL = {
"key": "fav_label",
}
FAV_SRV = {
"key": "fav_server",
}
IP_LIST = {
"key": "ip_list",
}
START_TAB = {
"key": "start_tab",
}
class NotebookPage(EnumWithAttrs): @dataclass(slots=True, frozen=True)
OFFLINE = {"crumbs": strings.crumbs.offline, "statusbar": True} class ConfigKey:
CHANGELOG = {"crumbs": strings.crumbs.changelog, "statusbar": True} key: str
DEVELOPERS = {"crumbs": strings.crumbs.developers, "statusbar": False}
HELP = {"crumbs": strings.crumbs._help, "statusbar": True}
KEYS = {"crumbs": strings.crumbs.keys, "statusbar": True}
LOG = {"crumbs": strings.crumbs.log, "statusbar": True}
MODS = {"crumbs": strings.crumbs.mods, "statusbar": True}
OPTIONS = {"crumbs": strings.crumbs.options, "statusbar": False}
SERVERS = {"crumbs": strings.crumbs.servers, "statusbar": True}
THANKS = {"crumbs": strings.crumbs.thanks, "statusbar": True}
CONNECTION = {"crumbs": "Connect", "statusbar": True}
class RowType(EnumWithAttrs): @dataclass(slots=True, frozen=True)
# TODO: strings class NotebookMeta:
CHANGELOG = { crumbs: str
"label": "View changelog", has_statusbar: bool
"tooltip": "Opens the DZGUI changelog",
}
SHOW_LOG = {
"label": "Show debug log",
"tooltip": "Read the DZGUI log generated since startup",
}
SEPARATOR = {
"label": "SEPARATOR",
"tooltip": "",
}
DUMP_LOG = {
"label": "Output system info to log file",
"tooltip": "Dump diagnostic data for troubleshooting",
}
DOCS = {
"label": "Documentation/help files (GitHub) ⧉",
"tooltip": "Open the DZGUI documentation in a browser",
}
DOCS_FALLBACK = {
"label": "Documentation/help files (Codeberg mirror) ⧉",
"tooltip": "Open the DZGUI documentation in a browser",
}
BUGS = {
"label": "Report a bug (GitHub) ⧉",
"tooltip": "Open the DZGUI issue tracker in a browser",
}
FORUM = {
"label": "DZGUI Subreddit ⧉",
"tooltip": "Open the DZGUI discussion forum in a browser",
}
SPONSOR = {
"label": "Sponsor (GitHub) ⧉",
"tooltip": "Sponsor development of the DZGUI project",
}
THANKS = {
"label": "Special thanks",
"tooltip": "A list of contributors, testers, and sponsors",
}
# TODO: rename to ContextMenuItem @dataclass(slots=True, frozen=True)
class ContextMenu(EnumWithAttrs): class RowMeta:
ADD_NOTE = {"label": strings.add_note} label: str
ADD_SERVER = {"label": strings.add} tooltip: str
CONNECT = {"label": strings.connect}
COPY_LOG_CLIPBOARD = {"label": strings.copy_log}
COPY_SERVER_IP = {"label": strings.copy_ip} @dataclass(slots=True, frozen=True)
COPY_SERVER_NAME = {"label": strings.copy_name} class ContextMenuMeta:
UNSUB_MOD = {"label": strings.unsub_mod} label: str
OPEN_WORKSHOP = {"label": strings.open_workshop}
REFRESH_PLAYERS = {"label": strings.refresh_players}
REMOVE_HISTORY = {"label": strings.remove_history} class ContextMenuItem(Enum):
REMOVE_SERVER = {"label": strings.remove} ADD_NOTE = ContextMenuMeta(strings.add_note)
SET_FAV = {"label": strings.add_fav} ADD_SERVER = ContextMenuMeta(strings.add)
SHOW_DETAILS = {"label": strings.show_details} CONNECT = ContextMenuMeta(strings.connect)
SHOW_MODS = {"label": strings.show_mods} COPY_LOG_CLIPBOARD = ContextMenuMeta(strings.copy_log)
COPY_SERVER_IP = ContextMenuMeta(strings.copy_ip)
COPY_SERVER_NAME = ContextMenuMeta(strings.copy_name)
UNSUB_MOD = ContextMenuMeta(strings.unsub_mod)
VALIDATE_MOD = ContextMenuMeta(strings.validate_mod)
OPEN_WORKSHOP = ContextMenuMeta(strings.open_workshop)
REFRESH_PLAYERS = ContextMenuMeta(strings.refresh_players)
REMOVE_HISTORY = ContextMenuMeta(strings.remove_history)
REMOVE_SERVER = ContextMenuMeta(strings.remove)
SET_FAV = ContextMenuMeta(strings.add_fav)
SHOW_DETAILS = ContextMenuMeta(strings.show_details)
SHOW_MODS = ContextMenuMeta(strings.show_mods)
@property
def label(self) -> str:
return self.value.label
class ContextMenuGroup(Enum): class ContextMenuGroup(Enum):
@@ -171,109 +110,193 @@ class ContextMenuGroup(Enum):
Groupings of context menu items Groupings of context menu items
""" """
SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,) SERVER_MOD = (ContextMenuItem.OPEN_WORKSHOP,)
MOD = (ContextMenu.OPEN_WORKSHOP, ContextMenu.UNSUB_MOD) MOD = (
ContextMenuItem.OPEN_WORKSHOP,
ContextMenuItem.UNSUB_MOD,
ContextMenuItem.VALIDATE_MOD,
)
MOD_OFFLINE = (None,) MOD_OFFLINE = (None,)
LOG = (ContextMenu.COPY_LOG_CLIPBOARD,) LOG = (ContextMenuItem.COPY_LOG_CLIPBOARD,)
SERVER_BROWSER = ( SERVER_BROWSER = (
ContextMenu.CONNECT, ContextMenuItem.CONNECT,
ContextMenu.ADD_SERVER, ContextMenuItem.ADD_SERVER,
ContextMenu.SET_FAV, ContextMenuItem.SET_FAV,
ContextMenu.COPY_SERVER_NAME, ContextMenuItem.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP, ContextMenuItem.COPY_SERVER_IP,
ContextMenu.ADD_NOTE, ContextMenuItem.ADD_NOTE,
ContextMenu.SHOW_MODS, ContextMenuItem.SHOW_MODS,
ContextMenu.SHOW_DETAILS, ContextMenuItem.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS, ContextMenuItem.REFRESH_PLAYERS,
) )
SCAN_LAN = ( SCAN_LAN = (
ContextMenu.CONNECT, ContextMenuItem.CONNECT,
ContextMenu.SET_FAV, ContextMenuItem.SET_FAV,
ContextMenu.COPY_SERVER_NAME, ContextMenuItem.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP, ContextMenuItem.COPY_SERVER_IP,
ContextMenu.ADD_NOTE, ContextMenuItem.ADD_NOTE,
ContextMenu.SHOW_MODS, ContextMenuItem.SHOW_MODS,
ContextMenu.SHOW_DETAILS, ContextMenuItem.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS, ContextMenuItem.REFRESH_PLAYERS,
) )
SAVED = ( SAVED = (
ContextMenu.CONNECT, ContextMenuItem.CONNECT,
ContextMenu.SET_FAV, ContextMenuItem.SET_FAV,
ContextMenu.REMOVE_SERVER, ContextMenuItem.REMOVE_SERVER,
ContextMenu.COPY_SERVER_NAME, ContextMenuItem.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP, ContextMenuItem.COPY_SERVER_IP,
ContextMenu.ADD_NOTE, ContextMenuItem.ADD_NOTE,
ContextMenu.SHOW_MODS, ContextMenuItem.SHOW_MODS,
ContextMenu.SHOW_DETAILS, ContextMenuItem.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS, ContextMenuItem.REFRESH_PLAYERS,
) )
RECENT = ( RECENT = (
ContextMenu.CONNECT, ContextMenuItem.CONNECT,
ContextMenu.ADD_SERVER, ContextMenuItem.ADD_SERVER,
ContextMenu.SET_FAV, ContextMenuItem.SET_FAV,
ContextMenu.REMOVE_HISTORY, ContextMenuItem.REMOVE_HISTORY,
ContextMenu.COPY_SERVER_NAME, ContextMenuItem.COPY_SERVER_NAME,
ContextMenu.COPY_SERVER_IP, ContextMenuItem.COPY_SERVER_IP,
ContextMenu.ADD_NOTE, ContextMenuItem.ADD_NOTE,
ContextMenu.SHOW_MODS, ContextMenuItem.SHOW_MODS,
ContextMenu.SHOW_DETAILS, ContextMenuItem.SHOW_DETAILS,
ContextMenu.REFRESH_PLAYERS, ContextMenuItem.REFRESH_PLAYERS,
) )
class ModButton(EnumWithAttrs): class Preferences(Enum):
SELECT_ALL = { STEAM = ConfigKey("steam_api")
"label": strings.mod_panel.select_all, CLIENT = ConfigKey("client")
"tooltip": strings.mod_panel.select_all_tooltip, WINDOW = ConfigKey("fullscreen")
} DIST = ConfigKey("use_miles")
UNSELECT_ALL = { NAME = ConfigKey("name")
"label": strings.mod_panel.unselect_all, DEFAULT = ConfigKey("default_steam_path")
"tooltip": strings.mod_panel.unselect_all_tooltip, FAV_LBL = ConfigKey("fav_label")
} FAV_SRV = ConfigKey("fav_server")
HIGHLIGHT_STALE = { IP_LIST = ConfigKey("ip_list")
"label": strings.mod_panel.highlight_stale, START_TAB = ConfigKey("start_tab")
"tooltip": strings.mod_panel.highlight_stale_tooltip,
} @property
UNHIGHLIGHT_STALE = { def key(self) -> str:
"label": strings.mod_panel.unhighlight_stale, return self.value.key
"tooltip": strings.mod_panel.unhighlight_stale_tooltip,
}
UNSUB_SELECTED = {
"label": strings.mod_panel.unsub_selected,
"tooltip": strings.mod_panel.unsub_selected_tooltip,
}
SELECT_STALE = {
"label": strings.mod_panel.select_stale,
"tooltip": strings.mod_panel.select_stale_tooltip,
}
class ButtonType(EnumWithAttrs): class NotebookPage(Enum):
SERVERS = { OFFLINE = NotebookMeta(strings.crumbs.offline, True)
"label": strings.buttons.servers_label, CHANGELOG = NotebookMeta(strings.crumbs.changelog, True)
"tooltip": strings.buttons.servers_tooltip, DEVELOPERS = NotebookMeta(strings.crumbs.developers, False)
"opens": NotebookPage.SERVERS, HELP = NotebookMeta(strings.crumbs._help, True)
} KEYS = NotebookMeta(strings.crumbs.keys, True)
MODS = { LOG = NotebookMeta(strings.crumbs.log, True)
"label": strings.buttons.mods_label, MODS = NotebookMeta(strings.crumbs.mods, True)
"tooltip": strings.buttons.mods_tooltip, OPTIONS = NotebookMeta(strings.crumbs.options, False)
"opens": NotebookPage.MODS, SERVERS = NotebookMeta(strings.crumbs.servers, True)
} THANKS = NotebookMeta(strings.crumbs.thanks, True)
OPTIONS = { CONNECTION = NotebookMeta(strings.crumbs.connect, True)
"label": strings.buttons.options_label,
"tooltip": strings.buttons.options_tooltip, @property
"opens": NotebookPage.OPTIONS, def crumbs(self) -> str:
} return self.value.crumbs
HELP = {
"label": strings.buttons.help_label, @property
"tooltip": strings.buttons.help_tooltip, def has_statusbar(self) -> bool:
"opens": NotebookPage.HELP, return self.value.has_statusbar
}
EXIT = {
"label": strings.buttons.exit_label, class RowType(Enum):
"tooltip": strings.buttons.exit_tooltip, CHANGELOG = RowMeta(rows.changelog_label, rows.changelog_status)
"opens": None, SHOW_LOG = RowMeta(rows.log_label, rows.log_status)
} SEPARATOR = RowMeta(rows.separator_label, rows.separator_status)
DUMP_LOG = RowMeta(rows.system_log_label, rows.system_log_status)
DOCS = RowMeta(rows.docs_label, rows.docs_status)
DOCS_FALLBACK = RowMeta(rows.docs_fallback_label, rows.docs_fallback_status)
BUGS = RowMeta(rows.bugs_label, rows.bugs_status)
FORUM = RowMeta(rows.forum_label, rows.forum_status)
SPONSOR = RowMeta(rows.sponsor_label, rows.sponsor_status)
THANKS = RowMeta(rows.thanks_label, rows.thanks_status)
@property
def label(self) -> str:
return self.value.label
@property
def tooltip(self) -> str:
return self.value.tooltip
class ModButton(Enum):
SELECT_ALL = ModButtonMeta(
strings.mod_panel.select_all, strings.mod_panel.select_all_tooltip
)
UNSELECT_ALL = ModButtonMeta(
strings.mod_panel.unselect_all, strings.mod_panel.unselect_all_tooltip
)
HIGHLIGHT_STALE = ModButtonMeta(
strings.mod_panel.highlight_stale, strings.mod_panel.highlight_stale_tooltip
)
UNHIGHLIGHT_STALE = ModButtonMeta(
strings.mod_panel.unhighlight_stale, strings.mod_panel.unhighlight_stale_tooltip
)
UNSUB_SELECTED = ModButtonMeta(
strings.mod_panel.unsub_selected,
strings.mod_panel.unsub_selected_tooltip,
)
SELECT_STALE = ModButtonMeta(
strings.mod_panel.select_stale,
strings.mod_panel.select_stale_tooltip,
)
VALIDATE = ModButtonMeta(
strings.mod_panel.validate,
strings.mod_panel.validate_tooltip,
)
@property
def label(self) -> str:
return self.value.label
@property
def tooltip(self) -> str:
return self.value.tooltip
class ButtonType(Enum):
SERVERS = ButtonTypeMeta(
strings.buttons.servers_label,
strings.buttons.servers_tooltip,
NotebookPage.SERVERS,
)
MODS = ButtonTypeMeta(
strings.buttons.mods_label,
strings.buttons.mods_tooltip,
NotebookPage.MODS,
)
OPTIONS = ButtonTypeMeta(
strings.buttons.options_label,
strings.buttons.options_tooltip,
NotebookPage.OPTIONS,
)
HELP = ButtonTypeMeta(
strings.buttons.help_label,
strings.buttons.help_tooltip,
NotebookPage.HELP,
)
EXIT = ButtonTypeMeta(
strings.buttons.exit_label,
strings.buttons.exit_tooltip,
None,
)
@property
def label(self) -> str:
return self.value.label
@property
def tooltip(self) -> str:
return self.value.tooltip
@property
def opens(self) -> NotebookPage | None:
return self.value.opens
HELP_MENU_ROWS = ( HELP_MENU_ROWS = (
+20
View File
@@ -0,0 +1,20 @@
ICON_CALCULATOR = "accessories-calculator-symbolic"
ICON_CARET_DOWN = "go-down-symbolic"
ICON_CARET_UP = "go-up-symbolic"
ICON_CLIPBOARD = "edit-copy-symbolic"
ICON_CLOSE = "window-close-symbolic"
ICON_DAY = "weather-clear-symbolic"
ICON_EDIT_DELETE = "edit-delete-symbolic"
ICON_ERROR = "dialog-error-symbolic"
ICON_FOLDER = "folder-symbolic"
ICON_HELP_BUBBLE = "help-about-symbolic"
ICON_INPUT_KEYBOARD = "input-keyboard-symbolic"
ICON_LIST_ADD = "list-add-symbolic"
ICON_NIGHT = "weather-clear-night-symbolic"
ICON_REFRESH = "view-refresh-symbolic"
ICON_SEARCH = "system-search-symbolic"
ICON_STEAM = "steam-tray-symbolic"
ICON_VIEW_CONCEAL = "view-conceal-symbolic"
ICON_VIEW_REVEAL = "view-reveal-symbolic"
ICON_WARNING = "dialog-warning-symbolic"
ICON_WEB_BROWSER = "web-browser-symbolic"
+6 -2
View File
@@ -3,8 +3,8 @@ from typing import TYPE_CHECKING
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository.Gtk import ListStore # noqa E402 from gi.repository.Gtk import ListStore
from gi.repository import Gdk, GObject # noqa E402 from gi.repository import GObject
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.const.enum import NotebookPage, ServerTab from dzgui.const.enum import NotebookPage, ServerTab
@@ -111,6 +111,10 @@ class Emitter(GObject.GObject):
def check_toggled(self, label: str, state: bool) -> None: def check_toggled(self, label: str, state: bool) -> None:
pass pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def servers_timed_out(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def servers_loaded(self, tag: "ServerTab") -> None: def servers_loaded(self, tag: "ServerTab") -> None:
pass pass
+34 -17
View File
@@ -7,11 +7,12 @@ import dzgui.util._json as JSON # noqa
from dzgui.const.enum import ( from dzgui.const.enum import (
FilterMode,
Preferences,
NotebookPage,
ButtonType, ButtonType,
ContextMenu, ContextMenuItem,
FilterMode,
NotebookPage,
Preferences,
ServerTab,
) )
from dzgui.config.userprefs import UserPrefs from dzgui.config.userprefs import UserPrefs
@@ -31,13 +32,12 @@ from dzgui.views.dialogs.generic import ExceptionDialog
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402 from gi.repository import Gtk, Gdk, GLib, GObject
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.const.enum import ServerTab
from dzgui.managers.connection import Prerequisites from dzgui.managers.connection import Prerequisites
from dzgui.managers.filter import FilterManager from dzgui.managers.filter import FilterManager
from dzgui.util.dist import Haversine from dzgui.util.dist import Haversine
@@ -77,7 +77,7 @@ class AppNavigation:
class Controller(GObject.GObject): class Controller(GObject.GObject):
def __init__(self) -> None: def __init__(self) -> None:
self.dist_cache: dict[str, "Haversine"] = {} self.dist_cache: dict[str, "Haversine" | None] = {}
self.mediator = AppNavigation() self.mediator = AppNavigation()
self.prefs: UserPrefs self.prefs: UserPrefs
@@ -102,6 +102,16 @@ class Controller(GObject.GObject):
except AttributeError: except AttributeError:
logger.critical(f"{attr} is not a valid AppNavigation attribute.") logger.critical(f"{attr} is not a valid AppNavigation attribute.")
def validate_mods(
self, treeview: Union["ModTreeView", "OfflineModTreeView", None] = None
) -> None:
if treeview is None:
view = self.mediator.modtreeview
else:
view = treeview
mod_man = view.get_mod_man()
mod_man.validate_mods()
def get_prefs(self) -> UserPrefs: def get_prefs(self) -> UserPrefs:
return self.prefs return self.prefs
@@ -186,7 +196,9 @@ class Controller(GObject.GObject):
def show_developers_page(self) -> None: def show_developers_page(self) -> None:
self.open_page(NotebookPage.DEVELOPERS) self.open_page(NotebookPage.DEVELOPERS)
def open_page(self, page: NotebookPage) -> None: def open_page(self, page: NotebookPage | None) -> None:
if page is None:
return
self.mediator.grid.notebook.set_page_by_enum(page) self.mediator.grid.notebook.set_page_by_enum(page)
def open_page_by_button(self, button: "ContextualButton") -> None: def open_page_by_button(self, button: "ContextualButton") -> None:
@@ -252,7 +264,7 @@ class Controller(GObject.GObject):
logger.critical(e) logger.critical(e)
return return
def menu_action(self, action: ContextMenu, tree: "ServerTreeView") -> None: def menu_action(self, action: ContextMenuItem, tree: "ServerTreeView") -> None:
context_man = ContextMenuManager(tree, self) context_man = ContextMenuManager(tree, self)
context_man.process(action) context_man.process(action)
@@ -264,7 +276,7 @@ class Controller(GObject.GObject):
except Exception as e: except Exception as e:
dialog = ExceptionDialog(self, str(e)) dialog = ExceptionDialog(self, str(e))
dialog.show_all() dialog.show_all()
dialog.run() dialog.run() # type: ignore[no-untyped-call]
def select_colorized(self) -> None: def select_colorized(self) -> None:
mod_man = self.mediator.modtreeview.get_mod_man() mod_man = self.mediator.modtreeview.get_mod_man()
@@ -298,7 +310,7 @@ class Controller(GObject.GObject):
except Exception as e: except Exception as e:
dialog = ExceptionDialog(self, str(e)) dialog = ExceptionDialog(self, str(e))
dialog.show_all() dialog.show_all()
dialog.run() dialog.run() # type: ignore[no-untyped-call]
def update_steam_api_key(self, text: str) -> None: def update_steam_api_key(self, text: str) -> None:
self.config_man.update_steam_api_key(text) self.config_man.update_steam_api_key(text)
@@ -345,22 +357,21 @@ class Controller(GObject.GObject):
filter_man.set_filter(filt, state) filter_man.set_filter(filt, state)
self.mediator.filters.set_filters(filters) self.mediator.filters.set_filters(filters)
def get_dist_cache(self) -> dict[str, "Haversine"]: def get_dist_cache(self) -> dict[str, Union["Haversine", None]]:
return self.dist_cache return self.dist_cache
def set_dist_cache(self, addr: str, haversine: "Haversine") -> None: def set_dist_cache(self, addr: str, haversine: Union["Haversine", None]) -> None:
self.dist_cache[addr] = haversine self.dist_cache[addr] = haversine
def get_map_store(self) -> Gtk.ListStore: def get_map_store(self) -> Gtk.ListStore:
filter_man = self.get_filter_man() filter_man = self.get_filter_man()
return filter_man.get_map_store() return filter_man.get_map_store()
def get_enabled_filters(self) -> dict: def get_enabled_filters(self) -> dict[str, bool]:
filter_man = self.get_filter_man() filter_man = self.get_filter_man()
return filter_man.get_filters() return filter_man.get_filters()
# FIXME: wipe maps store when changing tabs if model is none # FIXME: wipe maps store when changing tabs if model is none
def has_server_model(self) -> bool: def has_server_model(self) -> bool:
treeview = self.get_active_treeview() treeview = self.get_active_treeview()
proxy_man = treeview.get_proxy_man() proxy_man = treeview.get_proxy_man()
@@ -498,6 +509,9 @@ class Controller(GObject.GObject):
def set_active_keyword(self, keyword: str) -> None: def set_active_keyword(self, keyword: str) -> None:
self.get_filter_man().set_active_keyword(keyword) self.get_filter_man().set_active_keyword(keyword)
ServerModelManager(self, self.get_active_treeview()).refilter(
FilterMode.TOGGLE_ON
)
def get_modtreeview(self) -> "ModTreeView": def get_modtreeview(self) -> "ModTreeView":
return self.mediator.modtreeview return self.mediator.modtreeview
@@ -523,6 +537,7 @@ class Controller(GObject.GObject):
def set_start_tab(self) -> None: def set_start_tab(self) -> None:
ind = self.config_man.get_start_tab() ind = self.config_man.get_start_tab()
logger.info(f"Setting start tab to index {ind}")
self.get_servers().notebook.set_current_page(ind) self.get_servers().notebook.set_current_page(ind)
def get_debug_args(self) -> str: def get_debug_args(self) -> str:
@@ -537,8 +552,10 @@ class Controller(GObject.GObject):
def update_status(self) -> None: def update_status(self) -> None:
self.mediator.preconnect.mark_finished() self.mediator.preconnect.mark_finished()
def get_steam_client_name(self) -> str: def get_client_name(self) -> str:
return self.mediator.options.get_client_name() # 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: def present_window(self) -> None:
self.mediator.window.present_with_time(Gdk.CURRENT_TIME) self.mediator.window.present_with_time(Gdk.CURRENT_TIME)
+3
View File
@@ -51,6 +51,8 @@
- Update map count (-u flag) - Update map count (-u flag)
- Filter button tooltips - Filter button tooltips
- Expand/collapse changelog versions - Expand/collapse changelog versions
- Support local light/dark mode changes on system theme
- Server time calculator
### Changed ### Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
@@ -89,6 +91,7 @@
- Rare segfaults when changing maps (threading) - Rare segfaults when changing maps (threading)
- Moved dialogs out of threads - Moved dialogs out of threads
- History table not being in lock-step with state file - History table not being in lock-step with state file
- Current keyword filter not being applied when refreshing table
### Unreleased ### Unreleased
- Load offline mods - Load offline mods
+4
View File
@@ -24,6 +24,10 @@
.left-label { .left-label {
font-size: 1.3rem; font-size: 1.3rem;
} }
.server-subheading {
font-size: 1.1rem;
font-weight: 700;
}
.details-heading { .details-heading {
font-size: 1.2rem font-size: 1.2rem
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 361 KiB

@@ -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

+2 -2
View File
@@ -10,10 +10,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
def get_local_coords(path: "Path") -> Coords | None: def get_local_coords(db_path: "Path") -> Coords | None:
try: try:
my_ip = get_local_ip() my_ip = get_local_ip()
return get_coords(path, my_ip) return get_coords(db_path, my_ip)
except Exception as e: except Exception as e:
logger.warn(e) logger.warn(e)
return None return None
+9 -2
View File
@@ -4,17 +4,24 @@ from pathlib import Path
from dzgui.api.steam import get_app_path from dzgui.api.steam import get_app_path
from dzgui.config.query import lookup from dzgui.config.query import lookup
from dzgui.const.constants import APPID_DAYZ, APP_NAME from dzgui.const.constants import APPID_DAYZ, APP_NAME, APPNAME_DAYZ
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.strings.preconnect import resync
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
class VDFSyncError(Exception):
pass
def is_dayz_installed(config: Path) -> None: def is_dayz_installed(config: Path) -> None:
try: try:
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
get_app_path(Path(path), APPID_DAYZ) get_app_path(Path(path), APPID_DAYZ)
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
raise e msg = resync.format(APPNAME_DAYZ)
raise VDFSyncError(msg)
+3 -2
View File
@@ -5,8 +5,9 @@ import sys
from pathlib import Path from pathlib import Path
from typing import TextIO from typing import TextIO
from dzgui.views.dialogs.early_alert import EarlyAlertDialog from dzgui.const.constants import LOCK_FILE
from dzgui.util.strings import cannot_acquire_lock from dzgui.util.strings import cannot_acquire_lock
from dzgui.views.dialogs.early_alert import EarlyAlertDialog
def lock_release(lock: TextIO) -> None: def lock_release(lock: TextIO) -> None:
@@ -15,7 +16,7 @@ def lock_release(lock: TextIO) -> None:
def lock_acquire() -> TextIO: def lock_acquire() -> TextIO:
lockfile = Path("/tmp/dzgui.flock") lockfile = Path(LOCK_FILE)
lock = open(lockfile, "w") lock = open(lockfile, "w")
try: try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+3 -3
View File
@@ -1,3 +1,4 @@
import json
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -7,11 +8,10 @@ from dzgui.config.convert import rc2json
from dzgui.util._json import read_json, write_json from dzgui.util._json import read_json, write_json
def migrate_legacy_conf(config: Path) -> None: def migrate_legacy_conf(config: Path) -> Any:
old_conf = Path.home() / LEGACY_CONFIG_PATH old_conf = Path.home() / LEGACY_CONFIG_PATH
j = rc2json(old_conf) j = rc2json(old_conf)
config.parent.mkdir(parents=True, exist_ok=True) return json.loads(j)
config.write_text(j)
def has_new_config(config: Path) -> bool: def has_new_config(config: Path) -> bool:
+22 -6
View File
@@ -3,23 +3,39 @@ import shutil
import subprocess import subprocess
from dzgui.const.constants import APP_NAME, FLATPAK_APPID, FLATPAK_CMD, STEAM_CMD from dzgui.const.constants import APP_NAME, FLATPAK_APPID, FLATPAK_CMD, STEAM_CMD
from dzgui.init.proc import is_sandboxed
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
def has_steam_client() -> bool: def has_flatpak_client() -> bool:
if shutil.which(STEAM_CMD): if is_sandboxed():
return True return True
if not shutil.which(FLATPAK_CMD):
return False
try: try:
res = subprocess.run( 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: if FLATPAK_APPID in res.stdout:
return True return True
else: else:
return False return False
except subprocess.CalledProcessError as e: except Exception as e:
logger.critical(e) logger.critical(e)
return False return False
def has_steam_client() -> bool:
if shutil.which(STEAM_CMD):
return True
return False
def has_any_client() -> bool:
if shutil.which(STEAM_CMD):
return True
if not shutil.which(FLATPAK_CMD):
return False
return has_flatpak_client()
+42 -16
View File
@@ -1,7 +1,8 @@
import psutil
import subprocess
import shutil
import logging import logging
import os
import psutil
import shutil
import subprocess
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
@@ -10,42 +11,67 @@ from dzgui.const.constants import (
FLATPAK_CMD, FLATPAK_CMD,
FLATPAK_RUN_CMD, FLATPAK_RUN_CMD,
FLATPAK_SANDBOX, FLATPAK_SANDBOX,
FLATPAK_ROOT_PATH,
FLATPAK_STEAM_PATH,
) )
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
def is_steam_running(cmd: str) -> bool: def is_steam_running(cmd: str) -> bool:
if cmd == STEAM_CMD: if cmd in (FLATPAK_RUN_CMD, FLATPAK_SANDBOX):
if has_cmd(STEAM_CMD) is False:
return False
return is_running(STEAM_CMD)
elif cmd == FLATPAK_RUN_CMD or FLATPAK_SANDBOX:
return is_flatpak_steam_running() return is_flatpak_steam_running()
elif cmd == STEAM_CMD:
return is_native_steam_running()
else: else:
raise TypeError("Not a valid Steam client selection") raise TypeError("Not a valid Steam client selection")
# CHORE: test alternate clients def is_sandboxed() -> bool:
# TODO: has_flatpak_steam return bool(os.environ.get("FLATPAK_ID"))
def is_flatpak_steam_running() -> bool: def is_flatpak_steam_running() -> bool:
if is_sandboxed():
return True
if has_cmd(FLATPAK_CMD) is False: if has_cmd(FLATPAK_CMD) is False:
return False return False
proc = subprocess.check_output([FLATPAK_CMD, "ps"], text=True) proc = subprocess.check_output(
[FLATPAK_CMD, "ps", "--columns=application"], text=True
)
lines = proc.splitlines() lines = proc.splitlines()
if FLATPAK_APPID in lines: if FLATPAK_APPID in lines:
return True return True
return False return False
def is_running(proc: str) -> bool: def get_steam_procs() -> list[list[str]]:
for process in psutil.process_iter(["pid", "name"]): return [
if process.info["name"] == proc: p.cmdline()
return True for p in psutil.process_iter(["pid", "name"])
return False 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: def has_cmd(cmd: str) -> bool:
if shutil.which(cmd) is not None: if shutil.which(cmd) is not None:
return True return True
return False return False
def path_to_steam_cmd(path: str) -> str:
cmd = FLATPAK_RUN_CMD if FLATPAK_STEAM_PATH in path else STEAM_CMD
return cmd
+8 -2
View File
@@ -42,6 +42,12 @@ def main() -> None:
test_libgi_missing() test_libgi_missing()
# NOTE: only import ligbirepository modules after the above check # NOTE: only import ligbirepository modules after the above check
from dzgui.app_init import load_gui from dzgui.app_init import build_gui
from dzgui.init.flock import lock_acquire
load_gui(version, args.debug) lock = lock_acquire() # noqa
app = build_gui(version, args.debug)
if app is None:
sys.exit(0)
app.run(None)
+10 -21
View File
@@ -6,9 +6,6 @@ from typing import Any, TYPE_CHECKING
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
FLATPAK_RUN_CMD,
FLATPAK_SANDBOX,
STEAM_CMD,
WINDOW_DEFAULT_X, WINDOW_DEFAULT_X,
WINDOW_DEFAULT_Y, WINDOW_DEFAULT_Y,
) )
@@ -19,11 +16,6 @@ from dzgui.views.dialogs.generic import ExceptionDialog
from dzgui.strings import dialogs from dzgui.strings import dialogs
from dzgui.util._json import read_json, write_json from dzgui.util._json import read_json, write_json
import gi
gi.require_version("Gtk", "3.0")
from gi.repository.Gtk import main_quit # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.config.userprefs import UserPrefs from dzgui.config.userprefs import UserPrefs
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -132,16 +124,7 @@ class ConfigManager:
return False return False
def enum_to_key(self, enum: Preferences) -> str: def enum_to_key(self, enum: Preferences) -> str:
return str(enum.dict["key"]) return str(enum.key)
def get_client_index(self, client: str) -> int:
if client == STEAM_CMD:
return 0
if client == FLATPAK_RUN_CMD:
return 1
if client == FLATPAK_SANDBOX:
return 2
return 0
def toggle_config(self, key: Preferences) -> None: def toggle_config(self, key: Preferences) -> None:
try: try:
@@ -179,7 +162,7 @@ class ConfigManager:
trace = traceback.format_exc() trace = traceback.format_exc()
dialog = ExceptionDialog(self.controller, trace) dialog = ExceptionDialog(self.controller, trace)
dialog.show_all() dialog.show_all()
dialog.run() dialog.run() # type: ignore[no-untyped-call]
def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None: def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None:
columns = tv.get_columns() columns = tv.get_columns()
@@ -206,7 +189,10 @@ class ConfigManager:
logger.info("Normal user exit") logger.info("Normal user exit")
if window.props.is_maximized: if window.props.is_maximized:
main_quit() app = window.get_application()
if app is None:
return
app.quit()
return return
w, h = window.get_size() w, h = window.get_size()
@@ -218,7 +204,10 @@ class ConfigManager:
except Exception as e: except Exception as e:
logger.critical(e) logger.critical(e)
main_quit() app = window.get_application()
if app is None:
return
app.quit()
def set_resolution(self, window: "OuterWindow") -> None: def set_resolution(self, window: "OuterWindow") -> None:
if self.prefs.is_game_mode: if self.prefs.is_game_mode:
+26 -18
View File
@@ -14,6 +14,7 @@ from dzgui.api.shortcuts import Shortcuts
from dzgui.api.steam import ( from dzgui.api.steam import (
connect, connect,
connect_debug, connect_debug,
is_dayz_downloading,
get_app_allows_downloads, get_app_allows_downloads,
get_app_name, get_app_name,
get_needs_update, get_needs_update,
@@ -47,18 +48,13 @@ from dzgui.strings.dialogs import (
waiting_for_directories, waiting_for_directories,
) )
from dzgui.strings import kb from dzgui.strings import kb
from dzgui.strings.server_mods import checkmark, resync from dzgui.strings.server_mods import checkmark, resync, invalid_mod
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.strings import dialog, server_timeout
from dzgui.util.symlink import rebuild_symlinks from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.dialogs.generic import ExceptionDialog from dzgui.views.dialogs.generic import ExceptionDialog
from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog from dzgui.views.dialogs.servers import ServerDetailsDialog, ServerModDialog
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record from dzgui.api.servers import A2SInfo, Record
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -84,6 +80,7 @@ class Prerequisites:
available_space: float available_space: float
passworded: bool passworded: bool
dayz_running: bool dayz_running: bool
download_paused: bool
steam_proc: SteamProcess steam_proc: SteamProcess
mods: list[list[str]] mods: list[list[str]]
game_mode: bool game_mode: bool
@@ -169,6 +166,7 @@ class ConnectionManager:
free_mib = format_mib(free) free_mib = format_mib(free)
dayz_running = is_dayz_running() dayz_running = is_dayz_running()
download_paused = is_dayz_downloading(steam_path, self.appid)
running_app = get_running_app() running_app = get_running_app()
if running_app is not None: if running_app is not None:
@@ -184,8 +182,8 @@ class ConnectionManager:
else: else:
allows_downloads = (True, "") allows_downloads = (True, "")
client_name = self.controller.get_steam_client_name()
client = self.controller.query_config(Preferences.CLIENT) client = self.controller.query_config(Preferences.CLIENT)
client_name = self.controller.get_client_name()
running = is_steam_running(client) running = is_steam_running(client)
steam_proc = SteamProcess(client_name, running) steam_proc = SteamProcess(client_name, running)
self.client = client self.client = client
@@ -194,9 +192,8 @@ class ConnectionManager:
is_last = self.is_last_server() is_last = self.is_last_server()
# TODO: strings
invalid_mods = [ invalid_mods = [
(mod[0], mod[1]) for mod in remote_mods if mod[2] == "Invalid mod" (mod[0], mod[1]) for mod in remote_mods if mod[2] == invalid_mod
] ]
prereqs = Prerequisites( prereqs = Prerequisites(
@@ -210,6 +207,7 @@ class ConnectionManager:
available_space=free_mib, available_space=free_mib,
passworded=info.password_protected, passworded=info.password_protected,
dayz_running=dayz_running, dayz_running=dayz_running,
download_paused=download_paused,
steam_proc=steam_proc, steam_proc=steam_proc,
mods=remote_mods, mods=remote_mods,
game_mode=game_mode, game_mode=game_mode,
@@ -235,7 +233,7 @@ class ConnectionManager:
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def query_details(self, record: Servers.Record) -> None: def query_details(self, record: Servers.Record) -> None:
details = Servers.get_details(record) details = Servers.get_details(record)
if details.success is False: if details is None:
self.thread_man.set_cleanup_func( self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True StoredFunc(self._server_timeout), destroy_first=True
) )
@@ -273,8 +271,7 @@ class ConnectionManager:
for mod in alpha_mods: for mod in alpha_mods:
# NOTE: if the mod is neither synched or out of date, it is a malformed mod # NOTE: if the mod is neither synched or out of date, it is a malformed mod
if mod[2] == "": if mod[2] == "":
# TODO: strings mod[2] = invalid_mod
mod[2] = "Invalid mod"
return alpha_mods, missing_mods return alpha_mods, missing_mods
@@ -294,17 +291,22 @@ class ConnectionManager:
def _present_modlist_dialog(self, mods: list[list[str]]) -> None: def _present_modlist_dialog(self, mods: list[list[str]]) -> None:
dialog = ServerModDialog(self.controller, mods) dialog = ServerModDialog(self.controller, mods)
dialog.run() dialog.run() # type: ignore[no-untyped-call]
def _present_details_dialog(self, details: Servers.Details) -> None: def _present_details_dialog(self, details: Servers.Details) -> None:
dialog = ServerDetailsDialog(self.controller, details) dialog = ServerDetailsDialog(self.controller, details)
dialog.run() dialog.run() # type: ignore[no-untyped-call]
def _workshop_timeout(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.show_all()
dialog.run() # type: ignore[no-untyped-call]
def _server_timeout(self) -> None: def _server_timeout(self) -> None:
dialog = ExceptionDialog(self.controller, server_timeout) dialog = ExceptionDialog(self.controller, server_timeout)
dialog.set_details_buffer(kb.DZG_006) dialog.set_details_buffer(kb.DZG_006)
dialog.show_all() dialog.show_all()
dialog.run() dialog.run() # type: ignore[no-untyped-call]
def get_debug_args(self) -> str: def get_debug_args(self) -> str:
addr = f"{self.record.ip}:{self.record.gameport}" addr = f"{self.record.ip}:{self.record.gameport}"
@@ -351,8 +353,14 @@ class ConnectionManager:
for title, mod, stamp, size in self.missing_mods: for title, mod, stamp, size in self.missing_mods:
if self.controller.is_cancel_pending(): if self.controller.is_cancel_pending():
return return
subscribe(key, int(mod)) try:
time.sleep(API_RATE_LIMIT) 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) self.thread_man.update_dialog(waiting_for_directories)
for title, mod, stamp, size in self.missing_mods: for title, mod, stamp, size in self.missing_mods:
+23 -24
View File
@@ -1,9 +1,8 @@
import gi
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME from dzgui.const.constants import APP_NAME
from dzgui.const.enum import ContextMenu, Preferences from dzgui.const.enum import ContextMenuItem, Preferences
from dzgui.util.clip import copy_clipboard from dzgui.util.clip import copy_clipboard
from dzgui.util.open_links import open_workshop_page from dzgui.util.open_links import open_workshop_page
@@ -14,10 +13,6 @@ from dzgui.views.trees.tree_servers import ServerTreeView
from dzgui.views.trees.tree_server_mods import ServerModTreeView from dzgui.views.trees.tree_server_mods import ServerModTreeView
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -39,7 +34,7 @@ class ContextMenuManager:
self.controller = controller self.controller = controller
self.treeview = treeview self.treeview = treeview
def process(self, action: ContextMenu) -> None: def process(self, action: ContextMenuItem) -> None:
if isinstance(self.treeview, ServerTreeView): if isinstance(self.treeview, ServerTreeView):
record = self.treeview.get_record() record = self.treeview.get_record()
if record is None: if record is None:
@@ -47,14 +42,14 @@ class ContextMenuManager:
match action: match action:
# UNTHREADED # UNTHREADED
case ContextMenu.ADD_NOTE: case ContextMenuItem.ADD_NOTE:
dialog = NoteDialog(self.controller) dialog = NoteDialog(self.controller)
dialog.run() dialog.run() # type: ignore[no-untyped-call]
case ContextMenu.COPY_SERVER_IP: case ContextMenuItem.COPY_SERVER_IP:
self.copy_server_ip() self.copy_server_ip()
case ContextMenu.COPY_SERVER_NAME: case ContextMenuItem.COPY_SERVER_NAME:
self.copy_server_name() self.copy_server_name()
case ContextMenu.SET_FAV: case ContextMenuItem.SET_FAV:
name = self.treeview.get_value_at_index(0) name = self.treeview.get_value_at_index(0)
# NOTE: fully qualified ip # NOTE: fully qualified ip
fqip = self.treeview.get_record_string() fqip = self.treeview.get_record_string()
@@ -63,36 +58,40 @@ class ContextMenuManager:
self.controller.set_fav(name, fqip, simple) self.controller.set_fav(name, fqip, simple)
# THREADED # THREADED
case ContextMenu.ADD_SERVER: case ContextMenuItem.ADD_SERVER:
self.controller.add_by_record(record) self.controller.add_by_record(record)
case ContextMenu.CONNECT: case ContextMenuItem.CONNECT:
self.controller.connect_by_record(record) self.controller.connect_by_record(record)
case ContextMenu.REFRESH_PLAYERS: case ContextMenuItem.REFRESH_PLAYERS:
self.controller.refresh_players(record) self.controller.refresh_players(record)
case ContextMenu.REMOVE_HISTORY: case ContextMenuItem.REMOVE_HISTORY:
self.controller.remove_from_history(record) self.controller.remove_from_history(record)
case ContextMenu.REMOVE_SERVER: case ContextMenuItem.REMOVE_SERVER:
self.controller.remove_by_record(record) self.controller.remove_by_record(record)
case ContextMenu.SHOW_DETAILS: case ContextMenuItem.SHOW_DETAILS:
self.controller.get_details(record) self.controller.get_details(record)
case ContextMenu.SHOW_MODS: case ContextMenuItem.SHOW_MODS:
self.controller.get_modlist(record) self.controller.get_modlist(record)
if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)): if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)):
match action: match action:
case ContextMenu.UNSUB_MOD: case ContextMenuItem.OPEN_WORKSHOP:
self.controller.unsub_mods(self.treeview)
case ContextMenu.OPEN_WORKSHOP:
self.open_mod_page() self.open_mod_page()
case ContextMenuItem.UNSUB_MOD:
self.controller.unsub_mods(self.treeview)
case ContextMenuItem.VALIDATE_MOD:
self.controller.validate_mods(self.treeview)
case _:
pass
if isinstance(self.treeview, LogTreeView): if isinstance(self.treeview, LogTreeView):
match action: match action:
case ContextMenu.COPY_LOG_CLIPBOARD: case ContextMenuItem.COPY_LOG_CLIPBOARD:
self.copy_log() self.copy_log()
if isinstance(self.treeview, ServerModTreeView): if isinstance(self.treeview, ServerModTreeView):
match action: match action:
case ContextMenu.OPEN_WORKSHOP: case ContextMenuItem.OPEN_WORKSHOP:
self.open_mod_page() self.open_mod_page()
def copy_server_ip(self) -> None: def copy_server_ip(self) -> None:
+12 -17
View File
@@ -2,7 +2,7 @@ from typing import TYPE_CHECKING
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
from dzgui.util import strings from dzgui.util import strings
from dzgui.util.strings import all_maps from dzgui.strings.filters import all_maps
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.model.model_factory import FastInsertListStore from dzgui.model.model_factory import FastInsertListStore
@@ -22,20 +22,21 @@ class FilterManager:
strings.filter_night: True, strings.filter_night: True,
strings.filter_full: show_full, strings.filter_full: show_full,
strings.filter_official: True, strings.filter_official: True,
strings.filter_nonascii: False, strings.filter_modded: True,
strings.filter_lowpop: True, strings.filter_lowpop: True,
strings.filter_unofficial: True, strings.filter_unofficial: True,
strings.filter_unmodded: True,
strings.filter_duplicate: False, strings.filter_duplicate: False,
strings.filter_modded: True, strings.filter_nonascii: False,
} }
# TODO: strings # TODO: strings
self.tooltips = { self.tooltips = {
strings.filter_1pp: "First-person perspective", strings.filter_1pp: "First-person perspective",
strings.filter_day: "In-game time between 0700 and 1659", strings.filter_day: "In-game time between 0600 and 1759",
strings.filter_empty: "Servers with no players", strings.filter_empty: "Servers with no players",
strings.filter_3pp: "Third-person perspective", strings.filter_3pp: "Third-person perspective",
strings.filter_night: "In-game time between 1700 and 0659", strings.filter_night: "In-game time between 1800 and 0559",
strings.filter_full: "Servers with no open slots", strings.filter_full: "Servers with no open slots",
strings.filter_official: "Bohemia official servers", strings.filter_official: "Bohemia official servers",
strings.filter_nonascii: "Server names using non-standard, complex glyphs", strings.filter_nonascii: "Server names using non-standard, complex glyphs",
@@ -43,13 +44,13 @@ class FilterManager:
strings.filter_unofficial: "Third-party servers", strings.filter_unofficial: "Third-party servers",
strings.filter_duplicate: "Duplicates of existing servers (usually spoofed)", strings.filter_duplicate: "Duplicates of existing servers (usually spoofed)",
strings.filter_modded: "Servers with one or more mods", strings.filter_modded: "Servers with one or more mods",
strings.filter_unmodded: "Servers with no mods",
} }
self.active_keyword = "" self.active_keyword = ""
self.active_map = (0, all_maps) self.active_map = (0, all_maps)
self.prior_map = all_maps self.prior_map = all_maps
self.filters: list
self.enabled_filters = dict(self.default_filters) self.enabled_filters = dict(self.default_filters)
def get_tooltips(self) -> dict[str, str]: def get_tooltips(self) -> dict[str, str]:
@@ -73,11 +74,11 @@ class FilterManager:
def set_active_keyword(self, word: str) -> None: def set_active_keyword(self, word: str) -> None:
self.active_keyword = word self.active_keyword = word
def get_default_filters(self) -> dict: def get_default_filters(self) -> dict[str, bool]:
"""Deep copy of defaults""" """Deep copy of defaults"""
return dict(self.default_filters) return dict(self.default_filters)
def get_filters(self) -> dict: def get_filters(self) -> dict[str, bool]:
return self.enabled_filters return self.enabled_filters
def set_filter(self, label: str, state: bool) -> None: def set_filter(self, label: str, state: bool) -> None:
@@ -104,17 +105,11 @@ class FilterManager:
self.reinit_map_store() self.reinit_map_store()
maps.sort() maps.sort()
for m in maps: clean = [m for m in maps if m != all_maps]
# TODO: strings for m in clean:
if m == "All maps":
continue
self.append_map([m]) self.append_map([m])
# TODO: currently unused def get_all_filters(self) -> tuple[str, ...]:
def get_unique_maps(self) -> list[str]:
return [row[0] for row in self.map_store if row[0] != "All maps"]
def get_all_filters(self) -> tuple:
map_name = self.get_active_map_name() map_name = self.get_active_map_name()
enabled = self.get_filters() enabled = self.get_filters()
kw = self.get_active_keyword() kw = self.get_active_keyword()
+33 -5
View File
@@ -4,7 +4,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.api.steam import unsubscribe from dzgui.api.steam import unsubscribe, validate_mod
from dzgui.api.mods import ( from dzgui.api.mods import (
get_delimited_mods, get_delimited_mods,
find_stale_mods, find_stale_mods,
@@ -30,7 +30,7 @@ import dzgui.api.pefile as PeFile
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -85,7 +85,7 @@ class ModManager:
total_mods = len(self.store) total_mods = len(self.store)
self.emitter.emit("mods_updated", msg, total_mods) self.emitter.emit("mods_updated", msg, total_mods)
def unsub_mods(self) -> None: def get_selected_mods(self) -> list[tuple[str, Gtk.TreeIter]]:
sel = self.treeview.get_selection() sel = self.treeview.get_selection()
model, pathlist = sel.get_selected_rows() model, pathlist = sel.get_selected_rows()
# NOTE: reverse when multiple selection # NOTE: reverse when multiple selection
@@ -96,6 +96,15 @@ class ModManager:
continue continue
mod, _iter = res mod, _iter = res
mods.append((mod, _iter)) mods.append((mod, _iter))
return mods
def validate_mods(self) -> None:
mods = self.get_selected_mods()
self.thread_man.set_job_count(len(mods))
self.validate_all_mods(mods)
def unsub_mods(self) -> None:
mods = self.get_selected_mods()
self.thread_man.set_job_count(len(mods)) self.thread_man.set_job_count(len(mods))
self.unsub_all_mods(mods) self.unsub_all_mods(mods)
@@ -109,6 +118,21 @@ class ModManager:
mod = model.get_value(tree_iter, 2) mod = model.get_value(tree_iter, 2)
return mod, tree_iter return mod, tree_iter
def validate_atomic_mod(self, mod: str) -> None:
# NOTE: propagation to dayz exp will happen during
# subsequent symlink phase on connect
client = self.controller.query_config(Preferences.CLIENT)
validate_mod(client, mod)
@call_on_thread(dialogs.validating_mods)
def validate_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
for mod, _iter in mods:
self.validate_atomic_mod(mod)
self.thread_man.increment_dialog()
time.sleep(API_RATE_LIMIT)
func = StoredFunc(lambda: None)
self.thread_man.set_cleanup_func(func)
@call_on_thread(dialogs.deleting_mods) @call_on_thread(dialogs.deleting_mods)
def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None: def unsub_all_mods(self, mods: list[tuple[str, Gtk.TreeIter]]) -> None:
for mod, _iter in mods: for mod, _iter in mods:
@@ -122,7 +146,11 @@ class ModManager:
def unsub_atomic_mod(self, mod: str) -> None: def unsub_atomic_mod(self, mod: str) -> None:
config_man = self.controller.get_config_man() config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.STEAM) key = config_man.lookup(Preferences.STEAM)
unsubscribe(key, int(mod)) try:
unsubscribe(key, int(mod))
except Exception as e:
logger.critical(e)
return
steam_path = Path(self.path) steam_path = Path(self.path)
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
@@ -193,7 +221,7 @@ class ModManager:
total_size += mod[3] total_size += mod[3]
return total_mods, total_size return total_mods, total_size
def _on_stale_mods_found(self, stale_mods: list) -> None: def _on_stale_mods_found(self, stale_mods: list[int]) -> None:
"""Manipulates attached ListStore in the main event loop""" """Manipulates attached ListStore in the main event loop"""
model = self.treeview.get_model() model = self.treeview.get_model()
if model is None: if model is None:
+1 -1
View File
@@ -52,7 +52,7 @@ class OfflineManager:
is_valid = is_mission(folder) is_valid = is_mission(folder)
self.emitter.emit("custom_mission_loaded", str(folder), is_valid) self.emitter.emit("custom_mission_loaded", str(folder), is_valid)
def find_custom_mods(self, callback: Callable) -> None: def find_custom_mods(self, callback: Callable[[], None]) -> None:
folder = self.open_folderpicker(dialogs.custom_mod_dialog) folder = self.open_folderpicker(dialogs.custom_mod_dialog)
window = self.controller.get_window() window = self.controller.get_window()
window.set_sensitive(False) window.set_sensitive(False)
+4 -4
View File
@@ -15,15 +15,15 @@ if TYPE_CHECKING:
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402 from gi.repository import GLib
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
def call_on_thread( def call_on_thread(
dialog_str: str, show_dialog: bool = True, show_cancel: bool = False dialog_str: str, show_dialog: bool = True, show_cancel: bool = False
) -> Callable: ) -> Callable[..., Any]:
def decorator(func: Callable) -> Callable: def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@wraps(func) @wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> None: def wrapper(*args: Any, **kwargs: Any) -> None:
self = args[0] self = args[0]
@@ -42,7 +42,7 @@ def call_on_thread(
class StoredFunc: class StoredFunc:
def __init__(self, func: Callable, *args: Any, **kwargs: Any) -> None: def __init__(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
sig = inspect.signature(func) sig = inspect.signature(func)
self.func = func self.func = func
self.bindings = sig.bind(*args, **kwargs) self.bindings = sig.bind(*args, **kwargs)
+2 -7
View File
@@ -15,11 +15,6 @@ from dzgui.strings import dialogs
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.views.dialogs.generic import ExceptionDialog, QuitDialog from dzgui.views.dialogs.generic import ExceptionDialog, QuitDialog
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -60,9 +55,9 @@ class UpdateManager:
def _on_update_success(self) -> None: def _on_update_success(self) -> None:
msg = dialogs.update_success msg = dialogs.update_success
dialog = QuitDialog(self.controller, msg) dialog = QuitDialog(self.controller, msg)
dialog.run() dialog.run() # type: ignore[no-untyped-call]
def _on_update_failure(self, msg: str) -> None: def _on_update_failure(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg) dialog = ExceptionDialog(self.controller, msg)
dialog.show_all() dialog.show_all()
dialog.run() dialog.run() # type: ignore[no-untyped-call]
+4 -4
View File
@@ -7,11 +7,11 @@ from dzgui.util.strings import delimiter
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository.Gtk import ListStore, TreeIter # noqa E402 from gi.repository.Gtk import ListStore, TreeIter
from gi.repository import GObject # noqa E402 from gi.repository import GObject
GTYPE_TO_PYTHON = { GTYPE_TO_PYTHON = {
GObject.type_from_name(GObject.type_name(ptype)): ptype GObject.type_from_name(GObject.type_name(ptype)): ptype # type: ignore[no-untyped-call]
for ptype in (int, float, str, bool, object) for ptype in (int, float, str, bool, object)
} }
@@ -147,7 +147,7 @@ class ModelFactory:
def make_help_store(self) -> FastInsertListStore: def make_help_store(self) -> FastInsertListStore:
store = self.new_model_from_class(MenuCols) store = self.new_model_from_class(MenuCols)
rows = [[row.dict["label"], row] for row in HELP_MENU_ROWS] rows = [[row.label, row] for row in HELP_MENU_ROWS]
store.extend(rows) store.extend(rows)
return store return store
+29 -15
View File
@@ -1,10 +1,12 @@
import re import re
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING, Union from typing import Any, TYPE_CHECKING, Union
from dzgui.const.enum import FilterMode from dzgui.const.enum import FilterMode
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
from dzgui.util import strings from dzgui.util import strings
from dzgui.strings import filters as str_filters
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.managers.filter import FilterManager from dzgui.managers.filter import FilterManager
@@ -12,8 +14,24 @@ if TYPE_CHECKING:
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.model.servers import NewPlayerCount from dzgui.model.servers import NewPlayerCount
DAY_REG = r"([0][7-9]|[1][0-6])" DAY_REG = r"([0][6-9]|[1][0-7])"
NIGHT_REG = r"([0][0-6]|[1][7-9]|[2][0-3])" NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])"
@dataclass(slots=True, frozen=True)
class ServerRow:
name: str
mapname: str
view: str
time: str
players: int
max_players: int
queue: int
ip: str
qport: int
ping: str
provider: str
modded: bool
class ProxyModelManager: class ProxyModelManager:
@@ -30,7 +48,7 @@ class ProxyModelManager:
def __init__(self, filter_man: "FilterManager") -> None: def __init__(self, filter_man: "FilterManager") -> None:
self.filter_cache: dict[ self.filter_cache: dict[
tuple[str], tuple[str, ...],
list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]], list[tuple[str, str, str, str, int, int, int, str, int, int, str, bool]],
] = {} ] = {}
@@ -51,9 +69,6 @@ class ProxyModelManager:
return False return False
return True return True
def append_row(self, row: list) -> None:
self.proxy_model.append(row)
def append_row_to_control( def append_row_to_control(
self, row: tuple[str, str, str, str, int, int, int, str, int, int, str, bool] self, row: tuple[str, str, str, str, int, int, int, str, int, int, str, bool]
) -> None: ) -> None:
@@ -67,7 +82,6 @@ class ProxyModelManager:
self.proxy_model[treeiter][4] = playercount.players self.proxy_model[treeiter][4] = playercount.players
self.proxy_model[treeiter][6] = playercount.queue self.proxy_model[treeiter][6] = playercount.queue
# TODO: use dataclass for record rows
def append_row_to_history( def append_row_to_history(
self, self,
history: tuple[str, str, str, str, int, int, int, str, int, int, str, bool], history: tuple[str, str, str, str, int, int, int, str, int, int, str, bool],
@@ -175,7 +189,7 @@ class ProxyModelManager:
rows = self.filtered rows = self.filtered
sel_map = self.filter_man.get_active_map_name() sel_map = self.filter_man.get_active_map_name()
if sel_map == strings.all_maps: if sel_map == str_filters.all_maps:
return rows return rows
rows = [row for row in rows if row[1] == sel_map] rows = [row for row in rows if row[1] == sel_map]
@@ -243,9 +257,11 @@ class ProxyModelManager:
rows = [row for row in rows if (row[4] / row[5] * 100) > 30] rows = [row for row in rows if (row[4] / row[5] * 100) > 30]
case strings.filter_modded: case strings.filter_modded:
rows = [row for row in rows if not row[11]] rows = [row for row in rows if not row[11]]
case strings.filter_unmodded:
rows = [row for row in rows if row[11]]
return rows return rows
def filter_toggle_on(self, filters: tuple[str]) -> list: def filter_toggle_on(self, filters: tuple[str, ...]) -> list:
"""Effectively applies all filters""" """Effectively applies all filters"""
self.set_filtered(self.control_model) self.set_filtered(self.control_model)
self.set_filtered(self.filter_map(filters)) self.set_filtered(self.filter_map(filters))
@@ -256,13 +272,9 @@ class ProxyModelManager:
self.set_filtered(self.filter_toggle_off(filters, f)) self.set_filtered(self.filter_toggle_off(filters, f))
return self.filtered return self.filtered
def set_cache(self, filters: tuple[str], filtered_rows: list[tuple]) -> None: def set_cache(self, filters: tuple[str, ...], filtered_rows: list[tuple]) -> None:
self.filter_cache[filters] = filtered_rows self.filter_cache[filters] = filtered_rows
# @deprecated("Currently unused")
# def convert_model_to_list(self, model: "FastInsertListStore") -> list:
# return [[el for el in row] for row in model]
def set_filtered(self, rows: list | None) -> None: def set_filtered(self, rows: list | None) -> None:
if rows is None: if rows is None:
self.filtered = [] self.filtered = []
@@ -298,4 +310,6 @@ class ProxyModelManager:
def push(self, data: list[Any]) -> None: def push(self, data: list[Any]) -> None:
self.wipe_cache() self.wipe_cache()
self.set_control(data) self.set_control(data)
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)
+39 -11
View File
@@ -21,7 +21,7 @@ from dzgui.views.dialogs.generic import ExceptionDialog
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk, GLib
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record from dzgui.api.servers import A2SInfo, Record
@@ -92,6 +92,7 @@ class ServerModelManager:
job = Servers.query_api job = Servers.query_api
params = Servers.params params = Servers.params
servers = [] servers = []
with ThreadPoolExecutor() as executor: with ThreadPoolExecutor() as executor:
futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params] futures = [executor.submit(job, key, APPID_DAYZ, param) for param in params]
for future in as_completed(futures): for future in as_completed(futures):
@@ -200,19 +201,43 @@ class ServerModelManager:
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def add_by_ip(self, addr: str) -> None: def add_by_ip(self, addr: str) -> None:
res = Servers.query_by_ip(addr) self.preserve_on_fail = True
self._parse_single_record(res) try:
res = Servers.query_by_ip(addr)
self._parse_single_record(res)
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def add_by_record(self, record: Servers.Record) -> None: def add_by_record(self, record: Servers.Record) -> None:
self.preserve_on_fail = True
# NOTE: rationale: a raw Record as shown in server browser may resolve to a different IP # NOTE: rationale: a raw Record as shown in server browser may resolve to a different IP
res = Servers.query_by_record(record) try:
self._parse_single_record(res) res = Servers.query_by_record(record)
self._parse_single_record(res)
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def remove_by_record(self, record: Servers.Record) -> None: def remove_by_record(self, record: Servers.Record) -> None:
res = Servers.query_by_record(record) self.preserve_on_fail = True
self._parse_single_record(res, delete=True) try:
res = Servers.query_by_record(record)
self._parse_single_record(res, delete=True)
except Exception as e:
logger.warning(e)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_failure), destroy_first=True
)
return
def update_history( def update_history(
self, self,
@@ -279,7 +304,6 @@ class ServerModelManager:
proxy_man.update_playercount(self.playercount) proxy_man.update_playercount(self.playercount)
def _parse_single_record(self, response: "A2SInfo", delete: bool = False) -> None: def _parse_single_record(self, response: "A2SInfo", delete: bool = False) -> None:
self.preserve_on_fail = True
try: try:
row = response.as_row() row = response.as_row()
except Exception as e: except Exception as e:
@@ -390,6 +414,8 @@ class ServerModelManager:
def _cleanup_on_success(self) -> None: def _cleanup_on_success(self) -> None:
proxy = self.proxy_man.get_proxy_model() proxy = self.proxy_man.get_proxy_model()
self.tv.set_model(proxy) self.tv.set_model(proxy)
if self.tv.enum == ServerTab.BROWSER:
GLib.idle_add(self.tv.wipe_cache)
if self.preserve_on_fail is True: if self.preserve_on_fail is True:
self.proxy_man.wipe_cache() self.proxy_man.wipe_cache()
@@ -406,12 +432,14 @@ class ServerModelManager:
if show_dialog: if show_dialog:
dialog = ExceptionDialog(self.controller, dialogs.load_error_lan) dialog = ExceptionDialog(self.controller, dialogs.load_error_lan)
dialog.show_all() dialog.show_all()
dialog.run() dialog.run() # type: ignore[no-untyped-call]
def _cleanup_on_failure(self, show_dialog: bool = True) -> None: def _cleanup_on_failure(self, show_dialog: bool = True) -> None:
# TODO: disable map, keyword, and filter widgets if model is None # TODO: disable map, keyword, and filter widgets if model is None
# signal driven (servers_empty, servers_failed_to_load) # signal driven (servers_empty, servers_failed_to_load)
self.emitter.emit("servers_timed_out")
if self.preserve_on_fail is False: if self.preserve_on_fail is False:
self.tv.set_model(None) self.tv.set_model(None)
filter_man = self.tv.get_filter_man() filter_man = self.tv.get_filter_man()
@@ -423,14 +451,14 @@ class ServerModelManager:
dialog = ExceptionDialog(self.controller, server_timeout) dialog = ExceptionDialog(self.controller, server_timeout)
dialog.set_details_buffer(kb.DZG_006) dialog.set_details_buffer(kb.DZG_006)
dialog.show_all() dialog.show_all()
dialog.run() dialog.run() # type: ignore[no-untyped-call]
def _push_data(self, data: list[Any]) -> None: def _push_data(self, data: list[Any]) -> None:
self._get_proxy_man().push(data) self._get_proxy_man().push(data)
self._sort_unique_maps(data) self._sort_unique_maps(data)
self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success))
def _sort_unique_maps(self, data: list) -> None: def _sort_unique_maps(self, data: list[Any]) -> None:
u_maps = set([row[1] for row in data]) u_maps = set([row[1] for row in data])
self._set_new_maps(sorted(u_maps)) self._set_new_maps(sorted(u_maps))
+14
View File
@@ -1,2 +1,16 @@
clipboard_button = "Copy to clipboard"
clipboard_ip_button = "Copy IP to clipboard"
dock_button_collapse = "Collapse the connection panel"
dock_button_expand = "Expand the connection panel"
workshop = "Steam Workshop" workshop = "Steam Workshop"
workshop_tooltip = "Open your Workshop subscriptions" workshop_tooltip = "Open your Workshop subscriptions"
version_notifier = "Copied!"
version_tooltip = "Click to copy to clipboard"
update_button = "Updates available"
unset_button_label = "Unset"
unset_button_tooltip = "Unset this server as favorite"
+7
View File
@@ -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."
+17 -15
View File
@@ -1,20 +1,22 @@
add_button="Add" from dzgui.const.constants import UDP_PORT
add_con_heading="Add/connect"
add_button = "Add"
add_con_heading = "Add/connect"
add_popover = "This address is already in your Saved Servers" add_popover = "This address is already in your Saved Servers"
add_tooltip="Add to Saved Servers" add_tooltip = "Add to Saved Servers"
connect_button="Connect" connect_button = "Connect"
connect_tooltip="Connect to this server" connect_tooltip = "Connect to this server"
connect_entry_placeholder="Enter IP:Query port (e.g., 192.168.1.1:27016)" connect_entry_placeholder = f"Enter IP:Query port (e.g., 192.168.1.1:{UDP_PORT})"
fav_heading="Favorite server" fav_heading = "Favorite server"
favs_empty="None set. Right click a server and select 'Set favorite' to set." favs_empty = "None set. Right click a server and select 'Set favorite' to set."
lan_heading="LAN query port" lan_heading = "LAN query port"
lan_default_button="Default port (27016)" lan_default_button = f"Default port ({UDP_PORT})"
lan_custom_button="Custom port" lan_custom_button = "Custom port"
lan_scan_button="Scan" lan_scan_button = "Scan"
lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster" lan_abort_tooltip = "Unless you have multiple DayZ servers on your LAN,\nleave this checked to get results faster"
lan_checkbox = "Stop scanning on first hit" lan_checkbox = "Stop scanning on first hit"
lan_entry_placeholder="Enter the query port (1-65535)" lan_entry_placeholder = "Enter the query port (1-65535)"
lan_entry_tooltip="Specify the port to search for DayZ servers on the local network" lan_entry_tooltip = "Specify the port to search for DayZ servers on the local network"
lan_scan_tooltip="Scan for servers" lan_scan_tooltip = "Scan for servers"
+8
View File
@@ -9,6 +9,7 @@ update_success = "Updated successfully. Please exit and relaunch."
load_error_lan = "Failed to find any servers on your network.\nCheck the server query port or your firewall settings." load_error_lan = "Failed to find any servers on your network.\nCheck the server query port or your firewall settings."
fetching_mods = "Fetching mod metadata" fetching_mods = "Fetching mod metadata"
deleting_mods = "Unsubscribing mods" deleting_mods = "Unsubscribing mods"
validating_mods = "Validating mods"
scanning_mods = "Scanning mods" scanning_mods = "Scanning mods"
parsing_mods = "Parsing mods" parsing_mods = "Parsing mods"
@@ -23,3 +24,10 @@ custom_mod_dialog = "Set location to a custom mods folder"
debug_heading = "Debug" debug_heading = "Debug"
debug_secondary = "Launch arguments" debug_secondary = "Launch arguments"
toggle_calc = "Toggle calculator"
server_details = "Server details"
server_message = "Server message"
caught_cancel_signal = "Caught cancel signal, cleaning up"
+1
View File
@@ -4,3 +4,4 @@ api_validation_error = (
api_popover = "API key validation failed." api_popover = "API key validation failed."
dayz_not_found = "Could not find a valid DayZ installation." dayz_not_found = "Could not find a valid DayZ installation."
workshop_endpoint_error = "Steam Workshop service unavailable, try again later."
+4
View File
@@ -0,0 +1,4 @@
all_maps = "All maps"
filters_heading = "Filters"
keyword_placeholder = "Filter by keyword"
map_placeholder = "Filter by map"
+1
View File
@@ -1 +1,2 @@
tab_changed_label = "*"
selectable_tree = "Ctrl-click to select multiple; Shift-click to select a range." selectable_tree = "Ctrl-click to select multiple; Shift-click to select a range."
+5
View File
@@ -0,0 +1,5 @@
dialog_title = "Add a short note/reminder"
dialog_subtitle = "Limit 30 chars"
delete_button = "Delete note"
ok_button = "Update note"
current_note = "Current note: '{0}'"
+9
View File
@@ -16,4 +16,13 @@ lan_combo = "LAN"
fullscreen_eventbox = "This option is not available on Steam Deck." fullscreen_eventbox = "This option is not available on Steam Deck."
save_button = "Save" save_button = "Save"
api_placeholder = "Enter API key"
api_failed = "API validation failed" api_failed = "API validation failed"
api_validate = "Validate"
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."
)
+5 -2
View File
@@ -11,8 +11,6 @@ mods = "Mods"
total_mods = "Total mods: " total_mods = "Total mods: "
up_to_date = "All mods are up to date." up_to_date = "All mods are up to date."
all_updated = "All mods updated." all_updated = "All mods updated."
"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." required_space = " Need to download {0} MiB of mod updates."
placeholder_no_mods = "This server has no mods." placeholder_no_mods = "This server has no mods."
@@ -36,6 +34,7 @@ steam_not_running = "'{0}' is set as the default Steam client, but it is either
use_desktop_mode = "Use Desktop Mode to download mods on Steam Deck." 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_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." version_missing = "Remote server is running the build '{0}', but it is not installed."
no_errors = "No errors."
# Warnings # Warnings
@@ -48,3 +47,7 @@ running_app = (
"Either stop the game first, or update your global Steam settings or the game's local settings.\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." "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."
)
+29
View File
@@ -0,0 +1,29 @@
changelog_label = "View changelog"
changelog_status = "Opens the DZGUI changelog"
log_label = "Show debug log"
log_status = "Read the DZGUI log generated since startup"
separator_label = "SEPARATOR"
separator_status = ""
system_log_label = "Output system info to log file"
system_log_status = "Dump diagnostic data for troubleshooting"
docs_label = "Documentation/help files (GitHub) ⧉"
docs_status = "Open the DZGUI documentation in a browser"
docs_fallback_label = "Documentation/help files (Codeberg mirror) ⧉"
docs_fallback_status = "Open the DZGUI documentation in a browser"
bugs_label = "Report a bug (GitHub) ⧉"
bugs_status = "Open the DZGUI issue tracker in a browser"
forum_label = "DZGUI Subreddit ⧉"
forum_status = "Open the DZGUI discussion forum in a browser"
sponsor_label = "Sponsor (GitHub) ⧉"
sponsor_status = "Sponsor development of the DZGUI project"
thanks_label = "Special thanks"
thanks_status = "A list of contributors, testers, and sponsors"
+2 -1
View File
@@ -3,4 +3,5 @@ mod_id = "ID"
up_to_date = "Up to date" up_to_date = "Up to date"
modlist = "Modlist" modlist = "Modlist"
checkmark = "✓" checkmark = "✓"
resync="⟳" resync = "⟳"
invalid_mod = "Invalid mod"
+2 -1
View File
@@ -30,7 +30,8 @@ path_remove_prefix = "The following path will be removed:"
not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall." not_installed = "It looks like DZGUI was not previously set up; nothing to uninstall."
standalone_uninstall = ( standalone_uninstall = (
"It looks like you are running the standalone version of DZGUI.\n" "It looks like you are running the standalone version of DZGUI.\n"
"To finalize the uninstall process, you can remove the 'dzgui' file." "To finalize the uninstall process, invoke './dzgui self remove',\n"
"then remove the 'dzgui' file."
) )
system_uninstall = ( system_uninstall = (
"It looks like DZGUI was installed via your system package manager or directly from source code.\n" "It looks like DZGUI was installed via your system package manager or directly from source code.\n"
+16 -2
View File
@@ -3,6 +3,8 @@ title_intro = "Welcome"
blurb_intro = """ blurb_intro = """
This wizard will help you set up some common config options before launching the application. This wizard will help you set up some common config options before launching the application.
""" """
error_sandbox = "Setup cannot be run from inside Flatpak sandbox.\nPlease launch the application directly."
welcome = "Welcome to {0}!"
### SteamPathPage ### SteamPathPage
heading_steam_path = "Steam path" heading_steam_path = "Steam path"
@@ -17,7 +19,7 @@ desc_flatpak_path = (
desc_ubuntu_path = "This is the default Steam path on <b>Ubuntu-based systems</b>." desc_ubuntu_path = "This is the default Steam path on <b>Ubuntu-based systems</b>."
desc_debian_path = "This is the default Steam path on <b>Debian-based systems</b>." desc_debian_path = "This is the default Steam path on <b>Debian-based systems</b>."
no_valid_paths = ( no_valid_paths = (
"No valid Steam paths found on system. Please install Steam to continue." "No valid Steam path or Steam command found on system. Please install Steam to continue."
) )
button_scan = "Scan for Steam" button_scan = "Scan for Steam"
@@ -70,7 +72,8 @@ checkbox_steam_shortcut = (
( (
"This creates a standalone copy of DZGUI and adds it to Steam. " "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. " "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." "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 = ( checkbox_start_menu = (
@@ -79,6 +82,17 @@ checkbox_start_menu = (
) )
checkbox_desktop_shortcut = ("Add a desktop shortcut", "Depends on the option above.") checkbox_desktop_shortcut = ("Add a desktop shortcut", "Depends on the option above.")
### Flatpak page
heading_flatpak = "Flatpak sandbox overrides"
blurb_flatpak = (
"You chose to add a shortcut directly to Flatpak Steam. "
"To launch DZGUI from Flatpak's sandbox, you must grant "
"permission to read from and write to the following locations."
)
tree_column = "Paths"
overrides_applied = "Overrides applied."
button_flatpak = "Grant permissions"
### Completion page ### Completion page
heading_completion = "Setup complete" heading_completion = "Setup complete"
blurb_completion = "Configuration completed successfully. Click 'Close' to close this dialog and launch DZGUI." blurb_completion = "Configuration completed successfully. Click 'Close' to close this dialog and launch DZGUI."
+7
View File
@@ -1,5 +1,12 @@
import shlex import shlex
from dzgui.const.constants import STEAM_CMD
from dzgui.init.proc import is_sandboxed
def concat_bash_args(command: str) -> list[str]: def concat_bash_args(command: str) -> list[str]:
# NOTE: NSGs spawned from sandbox cannot access host application commands,
# so fallback to Steam internal URIs
if is_sandboxed():
return [STEAM_CMD]
return shlex.split(command) return shlex.split(command)
+1 -2
View File
@@ -1,8 +1,7 @@
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0") from gi.repository import Gtk, Gdk
from gi.repository import Gtk, Gdk # noqa E402
def copy_clipboard(text: str) -> None: def copy_clipboard(text: str) -> None:
+1 -1
View File
@@ -5,7 +5,7 @@ from dzgui.const.constants import APP_NAME_LOWER, CSS_PATH
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa E402 from gi.repository import Gtk, Gdk
def add_class(widget: Gtk.Widget, label: str) -> None: def add_class(widget: Gtk.Widget, label: str) -> None:
+5 -10
View File
@@ -7,11 +7,6 @@ from dzgui.const.constants import APP_NAME
from dzgui.util.ip import get_coords from dzgui.util.ip import get_coords
from dzgui.util.localize import number from dzgui.util.localize import number
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from queue import Queue from queue import Queue
from dzgui.const.enum import ServerTab from dzgui.const.enum import ServerTab
@@ -59,7 +54,7 @@ class CalcDist:
self, self,
addr: str, addr: str,
enum: "ServerTab", enum: "ServerTab",
result_queue: "Queue", result_queue: "Queue[tuple[str, Haversine | None, ServerTab]]",
controller: "Controller", controller: "Controller",
cache: dict[str, Haversine], cache: dict[str, Haversine],
) -> None: ) -> None:
@@ -72,7 +67,7 @@ class CalcDist:
self.ip = self.addr self.ip = self.addr
dist = self.compare(self.ip) dist = self.compare(self.ip)
self.result_queue.put([self.addr, dist, self.enum]) self.result_queue.put((self.addr, dist, self.enum))
def compare(self, remote_ip: str) -> Haversine | None: def compare(self, remote_ip: str) -> Haversine | None:
prefs = self.controller.get_prefs() prefs = self.controller.get_prefs()
@@ -80,10 +75,10 @@ class CalcDist:
if local is None: if local is None:
return None return None
try: try:
remote = get_coords(prefs.paths.ips, remote_ip) remote = get_coords(prefs.paths.db, remote_ip)
except Exception: except Exception as e:
logger.critical(e)
return None return None
# TODO: handle failed remote dist
haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon) haversine = Haversine(local.lat, local.lon, remote.lat, remote.lon)
return haversine return haversine
+39
View File
@@ -0,0 +1,39 @@
import logging
import subprocess
from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME, FLATPAK_APPID, GTK_APP_ID, TMP_PATH
logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from pathlib import Path
def apply_overrides(xdg: dict[str, "Path"]) -> None:
try:
# NOTE: D-Bus registration required for sandbox
args = [
"flatpak",
"--user",
"override",
f"--own-name={GTK_APP_ID}",
FLATPAK_APPID,
]
subprocess.run([*args], capture_output=True)
paths: list[str] = []
paths.extend([str(path) for path in xdg.values()])
paths.append(TMP_PATH)
for path in paths:
args = [
"flatpak",
"--user",
"override",
f"--filesystem={path}",
FLATPAK_APPID,
]
subprocess.run([*args], capture_output=True)
except Exception as e:
logger.critical(e)
+3 -2
View File
@@ -1,4 +1,5 @@
import re import re
from typing import Any
from dzgui.util.localize import number from dzgui.util.localize import number
from dzgui.strings.generic import selectable_tree from dzgui.strings.generic import selectable_tree
@@ -7,7 +8,7 @@ from dzgui.util.strings import no_mods, no_servers, workshop
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk
def format_pango(text: str) -> str: def format_pango(text: str) -> str:
@@ -60,7 +61,7 @@ def format_server_mods(mods: int) -> str:
return f"Found {mods:n} {plural}. {workshop}" return f"Found {mods:n} {plural}. {workshop}"
def format_player_count(model: Gtk.TreeModel | None, control: list) -> str: def format_player_count(model: Gtk.TreeModel | None, control: list[Any]) -> str:
players = 0 players = 0
hits: int hits: int
status: str status: str
+16 -55
View File
@@ -1,15 +1,16 @@
import a2s import a2s
from ipaddress import ip_address
import requests import requests
import shutil import shutil
import subprocess import subprocess
from warnings import deprecated
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.config.ipdb import read_from_table
from dzgui.const.constants import REQUEST_TIMEOUT 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: if TYPE_CHECKING:
@@ -30,58 +31,25 @@ class ReservedIPError(Exception):
pass pass
def ip2list(ip: str) -> list: def ip2list(ip: str) -> list[int]:
return [int(digit) for digit in ip.split(".")] return [int(digit) for digit in ip.split(".")]
# TODO: tests def is_usable(ip: str) -> bool:
def is_reserved(digits: list) -> bool: address = ip_address(ip)
reserved = [0, 10, 127] if address.is_reserved or address.is_private:
return False
if digits[0] in reserved: return True
return True
if digits[0] == 192 and digits[1] == 168:
return True
return False
def get_coords(ips: "Path", ip: str) -> Coords: def get_coords(db_path: "Path", ip: str) -> Coords:
split = ip2list(ip) if not is_usable(ip):
if is_reserved(split):
raise ReservedIPError(f"{ip} is a reserved IP address.") raise ReservedIPError(f"{ip} is a reserved IP address.")
try:
prefix = f"^{split[0]}.{split[1]}." lat, lon = read_from_table(db_path, ip)
return Coords(lat, lon)
if shutil.which("rg") is not None: except Exception:
args = ["/usr/bin/rg", prefix, str(ips)] raise GeolocationError("No matching records found")
else:
args = ["/usr/bin/grep", "-E", prefix, str(ips)]
proc = subprocess.run(args, capture_output=True, text=True)
if proc.returncode != 0:
raise GeolocationError("Failed to split records")
ip_list = proc.stdout.splitlines()
for address in ip_list:
fields = address.split(",")
upper = fields[1]
upper_digits = ip2list(upper)
if ip == upper:
return Coords(float(fields[-2]), float(fields[-1]))
if split[2] > upper_digits[2]:
continue
if split[2] == upper_digits[2]:
if split[3] > upper_digits[3]:
continue
if split[2] <= upper_digits[2]:
return Coords(float(fields[-2]), float(fields[-1]))
raise GeolocationError("No matching records found")
def get_local_ip() -> str: def get_local_ip() -> str:
@@ -127,10 +95,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: if not port.isdigit() or int(port) == 0 or int(port[0]) == 0 or int(port) > 65535:
return True return True
return False return False
@deprecated("use ips.csv")
def get_local_coords(ip: str) -> str:
url = COORDS_API + "/" + ip
# local res=$(curl -Ls "$url" | jq -r '"\(.lat)\n\(.lon)"')
return url
+4 -1
View File
@@ -1,6 +1,8 @@
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gdk # noqa E402 from gi.repository import Gdk
def is_navkey(key: int) -> bool: def is_navkey(key: int) -> bool:
nav_keys = [ nav_keys = [
@@ -18,6 +20,7 @@ def is_navkey(key: int) -> bool:
return True return True
return False return False
def is_ctrl_mask(event: Gdk.EventKey) -> bool: def is_ctrl_mask(event: Gdk.EventKey) -> bool:
if event.state is Gdk.ModifierType.CONTROL_MASK: if event.state is Gdk.ModifierType.CONTROL_MASK:
return True return True
+3 -1
View File
@@ -1,6 +1,8 @@
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk
def set_surrounding_margins(widget: Gtk.Widget, margin: int) -> None: def set_surrounding_margins(widget: Gtk.Widget, margin: int) -> None:
""" """
+80
View File
@@ -0,0 +1,80 @@
from pathlib import Path
from typing import cast, TYPE_CHECKING
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, GLib
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
from dzgui.views.base import OuterWindow
from dzgui.views.mixins.context_mixin import ContextMixin
from gi.repository import GdkPixbuf
# TODO: randomize/mock PII distance values
class IdleScreenshot:
def __init__(
self, controller: "Controller", window: "OuterWindow", scrot_path: Path
) -> None:
self.window = window
self.controller = controller
self.scrot_path = scrot_path
clock = window.get_frame_clock()
if clock is None:
raise TypeError("Widget not realized, FrameClock not initialized")
clock.connect("after-paint", self.callback)
def callback(self, clock: Gdk.FrameClock) -> None:
GLib.timeout_add(1000, self.check_idle)
def check_idle(self) -> bool:
tv = self.controller.get_active_treeview()
loaded = tv.is_loaded()
if loaded is True:
self.emit_key_press(tv)
GLib.timeout_add(1000, self.save_and_quit)
return True
return False
def emit_key_press(self, widget: "ContextMixin") -> None:
window = widget.get_parent_window()
if window is None:
raise TypeError(f"Failed to find a valid parent window for {widget}")
event = Gdk.EventKey.new(Gdk.EventType.KEY_PRESS)
# NOTE: GTK introspection returns Gdk.Event type
event_key = cast(Gdk.EventKey, event)
event_key.keyval = Gdk.KEY_l
event_key.state = Gdk.ModifierType.CONTROL_MASK
event_key.window = window
event_key.time = Gdk.CURRENT_TIME
event_key.send_event = True
# TODO: suppress errors from synthetic key events
widget.present_menu(widget, event)
def save_and_quit(self) -> None:
self.save_pixbuf()
self.controller.save_res_and_quit()
def save_pixbuf(self) -> None:
pixbuf = self.capture_application()
if pixbuf:
pixbuf.savev(str(self.scrot_path), "png", [], [])
def capture_application(self) -> "GdkPixbuf.Pixbuf":
# NOTE: composited context menu over X11 window
root = Gdk.get_default_root_window()
xwindow = self.window.get_window()
if xwindow is None:
raise ValueError("Failed to get a valid X11 window")
index, x, y = xwindow.get_origin()
width, height = self.window.get_size()
application = Gdk.pixbuf_get_from_window(root, x, y, width, height)
if application is None:
raise ValueError("Failed to get a valid GdkPixbuf")
return application
+12 -7
View File
@@ -11,7 +11,6 @@ delimiter = "␞"
# GenericDialog # GenericDialog
main_menu = "Return to main menu" main_menu = "Return to main menu"
exit_app = "Exit" exit_app = "Exit"
server_details = "Server details"
modlist = "Modlist" modlist = "Modlist"
input_required = "User input required" input_required = "User input required"
confirm = "Confirmation" confirm = "Confirmation"
@@ -32,6 +31,7 @@ show_details = "Show server details"
refresh_players = "Refresh player count" refresh_players = "Refresh player count"
open_workshop = "Open in Steam Workshop" open_workshop = "Open in Steam Workshop"
unsub_mod = "Unsubscribe mod" unsub_mod = "Unsubscribe mod"
validate_mod = "Validate mod"
copy_name = "Copy name to clipboard" copy_name = "Copy name to clipboard"
copy_ip = "Copy IP to clipboard" copy_ip = "Copy IP to clipboard"
copy_log = "Copy record(s) to clipboard" copy_log = "Copy record(s) to clipboard"
@@ -59,7 +59,6 @@ browser_cols = [
] ]
# DetailsDialog # DetailsDialog
server_message = "Server message"
workshop = "Enter/double click a row to open in Steam Workshop." workshop = "Enter/double click a row to open in Steam Workshop."
# LanDialog # LanDialog
@@ -119,6 +118,7 @@ servers = {
"0": "toggle filter 10", "0": "toggle filter 10",
"Minus": "toggle filter 11", "Minus": "toggle filter 11",
"Backslash": "toggle filter 12", "Backslash": "toggle filter 12",
"Apostrophe": "toggle filter 13",
} }
vim = { vim = {
"j": "Move down a row/scroll up", "j": "Move down a row/scroll up",
@@ -158,9 +158,7 @@ filter_duplicate = "Duplicate"
filter_official = "Official" filter_official = "Official"
filter_unofficial = "Unofficial" filter_unofficial = "Unofficial"
filter_modded = "Modded" filter_modded = "Modded"
filter_unmodded = "Unmodded"
# maps
all_maps = "All maps"
# state # state
unknown = "Unknown" unknown = "Unknown"
@@ -168,11 +166,12 @@ none_provided = "None provided"
disabled = "Disabled" disabled = "Disabled"
enabled = "Enabled" enabled = "Enabled"
null = "-" null = "-"
none = "None"
unspecified = "not specified" unspecified = "not specified"
# DLC # DLC
dlc_frostline = "Frostline" dlc_frostline = "Frostline"
dlc_badlands = "Badlands"
dlc_none = "None"
# platform # platform
windows = "Windows" windows = "Windows"
@@ -200,6 +199,8 @@ class ModPanelStrings:
clear_highlights: str clear_highlights: str
select_stale: str select_stale: str
select_stale_tooltip: str select_stale_tooltip: str
validate: str
validate_tooltip: str
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
@@ -249,6 +250,7 @@ class Crumbs:
developers: str developers: str
default: str default: str
offline: str offline: str
connect: str
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
@@ -297,7 +299,7 @@ class Options:
init = Init( init = Init(
is_steam_running="Is Steam running? DZGUI must be run on top of Steam.", is_steam_running="Is Steam running? DZGUI must be run on top of Steam.",
is_dayz_running="Is DayZ already running? DZGUI cannot launch DayZ if another process is using it.", is_dayz_running="Is DayZ already running? DZGUI cannot launch DayZ if another process is using it.",
requires_steam="DZGUI requires that Steam or Flatpak Steam be installed on the system.", requires_steam="Your chosen client (Steam/Flatpak Steam) must be installed on the system.",
) )
mod_panel = ModPanelStrings( mod_panel = ModPanelStrings(
@@ -319,6 +321,8 @@ mod_panel = ModPanelStrings(
clear_highlights="Clears highlights and reverts the table to a default state", clear_highlights="Clears highlights and reverts the table to a default state",
select_stale="Select stale", select_stale="Select stale",
select_stale_tooltip="Only selects highlighted stale mods", select_stale_tooltip="Only selects highlighted stale mods",
validate="Validate selected",
validate_tooltip="Validates selected mods and updates them if necessary",
) )
@@ -434,6 +438,7 @@ crumbs = Crumbs(
developers="Options > Developers", developers="Options > Developers",
default="Servers > ", default="Servers > ",
offline="Mods > Play offline", offline="Mods > Play offline",
connect="Connect",
) )
no_mods = "No local mods found." no_mods = "No local mods found."
+10 -5
View File
@@ -2,7 +2,12 @@ import logging
from pathlib import Path from pathlib import Path
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
from dzgui.const.constants import 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.const.enum import Preferences
from dzgui.config.query import lookup from dzgui.config.query import lookup
@@ -20,6 +25,7 @@ def expunge_link(file: Path) -> None:
if str(file.stem)[:2] == "@C": if str(file.stem)[:2] == "@C":
file.unlink() file.unlink()
def rebuild_symlinks(config: Path) -> None: def rebuild_symlinks(config: Path) -> None:
# TODO: pass direct path as argument # TODO: pass direct path as argument
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
@@ -63,10 +69,9 @@ def symlink_mission(steam_path: Path, target: str) -> str:
stem = path.name stem = path.name
parent = path.parent parent = path.parent
source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT) source = dayz_path.joinpath(DAYZ_COMMUNITY_ROOT)
try: if source.exists():
source.symlink_to(parent) source.unlink()
except Exception: source.symlink_to(parent)
return ""
return f"{DAYZ_COMMUNITY_ROOT}/{stem}" return f"{DAYZ_COMMUNITY_ROOT}/{stem}"
+38 -27
View File
@@ -1,19 +1,23 @@
import logging import logging
import signal import signal
import warnings
from typing import TYPE_CHECKING, Literal from typing import Any, Literal, Self, TYPE_CHECKING
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.const.enum import NotebookPage
from dzgui.controllers.emitter import Emitter from dzgui.controllers.emitter import Emitter
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
from dzgui.util import css, strings from dzgui.util import css
from dzgui.util.keys import is_ctrl_mask from dzgui.util.keys import is_ctrl_mask
from dzgui.views.components.connect_panel import ConnectPanel from dzgui.views.components.connect_panel import ConnectPanel
from dzgui.views.components.crumbs import Breadcrumbs from dzgui.views.components.crumbs import Breadcrumbs
from dzgui.views.components.right_panel import RightPanel from dzgui.views.components.right_panel import RightPanel
from dzgui.views.components.statusbar import Statusbar from dzgui.views.components.statusbar import Statusbar
from dzgui.views.mixins.colorscheme import ColorAwareApp
from dzgui.views.mixins.scrollable_mixin import ScrollableMixin from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
# TODO: import notebook only and add components there? # TODO: import notebook only and add components there?
@@ -34,21 +38,19 @@ import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
gi.require_version("GLibUnix", "2.0") gi.require_version("GLibUnix", "2.0")
from gi.repository import Gtk, GLib, GLibUnix, Gdk # type: ignore # noqa E402 from gi.repository import Gtk, GLib, GLibUnix, Gdk # type: ignore
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.config.userprefs import UserPrefs from dzgui.config.userprefs import UserPrefs
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
# TODO: drop
# https://bugzilla.gnome.org/show_bug.cgi?id=708676
warnings.filterwarnings("ignore", ".*g_value_get_int", Warning)
class OuterWindow(Gtk.ApplicationWindow):
class OuterWindow(Gtk.Window): def __init__(self, app: "App") -> None:
def __init__(self) -> None: super().__init__(
super().__init__(title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER) application=app, title=APP_NAME, border_width=10, icon_name=APP_NAME_LOWER
)
self.hb = AppHeaderBar() self.hb = AppHeaderBar()
MainController.register_widget("window", self) MainController.register_widget("window", self)
@@ -76,6 +78,15 @@ class OuterWindow(Gtk.Window):
MainController.loaded = True MainController.loaded = True
MainController.populate_model(MainController.get_active_treeview()) MainController.populate_model(MainController.get_active_treeview())
try:
from dzgui.util.save_screenshot import IdleScreenshot
scrot = app.get_automation_param("screenshot_path")
self.fullscreen()
IdleScreenshot(MainController, self, scrot)
except KeyError:
pass
def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None: def _on_delete_event(self, window: "OuterWindow", event: Gdk.EventKey) -> None:
self.halt_proc_and_quit() self.halt_proc_and_quit()
@@ -279,13 +290,26 @@ class Grid(Gtk.Grid):
self.right_panel.refresh_button.set_visible(state) 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: def __init__(self, prefs: "UserPrefs") -> None:
super().__init__(application_id=GTK_APP_ID)
GLib.set_prgname(APP_NAME) GLib.set_prgname(APP_NAME)
MainController.set_prefs(prefs) MainController.set_prefs(prefs)
self.win = OuterWindow() self._setup_signals()
self.connect("activate", self._on_activate)
self.automation_params: dict[str, Any] = {}
def get_automation_param(self, key: str) -> Any:
return self.automation_params[key]
def set_automation_param(self, key: str, value: Any) -> None:
self.automation_params[key] = value
def _on_activate(self, app: Self) -> None:
self.win = OuterWindow(app)
accel = Gtk.AccelGroup() accel = Gtk.AccelGroup()
accel.connect( accel.connect(
@@ -296,9 +320,6 @@ class App(Gtk.Application):
) )
self.win.add_accel_group(accel) self.win.add_accel_group(accel)
self._setup_signals()
Gtk.main()
def _setup_signals(self) -> None: def _setup_signals(self) -> None:
SIGNAL_ADD = "signal_add" SIGNAL_ADD = "signal_add"
SIGNAL_ADD_FULL = "signal_add_full" SIGNAL_ADD_FULL = "signal_add_full"
@@ -329,13 +350,3 @@ class App(Gtk.Application):
MainController = Controller() MainController = Controller()
try:
theme = Gtk.IconTheme.get_default()
icons = theme.list_icons(None)
if STEAM_ICON not in icons:
logger.warn(strings.steam_icon_missing)
warnings.warn(strings.steam_icon_missing, stacklevel=2)
except Exception as e:
logger.warning(e)
warnings.warn(strings.gtk_theme_missing, stacklevel=2)
+1
View File
@@ -24,6 +24,7 @@ class VBox(GenericBox):
def __init__(self, spacing: int = 0) -> None: def __init__(self, spacing: int = 0) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing) super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing)
class ShortHBox(HBox): class ShortHBox(HBox):
def __init__(self, widget: Gtk.Widget) -> None: def __init__(self, widget: Gtk.Widget) -> None:
super().__init__(spacing=5) super().__init__(spacing=5)
+11 -7
View File
@@ -1,13 +1,13 @@
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.const.constants import APP_NAME, NO_EXPAND, NO_FILL, NO_PADDING from dzgui.const.constants import APP_NAME
from dzgui.const.enum import ButtonType from dzgui.const.enum import ButtonType
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa E402 from gi.repository import Gtk, Gdk
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
@@ -19,7 +19,11 @@ if TYPE_CHECKING:
class ContextualButton(Gtk.Button): class ContextualButton(Gtk.Button):
def __init__( def __init__(
self, label: str, opens: "NotebookPage", tooltip: str, context: ButtonType self,
label: str,
opens: "NotebookPage | None",
tooltip: str,
context: ButtonType,
) -> None: ) -> None:
super().__init__( super().__init__(
label=label, label=label,
@@ -51,9 +55,9 @@ class ButtonBox(Gtk.Box):
for side_button in ButtonType: for side_button in ButtonType:
button = ContextualButton( button = ContextualButton(
label=side_button.dict["label"], label=side_button.label,
opens=side_button.dict["opens"], opens=side_button.opens,
tooltip=side_button.dict["tooltip"], tooltip=side_button.tooltip,
context=side_button, context=side_button,
) )
@@ -63,7 +67,7 @@ class ButtonBox(Gtk.Box):
self.buttons.append(button) self.buttons.append(button)
button.connect("clicked", self._on_selection_button_clicked) button.connect("clicked", self._on_selection_button_clicked)
self.pack_start(button, NO_EXPAND, NO_FILL, NO_PADDING) self.pack_start(button, expand=False, fill=False, padding=0)
def _focus_first_button(self, emitter: "Emitter") -> None: def _focus_first_button(self, emitter: "Emitter") -> None:
self.buttons[0].grab_focus() self.buttons[0].grab_focus()
+35 -59
View File
@@ -1,28 +1,25 @@
from typing import Callable, Literal, Self, TYPE_CHECKING, Union from typing import Callable, Literal, Self, TYPE_CHECKING, Union
from dzgui.util.clip import copy_clipboard from dzgui.util.clip import copy_clipboard
from dzgui.util.format import pluralize
from dzgui.strings import buttons from dzgui.strings import buttons
from dzgui.util.strings import ( from dzgui.util.strings import (
alert_button_tooltip,
atomic_buttons, atomic_buttons,
) )
from dzgui.const.constants import ( from dzgui.const.icons import (
CLIPBOARD, ICON_CLIPBOARD,
CLOSE, ICON_CLOSE,
INPUT_KEYBOARD, ICON_INPUT_KEYBOARD,
LIST_ADD, ICON_LIST_ADD,
REFRESH_ICON, ICON_REFRESH,
STEAM_ICON, ICON_STEAM,
WARNING, ICON_WEB_BROWSER,
WEB_BROWSER,
) )
from dzgui.strings import connect_panel from dzgui.strings import connect_panel
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib # noqa E402 from gi.repository import Gtk, GLib
if TYPE_CHECKING: if TYPE_CHECKING:
from gi.repository import GLib from gi.repository import GLib
@@ -64,7 +61,8 @@ class IconButton(Gtk.Button):
self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end) self.icon = Icon(icon, margin_start=margin_start, margin_end=margin_end)
self.set_image(self.icon) self.set_image(self.icon)
self.set_image_position(position) self.set_image_position(position)
self.set_focus_on_click(False) self.set_focus_on_click(False) # type: ignore[no-untyped-call]
self.set_always_show_image(True)
def swap_icon(self, icon: str) -> None: def swap_icon(self, icon: str) -> None:
start = self.icon.get_margin_start() start = self.icon.get_margin_start()
@@ -100,15 +98,13 @@ class LargeIconTextButton(IconButton):
class ClipboardButton(IconTextButton): class ClipboardButton(IconTextButton):
def __init__(self, controller: Union["Controller", None], func: Callable) -> None: def __init__(self, func: Callable[[], str]) -> None:
super().__init__(CLIPBOARD, atomic_buttons.copy) super().__init__(ICON_CLIPBOARD, atomic_buttons.copy)
self.controller = controller
self.connect("clicked", self._on_button_clicked, func) self.connect("clicked", self._on_button_clicked, func)
# TODO: strings self.set_tooltip_text(buttons.clipboard_button)
self.set_tooltip_text("Copy to clipboard")
def _on_button_clicked(self, button: Self, func: Callable) -> None: def _on_button_clicked(self, button: Self, func: Callable[[], str]) -> None:
def revert() -> Literal[False]: def revert() -> Literal[False]:
self.revert_icon() self.revert_icon()
return False return False
@@ -119,33 +115,30 @@ class ClipboardButton(IconTextButton):
GLib.timeout_add(600, revert) GLib.timeout_add(600, revert)
# TODO: determine when controller would be passed to this button or drop
class CopyIpButton(ClipboardButton): class CopyIpButton(ClipboardButton):
def __init__(self, controller: Union["Controller", None], func: Callable) -> None: def __init__(self, func: Callable[[], str]) -> None:
super().__init__(controller, func) super().__init__(func)
# TODO: strings self.set_tooltip_text(buttons.clipboard_ip_button)
self.set_tooltip_text("Copy IP to clipboard")
class CloseButton(IconTextButton): class CloseButton(IconTextButton):
def __init__(self, label: str) -> None: def __init__(self, label: str) -> None:
super().__init__(icon=CLOSE, label=label) super().__init__(icon=ICON_CLOSE, label=label)
# TODO: strings self.set_tooltip_text(buttons.unset_button_tooltip)
self.set_tooltip_text("Unset this server as favorite")
class WebButton(IconTextButton): class WebButton(IconTextButton):
def __init__(self, label: str) -> None: def __init__(self, label: str) -> None:
super().__init__(icon=WEB_BROWSER, label=label) super().__init__(icon=ICON_WEB_BROWSER, label=label)
pass pass
class RefreshButton(IconTextButton): class RefreshButton(IconTextButton):
def __init__(self, controller: "Controller") -> None: def __init__(self, controller: "Controller") -> None:
super().__init__( super().__init__(
icon=REFRESH_ICON, icon=ICON_REFRESH,
label=atomic_buttons.refresh, label=atomic_buttons.refresh,
) )
@@ -162,18 +155,24 @@ class RefreshButton(IconTextButton):
self.set_tooltip_text(atomic_buttons.refresh_tooltip) self.set_tooltip_text(atomic_buttons.refresh_tooltip)
self.connect("clicked", self._on_refresh_clicked) self.connect("clicked", self._on_refresh_clicked)
emitter.connect("servers_loaded", self.start_decrement) emitter.connect("servers_loaded", self._on_load)
emitter.connect("servers_timed_out", self._on_timeout)
def _on_timeout(self, emitter: "Emitter") -> None:
self.is_clicked = True
self.start_decrement()
def _on_load(self, emitter: "Emitter", tab: "ServerTab") -> None:
self.start_decrement()
def _on_refresh_clicked(self, button: Self) -> None: def _on_refresh_clicked(self, button: Self) -> None:
"""Spawned in a thread""" """Spawned in a thread"""
if self.controller.get_active_treeview().is_loaded() is False:
return
self.is_clicked = True self.is_clicked = True
self.controller.refresh_tree() self.controller.refresh_tree()
# TODO: get server tab enum # TODO: get server tab enum
# if LAN tab, reload existing entries in place # if LAN tab, reload existing entries in place
def start_decrement(self, emitter: "Emitter", tab: "ServerTab") -> None: def start_decrement(self) -> None:
if self.is_clicked is False: if self.is_clicked is False:
return return
if self.loading: if self.loading:
@@ -204,7 +203,7 @@ class RefreshButton(IconTextButton):
class KeysButton(IconTextButton): class KeysButton(IconTextButton):
def __init__(self, controller: "Controller") -> None: def __init__(self, controller: "Controller") -> None:
super().__init__(icon=INPUT_KEYBOARD, label=atomic_buttons.keys) super().__init__(icon=ICON_INPUT_KEYBOARD, label=atomic_buttons.keys)
self.controller = controller self.controller = controller
self.set_margin_top(10) self.set_margin_top(10)
@@ -221,7 +220,7 @@ class KeysButton(IconTextButton):
class SteamConnectButton(LargeIconTextButton): class SteamConnectButton(LargeIconTextButton):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(icon=STEAM_ICON, label=connect_panel.connect_button) super().__init__(icon=ICON_STEAM, label=connect_panel.connect_button)
self.set_tooltip_text(connect_panel.connect_tooltip) self.set_tooltip_text(connect_panel.connect_tooltip)
@@ -241,33 +240,10 @@ class SteamWorkshopButton(SteamTextButton):
class AddButton(IconTextButton): class AddButton(IconTextButton):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(icon=LIST_ADD, label=connect_panel.add_button) super().__init__(icon=ICON_LIST_ADD, label=connect_panel.add_button)
self.set_tooltip_text(connect_panel.add_tooltip) self.set_tooltip_text(connect_panel.add_tooltip)
class LoggerAlertsButton(IconTextButton):
def __init__(self, warnings: int, errors: int) -> None:
warnings_text = ""
errors_text = ""
separator = ""
if warnings > 0:
warnings_plural = pluralize("warnings", warnings)
warnings_text = f"{warnings} {warnings_plural}"
if errors > 0:
errors_plural = pluralize("errors", errors)
errors_text = f" {errors} {errors_plural}"
if warnings + errors > 1:
separator = ","
concat = warnings_text + separator + errors_text
super().__init__(
icon=WARNING,
label=f"Loaded with{concat}",
)
self.set_halign(Gtk.Align.END)
self.set_hexpand(True)
self.set_tooltip_text(alert_button_tooltip)
class SpinnerButton(Gtk.Button): class SpinnerButton(Gtk.Button):
def __init__(self, label: str) -> None: def __init__(self, label: str) -> None:
super().__init__() super().__init__()
+16 -15
View File
@@ -1,8 +1,14 @@
from typing import Self, TYPE_CHECKING from typing import Self, TYPE_CHECKING
from dzgui.const.constants import CARET_DOWN, CARET_UP, UDP_PORT from dzgui.const.icons import ICON_CARET_DOWN, ICON_CARET_UP
from dzgui.const.constants import UDP_PORT
from dzgui.model.servers import ServerModelManager from dzgui.model.servers import ServerModelManager
from dzgui.strings import connect_panel from dzgui.strings import connect_panel
from dzgui.strings.buttons import (
dock_button_collapse,
dock_button_expand,
unset_button_label,
)
from dzgui.util.keys import is_ctrl_mask from dzgui.util.keys import is_ctrl_mask
from dzgui.views.components.buttons import ( from dzgui.views.components.buttons import (
AddButton, AddButton,
@@ -18,7 +24,7 @@ from dzgui.views.components.labels import BoldLabel
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa E402 from gi.repository import Gtk, Gdk
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -30,27 +36,24 @@ ROWS = 1
class DockButton(IconButton): class DockButton(IconButton):
def __init__(self, child: Gtk.Widget) -> None: def __init__(self, child: Gtk.Widget) -> None:
super().__init__(CARET_DOWN) super().__init__(ICON_CARET_DOWN)
self.expanded = True self.expanded = True
self.child = child self.child = child
self.connect("clicked", self._on_click) self.connect("clicked", self._on_click)
self.collapse_text = "Collapse the connection panel"
self.expand_text = "Expand the connection panel"
self.set_margin_top(10) self.set_margin_top(10)
self.set_tooltip_text(self.collapse_text) self.set_tooltip_text(dock_button_collapse)
def collapse(self) -> None: def collapse(self) -> None:
self.set_image(Icon(CARET_UP)) self.set_image(Icon(ICON_CARET_UP))
self.child.hide() self.child.hide()
self.set_tooltip_text(self.expand_text) self.set_tooltip_text(dock_button_expand)
def expand(self) -> None: def expand(self) -> None:
self.set_image(Icon(CARET_DOWN)) self.set_image(Icon(ICON_CARET_DOWN))
self.child.show() self.child.show()
self.set_tooltip_text(self.collapse_text) self.set_tooltip_text(dock_button_collapse)
def _on_click(self, button: Self) -> None: def _on_click(self, button: Self) -> None:
if self.expanded: if self.expanded:
@@ -81,7 +84,6 @@ class LanPanel(Gtk.Frame):
self.scan = Gtk.Button(label=connect_panel.lan_scan_button) self.scan = Gtk.Button(label=connect_panel.lan_scan_button)
self.scan.connect("clicked", self._on_scan_clicked) self.scan.connect("clicked", self._on_scan_clicked)
# TODO: strings
self.early_abort = Gtk.CheckButton(label=connect_panel.lan_checkbox) self.early_abort = Gtk.CheckButton(label=connect_panel.lan_checkbox)
self.early_abort.set_active(True) self.early_abort.set_active(True)
self.early_abort.set_tooltip_text(connect_panel.lan_abort_tooltip) self.early_abort.set_tooltip_text(connect_panel.lan_abort_tooltip)
@@ -187,10 +189,9 @@ class FavPanel(Gtk.Frame):
self.fav_button = SteamConnectButton() self.fav_button = SteamConnectButton()
self.fav_button.connect("clicked", self._on_connect_clicked) self.fav_button.connect("clicked", self._on_connect_clicked)
self.copy_button = CopyIpButton(self.controller, self.get_fav_ip) self.copy_button = CopyIpButton(self.get_fav_ip)
# TODO: strings self.unset_button = CloseButton(unset_button_label)
self.unset_button = CloseButton("Unset")
self.unset_button.connect("clicked", self._on_unset_clicked) self.unset_button.connect("clicked", self._on_unset_clicked)
if favorite is None: if favorite is None:
+2 -2
View File
@@ -7,7 +7,7 @@ from dzgui.util.format import embolden
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 from gi.repository import Gtk
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -43,7 +43,7 @@ class Breadcrumbs(Gtk.Label):
label = page.get_current_tab_text() label = page.get_current_tab_text()
self.set_server_crumbs(label) self.set_server_crumbs(label)
else: else:
crumbs = enum.dict["crumbs"] crumbs = enum.crumbs
self.set_crumbs(crumbs) self.set_crumbs(crumbs)
def set_server_crumbs(self, suffix: str) -> None: def set_server_crumbs(self, suffix: str) -> None:
+12 -14
View File
@@ -1,15 +1,16 @@
from typing import Any, Callable, TYPE_CHECKING from typing import Any, Callable, TYPE_CHECKING
from dzgui.api.servers import validate_ip from dzgui.api.servers import validate_ip
from dzgui.const.constants import VIEW_CONCEAL, VIEW_REVEAL from dzgui.const.icons import ICON_VIEW_CONCEAL, ICON_VIEW_REVEAL
from dzgui.util.css import add_class, remove_class from dzgui.util.css import add_class, remove_class
from dzgui.strings import connect_panel from dzgui.strings import connect_panel
from dzgui.strings.options import api_placeholder, api_validate
from dzgui.strings.errors import api_popover from dzgui.strings.errors import api_popover
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GObject # noqa E402 from gi.repository import Gtk, Gdk, GObject
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -38,7 +39,7 @@ class ValidatedEntry(Gtk.Entry):
def __init__( def __init__(
self, self,
controller: "Controller", controller: "Controller",
func: Callable, func: Callable[[str], bool],
placeholder_text: str = "", placeholder_text: str = "",
tooltip_text: str = "", tooltip_text: str = "",
) -> None: ) -> None:
@@ -46,7 +47,6 @@ class ValidatedEntry(Gtk.Entry):
hexpand=True, placeholder_text=placeholder_text, tooltip_text=tooltip_text hexpand=True, placeholder_text=placeholder_text, tooltip_text=tooltip_text
) )
self.func: Callable
self.set_validation_func(func) self.set_validation_func(func)
self.controller = controller self.controller = controller
@@ -72,7 +72,7 @@ class ValidatedEntry(Gtk.Entry):
self.emit("string_validated", False) self.emit("string_validated", False)
remove_class(self, self.classname) remove_class(self, self.classname)
def set_validation_func(self, func: Callable) -> None: def set_validation_func(self, func: Callable[[str], bool]) -> None:
self.func = func self.func = func
def insert_icon(self) -> None: def insert_icon(self) -> None:
@@ -141,21 +141,19 @@ class APIEntry(Gtk.Box):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.func: Callable | None = None self.func: Callable[[str], None] | None = None
# TODO: strings
self.entry = Gtk.Entry( self.entry = Gtk.Entry(
width_chars=60, hexpand=True, placeholder_text="Enter API key" width_chars=60, hexpand=True, placeholder_text=api_placeholder
) )
self.entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, VIEW_REVEAL) self.entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, ICON_VIEW_REVEAL)
self.entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) self.entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
self.entry.set_visibility(False) self.entry.set_visibility(False)
self.entry.connect("changed", self._on_text_changed) self.entry.connect("changed", self._on_text_changed)
self.entry.connect("icon-release", self._on_icon_release) self.entry.connect("icon-release", self._on_icon_release)
self.entry.connect("activate", self._on_field_activated) self.entry.connect("activate", self._on_field_activated)
# TODO: strings self.submit = Gtk.Button(label=api_validate)
self.submit = Gtk.Button(label="Validate")
self.submit.set_sensitive(False) self.submit.set_sensitive(False)
self.submit.connect("clicked", self._on_submit) self.submit.connect("clicked", self._on_submit)
@@ -206,9 +204,9 @@ class APIEntry(Gtk.Box):
) -> None: ) -> None:
visible = widget.get_visibility() visible = widget.get_visibility()
if visible: if visible:
icon, state = VIEW_REVEAL, False icon, state = ICON_VIEW_REVEAL, False
else: else:
icon, state = VIEW_CONCEAL, True icon, state = ICON_VIEW_CONCEAL, True
widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon) widget.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
widget.set_visibility(state) widget.set_visibility(state)
@@ -222,7 +220,7 @@ class APIEntry(Gtk.Box):
res = self.func(text) res = self.func(text)
return res return res
def set_validation_func(self, func: Callable | None) -> None: def set_validation_func(self, func: Callable[[str], None] | None) -> None:
self.func = func self.func = func
def disable_button(self) -> None: def disable_button(self) -> None:
+3 -3
View File
@@ -1,12 +1,12 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.const.enum import NotebookPage from dzgui.const.enum import NotebookPage
from dzgui.const.constants import HELP_BUBBLE from dzgui.const.icons import ICON_HELP_BUBBLE
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa E402 from gi.repository import Gtk, Gdk
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -20,7 +20,7 @@ class InfoEventBox(Gtk.EventBox):
self.text = text self.text = text
self.icon = Gtk.Image.new_from_icon_name( self.icon = Gtk.Image.new_from_icon_name(
HELP_BUBBLE, Gtk.IconSize.LARGE_TOOLBAR ICON_HELP_BUBBLE, Gtk.IconSize.LARGE_TOOLBAR
) )
self.icon.set_opacity(0.8) self.icon.set_opacity(0.8)
box = Gtk.Box() box = Gtk.Box()
+23 -25
View File
@@ -3,23 +3,22 @@ from typing import Literal, TYPE_CHECKING
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
EXPAND,
NO_EXPAND,
NO_FILL,
NO_PADDING,
SEARCH_ICON,
) )
from dzgui.const.enum import FilterMode from dzgui.const.icons import ICON_SEARCH
from dzgui.model.servers import ServerModelManager
from dzgui.util.format import format_exception from dzgui.util.format import format_exception
from dzgui.util.strings import all_maps from dzgui.strings.filters import (
all_maps,
filters_heading,
keyword_placeholder,
map_placeholder,
)
from dzgui.views.components.maps_combo import MapsCombo from dzgui.views.components.maps_combo import MapsCombo
from dzgui.views.components.labels import BoldLabel from dzgui.views.components.labels import BoldLabel
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, Pango, GLib # noqa E402 from gi.repository import Gtk, Gdk, Pango, GLib
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
@@ -32,7 +31,7 @@ class ButtonGrid(Gtk.Grid):
def __init__( def __init__(
self, self,
controller: "Controller", controller: "Controller",
defaults: dict[str, str], defaults: dict[str, bool],
tooltips: dict[str, str], tooltips: dict[str, str],
) -> None: ) -> None:
super().__init__( super().__init__(
@@ -44,7 +43,10 @@ class ButtonGrid(Gtk.Grid):
self.checks: list[Gtk.CheckButton] = [] self.checks: list[Gtk.CheckButton] = []
flowbox = Gtk.FlowBox( flowbox = Gtk.FlowBox(
halign=Gtk.Align.CENTER, min_children_per_line=3, max_children_per_line=3, selection_mode=Gtk.SelectionMode.NONE halign=Gtk.Align.CENTER,
min_children_per_line=3,
max_children_per_line=3,
selection_mode=Gtk.SelectionMode.NONE,
) )
for check in defaults.keys(): for check in defaults.keys():
@@ -95,14 +97,13 @@ class ButtonGrid(Gtk.Grid):
logger.info(f"User toggled button '{label}' to {state}") logger.info(f"User toggled button '{label}' to {state}")
self.emitter.emit("check_toggled", label, state) self.emitter.emit("check_toggled", label, state)
def get_checkboxes(self) -> list: def get_checkboxes(self) -> list[Gtk.CheckButton]:
return self.checks return self.checks
class KeywordEntry(Gtk.Entry): class KeywordEntry(Gtk.Entry):
def __init__(self, controller: "Controller") -> None: def __init__(self, controller: "Controller") -> None:
# TODO :strings super().__init__(placeholder_text=keyword_placeholder)
super().__init__(placeholder_text="Filter by keyword")
self.keyword = "" self.keyword = ""
self.controller = controller self.controller = controller
@@ -111,7 +112,7 @@ class KeywordEntry(Gtk.Entry):
self.connect("key-press-event", self._on_keypress) self.connect("key-press-event", self._on_keypress)
self.connect("icon-release", lambda *args: self.activate()) self.connect("icon-release", lambda *args: self.activate())
self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, SEARCH_ICON) self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, ICON_SEARCH)
self.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True) self.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, True)
self.emitter.connect("load_maps", self._on_maps_loaded) self.emitter.connect("load_maps", self._on_maps_loaded)
@@ -148,10 +149,6 @@ class KeywordEntry(Gtk.Entry):
self.controller.set_active_keyword(keyword) self.controller.set_active_keyword(keyword)
logger.info(f"User filtered by keyword '{keyword}'") logger.info(f"User filtered by keyword '{keyword}'")
ServerModelManager(
self.controller, self.controller.get_active_treeview()
).refilter(FilterMode.TOGGLE_ON)
class FilterPanel(Gtk.Box): class FilterPanel(Gtk.Box):
def __init__(self, controller: "Controller") -> None: def __init__(self, controller: "Controller") -> None:
@@ -180,8 +177,7 @@ class FilterPanel(Gtk.Box):
self.keyword_entry = KeywordEntry(self.controller) self.keyword_entry = KeywordEntry(self.controller)
self.button_grid = ButtonGrid(self.controller, defaults, tooltips) self.button_grid = ButtonGrid(self.controller, defaults, tooltips)
# TODO: strings self.filters_label = BoldLabel(filters_heading)
self.filters_label = BoldLabel("Filters")
self.emitter.connect( self.emitter.connect(
"request_keyword_focus", lambda _: self.keyword_entry.grab_focus() "request_keyword_focus", lambda _: self.keyword_entry.grab_focus()
@@ -201,13 +197,12 @@ class FilterPanel(Gtk.Box):
self.maps_entry: Gtk.Entry = self.maps_combo.get_child() # type: ignore self.maps_entry: Gtk.Entry = self.maps_combo.get_child() # type: ignore
self.maps_entry.set_completion(completion) self.maps_entry.set_completion(completion)
# TODO: strings self.maps_entry.set_placeholder_text(map_placeholder)
self.maps_entry.set_placeholder_text("Filter by map")
self.maps_entry.connect("changed", self._on_map_completion, True) self.maps_entry.connect("changed", self._on_map_completion, True)
self.maps_entry.connect("key-press-event", self._on_map_entry_keypress) self.maps_entry.connect("key-press-event", self._on_map_entry_keypress)
self.maps_entry.connect("activate", self._on_maps_activated) self.maps_entry.connect("activate", self._on_maps_activated)
self.maps_combo.pack_start(renderer_text, EXPAND) self.maps_combo.pack_start(renderer_text, expand=True)
self.maps_combo.connect("changed", self._on_map_changed) self.maps_combo.connect("changed", self._on_map_changed)
self.maps_combo.connect("key-press-event", self._on_combo_keypress) self.maps_combo.connect("key-press-event", self._on_combo_keypress)
@@ -217,7 +212,7 @@ class FilterPanel(Gtk.Box):
self.maps_combo, self.maps_combo,
self.button_grid, self.button_grid,
): ):
self.pack_start(el, NO_EXPAND, NO_FILL, NO_PADDING) self.pack_start(el, expand=False, fill=False, padding=0)
def _on_maps_activated(self, entry: Gtk.Entry) -> None: def _on_maps_activated(self, entry: Gtk.Entry) -> None:
text = entry.get_text() text = entry.get_text()
@@ -311,6 +306,7 @@ class FilterPanel(Gtk.Box):
Gdk.KEY_0: 9, Gdk.KEY_0: 9,
Gdk.KEY_minus: 10, Gdk.KEY_minus: 10,
Gdk.KEY_backslash: 11, Gdk.KEY_backslash: 11,
Gdk.KEY_apostrophe: 12,
} }
if keyval not in mappings: if keyval not in mappings:
return False return False
@@ -319,6 +315,8 @@ class FilterPanel(Gtk.Box):
return True return True
def toggle_check(self, digit: int) -> None: def toggle_check(self, digit: int) -> None:
if not self.button_grid.is_sensitive():
return
checks = self.button_grid.get_checkboxes() checks = self.button_grid.get_checkboxes()
check = checks[digit] check = checks[digit]
state = check.get_active() state = check.get_active()
+4 -1
View File
@@ -1,7 +1,7 @@
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk
class Icon(Gtk.Image): class Icon(Gtk.Image):
@@ -9,3 +9,6 @@ class Icon(Gtk.Image):
super().__init__( super().__init__(
icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin
) )
def set_icon_name(self, name: str) -> None:
super().set_from_icon_name(name, Gtk.IconSize.BUTTON)
+1 -1
View File
@@ -3,7 +3,7 @@ from dzgui.util.format import embolden
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk
class BoldLabel(Gtk.Label): class BoldLabel(Gtk.Label):
+1 -1
View File
@@ -1,7 +1,7 @@
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402 from gi.repository import Gtk
class MapsCombo(Gtk.EntryCompletion): class MapsCombo(Gtk.EntryCompletion):
+37 -6
View File
@@ -1,49 +1,80 @@
from dzgui.const.constants import ERROR, FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD from typing import Self
from warnings import deprecated
from dzgui.const.constants import FLATPAK_RUN_CMD, FLATPAK_SANDBOX, STEAM_CMD
from dzgui.const.icons import ICON_ERROR
from dzgui.init.prereqs import has_steam_client, has_flatpak_client
from dzgui.strings import options from dzgui.strings import options
from dzgui.views.components.box import HBox from dzgui.views.components.box import HBox
from dzgui.views.components.buttons import Icon from dzgui.views.components.buttons import Icon
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa from gi.repository import Gtk # noqa
@deprecated("Client choice is determined at setup time")
class ClientCombo(Gtk.ComboBox): class ClientCombo(Gtk.ComboBox):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.client_store = Gtk.ListStore(str, str) has_steam = has_steam_client()
clients = ( has_flatpak = has_flatpak_client()
self.client_store = Gtk.ListStore(str, str, bool)
self.clients = (
( (
options.steam_combo, options.steam_combo,
STEAM_CMD, STEAM_CMD,
has_steam,
), ),
( (
options.flatpak_combo, options.flatpak_combo,
FLATPAK_RUN_CMD, FLATPAK_RUN_CMD,
has_flatpak,
), ),
( (
options.flatpak_container_combo, options.flatpak_container_combo,
FLATPAK_SANDBOX, FLATPAK_SANDBOX,
has_flatpak,
), ),
) )
for client in clients: for client in self.clients:
self.client_store.append(client) self.client_store.append(client)
self.set_model(self.client_store) self.set_model(self.client_store)
renderer_text = Gtk.CellRendererText() renderer_text = Gtk.CellRendererText()
self.pack_start(renderer_text, True) self.pack_start(renderer_text, True)
self.add_attribute(renderer_text, "text", 0) self.add_attribute(renderer_text, "text", 0)
self.set_active(0) self.set_active(0)
self.set_cell_data_func(self.get_cells()[0], self.data_func)
def data_func(
self,
combo: Self,
cell: Gtk.CellRendererText,
model: Gtk.ListStore,
_iter: Gtk.TreeIter,
) -> None:
sens = model[_iter][2]
cell.set_property("sensitive", sens)
def get_client_human_readable(self, cmd: str) -> str:
for client in self.clients:
human, command, sens = client
if command == cmd:
return human
raise TypeError("Not a valid Steam client selection")
class ErrorPopover(Gtk.Popover): class ErrorPopover(Gtk.Popover):
def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None: def __init__(self, relative_to: Gtk.Widget, position: Gtk.PositionType) -> None:
super().__init__(relative_to=relative_to, position=position) super().__init__(relative_to=relative_to, position=position)
self.hbox = HBox() self.hbox = HBox()
self.label = Gtk.Label(label="", margin_start=10, margin_end=10) self.label = Gtk.Label(label="", margin_start=10, margin_end=10)
error_icon = Icon(ERROR, margin_start=10) error_icon = Icon(ICON_ERROR, margin_start=10)
self.hbox.extend([error_icon, self.label]) self.hbox.extend([error_icon, self.label])
self.add(self.hbox) self.add(self.hbox)
self.show_all() self.show_all()
+9 -7
View File
@@ -1,14 +1,13 @@
from typing import Self, TYPE_CHECKING from typing import Self, TYPE_CHECKING
from dzgui.const.enum import ModButton from dzgui.const.enum import ModButton
from dzgui.const.constants import NO_EXPAND, FILL, NO_PADDING
from dzgui.util.strings import mod_panel from dzgui.util.strings import mod_panel
from dzgui.views.components.labels import BoldLabel from dzgui.views.components.labels import BoldLabel
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -20,8 +19,8 @@ class EnumeratedModButton(Gtk.Button):
super().__init__(margin_start=10, margin_end=10, focus_on_click=False) super().__init__(margin_start=10, margin_end=10, focus_on_click=False)
self.enum = enum self.enum = enum
self.set_label(enum.dict["label"]) self.set_label(enum.label)
self.set_tooltip_text(enum.dict["tooltip"]) self.set_tooltip_text(enum.tooltip)
class ModPanelButton(EnumeratedModButton): class ModPanelButton(EnumeratedModButton):
@@ -50,11 +49,12 @@ class ModSelectionPanel(Gtk.Box):
ModButton.SELECT_ALL, ModButton.SELECT_ALL,
ModButton.UNSELECT_ALL, ModButton.UNSELECT_ALL,
ModButton.UNSUB_SELECTED, ModButton.UNSUB_SELECTED,
ModButton.VALIDATE,
) )
for button in buttons: for button in buttons:
b = EnumeratedModButton(button) b = EnumeratedModButton(button)
b.connect("clicked", self._on_button_clicked) b.connect("clicked", self._on_button_clicked)
self.main_panel.pack_start(b, NO_EXPAND, FILL, NO_PADDING) self.main_panel.pack_start(b, expand=False, fill=True, padding=0)
self.stale_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) self.stale_panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.highlight_stale_button = ModPanelButton(ModButton.HIGHLIGHT_STALE) self.highlight_stale_button = ModPanelButton(ModButton.HIGHLIGHT_STALE)
@@ -66,11 +66,11 @@ class ModSelectionPanel(Gtk.Box):
self.unhighlight_stale_button, self.unhighlight_stale_button,
self.select_stale_button, self.select_stale_button,
): ):
self.stale_panel.pack_start(b, NO_EXPAND, FILL, NO_PADDING) self.stale_panel.pack_start(b, expand=False, fill=True, padding=0)
b.connect("clicked", self._on_button_clicked) b.connect("clicked", self._on_button_clicked)
for el in header, self.main_panel, self.stale_panel: for el in header, self.main_panel, self.stale_panel:
self.pack_start(el, NO_EXPAND, FILL, NO_PADDING) self.pack_start(el, expand=False, fill=True, padding=0)
self.connect("map", self._reinit_button) self.connect("map", self._reinit_button)
@@ -114,3 +114,5 @@ class ModSelectionPanel(Gtk.Box):
self.swap_sensitive(False) self.swap_sensitive(False)
case ModButton.SELECT_STALE: case ModButton.SELECT_STALE:
self.controller.select_colorized() self.controller.select_colorized()
case ModButton.VALIDATE:
self.controller.validate_mods()
+8 -13
View File
@@ -6,13 +6,10 @@ from typing import Literal, TYPE_CHECKING
from dzgui.const.constants import ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
NO_EXPAND,
NO_FILL,
FILL,
NO_PADDING,
) )
from dzgui.const.enum import ServerTab from dzgui.const.enum import ServerTab
from dzgui.managers.update import UpdateManager from dzgui.managers.update import UpdateManager
from dzgui.strings.buttons import update_button, version_notifier, version_tooltip
from dzgui.util.clip import copy_clipboard from dzgui.util.clip import copy_clipboard
from dzgui.util.file import is_writeable from dzgui.util.file import is_writeable
from dzgui.views.components.buttonbox import ButtonBox from dzgui.views.components.buttonbox import ButtonBox
@@ -23,7 +20,7 @@ from dzgui.views.components.buttons import IconTextButton, RefreshButton, KeysBu
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk, GLib # noqa E402 from gi.repository import Gtk, Gdk, GLib
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -65,20 +62,19 @@ class RightPanel(Gtk.Box):
vexpand=True, vexpand=True,
halign=Gtk.Align.END, halign=Gtk.Align.END,
valign=Gtk.Align.END, valign=Gtk.Align.END,
# TODO: strings tooltip_text=version_tooltip,
tooltip_text="Click to copy to clipboard",
) )
eb = Gtk.EventBox(halign=Gtk.Align.END, valign=Gtk.Align.END) eb = Gtk.EventBox(halign=Gtk.Align.END, valign=Gtk.Align.END)
eb.add(self.version_label) eb.add(self.version_label)
eb.connect("button-press-event", self._on_version_clicked) eb.connect("button-press-event", self._on_version_clicked)
for el in self.button_vbox, self.keys, self.filters_vbox, self.refresh_button: for el in self.button_vbox, self.keys, self.filters_vbox, self.refresh_button:
self.pack_start(el, NO_EXPAND, FILL, NO_PADDING) self.pack_start(el, expand=False, fill=True, padding=0)
self.pack_start(self.sel_panel, NO_EXPAND, NO_FILL, NO_PADDING) self.pack_start(self.sel_panel, expand=False, fill=False, padding=0)
self.update_button = IconTextButton( self.update_button = IconTextButton(
"dialog-information-symbolic", label="Updates available" "dialog-information-symbolic", label=update_button
) )
self.gutter_box = Gtk.Box( self.gutter_box = Gtk.Box(
@@ -96,7 +92,7 @@ class RightPanel(Gtk.Box):
"clicked", self._on_update_button_clicked, exe_path, update "clicked", self._on_update_button_clicked, exe_path, update
) )
self.gutter_box.add(eb) self.gutter_box.add(eb)
self.pack_start(self.gutter_box, NO_EXPAND, FILL, NO_PADDING) self.pack_start(self.gutter_box, expand=False, fill=True, padding=0)
def _on_update_button_clicked( def _on_update_button_clicked(
self, button: Gtk.Button, exe_path: str, url: str self, button: Gtk.Button, exe_path: str, url: str
@@ -131,7 +127,6 @@ class RightPanel(Gtk.Box):
return return
self.copying = True self.copying = True
version = self.version_label.get_text() version = self.version_label.get_text()
# TODO: strings self.version_label.set_text(version_notifier)
self.version_label.set_text("Copied!")
copy_clipboard(version) copy_clipboard(version)
GLib.timeout_add_seconds(1, revert) GLib.timeout_add_seconds(1, revert)
+3 -7
View File
@@ -2,12 +2,11 @@ from typing import Self, Union, TYPE_CHECKING
from dzgui.const.enum import NotebookPage, ServerTab from dzgui.const.enum import NotebookPage, ServerTab
from dzgui.util.strings import esc_to_return, question_to_return from dzgui.util.strings import esc_to_return, question_to_return
from dzgui.views.components.buttons import LoggerAlertsButton
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.const.enum import ServerTab from dzgui.const.enum import ServerTab
@@ -47,9 +46,6 @@ class Statusbar(Gtk.Grid):
self.emitter.connect("servers_loaded", self._on_servers_loaded) self.emitter.connect("servers_loaded", self._on_servers_loaded)
self.emitter.connect("mods_updated", self._on_mods_updated) self.emitter.connect("mods_updated", self._on_mods_updated)
def _on_alerts_clicked(self, button: LoggerAlertsButton) -> None:
self.controller.populate_log()
def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None: def _on_mods_updated(self, emitter: "Emitter", msg: str, mods: int) -> None:
self.set_by_context(NotebookPage.MODS, msg) self.set_by_context(NotebookPage.MODS, msg)
@@ -60,7 +56,7 @@ class Statusbar(Gtk.Grid):
return return
enum = notebook.get_page_by_enum() enum = notebook.get_page_by_enum()
show_statusbar = enum.dict["statusbar"] show_statusbar = enum.has_statusbar
if show_statusbar is False: if show_statusbar is False:
self.set_by_context(enum, "") self.set_by_context(enum, "")
@@ -152,5 +148,5 @@ class Statusbar(Gtk.Grid):
row = tree.get_value_at_index(1) row = tree.get_value_at_index(1)
if self.controller.loaded is False: if self.controller.loaded is False:
return return
tooltip = row.dict["tooltip"] tooltip = row.tooltip
self.set_by_context(NotebookPage.HELP, tooltip) self.set_by_context(NotebookPage.HELP, tooltip)
+2 -1
View File
@@ -2,8 +2,9 @@ from typing import Literal
from dzgui.util import css from dzgui.util import css
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402 from gi.repository import Gtk, GLib
class Toast(Gtk.EventBox): class Toast(Gtk.EventBox):
+10 -8
View File
@@ -4,6 +4,9 @@ from typing import Any, Literal, Self, TYPE_CHECKING
# import time # import time
from enum import Enum from enum import Enum
from dzgui.views.mixins.colorscheme import ColorAwareApp
# TODO: import dialog titles # TODO: import dialog titles
from dzgui.api.mods import remove_stale_signatures as remove_stale from dzgui.api.mods import remove_stale_signatures as remove_stale
from dzgui.config.ipdb import get_ipdb from dzgui.config.ipdb import get_ipdb
@@ -11,7 +14,6 @@ from dzgui.const.constants import HEX_GREEN, HEX_RED
from dzgui.init.coords import get_local_coords from dzgui.init.coords import get_local_coords
from dzgui.init.dayz import is_dayz_installed from dzgui.init.dayz import is_dayz_installed
from dzgui.init.update import check_updates from dzgui.init.update import check_updates
from dzgui.const.constants import EXPAND, FILL
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import preboot, dialogs from dzgui.strings import preboot, dialogs
from dzgui.util.format import format_exception from dzgui.util.format import format_exception
@@ -37,7 +39,7 @@ class Success(Enum):
# TODO: strings for "Running", "Failed", etc. # TODO: strings for "Running", "Failed", etc.
# TODO: add margins to tree # TODO: add margins to tree
class BootDialog(Gtk.Dialog): class BootDialog(ColorAwareApp, Gtk.Dialog): # type: ignore
def __init__(self, parent: "BootWindow", xdg: "Xdg", version: str) -> None: def __init__(self, parent: "BootWindow", xdg: "Xdg", version: str) -> None:
super().__init__( super().__init__(
title=dialog_header, title=dialog_header,
@@ -99,10 +101,10 @@ class BootDialog(Gtk.Dialog):
margin_top=30, margin_top=30,
margin_bottom=30, margin_bottom=30,
) )
self.error_label = Gtk.Label() self.error_label = Gtk.Label(margin_start=15, margin_end=15)
# TODO: abstract class # TODO: abstract class
self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text()) self.copy_button = ClipboardButton(self.error_label.get_text)
self.button_hbox = Gtk.Box( self.button_hbox = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.CENTER, spacing=10 orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.CENTER, spacing=10
) )
@@ -115,8 +117,8 @@ class BootDialog(Gtk.Dialog):
self.error_box.add(self.button_hbox) self.error_box.add(self.button_hbox)
self.content = self.get_content_area() self.content = self.get_content_area()
self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0) self.content.pack_start(self.scrollable_tree, expand=True, fill=True, padding=0)
self.content.pack_start(self.error_box, EXPAND, FILL, 0) self.content.pack_start(self.error_box, expand=True, fill=True, padding=0)
self.show_all() self.show_all()
self.error_box.hide() self.error_box.hide()
@@ -129,9 +131,9 @@ class BootDialog(Gtk.Dialog):
preboot.signatures, preboot.signatures,
False, False,
), ),
(StoredFunc(get_ipdb, self.xdg.ips), preboot.geo, False), (StoredFunc(get_ipdb, self.xdg.db), preboot.geo, False),
# TODO: handle IP DB failure and use coords fallback # TODO: handle IP DB failure and use coords fallback
(StoredFunc(get_local_coords, self.xdg.ips), preboot.coords, True), (StoredFunc(get_local_coords, self.xdg.db), preboot.coords, True),
(StoredFunc(check_updates, self.version), preboot.updates, True), (StoredFunc(check_updates, self.version), preboot.updates, True),
] ]
self.results: list[Any] = [] self.results: list[Any] = []
+457
View File
@@ -0,0 +1,457 @@
import gi
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from typing import Literal
from dzgui.const.icons import ICON_DAY, ICON_NIGHT
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
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(ICON_DAY)
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 = ICON_DAY if self.server_time.is_day() else ICON_NIGHT
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)
+4 -3
View File
@@ -3,14 +3,15 @@ import sys
from typing import Self from typing import Self
from dzgui.util.strings import dialog_error, dialog_header from dzgui.util.strings import dialog_error, dialog_header
from dzgui.views.mixins.colorscheme import ColorAwareApp
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk
class AbortDialog(Gtk.MessageDialog): class AbortDialog(ColorAwareApp, Gtk.MessageDialog): # type: ignore
def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None: def __init__(self, string: str, buttons: Gtk.ButtonsType) -> None:
super().__init__( super().__init__(
title=dialog_header, title=dialog_header,
@@ -45,7 +46,7 @@ class AbortDialog(Gtk.MessageDialog):
self.connect("response", self._on_response) self.connect("response", self._on_response)
self.run() self.run() # type: ignore[no-untyped-call]
self.destroy() self.destroy()
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
+8 -8
View File
@@ -2,10 +2,10 @@ from pathlib import Path
from dzgui.util.strings import picker from dzgui.util.strings import picker
import gi # noqa E402 import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk
class FilePicker(Gtk.FileChooserDialog): class FilePicker(Gtk.FileChooserDialog):
@@ -16,14 +16,14 @@ class FilePicker(Gtk.FileChooserDialog):
transient_for=parent, transient_for=parent,
resizable=True, resizable=True,
) )
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL) self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL) # type: ignore[no-untyped-call]
self.add_buttons("_Save", Gtk.ResponseType.OK) self.add_buttons("_Save", Gtk.ResponseType.OK) # type: ignore[no-untyped-call]
self.set_default_response(Gtk.ResponseType.OK) self.set_default_response(Gtk.ResponseType.OK)
self.set_current_name(picker.placeholder) self.set_current_name(picker.placeholder)
def pick_file(self) -> Path | None: def pick_file(self) -> Path | None:
res = self.run() res = self.run() # type: ignore[no-untyped-call]
if res in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): if res in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
self.destroy() self.destroy()
return None return None
@@ -43,12 +43,12 @@ class FolderPicker(Gtk.FileChooserDialog):
parent=parent, parent=parent,
resizable=True, resizable=True,
) )
self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL) self.add_buttons("_Cancel", Gtk.ResponseType.CANCEL) # type: ignore[no-untyped-call]
self.add_buttons("_OK", Gtk.ResponseType.OK) self.add_buttons("_OK", Gtk.ResponseType.OK) # type: ignore[no-untyped-call]
self.set_default_response(Gtk.ResponseType.OK) self.set_default_response(Gtk.ResponseType.OK)
def pick_folder(self) -> Path | None: def pick_folder(self) -> Path | None:
res = self.run() res = self.run() # type: ignore[no-untyped-call]
if res in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): if res in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
self.destroy() self.destroy()
return None return None
+17 -11
View File
@@ -1,6 +1,5 @@
from typing import Literal, Self, TYPE_CHECKING from typing import Literal, Self, TYPE_CHECKING
from dzgui.const.constants import NO_EXPAND, NO_FILL, NO_PADDING, EXPAND, FILL
from dzgui.strings import dialogs from dzgui.strings import dialogs
from dzgui.util import strings from dzgui.util import strings
from dzgui.views.components.buttons import ClipboardButton from dzgui.views.components.buttons import ClipboardButton
@@ -8,7 +7,7 @@ from dzgui.views.components.buttons import ClipboardButton
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 from gi.repository import Gtk, GLib
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -46,7 +45,7 @@ class GenericDialog(Gtk.MessageDialog):
def pack(self, widget: Gtk.Widget) -> None: def pack(self, widget: Gtk.Widget) -> None:
content = self.get_content_area() content = self.get_content_area()
content.pack_start(widget, EXPAND, FILL, NO_PADDING) content.pack_start(widget, expand=True, fill=True, padding=0)
class ConfirmationDialog(GenericDialog): class ConfirmationDialog(GenericDialog):
@@ -121,13 +120,13 @@ class WaitDialog(GenericDialog):
spinner_box.add(spinner) spinner_box.add(spinner)
if self.jobs > 1: if self.jobs > 1:
content.pack_start(self.prog, NO_EXPAND, NO_FILL, 0) content.pack_start(self.prog, expand=False, fill=False, padding=0)
else: else:
content.pack_start(spinner_box, NO_EXPAND, NO_FILL, 0) content.pack_start(spinner_box, expand=False, fill=False, padding=0)
spinner.start() spinner.start()
if show_cancel is True: if show_cancel is True:
content.pack_start(self.cancel, NO_EXPAND, NO_FILL, 0) content.pack_start(self.cancel, expand=False, fill=False, padding=0)
def update_text(self, msg: str) -> None: def update_text(self, msg: str) -> None:
self.format_secondary_text(msg) self.format_secondary_text(msg)
@@ -140,8 +139,7 @@ class WaitDialog(GenericDialog):
self.cur_job += 1 self.cur_job += 1
def _on_cancel_clicked(self, button: Gtk.Button) -> None: def _on_cancel_clicked(self, button: Gtk.Button) -> None:
# TODO: strings GLib.idle_add(self.update_text, dialogs.caught_cancel_signal)
GLib.idle_add(self.update_text, "Caught cancel signal, cleaning up")
self.controller.set_cancel_event() self.controller.set_cancel_event()
def show_cancel(self, state: bool) -> None: def show_cancel(self, state: bool) -> None:
@@ -187,7 +185,7 @@ class TextBufferDialog(GenericDialog):
secondary=secondary, secondary=secondary,
) )
self.text = text self.text = text
copy_button = ClipboardButton(controller, self.get_text) copy_button = ClipboardButton(self.get_text)
self.add_action_widget(copy_button, Gtk.ResponseType.NONE) self.add_action_widget(copy_button, Gtk.ResponseType.NONE)
self.add_button("OK", Gtk.ResponseType.OK) self.add_button("OK", Gtk.ResponseType.OK)
self.connect("response", self._on_response) self.connect("response", self._on_response)
@@ -272,7 +270,7 @@ class ExceptionDialog(TextBufferDialog):
bottom_margin=10, bottom_margin=10,
) )
textview.set_buffer(Gtk.TextBuffer(text=trace)) textview.set_buffer(Gtk.TextBuffer(text=trace))
box.pack_start(textview, EXPAND, FILL, 0) box.pack_start(textview, expand=True, fill=True, padding=0)
self.error_details = Gtk.ScrolledWindow( self.error_details = Gtk.ScrolledWindow(
overlay_scrolling=False, overlay_scrolling=False,
@@ -296,7 +294,7 @@ class ExceptionDialog(TextBufferDialog):
) )
details_textview.set_buffer(self.details_buffer) details_textview.set_buffer(self.details_buffer)
details_box.pack_start(details_textview, EXPAND, FILL, 10) details_box.pack_start(details_textview, expand=True, fill=True, padding=10)
self.error_details.add(details_box) self.error_details.add(details_box)
self.error_notebook = Gtk.Notebook(show_tabs=False, margin_bottom=15) self.error_notebook = Gtk.Notebook(show_tabs=False, margin_bottom=15)
@@ -309,6 +307,14 @@ class ExceptionDialog(TextBufferDialog):
content.set_spacing(0) content.set_spacing(0)
content.add(scrollable) 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( def _on_page_changed(
self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int
) -> None: ) -> None:
+2 -3
View File
@@ -1,7 +1,6 @@
import textwrap import textwrap
from typing import Self, TYPE_CHECKING from typing import Self, TYPE_CHECKING
from dzgui.const.constants import NO_EXPAND, NO_FILL
from dzgui.util.strings import notice from dzgui.util.strings import notice
from dzgui.views.dialogs.generic import GenericDialog from dzgui.views.dialogs.generic import GenericDialog
from dzgui.views.components.buttons import SteamTextButton from dzgui.views.components.buttons import SteamTextButton
@@ -9,7 +8,7 @@ from dzgui.views.components.buttons import SteamTextButton
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 from gi.repository import Gtk
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -42,7 +41,7 @@ class WorkshopLinkDialog(GenericDialog):
button.set_margin_start(60) button.set_margin_start(60)
button.set_margin_end(60) button.set_margin_end(60)
button.connect("clicked", self._on_button_clicked, uid) button.connect("clicked", self._on_button_clicked, uid)
self.dialogBox.pack_end(button, NO_EXPAND, NO_FILL, 0) self.dialogBox.pack_end(button, expand=False, fill=False, padding=0)
self.show_all() self.show_all()
self.connect("response", self._on_dialog_response) self.connect("response", self._on_dialog_response)
+11 -19
View File
@@ -1,12 +1,13 @@
from typing import Self, TYPE_CHECKING from typing import Self, TYPE_CHECKING
from dzgui.strings import notes
from dzgui.views.components.entry import ValidatedEntry from dzgui.views.components.entry import ValidatedEntry
from dzgui.views.dialogs.generic import GenericDialog from dzgui.views.dialogs.generic import GenericDialog
import gi # noqa E402 import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk # noqa E402 from gi.repository import Gtk
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -14,23 +15,20 @@ if TYPE_CHECKING:
class NoteDialog(GenericDialog): class NoteDialog(GenericDialog):
def __init__(self, controller: "Controller"): def __init__(self, controller: "Controller"):
# TODO: add to strings
title = "Add a short note/reminder."
secondary = "Limit 30 chars"
super().__init__( super().__init__(
controller=controller, controller=controller,
text=title, text=notes.dialog_title,
buttons=Gtk.ButtonsType.NONE, buttons=Gtk.ButtonsType.NONE,
mtype=Gtk.MessageType.INFO, mtype=Gtk.MessageType.INFO,
secondary=secondary, secondary=notes.dialog_subtitle,
) )
self.controller = controller self.controller = controller
delete_button = Gtk.Button(label="Delete note") delete_button = Gtk.Button(label=notes.delete_button)
delete_button.set_sensitive(False) delete_button.set_sensitive(False)
self.add_action_widget(delete_button, Gtk.ResponseType.CANCEL) self.add_action_widget(delete_button, Gtk.ResponseType.CANCEL)
self.ok = self.add_button("Update note", Gtk.ResponseType.OK) self.ok = self.add_button(notes.ok_button, Gtk.ResponseType.OK)
self.ok.grab_default() self.ok.grab_default()
self.entry = ValidatedEntry(controller, self.validate) self.entry = ValidatedEntry(controller, self.validate)
@@ -42,7 +40,7 @@ class NoteDialog(GenericDialog):
self.entry.set_text(note) self.entry.set_text(note)
if len(note) > 0: if len(note) > 0:
delete_button.set_sensitive(True) delete_button.set_sensitive(True)
self.format_secondary_text(f"Current note: '{note}'") self.format_secondary_text(notes.current_note.format(note))
elif len(note) == 0: elif len(note) == 0:
self.ok.set_sensitive(False) self.ok.set_sensitive(False)
@@ -55,9 +53,6 @@ class NoteDialog(GenericDialog):
self.entry.connect("changed", self._on_text_changed) self.entry.connect("changed", self._on_text_changed)
def _on_text_changed(self, entry: Gtk.Entry) -> None: def _on_text_changed(self, entry: Gtk.Entry) -> None:
"""
Called in conjunction with signal callback in the parent class
"""
if len(entry.get_text()) == 0: if len(entry.get_text()) == 0:
self.ok.set_sensitive(False) self.ok.set_sensitive(False)
@@ -71,14 +66,11 @@ class NoteDialog(GenericDialog):
def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None: def _on_response(self, dialog: Self, response: Gtk.ResponseType) -> None:
match response: match response:
case Gtk.ResponseType.DELETE_EVENT:
self.destroy()
case Gtk.ResponseType.OK: case Gtk.ResponseType.OK:
note = self.entry.get_text() note = self.entry.get_text()
self.controller.add_note(note) self.controller.add_note(note)
self.destroy()
case Gtk.ResponseType.CANCEL: case Gtk.ResponseType.CANCEL:
self.controller.delete_note() self.controller.delete_note()
self.destroy() case _:
case Gtk.ResponseType.CANCEL | Gtk.ResponseType.CLOSE: pass
self.destroy() self.destroy()
+46 -25
View File
@@ -1,20 +1,21 @@
import gi
import html import html
from typing import Self, TYPE_CHECKING from typing import Self, TYPE_CHECKING
from dzgui.const.constants import EXPAND, FILL from dzgui.const.icons import ICON_CALCULATOR
from dzgui.const.enum import ContextMenuGroup from dzgui.const.enum import ContextMenuGroup
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
from dzgui.util import css from dzgui.util import css
from dzgui.util import strings
from dzgui.util.format import format_hyperlinks, format_server_mods from dzgui.util.format import format_hyperlinks, format_server_mods
from dzgui.strings import server_mods 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.dialogs.generic import GenericDialog
from dzgui.views.trees.tree_base import TreeView from dzgui.views.trees.tree_base import TreeView
import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa from gi.repository import Gtk, Gdk, Pango # noqa
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import Details from dzgui.api.servers import Details
@@ -46,12 +47,19 @@ class ServerDialog(GenericDialog):
self.connect("response", self._on_response) self.connect("response", self._on_response)
self.view.connect("key-press-event", self._on_keypress) self.view.connect("key-press-event", self._on_keypress)
self.scrollable_tree = Gtk.ScrolledWindow() self.scrollable_tree = Gtk.ScrolledWindow(vexpand=True)
self.scrollable_tree.add(self.view) 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 = 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: def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool:
# NOTE: ESC normally unfocuses treeview instead of destroying dialog # NOTE: ESC normally unfocuses treeview instead of destroying dialog
@@ -66,9 +74,7 @@ class ServerDialog(GenericDialog):
class ServerDetailsDialog(ServerDialog): class ServerDetailsDialog(ServerDialog):
def __init__(self, controller: "Controller", details: "Details"): def __init__(self, controller: "Controller", details: "Details"):
# TODO: server name should also be packed in details struct super().__init__(controller, dialogs.server_details, details.name, menu=None)
name = controller.get_server_name()
super().__init__(controller, strings.server_details, name, menu=None)
self.store = Gtk.ListStore(str, str, Pango.Weight) self.store = Gtk.ListStore(str, str, Pango.Weight)
self.view.connect("row-activated", self._on_row_activated) self.view.connect("row-activated", self._on_row_activated)
@@ -85,14 +91,9 @@ class ServerDetailsDialog(ServerDialog):
column.set_sort_column_id(i) column.set_sort_column_id(i)
column.set_expand(True) column.set_expand(True)
# NOTE: constrain tree area to logical height of metadata scrollable_message = Gtk.ScrolledWindow(vexpand=True)
# leaving room for server message desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START)
self.scrollable_tree.set_size_request(700, 150) css.add_class(desc, "server-subheading")
# TODO: make "Server message" text boldface
scrollable_message = Gtk.ScrolledWindow()
desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START)
css.add_class(desc, "details-heading")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER)
self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True) self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True)
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
@@ -100,11 +101,7 @@ class ServerDetailsDialog(ServerDialog):
for el in desc, sep, self.description: for el in desc, sep, self.description:
box.add(el) box.add(el)
scrollable_message.add(box) scrollable_message.add(box)
self.content_box.add(scrollable_message)
self.pack(scrollable_message)
if details.data is None:
return
for row in details.data: for row in details.data:
self.store.append(row + [Pango.Weight.BOLD]) self.store.append(row + [Pango.Weight.BOLD])
@@ -113,8 +110,32 @@ class ServerDetailsDialog(ServerDialog):
text = format_hyperlinks(text) text = format_hyperlinks(text)
self.description.set_markup(html.escape(text)) self.description.set_markup(html.escape(text))
calc_button = IconTextButton(ICON_CALCULATOR, dialogs.toggle_calc)
calc_button.connect("clicked", self._on_calc_pressed)
calc_button.set_halign(Gtk.Align.END)
calc_button.set_valign(Gtk.Align.START)
self.content.pack_start(calc_button, False, False, 0)
self.content.reorder_child(calc_button, 2)
self.scrollable_tree.set_size_request(700, 250)
self.calc = ServerTimeCalculator(
Time(details.gametime, details.day_accel, details.night_accel)
)
self.stack.add_named(self.calc, "CALCULATOR")
self.connect("map", self._on_map)
self.show_all() self.show_all()
def _on_map(self, dialog: Self) -> None:
if widget := self.get_widget_for_response(Gtk.ResponseType.OK):
widget.grab_focus()
def _on_calc_pressed(self, button: Gtk.Button) -> None:
if self.stack.get_visible_child_name() == "METADATA":
self.stack.set_visible_child_name("CALCULATOR")
else:
self.stack.set_visible_child_name("METADATA")
def _on_row_activated( def _on_row_activated(
self, self,
treeview: Gtk.TreeView, treeview: Gtk.TreeView,
+7 -6
View File
@@ -13,15 +13,16 @@ from dzgui.util.css import load_css
from dzgui.util.format import format_exception from dzgui.util.format import format_exception
from dzgui.views.components.box import HBox from dzgui.views.components.box import HBox
from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel from dzgui.views.dialogs.wizard import ScrolledWizardPage, CheckboxWithLabel
from dzgui.views.mixins.colorscheme import ColorAwareApp
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Pango # noqa E402 from gi.repository import Gtk, GLib, Pango
class Assistant(Gtk.Assistant): class Assistant(Gtk.Assistant):
def __init__(self, is_deck: bool, paths: dict[str, str]): def __init__(self, is_deck: bool, paths: dict[str, Path]):
super().__init__() super().__init__()
if is_deck: if is_deck:
# NOTE: deemed to be "safe" dimensions that exclude taskbar size # NOTE: deemed to be "safe" dimensions that exclude taskbar size
@@ -62,7 +63,7 @@ class Assistant(Gtk.Assistant):
class Window(Gtk.Window): class Window(Gtk.Window):
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None: def __init__(self, is_deck: bool, paths: dict[str, Path]) -> None:
super().__init__(title=APP_NAME, icon_name=APP_NAME) super().__init__(title=APP_NAME, icon_name=APP_NAME)
self.assistant = Assistant(is_deck, paths) self.assistant = Assistant(is_deck, paths)
@@ -120,7 +121,7 @@ class CheckboxWithPath(CheckboxWithLabel):
class UninstallPage(ScrolledWizardPage): class UninstallPage(ScrolledWizardPage):
def __init__(self, paths: dict[str, str], pyapp: str | None): def __init__(self, paths: dict[str, Path], pyapp: str | None):
super().__init__(heading=uninstall.pg1_heading, description=uninstall.pg1_blurb) super().__init__(heading=uninstall.pg1_heading, description=uninstall.pg1_blurb)
self.pyapp = pyapp self.pyapp = pyapp
@@ -217,8 +218,8 @@ class UninstallPage(ScrolledWizardPage):
pass pass
class UninstallWizard(Gtk.Application): class UninstallWizard(ColorAwareApp, Gtk.Application): # type: ignore
def __init__(self, is_deck: bool, paths: dict[str, str]) -> None: def __init__(self, is_deck: bool, paths: dict[str, Path]) -> None:
super().__init__() super().__init__()
config = Path(paths["XDG_CONFIG_HOME"]) config = Path(paths["XDG_CONFIG_HOME"])
if not config.is_dir() or not config.joinpath("config.json").is_file(): if not config.is_dir() or not config.joinpath("config.json").is_file():
+139 -65
View File
@@ -1,4 +1,3 @@
import os
import textwrap import textwrap
from enum import Enum from enum import Enum
@@ -13,27 +12,30 @@ from dzgui.const.constants import (
APP_NAME, APP_NAME,
APP_NAME_LOWER, APP_NAME_LOWER,
HERO_PATH, HERO_PATH,
FLATPAK_ROOT_PATH,
LEGACY_CONFIG_PATH, LEGACY_CONFIG_PATH,
TMP_PATH,
) )
from dzgui.const.boilerplate import config_boilerplate from dzgui.const.boilerplate import config_boilerplate
from dzgui.const.endpoints import STEAM_API_SETUP from dzgui.const.endpoints import STEAM_API_SETUP
from dzgui.const.enum import Preferences
from dzgui.config import freedesktop from dzgui.config import freedesktop
from dzgui.config.query import lookup from dzgui.init.proc import is_sandboxed, path_to_steam_cmd
from dzgui.init.migrate import migrate_legacy_conf from dzgui.init.migrate import migrate_legacy_conf
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import wizard from dzgui.strings import wizard
from dzgui.util._json import write_json from dzgui.util._json import write_json
from dzgui.util.flatpak import apply_overrides
from dzgui.util.open_links import open_link_by_url from dzgui.util.open_links import open_link_by_url
from dzgui.util.css import add_class, load_css from dzgui.util.css import add_class, load_css
from dzgui.views.components.buttons import WebButton from dzgui.views.components.buttons import WebButton
from dzgui.views.components.entry import APIEntry from dzgui.views.components.entry import APIEntry
from dzgui.views.components.misc import ClientCombo from dzgui.views.mixins.colorscheme import ColorAwareApp
from dzgui.views.trees.tree_simple import SimpleTree
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402 from gi.repository import Gtk, GLib, GObject, GdkPixbuf
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.config.xdg import Xdg from dzgui.config.xdg import Xdg
@@ -46,7 +48,8 @@ class PageNum(Enum):
STEAM_API = 4 STEAM_API = 4
USER_PREFS = 5 USER_PREFS = 5
SHORTCUTS = 6 SHORTCUTS = 6
FINAL = 7 FLATPAK = 7
FINAL = 8
class OptionalPageMixin: class OptionalPageMixin:
@@ -145,7 +148,7 @@ class NotificationFrame(Gtk.Frame):
margin_start=50, margin_start=50,
margin_end=50, margin_end=50,
) )
wrapped = textwrap.fill(label, width=80) wrapped = textwrap.fill(label, width=60)
self.label = Gtk.Label( self.label = Gtk.Label(
label=wrapped, label=wrapped,
justify=Gtk.Justification.CENTER, justify=Gtk.Justification.CENTER,
@@ -159,13 +162,18 @@ class NotificationFrame(Gtk.Frame):
add_class(self, "error-frame") add_class(self, "error-frame")
def set_text(self, text: str) -> None: def set_text(self, text: str) -> None:
wrapped = textwrap.fill(text, width=80) wrapped = textwrap.fill(text, width=60)
self.label.set_markup(wrapped) self.label.set_markup(wrapped)
class APIValidationPage(EnumeratedWizardPage): class APIValidationPage(EnumeratedWizardPage):
def __init__( def __init__(
self, enum: PageNum, heading: str, description: str, link: str, func: Callable self,
enum: PageNum,
heading: str,
description: str,
link: str,
func: Callable[[str], None],
) -> None: ) -> None:
super().__init__( super().__init__(
enum=enum, enum=enum,
@@ -244,12 +252,23 @@ class IntroductionPage(EnumeratedWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
enum=PageNum.INTRO, enum=PageNum.INTRO,
heading=f"Welcome to {APP_NAME}!", heading=wizard.welcome.format(APP_NAME),
description=wizard.blurb_intro, description=wizard.blurb_intro,
) )
self.page_type = Gtk.AssistantPageType.INTRO self.page_type = Gtk.AssistantPageType.INTRO
self.set_title(wizard.title_intro) self.set_title(wizard.title_intro)
self.error_box = NotificationFrame(wizard.error_sandbox, error=True)
self.add_start(self.error_box)
self.connect("map", self._on_map)
def _on_map(self, page: "EnumeratedWizardPage") -> None:
self.error_box.hide()
if is_sandboxed():
self.error_box.show()
EMITTER.emit("step_pending")
class Heading(Gtk.Label): class Heading(Gtk.Label):
def __init__(self, label: str): def __init__(self, label: str):
@@ -304,6 +323,7 @@ class ConfigMigrationPage(EnumeratedWizardPage):
) )
self.migrated = False self.migrated = False
self.migrated_conf: dict[str, Any] = {}
self.config = config self.config = config
self.page_type = Gtk.AssistantPageType.INTRO self.page_type = Gtk.AssistantPageType.INTRO
@@ -343,8 +363,7 @@ class ConfigMigrationPage(EnumeratedWizardPage):
def _on_import_clicked(self, button: Gtk.Button) -> None: def _on_import_clicked(self, button: Gtk.Button) -> None:
self.grid.set_sensitive(False) self.grid.set_sensitive(False)
try: try:
# TODO: this could be deferred to the final page (prevents accidental destruction of dialog via ESC) self.migrated_conf = migrate_legacy_conf(self.config)
migrate_legacy_conf(self.config)
self.migrated = True self.migrated = True
self.success_box.set_visible(True) self.success_box.set_visible(True)
except Exception: except Exception:
@@ -386,26 +405,17 @@ class PreferencesPage(EnumeratedWizardPage):
self.dist_box.add(self.radio_km) self.dist_box.add(self.radio_km)
self.dist_box.add(self.radio_miles) self.dist_box.add(self.radio_miles)
client_label = Gtk.Label(label=wizard.label_client, halign=Gtk.Align.START)
self.client_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
self.client_combo = ClientCombo()
self.client_box.add(client_label)
self.client_box.add(self.client_combo)
outer_box = Gtk.Box( outer_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, spacing=10 orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER, spacing=10
) )
for el in self.name_box, self.client_box, self.dist_box: for el in self.name_box, self.dist_box:
outer_box.add(el) outer_box.add(el)
self.add_start(outer_box) self.add_start(outer_box)
def get_prefs(self) -> tuple[str, bool, str]: def get_prefs(self) -> tuple[str, bool]:
name = self.name_entry.get_text().strip() name = self.name_entry.get_text().strip()
use_miles = self.radio_miles.get_active() use_miles = self.radio_miles.get_active()
model = self.client_combo.get_model() return name, use_miles
ind = self.client_combo.get_active()
client = model[ind][1]
return name, use_miles, client
def _on_entry_changed(self, entry: Gtk.Entry) -> None: def _on_entry_changed(self, entry: Gtk.Entry) -> None:
text = entry.get_text() text = entry.get_text()
@@ -415,6 +425,44 @@ class PreferencesPage(EnumeratedWizardPage):
EMITTER.emit("step_complete") EMITTER.emit("step_complete")
class FlatpakOverridePage(EnumeratedWizardPage):
def __init__(self, xdg_paths: dict[str, Path]) -> None:
super().__init__(
enum=PageNum.FLATPAK,
heading=wizard.heading_flatpak,
description=wizard.blurb_flatpak,
)
self.xdg_paths = xdg_paths
self.page_type = Gtk.AssistantPageType.INTRO
store = Gtk.ListStore(str)
for path in xdg_paths.values():
store.append((str(path),))
store.append((TMP_PATH,))
tree = SimpleTree(store, wizard.tree_column)
tree.set_halign(Gtk.Align.CENTER)
self.authorize_button = Gtk.Button(
label=wizard.button_flatpak, halign=Gtk.Align.CENTER
)
self.authorize_button.connect("clicked", self._on_authorize)
self.notice = NotificationFrame(wizard.overrides_applied)
self.connect("map", self._on_map)
for el in tree, self.authorize_button, self.notice:
self.add_start(el)
def _on_map(self, page: "EnumeratedWizardPage") -> None:
self.notice.hide()
def _on_authorize(self, button: Gtk.Button) -> None:
apply_overrides(self.xdg_paths)
self.notice.show()
EMITTER.emit("step_complete")
class CompletionPage(EnumeratedWizardPage): class CompletionPage(EnumeratedWizardPage):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
@@ -428,7 +476,7 @@ class CompletionPage(EnumeratedWizardPage):
class Assistant(Gtk.Assistant): class Assistant(Gtk.Assistant):
def __init__(self, is_deck: bool, XDG: "Xdg"): def __init__(self, is_deck: bool, XDG: "Xdg", xdg_paths: dict[str, Path]):
super().__init__() super().__init__()
if is_deck: if is_deck:
# NOTE: deemed to be "safe" dimensions that exclude taskbar size # NOTE: deemed to be "safe" dimensions that exclude taskbar size
@@ -436,20 +484,19 @@ class Assistant(Gtk.Assistant):
else: else:
self.set_default_size(1500, 900) self.set_default_size(1500, 900)
self.is_binary = False if os.getenv("PYAPP") is None else True
self.config_path = XDG.config self.config_path = XDG.config
self.config_values: dict[str, Any] = config_boilerplate self.config_values: dict[str, Any] = config_boilerplate
self.setup_complete = False self.setup_complete = False
self.page1 = IntroductionPage() self.page_intro = IntroductionPage()
self.page2 = ConfigMigrationPage(XDG.config) self.page_migration = ConfigMigrationPage(XDG.config)
self.page3 = SteamPathPage() self.page_paths = SteamPathPage()
self.page4 = SteamValidationPage() self.page_api = SteamValidationPage()
self.page5 = PreferencesPage() self.page_prefs = PreferencesPage()
self.page6 = ShortcutCreationPage(XDG.shortcut) self.page_shortcuts = ShortcutCreationPage(XDG.shortcut)
self.page7 = CompletionPage() self.page_flatpak = FlatpakOverridePage(xdg_paths)
self.page_completion = CompletionPage()
self.set_forward_page_func(self._advance_page) self.set_forward_page_func(self._advance_page)
@@ -457,16 +504,19 @@ class Assistant(Gtk.Assistant):
EMITTER.connect("step_pending", self._mark_page_incomplete) EMITTER.connect("step_pending", self._mark_page_incomplete)
EMITTER.connect("config", self._set_config_state) EMITTER.connect("config", self._set_config_state)
self.pages: dict[EnumeratedWizardPage, int] = {}
legacy_path = Path.home().joinpath(LEGACY_CONFIG_PATH) legacy_path = Path.home().joinpath(LEGACY_CONFIG_PATH)
self.has_legacy_config = legacy_path.is_file() self.has_legacy_config = legacy_path.is_file()
for page in ( for page in (
self.page1, self.page_intro,
self.page2, self.page_migration,
self.page3, self.page_paths,
self.page4, self.page_api,
self.page5, self.page_prefs,
self.page6, self.page_shortcuts,
self.page7, self.page_flatpak,
self.page_completion,
): ):
# NOTE: skip config migration page if no legacy config file # NOTE: skip config migration page if no legacy config file
if ( if (
@@ -474,10 +524,8 @@ class Assistant(Gtk.Assistant):
and self.has_legacy_config is False and self.has_legacy_config is False
): ):
continue continue
# NOTE: disabled for now on system-provided packages ind = self._add_page(page, page.get_page_type())
if isinstance(page, ShortcutCreationPage) and not self.is_binary: self.pages[page] = ind
continue
self._add_page(page, page.get_page_type())
self.connect("prepare", self._on_page_prepare) self.connect("prepare", self._on_page_prepare)
self.connect("cancel", self.destroy_and_quit) self.connect("cancel", self.destroy_and_quit)
@@ -485,8 +533,14 @@ class Assistant(Gtk.Assistant):
self.show_all() self.show_all()
load_css() load_css()
def write_config(self) -> None: def write_config(self, config: dict[str, Any]) -> None:
write_json(self.config_values, self.config_path) write_json(config, self.config_path)
def get_completion_page(self) -> int:
return self.pages[self.page_completion]
def get_shortcut_page(self) -> int:
return self.pages[self.page_shortcuts]
def _advance_page(self, index: int) -> int: def _advance_page(self, index: int) -> int:
page = self.get_nth_page(index) page = self.get_nth_page(index)
@@ -495,30 +549,42 @@ class Assistant(Gtk.Assistant):
pass pass
case ConfigMigrationPage(): case ConfigMigrationPage():
if page.is_migrated(): if page.is_migrated():
steam_path = lookup(self.config_path, Preferences.DEFAULT) sp = page.migrated_conf["default_steam_path"]
self.page6.set_steam_path(steam_path) self.page_shortcuts.set_steam_path(sp)
offset = 1 if not self.is_binary else 2 self.config_values = self.page_migration.migrated_conf
self.setup_complete = True return self.get_shortcut_page()
return self.get_n_pages() - offset
case SteamPathPage(): case SteamPathPage():
self.config_values["default_steam_path"] = page.get_path_from_radio() path = page.get_path_from_radio()
self.config_values["default_steam_path"] = path
self.config_values["client"] = path_to_steam_cmd(path)
case SteamValidationPage(): case SteamValidationPage():
self.config_values["steam_api"] = page.get_api_key() self.config_values["steam_api"] = page.get_api_key()
case PreferencesPage(): case PreferencesPage():
# NOTE: collects config values before advancing to last page # NOTE: collects config values before advancing to last page
name, use_miles, client = self.page5.get_prefs() name, use_miles = self.page_prefs.get_prefs()
self.config_values["name"] = name self.config_values["name"] = name
self.config_values["use_miles"] = use_miles self.config_values["use_miles"] = use_miles
self.config_values["client"] = client self.page_shortcuts.set_steam_path(
self.write_config() self.config_values["default_steam_path"]
self.page6.set_steam_path(self.config_values["default_steam_path"]) )
self.setup_complete = True
case ShortcutCreationPage(): case ShortcutCreationPage():
page.create_shortcuts() page.create_shortcuts()
if not self.needs_flatpak_override():
return self.get_completion_page()
case FlatpakOverridePage():
pass
case _: case _:
raise AttributeError("Trying to advance a non-canonical page") raise AttributeError("Trying to advance a non-canonical page")
return index + 1 return index + 1
def needs_flatpak_override(self) -> bool:
if (
self.page_shortcuts.is_steam_checkbox_active()
and FLATPAK_ROOT_PATH in self.config_values["default_steam_path"]
):
return True
return False
def is_setup_complete(self) -> bool: def is_setup_complete(self) -> bool:
return self.setup_complete return self.setup_complete
@@ -542,11 +608,12 @@ class Assistant(Gtk.Assistant):
def _add_page( def _add_page(
self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType self, page: EnumeratedWizardPage, ptype: Gtk.AssistantPageType
) -> None: ) -> int:
self.append_page(page) ind = self.append_page(page)
self.set_page_type(page, ptype) self.set_page_type(page, ptype)
self.set_page_title(page, page.get_title()) self.set_page_title(page, page.get_title())
self.set_page_complete(page, True) self.set_page_complete(page, True)
return ind
def _set_config_state(self, emitter: "Emitter", state: bool) -> None: def _set_config_state(self, emitter: "Emitter", state: bool) -> None:
self.config = state self.config = state
@@ -563,6 +630,10 @@ class Assistant(Gtk.Assistant):
if not isinstance(page, IntroductionPage): if not isinstance(page, IntroductionPage):
EMITTER.emit("step_pending") EMITTER.emit("step_pending")
if page == self.page_completion:
self.write_config(self.config_values)
self.setup_complete = True
class CheckboxWithLabel(Gtk.Box): class CheckboxWithLabel(Gtk.Box):
def __init__(self, text: str, blurb_text: str) -> None: def __init__(self, text: str, blurb_text: str) -> None:
@@ -639,6 +710,9 @@ class ShortcutCreationPage(OptionalPageMixin, EnumeratedWizardPage): # type: ig
def set_steam_path(self, path: Path) -> None: def set_steam_path(self, path: Path) -> None:
self.steam_path = path self.steam_path = path
def is_steam_checkbox_active(self) -> bool:
return self.steam_checkbox.get_active()
def create_shortcuts(self) -> None: def create_shortcuts(self) -> None:
# NOTE: best-effort, permissive even on failure (page is already marked as complete) # NOTE: best-effort, permissive even on failure (page is already marked as complete)
if self.steam_checkbox.get_active(): if self.steam_checkbox.get_active():
@@ -706,11 +780,11 @@ class SteamPathPage(EnumeratedWizardPage):
self.err_box.set_visible(False) self.err_box.set_visible(False)
class SetupWizard(Gtk.Application): class SetupWizard(ColorAwareApp, Gtk.Application): # type: ignore
def __init__(self, is_deck: bool, XDG: "Xdg") -> None: def __init__(self, is_deck: bool, XDG: "Xdg", xdg_paths: dict[str, Path]) -> None:
super().__init__() super().__init__()
GLib.set_prgname(APP_NAME) GLib.set_prgname(APP_NAME)
self.win = Window(is_deck, XDG) self.win = Window(is_deck, XDG, xdg_paths)
Gtk.main() Gtk.main()
def is_setup_complete(self) -> int: def is_setup_complete(self) -> int:
@@ -718,9 +792,9 @@ class SetupWizard(Gtk.Application):
class Window(Gtk.Window): class Window(Gtk.Window):
def __init__(self, is_deck: bool, XDG: "Xdg") -> None: def __init__(self, is_deck: bool, XDG: "Xdg", xdg_paths: dict[str, Path]) -> None:
super().__init__(title=APP_NAME, icon_name=APP_NAME) super().__init__(title=APP_NAME, icon_name=APP_NAME)
self.assistant = Assistant(is_deck, XDG) self.assistant = Assistant(is_deck, XDG, xdg_paths)
class Emitter(GObject.GObject): class Emitter(GObject.GObject):
+37
View File
@@ -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)
+11 -15
View File
@@ -1,7 +1,7 @@
import logging import logging
from dzgui.const.constants import APP_NAME from dzgui.const.constants import APP_NAME
from dzgui.const.enum import ContextMenu from dzgui.const.enum import ContextMenuItem
from dzgui.util.keys import is_ctrl_mask, is_navkey from dzgui.util.keys import is_ctrl_mask, is_navkey
from dzgui.util.strings import edit_note from dzgui.util.strings import edit_note
from dzgui.views.trees.tree_base import TreeView from dzgui.views.trees.tree_base import TreeView
@@ -9,17 +9,13 @@ from dzgui.views.trees.tree_base import TreeView
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 from gi.repository import Gtk, Gdk
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
class ContextMixin(TreeView): class ContextMixin(TreeView):
def present_menu( def present_menu(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
self,
widget: Gtk.Widget,
event: Gdk.EventButton | Gdk.EventKey,
) -> bool:
if self.menu is None: if self.menu is None:
return False return False
@@ -28,7 +24,7 @@ class ContextMixin(TreeView):
match event.type: match event.type:
case Gdk.EventType.BUTTON_PRESS: case Gdk.EventType.BUTTON_PRESS:
if event.button != 3: if event.button != Gdk.BUTTON_SECONDARY: # type: ignore[comparison-overlap]
return False return False
self._process_button_event(event) # type: ignore self._process_button_event(event) # type: ignore
case Gdk.EventType.KEY_PRESS: case Gdk.EventType.KEY_PRESS:
@@ -62,17 +58,17 @@ class ContextMixin(TreeView):
self.context_menu.select_first(False) self.context_menu.select_first(False)
return True return True
def _process_dynamic_row(self, row: ContextMenu) -> Gtk.MenuItem: def _process_dynamic_row(self, row: ContextMenuItem) -> Gtk.MenuItem:
if row == ContextMenu.ADD_SERVER and self.is_in_favs(): # type: ignore if row == ContextMenuItem.ADD_SERVER and self.is_in_favs(): # type: ignore
row = ContextMenu.REMOVE_SERVER row = ContextMenuItem.REMOVE_SERVER
item = Gtk.MenuItem(label=row.dict["label"]) item = Gtk.MenuItem(label=row.label)
item.connect("activate", self._on_menu_click, row) item.connect("activate", self._on_menu_click, row)
if row == ContextMenu.SHOW_MODS: if row == ContextMenuItem.SHOW_MODS:
item.set_sensitive(self.is_modded()) # type: ignore item.set_sensitive(self.is_modded()) # type: ignore
if row == ContextMenu.ADD_NOTE: if row == ContextMenuItem.ADD_NOTE:
if self.controller.has_note(): if self.controller.has_note():
item.set_label(edit_note) item.set_label(edit_note)
@@ -96,7 +92,7 @@ class ContextMixin(TreeView):
except AttributeError: except AttributeError:
return False return False
def _on_menu_click(self, widget: Gtk.MenuItem, enum: ContextMenu) -> None: def _on_menu_click(self, widget: Gtk.MenuItem, enum: ContextMenuItem) -> None:
self.controller.menu_action(enum, self) # type: ignore self.controller.menu_action(enum, self) # type: ignore
def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None: def _on_key(self, menu: Gtk.Menu, event: Gdk.EventKey) -> bool | None:
+1 -1
View File
@@ -5,7 +5,7 @@ from dzgui.util.keys import is_ctrl_mask
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 from gi.repository import Gtk, Gdk
class CursorMixin: class CursorMixin:
+1 -1
View File
@@ -3,7 +3,7 @@ from dzgui.views.trees.tree_base import TreeView
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa E402 from gi.repository import Gtk
class ModsMixin: class ModsMixin:
+1 -1
View File
@@ -15,7 +15,7 @@ from dzgui.views.mixins.scrollable_mixin import ScrollableMixin
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk
logger = logging.getLogger(APP_NAME) logger = logging.getLogger(APP_NAME)
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more