Compare commits

...
235 Commits
Author SHA1 Message Date
aclist d8239f1c34 Merge pull request #405 from aclist/chore/update-licenses
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
chore: list missing library
2026-07-12 12:34:32 +09:00
aclist 8c3ec4512a chore: list missing library 2026-07-12 12:34:00 +09:00
aclist 1a18c8ff02 Merge pull request #404 from aclist/fix/app-path-tests
chore: update tests to reflect get_app_path() changes
2026-07-12 12:23:05 +09:00
aclist 92c23ec609 chore: update tests to reflect get_app_path() changes 2026-07-12 12:22:12 +09:00
aclist e9d4054e76 Merge pull request #403 from aclist/prerelease/7.0.0b16
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
chore: bump version
2026-07-12 10:35:11 +09:00
aclist 1c9e026f70 chore: bump version 2026-07-12 10:34:24 +09:00
aclist eb076b7dcd Merge pull request #402 from aclist/fix/clean-acf
chore: drop comment
2026-07-12 10:32:27 +09:00
aclist 8629e02670 chore: drop comment 2026-07-12 10:30:06 +09:00
aclist 3ba38f30ad Merge pull request #401 from aclist/feat/collapsible-changelog
feat: collapsible changelog
2026-07-12 10:28:18 +09:00
aclist 7c37fd8b6e Merge pull request #400 from aclist/feat/shortcuts
feat: generate Steam shortcut
2026-07-12 10:27:27 +09:00
aclist 1a065123bb Merge branch 'feat/shortcuts' of github.com:aclist/dztui into feat/shortcuts
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-07-12 10:25:06 +09:00
aclist 2586306323 chore: drop comment 2026-07-12 10:24:45 +09:00
aclist 1ccb99d935 Merge branch 'dzgui7' into feat/shortcuts 2026-07-12 10:24:04 +09:00
aclist 054fe968d1 Merge pull request #399 from aclist/feat/reaper-proc
feat: config parsers and reaper process monitor
2026-07-12 10:22:45 +09:00
aclist 1760e6e928 chore: cleanup
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-07-12 10:21:06 +09:00
aclist a2a39e9927 fix: backport fixes from feat/shortcuts 2026-07-12 10:15:58 +09:00
aclist e664f68624 fix: restore prior proc monitor logic (avoids conflicts) 2026-07-12 10:14:55 +09:00
aclist 2e41856348 chore: backport changelog fixes 2026-07-12 10:14:15 +09:00
aclist 27d444ea6f feat: parse changelog chunks 2026-07-11 22:59:28 +09:00
aclist 26a2362172 fix: clear typehinting errors
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-07-11 20:11:18 +09:00
aclist 50cf347164 fix: typo 2026-07-11 20:10:25 +09:00
aclist dd69bc88b1 chore: add shortcut tests 2026-07-11 20:04:45 +09:00
aclist 5c84b5ae0a chore: drop test code 2026-07-11 19:23:37 +09:00
aclist 6fdf5547dc fix: handle zero-length shortcuts file 2026-07-11 18:10:30 +09:00
aclist fc1311579c fix: clear typehinting errors 2026-07-11 17:53:55 +09:00
aclist da993db2e2 fix: update imports 2026-07-11 17:53:13 +09:00
aclist 207d8fd407 chore: add logging 2026-07-11 17:52:39 +09:00
aclist 72b10ad543 chore: drop unused code 2026-07-11 17:52:21 +09:00
aclist 35680b1373 chore: clarify dialog strings 2026-07-11 17:51:32 +09:00
aclist 5121e6d005 docs: update changelog 2026-07-11 17:51:09 +09:00
aclist 2f61cc7ff7 feat: make checkboxes in lock-step 2026-07-11 16:33:40 +09:00
aclist 8733094267 chore: wrap in try/except statement 2026-07-11 16:33:15 +09:00
aclist 9d14b7f219 chore: error handling 2026-07-11 16:32:52 +09:00
aclist 0084af4959 feat: write desktop file 2026-07-11 16:32:24 +09:00
aclist caf071f997 feat: generate shortcuts 2026-07-11 15:33:52 +09:00
aclist b50d061e1f fix: return value
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-07-10 17:31:32 +09:00
aclist e913c95c70 chore: remove unused imports 2026-07-10 17:31:05 +09:00
aclist d6bf63886e feat: warn if background downloads are disabled 2026-07-10 17:30:23 +09:00
aclist ec8f6a3d36 chore: add config tests 2026-07-10 17:09:49 +09:00
aclist b8fbbbb1cd chore: abstract function to facilitate unit testing 2026-07-10 17:07:30 +09:00
aclist fb2772cde2 fix: add cancel button to offline launch dialog 2026-07-10 15:57:34 +09:00
aclist 6c36b741f4 fix: convert str to Path 2026-07-10 15:57:11 +09:00
aclist aa43b017d1 chore: add note
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-07-09 12:37:23 +09:00
aclist fc86b75ca1 fix: convert str to Path 2026-07-09 12:37:09 +09:00
aclist 6a3b4b2bfb fix: cast result to str
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-07-08 10:58:40 +09:00
aclist 8652259053 feat: convert appid to name 2026-07-08 10:58:12 +09:00
aclist e92df8c062 Merge pull request #398 from u-alexandru/fix/history-file-not-found
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
fix: prevent stuck launch dialog when history file is missing
2026-07-08 10:41:36 +09:00
aclist eff31e2bd3 chore: clear typehinting errors 2026-07-08 10:07:16 +09:00
u-alexandru 494311e8df fix: prevent stuck launch dialog when history file is missing
update_history_file() opened dzg.history in read mode without handling a
missing file. dzg.history is only created the first time it is written, so
on a fresh install the very first server connection raised FileNotFoundError
inside the post-launch cleanup callback:

  _destroy_on_idle -> _add_to_history_and_return -> update_history_file

The exception propagated out of the GLib idle callback before destroy_dialog()
ran, orphaning the "Waiting for DayZ to launch" modal so it could never be
closed from the UI and its Cancel button was unresponsive.

Treat a missing history file as an empty history so the first connection
records history normally instead of crashing the cleanup callback.
2026-07-07 18:45:22 +03:00
aclist a2d1f9b0e1 chore: simplify path concatenation logic 2026-07-07 19:52:44 +09:00
aclist b731fb5566 chore: drop test code 2026-07-07 19:43:41 +09:00
aclist e8d991ccb5 chore: drop legacy functions 2026-07-07 19:43:00 +09:00
aclist f4aa6c749e chore: move get_app_path() 2026-07-07 19:36:44 +09:00
aclist 3ed4747d08 chore: drop unused import 2026-07-07 18:38:25 +09:00
aclist d2042a3dde fix: clear typehinting errors 2026-07-07 18:38:14 +09:00
aclist 09ef0f4f9a chore: add reminder 2026-07-07 18:11:40 +09:00
aclist a91ce17501 feat: config parsing POC 2026-07-07 18:11:30 +09:00
aclist 48f0f3913c chore: add reminder 2026-07-07 18:11:07 +09:00
aclist 33e367182e feat: ACF class 2026-07-07 18:10:52 +09:00
aclist 0ce3ab3915 chore: add vdf dependency 2026-07-07 18:10:12 +09:00
aclist 90c7d9f73e Merge pull request #395 from aclist/chore/contributing-docs
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
docs: add contributing docs
2026-07-07 16:52:16 +09:00
aclist 5923d5d935 fix: typo 2026-07-07 16:46:01 +09:00
aclist 9f73c545ef docs: port contributing file from dzgui6, with changes 2026-07-07 16:42:25 +09:00
aclist 041438cb7f docs: update readme to reflect current release workflow 2026-07-07 16:42:03 +09:00
aclist c6e69f9aa0 Merge pull request #393 from aclist/feat/redaction-filter
feat: redaction filter
2026-07-07 16:33:22 +09:00
aclist 8f1e4ff9d0 chore: drop fixed comment
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-07-07 16:32:23 +09:00
aclist 92792b0b4f chore: drop extraneous comment 2026-07-07 16:29:10 +09:00
aclist a7cfd4c346 fix: monkeypatch system env 2026-07-07 16:28:52 +09:00
aclist fa5f638697 chore: add comment 2026-07-07 12:03:10 +09:00
aclist 78151b3bb3 fix: update regex
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-07-04 14:30:59 +09:00
aclist 2418d8be68 chore: update pytest marks 2026-07-04 14:12:46 +09:00
aclist 9fa7172056 chore: add log redaction test 2026-07-04 13:18:44 +09:00
aclist d75f87768e chore: drop extraneous newline 2026-07-04 08:48:03 +09:00
aclist 5bb4271d36 feat: log-level redaction filter 2026-07-04 08:47:34 +09:00
aclist 6c86bff869 fix: drop branch from diagnostic log 2026-07-04 08:46:54 +09:00
aclist 92ba7c2267 chore: drop unused imports, vars 2026-07-04 08:42:53 +09:00
aclist 6c56f16633 Merge pull request #391 from aclist/fix/copy-bare-configs
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
fix: copy bare config files to named directory
2026-06-29 07:36:49 +09:00
aclist 2b87272b52 chore: update test conditions
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-06-29 07:28:35 +09:00
aclist 4de5147899 fix: parse parent directory
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-06-28 23:52:13 +09:00
aclist 4c31cd7ecd chore: add test for no import case 2026-06-28 00:07:51 +09:00
aclist 61f90d6016 fix: copy bare config files to named directory (#375) 2026-06-27 23:51:20 +09:00
aclist cd1fd1b179 Merge pull request #390 from aclist/fix/ctypes-libgi
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
fix: platform-agnostic shared library check
2026-06-25 21:19:09 +09:00
aclist 1b25c8fadb chore: bump version 2026-06-25 21:17:30 +09:00
aclist 18146bf66f docs: fix typo 2026-06-25 21:15:54 +09:00
aclist 8ae54d436a chore: drop unused import 2026-06-25 21:15:36 +09:00
aclist 4d94551f87 fix: use platform-agnostic shared library check 2026-06-25 21:15:08 +09:00
aclist 1154789f8b Merge pull request #389 from aclist/fix/defer-import
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
fix: defer flock action until GUI is loaded
2026-06-25 00:05:27 +09:00
aclist cbccd4c5f6 fix: defer flock action until GUI is loaded 2026-06-25 00:04:18 +09:00
aclist 8cac752a0d Merge pull request #388 from aclist/prerelease/7.0.0b14
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
chore: bump version
2026-06-24 22:07:38 +09:00
aclist 580273053c chore: bump version 2026-06-24 21:43:45 +09:00
aclist 8d75c1f7b2 Merge pull request #386 from aclist/feat/libgi-preboot
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
feat: libgi preboot test
2026-06-24 01:06:51 +09:00
aclist 320c2f692e chore: more explicit library name 2026-06-24 01:05:12 +09:00
aclist 413d84b2b5 chore: reword comment 2026-06-24 01:04:07 +09:00
aclist c751b277d0 fix: unhandled exception 2026-06-24 00:59:56 +09:00
aclist 7f36a3acad fix: mislabeled keybindings 2026-06-23 23:02:39 +09:00
aclist 5780d3daff feat: abort gracefully if libgirepository is missing 2026-06-23 23:02:18 +09:00
aclist e692e1d139 Merge pull request #385 from aclist/fix/maps-combo-unloaded
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
fix: maps combo not updating on unloaded model
2026-06-23 19:30:36 +09:00
aclist 38d44a7952 chore: drop unused string 2026-06-23 19:29:23 +09:00
aclist 611e3e6e58 fix: drop extraneous chained thread 2026-06-23 19:26:57 +09:00
aclist d5b415316e fix: update dialog string 2026-06-23 19:17:47 +09:00
aclist 0eeec490c2 chore: clear typehinting errors 2026-06-23 19:13:15 +09:00
aclist 782157006a fix: regression introduced in #382 2026-06-23 19:12:30 +09:00
aclist 3be7bc0e02 fix: reinit map store on empty model (#359) 2026-06-23 19:12:06 +09:00
aclist 9e7102aaba fix: wipe cache on refresh action (#384) 2026-06-23 19:11:30 +09:00
aclist 6516e7ae3c Merge pull request #383 from aclist/fix/block-refresh
fix: do not trigger decrement if tree is unloaded
2026-06-23 18:29:55 +09:00
aclist 46c1da232e fix: do not trigger decrement if tree is unloaded (#360) 2026-06-23 18:27:34 +09:00
aclist 5280121e43 Merge pull request #382 from aclist/fix/needs-update-column
fix: use unified mod list logic
2026-06-23 18:26:45 +09:00
aclist 17c63cf85f chore: drop unused code 2026-06-23 18:26:18 +09:00
aclist 488d98800f chore: clear typehinting errors 2026-06-23 18:14:37 +09:00
aclist 4eac5055cb fix: use unified logic for modlist queries (#354) 2026-06-23 18:09:57 +09:00
aclist 81168d3952 Merge pull request #381 from aclist/fix/modpanel-sens
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
fix: toggle mod panel sensitivity on map
2026-06-23 00:37:40 +09:00
aclist ee90be42ee fix: toggle mod panel sensitivity on map 2026-06-23 00:18:02 +09:00
aclist 48f341bae2 Merge pull request #379 from aclist/fix/synch-icons
fix: synch icons
2026-06-22 22:59:38 +09:00
aclist 0653f064b2 chore: add tests for mod signatures
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-06-22 22:55:44 +09:00
aclist 525dc24508 fix: missing import 2026-06-22 17:02:39 +09:00
aclist 4e7c831a53 fix: pass mods and iters to callback 2026-06-22 17:02:27 +09:00
aclist e41bef7b00 fix: use list comprehension 2026-06-22 17:01:45 +09:00
aclist 27136a0906 Merge pull request #378 from aclist/chore/log-exception
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
chore: log exception when launch process cannot be found
2026-06-22 00:02:05 +09:00
aclist cd95a71782 chore: log exception when launch process cannot be found 2026-06-21 23:58:44 +09:00
aclist 0c014a7859 Merge pull request #377 from aclist/fix/update-symlinks
fix: rebuild symlinks on load
2026-06-21 23:58:07 +09:00
aclist 65c159505f Merge branch 'dzgui7' into fix/update-symlinks 2026-06-21 23:54:32 +09:00
aclist e056ac924a Merge pull request #376 from aclist/fix/missing-config-key
fix: missing config key
2026-06-21 23:52:53 +09:00
aclist d540680330 Merge pull request #374 from aclist/fix/show-mod-progress
fix: show mod progress
2026-06-21 23:52:30 +09:00
aclist 98c45afe7f chore: drop print statement
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-06-21 23:51:42 +09:00
aclist e86040747f fix: rebuild symlinks on load 2026-06-21 23:50:18 +09:00
aclist a559485d0a chore: change structure of fixtures 2026-06-21 22:20:38 +09:00
aclist 8695793af4 chore: add config test 2026-06-21 22:18:35 +09:00
aclist a2aeac0242 fix: workaround for circular import in tests 2026-06-21 22:18:02 +09:00
aclist 879d0650d4 fix: insert missing values prior to serialization 2026-06-21 22:17:38 +09:00
aclist 5c85c746f1 fix: update button label conditionally
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-06-20 23:28:48 +09:00
aclist fa8792ba69 chore: add logging when opening options page 2026-06-20 19:45:27 +09:00
aclist 5e3392ddec chore: drop unused import 2026-06-20 18:30:35 +09:00
aclist 796f6b8435 feat: call emitter from ThreadingManager 2026-06-20 18:30:08 +09:00
aclist 780402d14f change: ease rate limit 2026-06-20 18:29:54 +09:00
aclist 3ae0c8428a change: ease rate limit 2026-06-20 18:29:39 +09:00
aclist ed20a7cc84 fix: update table checkmarks when mods are synched (#370) 2026-06-20 18:28:59 +09:00
aclist 9225f3bc06 chore: update strings 2026-06-20 18:27:46 +09:00
aclist 075a2e5a74 chore: update rate limit consts 2026-06-20 18:26:49 +09:00
aclist 58a7878f60 fix: append mission param dynamically (#372) 2026-06-20 18:26:34 +09:00
aclist f839001097 Merge pull request #368 from aclist/fix/offline-symlinks
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
fix: rebuild symlinks when launching offline
2026-06-18 01:08:10 +09:00
aclist ab2b651b6c chore: bump version 2026-06-18 01:07:00 +09:00
aclist e5934848c7 fix: rebuild symlinks when launching 2026-06-18 01:06:49 +09:00
aclist cab10efc96 Merge pull request #367 from aclist/prerelease/7.0.0b12
Prerelease/7.0.0b12
2026-06-17 22:22:04 +09:00
aclist 0a4efba905 docs: add user to thanks 2026-06-17 22:16:00 +09:00
aclist 1e4e09e52c docs: update changelog 2026-06-17 22:14:56 +09:00
aclist 910c068109 chore: bump version 2026-06-17 22:14:30 +09:00
aclist 1dd773a9bf Merge pull request #366 from aclist/fix/validate-offline-mods
fix: validate custom folders before launch
2026-06-17 22:13:56 +09:00
aclist f4d065d766 fix: typo 2026-06-17 22:13:17 +09:00
aclist 1fe66076c6 fix: validate custom folders before launch 2026-06-17 22:12:12 +09:00
aclist 668ec1d7a0 Merge pull request #365 from aclist/fix/unsub
Fix/unsub
2026-06-17 21:43:11 +09:00
aclist 412f0d3193 chore: clear typehinting errors
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-06-17 21:42:02 +09:00
aclist 894a9b106d chore: move HBox, VBox classes 2026-06-17 21:41:34 +09:00
aclist 1b0a9c9a69 chore: drop foreground logic 2026-06-17 21:33:49 +09:00
aclist c4818d2097 fix: name collision between function signature and variable 2026-06-17 21:33:28 +09:00
aclist 69135150c7 chore: add deprecaiton warning
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-06-17 03:16:44 +09:00
aclist 4d8949427b feat: acf parser 2026-06-17 03:15:57 +09:00
aclist 0139f6bfba fix: cast str to int 2026-06-17 02:57:50 +09:00
aclist 8d77d46eda feat: unsubscribe from mods gracefully 2026-06-17 02:56:26 +09:00
aclist 179f92234a chore: clear typehinting errors 2026-06-17 02:55:13 +09:00
aclist 650a5ecd7b chore: drop unused imports 2026-06-17 02:54:50 +09:00
aclist 8cebb362e9 chore: clear typehinting errors 2026-06-17 02:53:32 +09:00
aclist bfd1d3e0f9 change: move workshop button to mods page 2026-06-17 02:52:57 +09:00
aclist e8b18d83a5 Merge pull request #354 from aclist/feat/launch-offline
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
feat: launch offline
2026-06-14 22:03:17 +09:00
aclist d305f47bb9 Merge branch 'dzgui7' into feat/launch-offline
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-06-14 22:03:02 +09:00
aclist 43f6c17c4b chore: port changes from dzgui7 branch 2026-06-14 22:02:21 +09:00
aclist 38bfef3836 chore: clear typehinting errors 2026-06-14 21:56:49 +09:00
aclist c818945a28 chore: add comment 2026-06-14 21:54:59 +09:00
aclist 5cb4f69500 fix: initialize string 2026-06-14 19:45:48 +09:00
aclist cee9052dea fix: append store to mod manager 2026-06-14 19:31:52 +09:00
aclist b3b98aa407 fix: parse parent dir 2026-06-14 19:11:35 +09:00
aclist b8230cbf43 feat: make symlink to mission path
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-06-14 01:54:08 +09:00
aclist 4b16edbbee fix: grab focus on unmap
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-06-13 04:14:28 +09:00
aclist 966d935032 fix: allow loading with only mission file 2026-06-13 03:52:20 +09:00
aclist 8b8c6268ab feat: expunge ephemeral symlinks 2026-06-13 03:36:59 +09:00
aclist 513c445b24 fix: restore statusbar when returning 2026-06-13 03:06:55 +09:00
aclist 55f82c2e91 chore: drop comment 2026-06-13 03:06:41 +09:00
aclist ad589ba850 fix: grab focus after popup 2026-06-13 03:06:19 +09:00
aclist 85f6fa053d fix: disable context menu on custom mod tree 2026-06-13 02:10:41 +09:00
aclist f7a42e2cdb fix: clear typehinting errors 2026-06-13 02:03:00 +09:00
aclist e1cb682e74 feat: custom symlink creation in thread 2026-06-13 01:50:34 +09:00
aclist a8b7ea5008 chore: clear type annotation errors
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-06-11 19:25:08 +09:00
aclist 6a6b4c8027 chore: drop extraneous setting 2026-06-11 19:22:03 +09:00
aclist c2ef7449e5 fix: override ctrl-click signal 2026-06-11 19:21:33 +09:00
aclist 96fb484b38 feat: add context aware multi select 2026-06-11 19:20:02 +09:00
aclist 1e6ff9c4f7 fix: set filepicker title 2026-06-11 19:19:36 +09:00
aclist 4313075b61 feat: left aligned icon 2026-06-11 18:02:58 +09:00
aclist 88270382f1 fix: signal arguments 2026-06-11 17:46:52 +09:00
aclist ad85bb7343 drop collision check for custom mods
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
Mod hashes are taken from the directory names, which cannot be the same
at the filesystem level. Custom mod hashes are also unique from local
mods because they are given a different namespace.
2026-06-11 16:17:02 +09:00
aclist 77df66979a chore: cleanup 2026-06-11 15:40:09 +09:00
aclist d4825390f7 chore: update strings 2026-06-11 15:39:52 +09:00
aclist d5a857fe3a fix: parse tree iters correctly 2026-06-11 15:31:49 +09:00
aclist 241df72bf3 fix: pass custom flag 2026-06-11 15:31:17 +09:00
aclist 4f51d962e7 fix: parse filepath correctly 2026-06-11 15:15:43 +09:00
aclist 98b550cec6 chore: dedicated mission frame 2026-06-11 15:13:09 +09:00
aclist 20c850a977 chore: drop print statement 2026-06-11 15:12:13 +09:00
aclist 5a8d85ac92 fix: less brittle meta parsing 2026-06-11 15:11:38 +09:00
aclist 450d54d7fd fix: swap logic 2026-06-11 12:37:27 +09:00
aclist 65fb07c8da chore: clear type annotation errors 2026-06-11 12:10:37 +09:00
aclist 8123893a90 chore: clean up offline mod manager 2026-06-11 12:03:44 +09:00
aclist 3c9b5c9ed3 chore: update strings 2026-06-11 12:03:22 +09:00
aclist f9be6dfae4 chore: drop unused method 2026-06-11 12:02:44 +09:00
aclist 586f7e6c66 feat: parse mission and mods 2026-06-11 12:02:28 +09:00
aclist 49eb3caf0a fix: handle empty store 2026-06-11 12:02:05 +09:00
aclist 56da5717fa feat: add emitter signal 2026-06-11 12:01:32 +09:00
aclist 9983e91526 feat: test mission integrity 2026-06-11 12:01:17 +09:00
aclist 820deb2955 fix: incorrect error state 2026-06-11 10:52:49 +09:00
aclist 0137bf4610 fix: process escape key 2026-06-11 10:47:32 +09:00
aclist 687b1a915e feat: capture escape signal in BootDialog 2026-06-11 10:43:52 +09:00
aclist 401c8625ad chore: add placeholder notes
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-06-11 08:07:58 +09:00
aclist 8503d7e698 feat: namespace mod hashes 2026-06-11 08:02:27 +09:00
aclist b35b6148bc change: tweak expansion properties 2026-06-11 07:51:16 +09:00
aclist 45fd180dcb fix: accept emitter as arg
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-06-10 19:23:14 +09:00
aclist 486da7749a fix: emitter arguments 2026-06-10 18:37:31 +09:00
aclist 606df3423c change: delegate early checks to BootWindow 2026-06-10 15:59:44 +09:00
aclist ab439144ea fix: use StoredFunc
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-06-10 15:21:04 +09:00
aclist db14c5252c change: clearer startup error 2026-06-10 15:18:42 +09:00
aclist 7d8b3b402f feat: use emitter 2026-06-10 14:30:50 +09:00
aclist 4506a0cfda chore: revert IconButton alignment changes 2026-06-10 08:27:24 +09:00
aclist 978edc5f5f chore: add format strings 2026-06-10 08:26:50 +09:00
aclist 2466887014 chore: drop print statement
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-06-10 06:45:51 +09:00
aclist 6050a370f8 feat: folder icons 2026-06-10 06:21:12 +09:00
aclist d4a99f8229 feat: prepare mods in manager (WIP) 2026-06-10 06:15:27 +09:00
aclist 46fba4eef1 chore: clear typehinting errors 2026-06-10 05:45:08 +09:00
aclist bc1118a29f feat: OfflineManager 2026-06-10 05:27:57 +09:00
aclist 775f582ea6 fix: drop extraneous arg 2026-06-10 05:27:39 +09:00
aclist 2dadd70be3 fix: pack tree and statusbar in vbox 2026-06-10 05:05:22 +09:00
aclist 3473f03350 fix: pad inner scrollbar 2026-06-10 04:51:05 +09:00
aclist e081d0ad16 feat: collect appid on toggle 2026-06-09 03:09:34 +09:00
aclist e6026e84f9 feat: launch offline (WIP) 2026-06-09 02:37:50 +09:00
aclist b17b8c24ff fix: fill model inside thread 2026-06-08 19:27:40 +09:00
aclist 4b9687a84d chore: clear typehinting errors 2026-06-07 20:22:32 +09:00
aclist 0c0f280d15 docs: update changelog 2026-06-07 20:18:27 +09:00
aclist 831bd9145d feat: launch offline (WIP)) 2026-06-07 20:17:58 +09:00
aclist 0070fe4061 fix: suppress headers if no model is loaded 2026-06-07 20:17:32 +09:00
aclist 9d1443cf65 feat: launch offline (WIP) 2026-06-07 20:15:30 +09:00
105 changed files with 3250 additions and 681 deletions

No files matched your search

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