Compare commits

...
99 Commits
Author SHA1 Message Date
aclist dac02d75f6 Merge pull request #319 from aclist/feat/test-writeable
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
feat: disallow updates if binary path is not writeable
2026-05-29 01:09:25 +09:00
aclist 3a5c061dbf chore: update docstring 2026-05-29 01:03:29 +09:00
aclist 3851b76ca0 docs: update changelog 2026-05-29 00:44:35 +09:00
aclist 387930b147 chore: update strings file 2026-05-29 00:44:17 +09:00
aclist c0c3ee8c64 fix: catch error if no GTK theme is available (headless installs) 2026-05-29 00:44:00 +09:00
aclist 1212c09110 feat: add tooltip to load menu button 2026-05-29 00:43:34 +09:00
aclist 86e2249a23 change: enable empty/full filters on first run of LAN page 2026-05-29 00:43:01 +09:00
aclist 302a43e65e change: enable empty/full filters on first run of LAN page 2026-05-29 00:42:43 +09:00
aclist 3dc58c1a69 fix: abort if deps could not be installed in cpython distribution 2026-05-29 00:41:47 +09:00
aclist 9af2145ee8 fix: use dedicated LAN error message 2026-05-29 00:41:24 +09:00
aclist 7b4382b178 chore: bump version 2026-05-28 05:52:46 +09:00
aclist ea3631072b chore: add typehint ignore 2026-05-28 05:49:16 +09:00
aclist 67bae3c3e0 chore: uncomment connection logic 2026-05-28 05:48:25 +09:00
aclist 1ea30270ce fix: break after 10th record 2026-05-28 05:47:59 +09:00
aclist 2c9b36ba84 fix: append history queue (WIP) 2026-05-28 05:24:23 +09:00
aclist fd9eb33714 feat: load direct to menu 2026-05-28 03:34:11 +09:00
aclist 15d928026a feat: add file module 2026-05-28 02:41:41 +09:00
aclist 24d3bc8209 chore: drop comment 2026-05-28 02:41:26 +09:00
aclist 881847cee9 feat: disallow updates if binary path is not writeable 2026-05-28 02:40:13 +09:00
aclist d3a06cf970 Merge pull request #318 from aclist/docs/MTU
docs: update DZG-001 (#315)
2026-05-28 02:18:43 +09:00
aclist 741344e75e docs: update DZG-001 (#315) 2026-05-28 02:18:08 +09:00
aclist a8a7aba338 Merge pull request #317 from aclist/chore/update-actions
chore: update checkout action to v5
2026-05-28 02:11:48 +09:00
aclist fafd4c6000 chore: update checkout action to v5 2026-05-28 02:10:49 +09:00
aclist 7e5e326ebe Merge pull request #316 from aclist/chore/geofile-stripping
chore: add geofile test
2026-05-28 02:09:45 +09:00
aclist e7644b49f5 chore: add geofile test 2026-05-28 02:07:39 +09:00
aclist 88aa1c31d5 Merge pull request #314 from aclist/feat/preboot-dialog
feat: preboot dialog
2026-05-28 01:26:42 +09:00
aclist dbeaad73c6 trigger checks 2026-05-28 01:24:45 +09:00
aclist be6be2af7c fix: use absolute path 2026-05-28 00:34:19 +09:00
aclist 63889c2299 fix: UI frames dropping during IO events 2026-05-28 00:32:11 +09:00
aclist 78e17121b6 chore: add preboot strings file 2026-05-28 00:31:35 +09:00
aclist 69ab3723a1 feat: add get_enum() to ScrolledWizardPage 2026-05-27 21:16:28 +09:00
aclist c82d42da09 docs: update changelog 2026-05-27 21:16:08 +09:00
aclist 9266473853 chore: optimize ipv6 strip logic 2026-05-27 15:49:18 +09:00
aclist b5d9c030dd fix: use dedicated copy button 2026-05-27 15:49:02 +09:00
aclist 4852e4a31f fix: abort early on failure 2026-05-27 05:13:31 +09:00
aclist d56cfdd0b2 docs: update special thanks 2026-05-27 04:55:44 +09:00
aclist 760208917c chore: bump version 2026-05-27 04:41:11 +09:00
aclist 26006daa74 docs: update changelog 2026-05-27 04:41:00 +09:00
aclist 97244e783c fix: make BMValidationPage optional (#311) 2026-05-27 04:38:39 +09:00
aclist 162839672b fix: make parents if missing (#312) 2026-05-27 04:38:00 +09:00
aclist ac9f582def chore: clear typehinting errors 2026-05-27 04:21:55 +09:00
aclist fe40346a01 chore: update user on tarfile 2026-05-27 04:00:49 +09:00
aclist d1cf2a9850 chore: clear typehinting errors 2026-05-27 04:00:15 +09:00
aclist 4face6f115 feat: preboot dialog (WIP) 2026-05-27 03:59:57 +09:00
aclist e3027fa830 Merge pull request #310 from aclist/fix/7.0.0b6-hotfixes
fix: 7.0.0b6 hotfixes
2026-05-26 04:54:48 +09:00
aclist 92699ebaa9 chore: revert delay to 3s 2026-05-26 04:42:50 +09:00
aclist ef2709561d fix: resolve #306 2026-05-26 04:40:14 +09:00
aclist 89a6996695 Merge pull request #304 from aclist/chore/prebuild-script
chore: update build tools
2026-05-26 04:01:24 +09:00
aclist 41b3ad8eca chore: clear typehinting errors 2026-05-26 03:59:11 +09:00
aclist d57277f527 fix: drop unused import 2026-05-26 03:57:13 +09:00
aclist c45678ca88 chore: add custom ScrolledWindow class 2026-05-26 03:56:35 +09:00
aclist c95ad53f6f chore: bump version 2026-05-26 03:55:51 +09:00
aclist 3e144dc7d2 fix: resolve #305 2026-05-26 03:54:30 +09:00
aclist afdfa76fdf fix: resolve #309, #307 2026-05-26 03:53:01 +09:00
aclist 00c27ad020 fix: resolve #308 2026-05-26 03:52:11 +09:00
aclist d60204b676 chore: reorder operations in entrypoint function 2026-05-26 03:51:44 +09:00
aclist 3d2e27bfe1 chore: update release script 2026-05-26 00:56:02 +09:00
aclist 8a66bafe4c chore: update build tools 2026-05-26 00:55:11 +09:00
aclist 2963c4e01c Merge pull request #299 from aclist/chore/move-updates
chore: move updates
2026-05-25 16:18:07 +09:00
aclist f849cbc183 chore: update tests 2026-05-25 16:07:44 +09:00
aclist 77505db621 fix: collision with native method name 2026-05-25 15:13:08 +09:00
aclist ba7a2b7ab4 fix: fallback for missing entries in columns file 2026-05-25 15:11:21 +09:00
aclist 6d7ed80996 Merge branch 'chore/move-updates' of github.com:aclist/dztui into chore/move-updates 2026-05-25 00:04:14 +09:00
aclist 72024c5d43 fix: refresh increment overwriting when changing tabs 2026-05-25 00:04:06 +09:00
aclist 0426d50d3e fix: refresh increment ov refresh decrement resetting when changing tabs 2026-05-25 00:03:21 +09:00
aclist 26ee560cb4 chore: test build version after export 2026-05-24 18:56:41 +09:00
aclist 6ec77f6542 fix: pass update args to manager 2026-05-24 01:53:16 +09:00
aclist 59959ae3a1 chore: clear typehinting errors 2026-05-24 01:49:08 +09:00
aclist 05fd95cd8e chore: bump version 2026-05-24 01:34:22 +09:00
aclist 86ba8d9187 chore: move update logic to manager 2026-05-24 01:34:16 +09:00
aclist e715e6da1c fix: use LC_ALL var in test 2026-05-23 22:23:44 +09:00
aclist 18ba851b7e chore: drop unused code 2026-05-23 22:23:29 +09:00
aclist d2643d08e9 Merge pull request #296 from aclist/fix/empty-statusbar
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
fix: statusbar being wiped when calcdist fails on empty row
2026-05-21 21:07:23 +09:00
aclist 6274f62c93 chore: remove print statement
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-05-21 21:06:40 +09:00
aclist 24609a1c71 docs: update changelog 2026-05-21 20:58:32 +09:00
aclist 1b344c1260 chore: clear typehinting errors
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-05-21 20:55:01 +09:00
aclist e72e09eec1 feat: save filters on exit 2026-05-21 20:42:24 +09:00
aclist 00e78b1228 fix: set filter panel to insensitive on lan load 2026-05-21 20:42:00 +09:00
aclist efddd61ea1 chore: bump version 2026-05-21 01:49:55 +09:00
aclist d006908bbc fix: statusbar being wiped when calcdist fails on empty row 2026-05-21 01:47:05 +09:00
aclist 5ba6b6c040 Merge pull request #295 from aclist/fix/lan-server
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
fix: break out of loop when sek out of loop on first server
2026-05-20 00:36:58 +09:00
aclist d3b9ba031d Merge branch 'fix/lan-server' of github.com:aclist/dztui into fix/lan-server 2026-05-20 00:35:49 +09:00
aclist 8bfcb6c8fe fix: break out of loop when scanning LAN 2026-05-20 00:35:35 +09:00
aclist 353b10ebfc fix: break out of loop when sek out of loop on first server 2026-05-20 00:25:10 +09:00
aclist 698150645d Merge pull request #290 from aclist/feat/airgapped-installer
feat: airgapped installer
2026-05-20 00:09:31 +09:00
aclist 87f347870f chore: clean up try/except block
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-05-19 23:41:51 +09:00
aclist 74bf4b5cfc se str, not Path
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-05-19 18:35:47 +09:00
aclist 896799f904 chore: update string 2026-05-19 18:34:35 +09:00
aclist 0f591e658c fix: call proper workshop method 2026-05-19 18:31:59 +09:00
aclist f9083b33ef fix: change lan panel tate on map/unmap 2026-05-19 18:28:45 +09:00
aclist 89fcfcb5d6 fix: use hostname.local 2026-05-19 18:26:09 +09:00
aclist fc7f1d7815 chore: update build script paths 2026-05-19 18:25:29 +09:00
aclist fb36425083 chore: bump version 2026-05-19 06:23:53 +09:00
aclist c5d8afbc4d chore: build dependencies into distribution tarball 2026-05-19 06:08:55 +09:00
aclist 17f732fafe feat: continue to main app after config wizard closes 2026-05-19 06:05:22 +09:00
aclist 676a7ea9eb fix: notes file may not be present 2026-05-19 06:04:45 +09:00
aclist d3f64c1ba5 chore: trigger backup workflow
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
2026-05-18 22:05:46 +09:00
aclist 5da3397a29 Merge branch 'dzgui7' of github.com:aclist/dztui into dzgui7 2026-05-18 22:03:02 +09:00
aclist eca6df90d4 chore: add .github directory 2026-05-18 22:02:16 +09:00
63 changed files with 1194 additions and 289 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: aclist
+57
View File
@@ -0,0 +1,57 @@
name: Bug report
description: Submit bugs that affect functionality here
title: "[BUG] Your title here"
labels: ["bug"]
assignees:
- aclist
body:
- type: markdown
attributes:
value: |
### WARNING: When posting log/config data, redact any sensitive information like API keys.
- type: checkboxes
id: kb
attributes:
label: Did you check if this issue already exists?
options:
- label: I have checked the [Knowledge Base](https://aclist.github.io/dzgui/kb.html) and searched through [past issues](https://github.com/aclist/dztui/issues?q=is%3Aissue+is%3Aclosed) and did not find my issue there.
required: true
- type: dropdown
id: device
attributes:
label: Device type
options:
- Desktop PC
- Steam Deck
default: 0
validations:
required: true
- type: input
id: version
attributes:
label: DZGUI version
validations:
required: true
placeholder: "5.6.0"
- type: input
id: distro
attributes:
label: Linux distribution
validations:
required: true
placeholder: "Arch Linux"
- type: textarea
id: what-happened
attributes:
label: Describe the bug
description: What did you expect to happen?
placeholder: "E.g., When doing X, Y occurred. Include as much detail as possible."
validations:
required: true
- type: textarea
id: logs
attributes:
label: Additional log output
description: Attach the file `$HOME/.local/state/dzgui/DZGUI_DEBUG.log` by clicking this area to highlight it and then dragging the file in.
validations:
required: false
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+47
View File
@@ -0,0 +1,47 @@
name: Feature suggestion
description: Submit requests for new features here
title: "[FEAT] Your title here"
labels: ["enhancement"]
assignees:
- aclist
body:
- type: dropdown
id: device
attributes:
label: Device type
options:
- Desktop PC
- Steam Deck
default: 0
validations:
required: true
- type: input
id: version
attributes:
label: DZGUI version
validations:
required: true
placeholder: "5.6.0"
- type: input
id: distro
attributes:
label: Linux distribution
validations:
required: true
placeholder: "Arch Linux"
- type: textarea
id: what-happened
attributes:
label: Describe the feature
description: A clear and detailed description
placeholder: "E.g., Add a dialog to the Options menu that automatically lets the user do Y"
validations:
required: true
- type: textarea
id: why
attributes:
label: Describe the benefit
description: How will this feature benefit large numbers of users?
placeholder: "E.g., Currently, users have to manually do X to achieve Y. Implementing this feature would achieve A, B, and C."
validations:
required: true
+50
View File
@@ -0,0 +1,50 @@
name: Troubleshooting
description: Submit user error/troubleshooting questions here
title: "[HELP] Your title here"
labels: ["troubleshooting"]
assignees:
- aclist
body:
- type: markdown
attributes:
value: |
### WARNING: When posting log/config data, redact any sensitive information like API keys.
- type: dropdown
id: device
attributes:
label: Device type
options:
- Desktop PC
- Steam Deck
default: 0
validations:
required: true
- type: input
id: version
attributes:
label: DZGUI version
validations:
required: true
placeholder: "5.6.0"
- type: input
id: distro
attributes:
label: Linux distribution
validations:
required: true
placeholder: "Arch Linux"
- type: textarea
id: what-happened
attributes:
label: Describe the issue
description: A detailed description. What did you expect to happen?
placeholder: "Include as much detail as possible and state the steps leading up to the problem. E.g., First I do this, then I do this, then this happens."
validations:
required: true
- type: textarea
id: logs
attributes:
label: Additional log output
description: Attach the file `$HOME/.local/state/dzgui/DZGUI_DEBUG.log` by clicking this area to highlight it and then dragging the file in.
validations:
required: false
+18
View File
@@ -0,0 +1,18 @@
name: Mirror to Codeberg
on:
push:
workflow_dispatch:
jobs:
mirror-to-codeberg:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: pixta-dev/repository-mirroring-action@v1
if: ${{ vars.GIT_REMOTE != '' }}
with:
target_repo_url: ${{ vars.GIT_REMOTE }}
ssh_private_key: ${{ secrets.GIT_SSH_PRIVATE_KEY }}
+5 -1
View File
@@ -34,6 +34,9 @@
- Early load alerts button
- Preconnect dialog
- Preconnect warnings/failsafes like filesize
- Save filters per server context between sessions
- Preboot progress dialog
- Choose to jump into splash screen instead of server
## Changed
@@ -49,6 +52,8 @@
- Suppress log messages from imported modules
- Embed Workshop link in Options menu
- 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
## Dropped
- Debug mode
@@ -64,7 +69,6 @@
## Unreleased
- Load offline mods
- Choose to jump into splash screen instead of server
- Local documentation
- Raw debug command in context menu
+10 -5
View File
@@ -1,18 +1,23 @@
# TODO: link to here from sphinx docs
# Installing dev dependencies
uv pip install -e .[dev]
# Setting up precommit hooks
pre-commit install
# Fetching submodules
git submodule update --recursive --init
# Installing dev dependencies
uv pip install -e .[dev]
# Building documentation
sphinx-build -M html source build -a
# Building standalone DZGUI release binaries
1. Run `./scripts/dl_pyapp.sh`
2. Run `python3 scripts/release.py`
3. Generate a release and tag against the bundled `dzgui` executable
# TODO: secondary script that packs this archive
2. Download cpython 3.13, linux, gnu, v3
https://github.com/astral-sh/python-build-standalone/releases/download/20251014/cpython-3.13.9%2B20251014-x86_64_v3-unknown-linux-gnu-install_only_stripped.tar.gz
3. Explicitly install dependencies into site-packages
4. Pack back into a tarball
5. Run `python3 scripts/release.py`
6. Generate a release and tag against the bundled `dzgui` executable
+12 -7
View File
@@ -3,16 +3,21 @@ Knowledge Base
.. _DZG-001:
DZG-001: Periodically getting dropped from servers, or servers appear unreachable
DZG-001: Periodically getting dropped from servers, or servers time out in DZGUI
------------------------------------------------------------------------------------
It is a longstanding issue in DayZ that the game opens a large number of connections when querying servers and/or while connected to servers.
This can result in excess traffic on the user's PC which, depending on how much headroom your network has, can lead to getting dropped, unresponsiveness, or a timeout.
DayZ opens a large number of connections while querying/connected to servers.
If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. If it does, your wireless router settings do not have enough headroom for max simultaneous connections. Use a wired connection, or update your network settings to a more permissive setup.
In addition, packets sent from server responses are expected to be a standard size (see warning below).
Bohemia has acknowledged this issue going back 10+ years and stated that DayZ has a heavy impact on the network, but there is as yet no proposed solution on the DayZ side.
If your network does not have enough headroom or has settings departing from defaults, this may lead to getting dropped from servers,
unresponsiveness, or a timeout.
This issue is frequently seen on Steam Deck, due to the tendency for users to use it in an untethered Wi-Fi setup.
If you are on Wi-Fi, try switching to a wired connection and see if the problem resolves itself. Consumer Wi-Fi routers
tend to have less headroom than their wired counterparts.
.. important::
Ensure that MTU (maximum tranmission unit) on your network is set to the standard size of 1,500 bytes.
If your network preferences or router have "jumbo" frames enabled, packets may be dropped, causing server queries to time out.
.. _DZG-002:
@@ -34,7 +39,7 @@ DZG-004: On Steam Deck, some mods in the Workshop show a black screen when DZGUI
---------------------------------------------------------------------------------------------------------
This is a bug in the Steam client that is being tracked at Valve's Steam for Linux issue tracker here: https://github.com/ValveSoftware/steam-for-linux/issues/9598.
To resolve this issue, manually intervene in the Steam client by selecting a different context (e.g., Store, Library), waiting for it to load, then navigating back to the Workshop context. This should
To resolve this issue, manually intervene in the Steam client by selecting a different context (e.g., Store, Library), waiting for it to load, then navigating back to the Workshop context. This should
clear the blockage and allow the contents to render.
.. _DZG-005:
+1 -2
View File
@@ -456,6 +456,5 @@ def get_pretty_version(steam_path: Path, appid: int) -> str | None:
vers = get_dayz_version(pe_file_path)
dayz_version = dayz_version_to_str(vers)
return dayz_version
except Exception as e:
print(e)
except Exception:
return None
+8 -1
View File
@@ -61,6 +61,8 @@ class Details:
@dataclass(slots=True)
class Record:
# TODO: investigate whether this enum is still being used;
# can this be frozen?
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
@@ -106,7 +108,7 @@ class A2SInfo:
def get_netmask() -> str:
hostname = os.uname()[1]
i = socket.gethostbyname(hostname)
i = socket.gethostbyname(f"{hostname}.local")
netmask = i.rsplit(".", 1)[0]
return netmask
@@ -472,6 +474,7 @@ def query_by_ip(addr: str) -> A2SInfo:
return A2SInfo(Record("0", 0, 0), None)
return query_by_record(record, update_gameport=True)
def query_by_id(server_id: int, key: str) -> A2SInfo:
"""
Used with numeric Battlemetrics IDs
@@ -508,6 +511,10 @@ def fqip_to_record(addr: str) -> Optional[Record]:
return Record(r[0], int(r[1]), int(r[2]))
def record_to_fqip(record: Record) -> str:
return f"{record.ip}:{record.gameport}:{record.qport}"
def response_to_fqip(res: dict) -> str:
ip = res["addr"].split(":")[0]
gameport = res["gameport"]
+17
View File
@@ -146,6 +146,23 @@ def connect(client: str, addr: str, appid: int, name: str, mods: list[str]) -> i
return proc.returncode
def load_to_menu(client: str, addr: str, appid: int, name: str, mods: list[str]) -> int:
"""Loads to the menu screen with the selected mods; used for AFK/pre-joining"""
concat = concat_mods(mods)
client_args = concat_bash_args(client)
params = [
"-applaunch",
str(appid),
"-nolauncher",
"-nosplash",
"-skipintro",
f"-name={name}",
f"-mod={concat}",
]
proc = subprocess.run([*client_args, *params])
return proc.returncode
def find_user_id(path: Path) -> str | None:
resolved_path = path / "config" / "loginusers.vdf"
try:
+50 -39
View File
@@ -23,40 +23,36 @@ def find_download_url(url: str) -> str | None:
def get_ipdb(ips_path: Path) -> None:
url = find_download_url(DB_IP)
if url is None:
return
date = find_date(url)
month_file = ips_path.parent / ".month"
if ips_path.exists() and month_file.exists():
old_date = month_file.read_text().rstrip("\n")
if old_date == date:
logger.info(f"IP DB date matches: {date}")
try:
url = find_download_url(DB_IP)
if url is None:
return
# TODO: log additional output
logger.info(f"Fetching IPDB for {date} from {url}")
try:
date = find_date(url)
month_file = ips_path.parent / ".month"
if ips_path.exists() and month_file.exists():
old_date = month_file.read_text().rstrip("\n")
if old_date == date:
logger.info(f"IP DB date matches: {date}")
return
# TODO: log additional output
logger.info(f"Fetching IPDB for {date} from {url}")
tmp = serialize(url)
except Exception as e:
logger.critical(e)
return
logger.info(f"Extracting {tmp}")
unzip(tmp, ips_path)
logger.info(f"Extracting {tmp}")
unzip(tmp, ips_path)
logger.info("Stripping IPv6 records")
try:
logger.info("Stripping IPv6 records")
strip_ipv6(ips_path)
with open(month_file, "w") as f:
f.write(date)
logger.info(f"Wrote {date} to {month_file}")
except Exception as e:
# NOTE: in the event of failure, geolocation calc is simply not performed
logger.critical(e)
return
with open(month_file, "w") as f:
f.write(date)
logger.info(f"Wrote {date} to {month_file}")
def find_date(url: str) -> str:
date = re.sub(r"(dbip-city-lite-)(.*)(.csv.gz)", r"\2", url)
@@ -81,19 +77,34 @@ def serialize(url: str) -> Path:
return tmp
# TODO: optimize this function
# consider using grep
# cf. grep -vE "^[a-z0-9]{4}:" | grep -v "::" > "$ip_file"
def strip_ipv6(path: Path) -> None:
ips = []
with open(path, "r") as f:
s = f.read()
"""Can be IO intensive and cause visual lag on UI frames
running in the main thread even when run in its own thread;
lines are batched into memory-manageable chunks to reduce
disk writes. Raw file can be 8M+ records long, so it is
not read into memory at once.
reg = r"^\d{1,3}\..*"
ips = re.findall(reg, s, re.MULTILINE)
assert ips[0].split(",")[0] == "0.0.0.0"
assert ips[-1].split(",")[0] == "224.0.0.0"
with open(path, "w") as f:
for ip in ips:
f.write(ip + "\n")
Relative size is reduced by ~100MB by pruning unwanted columns
"""
# NOTE: "^::," is the boundary line between IPv4 and IPv6
# NOTE: deprecated regex matching (slower by 5s)
# reg = r"^\d{1,3}\..*"
alt_path = path.parent.joinpath("ips_stripped.csv")
merged = ""
its = 0
with open(path, "r") as f, open(alt_path, "w") as out:
for line in f:
els = line.split(",")
if "." not in els[0]:
break
final = ",".join([els[0], els[1], els[-2], els[-1]])
merged += final
its += 1
if its == 500:
out.write(merged)
its = 0
merged = ""
if its > 0:
out.write(merged)
path.unlink()
alt_path.rename(path)
+3 -3
View File
@@ -17,6 +17,7 @@ class Xdg:
system: Path
debug: Path
ips: Path
filters: Path
def is_writeable(path_str: str) -> bool:
@@ -63,9 +64,6 @@ def get_xdg_paths() -> dict:
def parse_filepaths(xdg: dict) -> Xdg:
# cache = xdg["XDG_CACHE_HOME"]
# data = xdg["XDG_DATA_HOME"]
config = xdg["XDG_CONFIG_HOME"]
state = xdg["XDG_STATE_HOME"]
@@ -80,6 +78,7 @@ def parse_filepaths(xdg: dict) -> Xdg:
history = state / "dzg.history"
versions = state / "dzg.versions"
ips = state / "ips.csv"
filters = state / "dzg.filters.json"
return Xdg(
config,
@@ -91,4 +90,5 @@ def parse_filepaths(xdg: dict) -> Xdg:
system,
debug,
ips,
filters
)
+1
View File
@@ -28,6 +28,7 @@ APP_NAME = "DZGUI"
APP_NAME_LOWER = "dzgui"
APP_NAME_ABBR = "dzg"
HEX_GREEN = "#32CD32"
HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C"
+4
View File
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
# TODO: if servers fail to load, may leave dangling widgets waiting for a signal
class Emitter(GObject.GObject):
def __init__(self) -> None:
super().__init__()
@@ -150,3 +151,6 @@ class Emitter(GObject.GObject):
def keyword_set(self, keyword: str) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def lan_page_initialized(self) -> None:
pass
+38 -13
View File
@@ -84,7 +84,6 @@ class Controller(GObject.GObject):
self.emitter = Emitter()
self.emitter.connect("map_selection_changed", self._on_map_selection_changed)
self.emitter.connect("check_toggled", self._on_check_toggled)
self.emitter.connect("servers_loaded_init", self._on_servers_loaded_init)
# NOTE: suppress requests until entire UI is loaded
self.loaded = False
@@ -260,6 +259,21 @@ class Controller(GObject.GObject):
def select_colorized(self) -> None:
self.mod_man.select_colorized()
def get_all_tree_filters(self) -> dict[str, dict[str, bool]]:
servers = self.get_servers()
all_filters: dict[str, dict[str, bool]] = {}
trees = (
servers.get_browser(),
servers.get_saved(),
servers.get_recent(),
servers.get_lan(),
)
for tree in trees:
enum = str(tree.get_enum())
filters = tree.get_filter_man().get_filters()
all_filters[enum] = filters
return all_filters
def get_filter_man(self) -> "FilterManager":
"""Each ServerTreeView has an atomic FilterManager"""
return self.get_active_treeview().get_filter_man()
@@ -303,8 +317,22 @@ class Controller(GObject.GObject):
self.emitter.emit("servers_loaded", tv.get_enum())
return
self.mediator.statusbar.set_by_context(tv.get_enum(), "")
self.mediator.filters.button_grid.block_toggles(True)
self.populate_filter_prefs()
self.mediator.filters.button_grid.block_toggles(False)
self.emitter.emit("lan_page_initialized")
ServerModelManager(self, tv).load()
def populate_filter_prefs(self) -> None:
tv = self.get_active_treeview()
filters = self.config_man.get_filters(tv)
if filters is None:
return
filter_man = self.get_filter_man()
for filt, state in filters.items():
filter_man.set_filter(filt, state)
self.mediator.filters.set_filters(filters)
def get_dist_cache(self) -> dict[str, "Haversine"]:
return self.dist_cache
@@ -319,16 +347,7 @@ class Controller(GObject.GObject):
filter_man = self.get_filter_man()
return filter_man.get_filters()
# TODO: rename
def _on_servers_loaded_init(self, emitter: "Emitter") -> None:
"""Triggered after servers load but prior to maps loading"""
# FIXME: wipe maps store when changing tabs if model is none
# e.g. select recent, toggle map, then select lan -> not wiped
tv = self.get_active_treeview()
if tv.loaded is False:
return
store = self.get_map_store()
self.emitter.emit("load_maps", store)
# FIXME: wipe maps store when changing tabs if model is none
def has_server_model(self) -> bool:
treeview = self.get_active_treeview()
@@ -390,9 +409,12 @@ class Controller(GObject.GObject):
saved_tree = self.get_servers().get_saved()
ServerModelManager(self, saved_tree).remove_by_record(record)
def add_to_history(self, record: dict[str, Any]) -> None:
def add_to_history(self, row: dict[str, Any], record: "Record") -> None:
tv = self.get_servers().get_recent()
ServerModelManager(self, tv).add_to_history(record)
ServerModelManager(self, tv).add_to_history((row, record))
# def append_to_history(self, record: "Record") -> None:
# self.config_man.append_to_history_file(record)
def remove_from_history(self, record: "Record") -> None:
# NOTE: remove action is only possible from history tree context menu,
@@ -489,6 +511,9 @@ 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_connect(self, raise_window: bool) -> None:
self.connection_man.update_and_connect(raise_window)
+5 -1
View File
@@ -34,6 +34,9 @@
- Early load alerts button
- Preconnect dialog
- Preconnect warnings/failsafes like filesize
- Save filters per server context between sessions
- Preboot progress dialog
- Choose to jump into splash screen instead of server
## Changed
@@ -49,6 +52,8 @@
- Suppress log messages from imported modules
- Embed Workshop link in Options menu
- 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
## Dropped
- Debug mode
@@ -64,7 +69,6 @@
## Unreleased
- Load offline mods
- Choose to jump into splash screen instead of server
- Local documentation
- Raw debug command in context menu
-5
View File
@@ -13,11 +13,6 @@ logger = logging.getLogger(APP_NAME)
def get_local_coords(path: "Path") -> Coords | None:
try:
my_ip = get_local_ip()
except Exception as e:
logger.warn(e)
return None
try:
return get_coords(path, my_ip)
except Exception as e:
logger.warn(e)
+21 -9
View File
@@ -1,6 +1,7 @@
import shutil
from pathlib import Path
from typing import Any
from dzgui.const.constants import LEGACY_CONFIG_PATH, LEGACY_COLS_PATH, LEGACY_IPS_PATH
from dzgui.config.convert import rc2json
from dzgui.util._json import read_json, write_json
@@ -17,17 +18,28 @@ def has_new_config(config: Path) -> bool:
return config.exists()
def migrate_cols_file(res: Path) -> None:
# NOTE: dzg.columns.json is API 7 spec
old_res = Path.home() / LEGACY_COLS_PATH
if old_res.is_file():
j = read_json(old_res)
cols = j["cols"]
if "View" in cols:
return
def convert_cols_file(res: Path) -> Any | None:
j = read_json(res)
cols = j["cols"]
# NOTE: implies prior conversion
if "View" in cols:
return None
# NOTE: user may not have changed these widths in API 6
try:
cols["View"] = cols.pop("Perspective")
cols["Max"] = cols.pop("Maximum")
write_json(j, res)
except Exception:
pass
return j
def migrate_cols_file(res: Path) -> None:
# NOTE: filename "dzg.columns.json" is API 7 spec
old_res = Path.home() / LEGACY_COLS_PATH
if old_res.is_file():
j = convert_cols_file(old_res)
if j is not None:
write_json(j, res)
def copy_state_files(state_path: Path) -> None:
+16 -16
View File
@@ -6,14 +6,11 @@ import warnings
from typing import TYPE_CHECKING
from dzgui.api.mods import remove_stale_signatures
from dzgui.const.constants import APP_NAME
from dzgui.const.enum import Preferences
from dzgui.config.ipdb import get_ipdb
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.coords import get_local_coords
from dzgui.init.dayz import is_dayz_installed
from dzgui.init.flock import lock_acquire
from dzgui.init.migrate import (
@@ -24,17 +21,16 @@ from dzgui.init.migrate import (
from dzgui.init.prefix import get_version
from dzgui.init.prereqs import has_steam_client
from dzgui.strings import boot
from dzgui.init.update import check_updates
# 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.symlink import rebuild_symlinks
from dzgui.util.strings import init, flags
from dzgui.views.base import App
from dzgui.views.dialogs.wizard import SetupWizard
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
@@ -112,14 +108,15 @@ def main() -> None:
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)
latest_release = check_updates(version)
if _is_steam_deck is False:
# TODO: sudo escalation dialog
# count = get_map_count()
@@ -127,16 +124,19 @@ def main() -> None:
if has_steam_client() is False:
EarlyAlertDialog(init.requires_steam)
# NOTE: clear versions file of unlinked mods
rebuild_symlinks(XDG.config)
remove_stale_signatures(XDG.config, XDG.version)
is_dayz_installed(XDG.config)
# TODO: handle IP DB failure and use coords fallback
get_ipdb(XDG.ips)
local_coords = get_local_coords(XDG.ips)
use_miles = lookup(XDG.config, Preferences.DIST)
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,
+32 -5
View File
@@ -1,6 +1,7 @@
import logging
import traceback
from collections import deque
from typing import Any, TYPE_CHECKING
from dzgui.const.constants import (
@@ -22,7 +23,6 @@ import gi
gi.require_version("Gtk", "3.0")
from gi.repository.Gtk import main_quit # noqa E402
# import dzgui.api.servers as Servers
if TYPE_CHECKING:
from dzgui.config.userprefs import UserPrefs
from dzgui.controllers.mc import Controller
@@ -53,11 +53,25 @@ class ConfigManager:
ips.append(record)
self.update_config(Preferences.IP_LIST, ips)
def update_history_file(self, records: list[Any]) -> None:
def update_history_file(self, fqip: str) -> None:
with open(self.prefs.paths.history, "r") as f:
ips = [line.rstrip() for line in f.readlines()]
seen = set()
ips.append(fqip)
# NOTE: Preserve linear order of records while deduplicating
unique = deque(maxlen=10) # type: ignore
for i in range(len(ips) - 1, -1, -1):
if ips[i] not in seen:
seen.add(ips[i])
unique.appendleft(ips[i])
# NOTE: appendleft will push 11th item off right edge
if len(unique) == 10:
break
with open(self.prefs.paths.history, "w") as f:
for record in records:
addr = f"{record[7]}:{record[8]}"
f.write(f"{addr}\n")
for record in unique:
f.write(f"{record}\n")
def remove_saved_server(self, record: str) -> None:
ips = self.lookup(Preferences.IP_LIST)
@@ -160,6 +174,9 @@ class ConfigManager:
def save_res_and_quit(self, tv: "ServerTreeView", window: "OuterWindow") -> None:
columns = tv.get_columns()
filters = self.controller.get_all_tree_filters()
filters_file = self.prefs.paths.filters
columns_file = self.prefs.paths.columns
try:
data = read_json(columns_file)
@@ -174,6 +191,7 @@ class ConfigManager:
try:
write_json(data, columns_file)
write_json(filters, filters_file)
except Exception as e:
logger.critical(e)
@@ -217,3 +235,12 @@ class ConfigManager:
h = WINDOW_DEFAULT_Y
logger.info(f"Using default window size {w},{h}")
window.set_default_size(w, h)
def get_filters(self, tv: "ServerTreeView") -> Any | None:
filters = self.prefs.paths.filters
try:
enum = tv.get_enum()
filters = read_json(filters)
return filters[str(enum)]
except Exception:
return None
+36 -11
View File
@@ -15,6 +15,7 @@ from dzgui.api.steam import (
enqueue_mod,
get_remote_signatures,
get_needs_update,
load_to_menu,
)
from dzgui.api.mods import (
@@ -74,6 +75,7 @@ class Prerequisites:
steam_proc: SteamProcess
mods: list[list[str]]
game_mode: bool
is_last_server: bool
class ConnectionManager:
@@ -161,7 +163,7 @@ class ConnectionManager:
if len(self.missing_mods) > 0:
required_size = sum(row[2] for row in self.missing_mods)
required_mib = format_mib(required_size)
free_mib = format_mib(required_size)
free_mib = format_mib(free)
dayz_running = is_dayz_running()
@@ -172,6 +174,8 @@ class ConnectionManager:
game_mode = prefs.is_game_mode
is_last = self.is_last_server()
prereqs = Prerequisites(
name=info.server_name,
appid=info.game_id,
@@ -186,11 +190,23 @@ class ConnectionManager:
steam_proc=steam_proc,
mods=remote_mods,
game_mode=game_mode,
is_last_server=is_last,
)
func = StoredFunc(self.controller.open_connection_assistant, prereqs)
self.thread_man.set_cleanup_func(func, destroy_first=True)
def is_last_server(self) -> bool:
prefs = self.controller.get_prefs()
history = prefs.paths.history
try:
lines = history.read_text().splitlines()
last = lines[-1]
current = Servers.record_to_fqip(self.record)
return last == current
except Exception:
return False
@call_on_thread(dialog.querying)
def query_details(self, record: Servers.Record) -> None:
details = Servers.get_details(record)
@@ -222,7 +238,8 @@ class ConnectionManager:
def query_modlist_and_present(self, record: Servers.Record) -> None:
try:
mods = self._query_modlist(record)
except Exception:
except Exception as e:
print(f"{type(e).__name__}: {e}")
self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True
)
@@ -244,11 +261,14 @@ class ConnectionManager:
dialog = ExceptionDialog(self.controller, server_timeout)
dialog.run()
def _connect_steam(self) -> None:
def _connect_steam(self, menu_only: bool) -> None:
addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME)
client = self.controller.query_config(Preferences.CLIENT)
rc = connect(client, addr, self.appid, playername, self.remote_mod_ids)
if menu_only:
rc = load_to_menu(client, addr, self.appid, playername, self.remote_mod_ids)
else:
rc = connect(client, addr, self.appid, playername, self.remote_mod_ids)
if rc != 0:
# TODO: log/pop the error
func = StoredFunc(self.controller.update_status)
@@ -270,17 +290,22 @@ class ConnectionManager:
self.thread_man.set_cleanup_func(func)
def _add_to_history_and_return(self) -> None:
self.controller.add_to_history(self.history)
self.controller.add_to_history(self.history, self.record)
self.controller.open_page(NotebookPage.SERVERS)
def _update_mods(self, raise_window: bool) -> None:
def _update_mods(self, raise_window: bool, menu_only: bool = False) -> None:
# NOTE: fast enqueue all mods in auto mode
prefs = self.controller.get_prefs()
for title, mod, stamp, size in self.missing_mods:
# TODO: check cancel and exit events
if self.controller.get_exit_event().is_set():
return
if self.controller.get_cancel_event().is_set():
self.controller.clear_cancel_event()
return
enqueue_mod(mod, self.appid)
time.sleep(2)
time.sleep(3)
if raise_window is True:
logger.info("Bringing window to foreground")
@@ -306,11 +331,11 @@ class ConnectionManager:
update_signatures(self.missing_mods, prefs.paths.version)
# TODO: just push steam path directly
rebuild_symlinks(prefs.paths.config)
self._connect_steam()
self._connect_steam(menu_only)
@call_on_thread(waiting_for_mods, show_cancel=True)
def update_and_connect(self, raise_window: bool) -> None:
def update_and_connect(self, raise_window: bool, menu_only: bool = False) -> None:
if len(self.missing_mods) > 0:
self._update_mods(raise_window)
self._update_mods(raise_window, menu_only)
else:
self._connect_steam()
self._connect_steam(menu_only)
+3 -3
View File
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
class FilterManager:
def __init__(self) -> None:
def __init__(self, show_empty: bool = False, show_full: bool = False) -> None:
self.map_store = ModelFactory().make_map_store()
@@ -17,10 +17,10 @@ class FilterManager:
self.default_filters = {
strings.filter_1pp: True,
strings.filter_day: True,
strings.filter_empty: False,
strings.filter_empty: show_empty,
strings.filter_3pp: True,
strings.filter_night: True,
strings.filter_full: False,
strings.filter_full: show_full,
strings.filter_lowpop: True,
strings.filter_nonascii: False,
strings.filter_duplicate: False,
+4 -1
View File
@@ -20,7 +20,10 @@ logger = logging.getLogger(APP_NAME)
class NoteManager:
def __init__(self, controller: "Controller", path: "Path") -> None:
self.notes_path = path
self.cache: dict[str, str] = read_json(self.notes_path)
try:
self.cache: dict[str, str] = read_json(self.notes_path)
except Exception:
self.cache = {}
def add_note(self, server: str, note: str) -> None:
self.cache[server] = note
+3 -3
View File
@@ -47,8 +47,8 @@ class StoredFunc:
self.func = func
self.bindings = sig.bind(*args, **kwargs)
def call(self) -> None:
self.func(*self.bindings.args, *self.bindings.kwargs)
def call(self) -> Any:
return self.func(*self.bindings.args, *self.bindings.kwargs)
class ThreadingManager:
@@ -113,8 +113,8 @@ class ThreadingManager:
func = self.get_cleanup_func()
if func is not None:
func.call()
self.set_cleanup_func(None)
func.call()
if not self.destroy_first:
self.destroy_dialog()
+67
View File
@@ -0,0 +1,67 @@
import logging
import requests
import shutil
import subprocess
import tarfile
from typing import TYPE_CHECKING
from dzgui.const.constants import (
APP_NAME,
TMP_EXE,
TMP_PATH,
TMP_TARBALL,
)
from dzgui.strings import dialogs
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.views.dialogs.generic import ExceptionDialog, QuitDialog
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib # noqa E402
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
logger = logging.getLogger(APP_NAME)
class UpdateManager:
def __init__(self, controller: "Controller") -> None:
self.thread_man = ThreadingManager(controller)
self.controller = controller
@call_on_thread(dialogs.fetching_update)
def update_version(self, exe_path: str, url: str) -> None:
try:
res = requests.get(url)
if res.status_code == 200:
with open(TMP_TARBALL, "wb") as file:
file.write(res.content)
with tarfile.open(TMP_TARBALL) as tar:
tar.extractall(TMP_PATH)
shutil.move(TMP_EXE, exe_path)
proc = subprocess.run([exe_path, "self", "restore"])
if proc.returncode == 0:
func = StoredFunc(self._on_update_success)
self.thread_man.set_cleanup_func(func, destroy_first=True)
else:
msg = dialogs.failed_to_update
func = StoredFunc(self._on_update_failure, msg)
self.thread_man.set_cleanup_func(func, destroy_first=True)
except Exception as e:
func = StoredFunc(self._on_update_failure, e)
self.thread_man.set_cleanup_func(func, destroy_first=True)
logger.warning(e)
def _on_update_success(self) -> None:
msg = dialogs.update_success
dialog = QuitDialog(self.controller, msg)
dialog.run()
def _on_update_failure(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.run()
+3 -2
View File
@@ -85,8 +85,9 @@ class ProxyModelManager:
if found is False:
self.control_model.append(history)
if len(self.control_model) == 11:
del self.control_model[0]
# if len(self.control_model) == 11:
# del self.control_model[0]
self.filter(FilterMode.INITIAL, skip_cache=True)
+43 -22
View File
@@ -14,6 +14,7 @@ from dzgui.const.constants import (
)
from dzgui.const.enum import FilterMode, Preferences, ServerTab
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import dialogs
from dzgui.util.strings import api_warn_msg, dialog
from dzgui.views.dialogs.generic import ExceptionDialog
@@ -23,7 +24,7 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402
if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo
from dzgui.api.servers import A2SInfo, Record
from dzgui.controllers.mc import Controller
from dzgui.model.proxy_model import ProxyModelManager
from dzgui.views.trees.tree_servers import ServerTreeView
@@ -73,6 +74,7 @@ class ServerModelManager:
self._dump_history()
case ServerTab.LAN:
# NOTE: LAN tab is only loaded on demand
self.emitter.emit("lan_page_initialized")
pass
case _:
pass
@@ -123,11 +125,13 @@ class ServerModelManager:
parsed = Servers.parse_json(servers)
self._push_data(parsed)
@call_on_thread(dialog.scanning)
@call_on_thread(dialog.scanning, show_cancel=True)
def dump_lan(self, port: int, early_abort: bool) -> None:
self.first_iteration = True
servers = []
ports = range(1, 256)
failure_func = StoredFunc(self._cleanup_on_failure)
failure_func = StoredFunc(self._cleanup_on_lan_failure)
event = threading.Event()
with ThreadPoolExecutor() as executor:
@@ -135,19 +139,20 @@ class ServerModelManager:
for future in as_completed(futures):
try:
if self.controller.get_exit_event().is_set():
event.set()
return
if self.controller.get_cancel_event().is_set():
event.set()
self.controller.clear_cancel_event()
return
res = future.result(timeout=LAN_TIMEOUT)
if res is None:
continue
servers.append(res)
if early_abort is True:
# NOTE: on first non-empty hit, flag pending threads to close
event.set()
servers.append(res)
self.thread_man.set_cleanup_func(
StoredFunc(self._cleanup_on_success)
)
return
servers.append(res)
break
except Exception as e:
logger.critical(e)
self.thread_man.set_cleanup_func(failure_func, destroy_first=True)
@@ -221,15 +226,12 @@ class ServerModelManager:
) = None,
) -> None:
proxy_man = self._get_proxy_man()
config_man = self.controller.get_config_man()
if rows is not None:
records = rows
else:
control_model = proxy_man.get_control()
records = control_model
config_man.update_history_file(records)
config_man.update_history_file(records)
self._sort_unique_maps(records)
proxy = proxy_man.get_proxy_model()
self.tv.set_model(proxy)
@@ -239,19 +241,26 @@ class ServerModelManager:
filter_man = self.tv.get_filter_man()
maps = self._get_new_maps()
filter_man.set_unique_maps(maps)
store = filter_man.get_map_store()
self.first_iteration = False
self.emitter.emit("servers_loaded_init")
self.emitter.emit("load_maps", store)
# self.emitter.emit("servers_loaded_init")
def add_to_history(self, record: dict[str, Any]) -> None:
# TODO: dataclass for record rows; check for other dict annotations
def add_to_history(self, data: tuple[dict[str, Any], "Record"]) -> None:
row, record = data
proxy_man = self._get_proxy_man()
rows = Servers.parse_json([record])
rows = Servers.parse_json([row])
try:
proxy_man.append_row_to_history(rows[0])
self.update_history()
except Exception:
self.update_history(rows)
return
self.update_history()
fqip = Servers.record_to_fqip(record)
config_man = self.controller.get_config_man()
config_man.update_history_file(fqip)
def remove_from_history(self, record: Servers.Record) -> None:
proxy_man = self._get_proxy_man()
@@ -318,6 +327,7 @@ class ServerModelManager:
self.thread_man.set_cleanup_func(func)
return
config_man.add_saved_server(fqip)
# FIXME: this is valid if saved servers tab is already open,
# but not if app was just booted; causes single row to appear prematurely
if proxy_man.has_control_model() is False:
@@ -368,33 +378,44 @@ class ServerModelManager:
proxy = self._get_proxy_man().get_proxy_model()
self.tv.set_model(proxy)
self.emitter.emit("servers_loaded", self.enum)
if self.controller.get_active_treeview().get_enum == ServerTab.SAVED:
self.emitter.emit("servers_loaded", self.enum)
filter_man = self.tv.get_filter_man()
# NOTE: maps are set outside of thread because it triggers map changed signals
maps = self._get_new_maps()
filter_man.set_unique_maps(maps)
store = filter_man.get_map_store()
self.emitter.emit("load_maps", store)
self.first_iteration = False
self.emitter.emit("servers_loaded_init")
self.emitter.emit("saved_servers_changed")
def _update_maps(self) -> None:
filter_man = self.tv.get_filter_man()
filter_man.set_unique_maps(self._get_new_maps())
self.emitter.emit("servers_loaded_init")
store = filter_man.get_map_store()
self.emitter.emit("load_maps", store)
self.first_iteration = False
def _cleanup_on_success(self) -> None:
proxy = self._get_proxy_man().get_proxy_model()
self.tv.set_model(proxy)
# TODO: servers_loaded vs servers_reloaded
self.emitter.emit("servers_loaded", self.enum)
if self.first_iteration:
self._update_maps()
def _cleanup_on_lan_failure(self, show_dialog: bool = True) -> None:
if self.preserve_on_fail is False:
self.tv.set_model(None)
filter_man = self.tv.get_filter_man()
filter_man.set_unique_maps([])
if show_dialog:
dialog = ExceptionDialog(self.controller, dialogs.load_error_lan)
dialog.run()
def _cleanup_on_failure(self, show_dialog: bool = True) -> None:
# TODO: disable map, keyword, and filter widgets if model is None
# signal driven (servers_empty, servers_failed_to_load)
+2
View File
@@ -4,3 +4,5 @@ waiting_for_mods = "Waiting for Steam to update mods"
fetching_update = "Fetching update"
failed_to_update = "Failed to update DZGUI executable"
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."
+5
View File
@@ -0,0 +1,5 @@
symlinks = "Rebuilding symlinks"
signatures = "Updating mod signatures"
geo = "Checking geolocation records"
coords = "Checking local coordinates"
updates = "Checking for updates"
+4
View File
@@ -1,5 +1,9 @@
update_mods = "Update mods and connect"
connect = "Connect"
connect_last = "Load to menu screen"
connect_last_tooltip = (
"Launch DayZ with these mods (if any) and\ngo to the menu screen without connecting."
)
back = "Back"
cancel = "Cancel"
warnings = "Warnings"
+1 -1
View File
@@ -67,4 +67,4 @@ label_client = "Steam client"
### Completion page
heading_completion = "Setup complete"
blurb_completion = "Configuration completed successfully. Please exit and restart DZGUI to apply changes."
blurb_completion = "Configuration completed successfully. Click 'Close' to close this dialog and launch DZGUI."
+6 -7
View File
@@ -2,20 +2,19 @@ import json
from pathlib import Path
from typing import Any
def read_json(path: Path) -> Any:
try:
with open(path, "r") as infile:
try:
data = json.load(infile)
return data
except json.decoder.JSONDecodeError as e:
raise e
except OSError as e:
data = json.load(infile)
return data
except Exception as e:
raise e
def write_json(data: dict, path: Path) -> None:
def write_json(data: dict[str, Any], path: Path) -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
j = json.dumps(data, indent=2)
path.write_text(j)
except Exception as e:
+15
View File
@@ -0,0 +1,15 @@
from datetime import datetime
from pathlib import Path
def is_writeable(path: "Path") -> bool:
now = int(datetime.now().timestamp())
suffix = ".dzgtmp"
file = str(now) + suffix
filepath = Path(str(path) + "_" + file)
try:
filepath.touch()
filepath.unlink()
return True
except OSError:
return False
+4 -1
View File
@@ -101,7 +101,7 @@ build_corrupted = (
"Steam settings or DayZ installation may be corrupted. Try restarting Steam."
)
api_warn_msg = """No servers returned. Please wait and try again.
If this issue persists, your API key may be defunct or your network is blocking requests.
This usually indicates a transitory network timeout. If this issue persists, your API key or network may be malfunctioning.
"""
server_timeout = "Timed out when querying server, check IP or try again later."
server_error = (
@@ -360,12 +360,14 @@ thanks = Thanks(
"bongjutsu",
"Deku",
"dj3hac",
"finntux",
"GaryBlackbourne",
"jiriks74",
"Johnofwrong",
"MatheusLasserr",
"nolan-perez",
"scandalouss",
"SnackSBR",
"StevelDusa",
"Thoughtduck216",
],
@@ -609,6 +611,7 @@ atomic_buttons = AtomicButton(
keys_tooltip="Toggles the keybindings dialog",
)
gtk_theme_missing = "No GTK theme provided"
steam_icon_missing = "Steam icon not found in IconTheme"
missing_changelog = "Error: failed to read changelog"
+10 -7
View File
@@ -43,11 +43,14 @@ def clone_symlinks(steam_path: Path) -> None:
# TODO: test these two operations
# use less time intensive logic like the above
for file in exp_path.iterdir():
if file.is_symlink():
file.unlink()
try:
for file in exp_path.iterdir():
if file.is_symlink():
file.unlink()
for file in dayz_path.iterdir():
if file.is_symlink():
uid = file.name
Path(exp_path / uid).symlink_to(dayz_path / uid)
for file in dayz_path.iterdir():
if file.is_symlink():
uid = file.name
Path(exp_path / uid).symlink_to(dayz_path / uid)
except Exception as e:
logger.critical(e)
+10 -6
View File
@@ -262,8 +262,8 @@ class Grid(Gtk.Grid):
self.emitter.connect("server_page_toggled", self.toggle_filter_panels)
# TODO: drop, use map/signal
def hide_widgets_on_init(self) -> None:
self.conpan.set_lan_visible(False)
self.right_panel.sel_panel.hide()
def toggle_filter_panels(self, emitter: "Emitter", state: bool) -> None:
@@ -323,8 +323,12 @@ class App(Gtk.Application):
MainController = Controller()
theme = Gtk.IconTheme.get_default()
icons = theme.list_icons(None)
if STEAM_ICON not in icons:
logger.warn(strings.steam_icon_missing)
warnings.warn(strings.steam_icon_missing, stacklevel=2)
try:
theme = Gtk.IconTheme.get_default()
icons = theme.list_icons(None)
if STEAM_ICON not in icons:
logger.warn(strings.steam_icon_missing)
warnings.warn(strings.steam_icon_missing, stacklevel=2)
except Exception as e:
logger.warning(e)
warnings.warn(strings.gtk_theme_missing, stacklevel=2)
+23 -8
View File
@@ -1,4 +1,4 @@
from typing import Callable, Self, TYPE_CHECKING
from typing import Callable, Self, TYPE_CHECKING, Union
from dzgui.util.clip import copy_clipboard
from dzgui.util.format import pluralize
@@ -70,18 +70,26 @@ class LargeIconTextButton(IconButton):
class ClipboardButton(IconTextButton):
def __init__(self, controller: "Controller", func: Callable) -> None:
def __init__(self, controller: Union["Controller", None], func: Callable) -> None:
super().__init__(CLIPBOARD, atomic_buttons.copy)
self.controller = controller
self.connect("clicked", self._on_button_clicked, func)
self.set_tooltip_text("Copy IP to clipboard")
self.set_tooltip_text("Copy to clipboard")
def _on_button_clicked(self, button: Self, func: Callable) -> None:
data = func()
copy_clipboard(data)
# TODO: determine when controller would be passed to this button or drop
class CopyIpButton(ClipboardButton):
def __init__(self, controller: Union["Controller", None], func: Callable) -> None:
super().__init__(controller, func)
self.set_tooltip_text("Copy IP to clipboard")
class WebButton(IconTextButton):
def __init__(self, label: str) -> None:
super().__init__(icon=WEB_BROWSER, label=label)
@@ -94,9 +102,11 @@ class RefreshButton(IconTextButton):
icon=REFRESH_ICON,
label=atomic_buttons.refresh,
)
self.controller = controller
emitter = self.controller.get_emitter()
self.loading = False
self.is_clicked = False
self.time = 30
@@ -110,17 +120,21 @@ class RefreshButton(IconTextButton):
def _on_refresh_clicked(self, button: Self) -> None:
"""Spawned in a thread"""
self.loading = True
self.is_clicked = True
self.controller.refresh_tree()
# TODO: get server tab enum
# if LAN tab, reload existing entries in place
def start_decrement(self, emitter: "Emitter", tab: "ServerTab") -> None:
if self.is_clicked is False:
return
if self.loading:
self.set_sensitive(False)
self.loading = False
self.show_time(True)
GLib.timeout_add_seconds(1, self.decrement)
return
self.set_sensitive(False)
self.loading = True
self.is_clicked = False
self.show_time(True)
GLib.timeout_add_seconds(1, self.decrement)
def decrement(self) -> bool:
self.time -= 1
@@ -128,6 +142,7 @@ class RefreshButton(IconTextButton):
self.time = 30
self.show_time(False)
self.set_sensitive(True)
self.loading = False
return False
self.show_time(True)
return True
+4 -6
View File
@@ -6,7 +6,7 @@ from dzgui.strings import connect_panel
from dzgui.util.keys import is_ctrl_mask
from dzgui.views.components.buttons import (
AddButton,
ClipboardButton,
CopyIpButton,
SteamConnectButton,
)
from dzgui.views.components.entry import IpEntry, PortEntry
@@ -151,7 +151,7 @@ class FavPanel(Gtk.Frame):
self.fav_button = SteamConnectButton()
self.fav_button.connect("clicked", self._on_connect_clicked)
self.copy_button = ClipboardButton(self.controller, self.get_fav_ip)
self.copy_button = CopyIpButton(self.controller, self.get_fav_ip)
if favorite is None:
self.toggle_buttons(False)
@@ -278,9 +278,7 @@ class ConnectPanel(Gtk.Box):
for el in self.lan, self.fav, self.add_panel:
self.add(el)
def set_lan_visible(self, state: bool) -> None:
self.lan.set_visible(state)
self.lan.set_visible(False)
def _on_lan_tab_toggled(self, emitter: "Emitter", state: bool) -> None:
self.set_lan_visible(state)
self.lan.set_visible(state)
+6
View File
@@ -68,6 +68,7 @@ class ButtonGrid(Gtk.Grid):
)
def reload_filters(self) -> None:
# TODO: may be superfluous
checkboxes = self.checks
self.block_toggles(True)
filters = self.controller.get_enabled_filters()
@@ -330,3 +331,8 @@ class FilterPanel(Gtk.Box):
self.block_map_change_propagation = False
return
self.emitter.emit("map_selection_changed", name)
def set_filters(self, filters: dict[str, bool]) -> None:
for check in self.button_grid.checks:
label = check.get_label()
check.set_active(filters[label])
+21 -57
View File
@@ -1,10 +1,7 @@
import logging
import os
import requests
import shutil
import subprocess
import tarfile
from pathlib import Path
from typing import Literal, TYPE_CHECKING
from dzgui.const.constants import (
@@ -13,20 +10,15 @@ from dzgui.const.constants import (
NO_FILL,
FILL,
NO_PADDING,
TMP_EXE,
TMP_PATH,
TMP_TARBALL,
)
from dzgui.const.update import ALLOW_UPDATES
from dzgui.const.enum import ServerTab
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import dialogs
from dzgui.managers.update import UpdateManager
from dzgui.util.clip import copy_clipboard
from dzgui.util.file import is_writeable
from dzgui.views.components.buttonbox import ButtonBox
from dzgui.views.components.filter_panel import FilterPanel
from dzgui.views.components.mod_panel import ModSelectionPanel
from dzgui.views.components.buttons import IconTextButton, RefreshButton, KeysButton
from dzgui.views.dialogs.generic import ExceptionDialog, QuitDialog
import gi
@@ -45,13 +37,13 @@ class RightPanel(Gtk.Box):
def __init__(self, controller: "Controller"):
super().__init__(spacing=6, orientation=Gtk.Orientation.VERTICAL)
self.thread_man = ThreadingManager(controller)
self.controller = controller
self.controller.register_widget("right_panel", self)
emitter = controller.get_emitter()
emitter.connect("servers_loaded", self._on_servers_loaded)
emitter.connect("server_page_changed", self._on_server_page_changed)
emitter.connect("lan_page_initialized", self._on_lan_page_init)
self.button_vbox = ButtonBox(controller)
self.filters_vbox = FilterPanel(controller)
@@ -95,54 +87,21 @@ class RightPanel(Gtk.Box):
valign=Gtk.Align.END,
spacing=10,
)
if ALLOW_UPDATES and update is not None:
self.update_button.set_halign(Gtk.Align.END)
self.gutter_box.add(self.update_button)
self.update_button.connect(
"clicked", self._on_update_button_clicked, update
)
exe_path = os.getenv("PYAPP")
if exe_path and update is not None:
if is_writeable(Path(exe_path)) is True:
self.update_button.set_halign(Gtk.Align.END)
self.gutter_box.add(self.update_button)
self.update_button.connect(
"clicked", self._on_update_button_clicked, exe_path, update
)
self.gutter_box.add(eb)
self.pack_start(self.gutter_box, NO_EXPAND, FILL, NO_PADDING)
def _on_update_success(self) -> None:
msg = dialogs.update_success
dialog = QuitDialog(self.controller, msg)
dialog.run()
def _on_update_failure(self, msg: str) -> None:
dialog = ExceptionDialog(self.controller, msg)
dialog.run()
@call_on_thread(dialogs.fetching_update)
def _on_update_button_clicked(self, button: Gtk.Button, url: str) -> None:
try:
res = requests.get(url)
if res.status_code == 200:
with open(TMP_TARBALL, "wb") as file:
file.write(res.content)
with tarfile.open(TMP_TARBALL) as tar:
tar.extractall(TMP_PATH)
exe_path = os.getenv("PYAPP")
if exe_path is None:
msg = dialogs.failed_to_update
func = StoredFunc(self._on_update_failure, msg)
self.thread_man.set_cleanup_func(func, destroy_first=True)
return
shutil.move(TMP_EXE, exe_path)
proc = subprocess.run([exe_path, "self", "restore"])
if proc.returncode == 0:
func = StoredFunc(self._on_update_success)
self.thread_man.set_cleanup_func(func, destroy_first=True)
else:
msg = dialogs.failed_to_update
func = StoredFunc(self._on_update_failure, msg)
self.thread_man.set_cleanup_func(func, destroy_first=True)
except Exception as e:
func = StoredFunc(self._on_update_failure, e)
self.thread_man.set_cleanup_func(func, destroy_first=True)
logger.warning(e)
def _on_update_button_clicked(
self, button: Gtk.Button, exe_path: str, url: str
) -> None:
UpdateManager(self.controller).update_version(exe_path, url)
def _on_server_page_changed(
self, emitter: "Emitter", page: "ServerTreeView"
@@ -151,6 +110,11 @@ class RightPanel(Gtk.Box):
if page.loaded is True:
return
self.filters_vbox.set_sensitive(False)
# TODO: unless it is lan page
# self.refresh_button.set_sensitive(False)
def _on_lan_page_init(self, emitter: "Emitter") -> None:
self.filters_vbox.set_sensitive(False)
def _on_servers_loaded(self, emitter: "Emitter", context: "ServerTab") -> None:
# TODO: similar logic on notebook page change
+9
View File
@@ -0,0 +1,9 @@
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa
class NoOverlayScrolledWindow(Gtk.ScrolledWindow):
def __init__(self) -> None:
super().__init__(overlay_scrolling=False)
+2 -2
View File
@@ -111,9 +111,9 @@ class Statusbar(Gtk.Grid):
context: Union["ServerTab", NotebookPage],
) -> None:
self.spinner.stop()
# TODO: CalcDist is being called when table is not loaded
# 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)
self.set_by_context(context, pretty)
+244
View File
@@ -0,0 +1,244 @@
import sys
from typing import Literal, 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.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.util.strings import dialog_header
from dzgui.util.symlink import rebuild_symlinks
from dzgui.views.components.buttons import ClipboardButton
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk # noqa
if TYPE_CHECKING:
from dzgui.config.xdg import Xdg
from dzgui.util.ip import Coords
class Success(Enum):
OK = 1
FAIL = 2
# TODO: strings for "Running", "Failed", etc.
# TODO: add margins to tree
class BootDialog(Gtk.Dialog):
def __init__(self, parent: "BootWindow", xdg: "Xdg", version: str) -> None:
super().__init__(
title=dialog_header,
parent=parent,
modal=True,
)
self.parent = parent
self.set_modal(True)
self.xdg = xdg
self.version = version
self.thread_man = ThreadingManager(None)
self.set_size_request(700, 500)
self.store = Gtk.ListStore(str, str, bool, int)
self.view = Gtk.TreeView(enable_search=False, headers_visible=False)
self.view.set_model(self.store)
for i, column_title in enumerate(["Task", "State"]):
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(column_title, renderer, text=i)
column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
column.set_resizable(False)
column.set_sort_column_id(i)
column.set_cell_data_func(renderer, self._format_color, func_data=None)
self.view.append_column(column)
if i == 0:
column.set_fixed_width(300)
self.spinner_renderer = Gtk.CellRendererSpinner()
self.spinner_renderer.set_property("size", Gtk.IconSize.LARGE_TOOLBAR)
col_bool = Gtk.TreeViewColumn("Spinner", self.spinner_renderer, active=3)
col_bool.set_alignment(0.0)
col_bool.set_cell_data_func(
self.spinner_renderer, self._set_spinner_vis, func_data=None
)
self.view.append_column(col_bool)
col_int = Gtk.TreeViewColumn("Int", Gtk.CellRendererText(), text=3)
col_int.set_visible(False)
self.view.append_column(col_int)
self.view.get_selection().set_mode(Gtk.SelectionMode.NONE)
self.connect("delete-event", self._on_delete)
self.scrollable_tree = Gtk.ScrolledWindow(overlay_scrolling=False)
self.scrollable_tree.add(self.view)
self.scrollable_tree.set_size_request(700, 400)
self.error_box = Gtk.Box(
halign=Gtk.Align.CENTER,
orientation=Gtk.Orientation.VERTICAL,
spacing=20,
margin_top=30,
margin_bottom=30,
)
self.error_label = Gtk.Label()
# TODO: abstract class
self.copy_button = ClipboardButton(None, lambda: self.error_label.get_text())
self.button_hbox = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.CENTER, spacing=10
)
self.exit_button = Gtk.Button(label="Exit", halign=Gtk.Align.CENTER)
self.exit_button.connect("clicked", lambda _: sys.exit(1))
self.button_hbox.add(self.copy_button)
self.button_hbox.add(self.exit_button)
self.error_box.add(self.error_label)
self.error_box.add(self.button_hbox)
self.content = self.get_content_area()
self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0)
self.content.pack_start(self.error_box, EXPAND, FILL, 0)
self.show_all()
self.error_box.hide()
steps = [
(StoredFunc(rebuild_symlinks, self.xdg.config), preboot.symlinks, False),
(
StoredFunc(remove_stale, self.xdg.config, self.xdg.version),
preboot.signatures,
False,
),
(StoredFunc(get_ipdb, self.xdg.ips), preboot.geo, False),
(StoredFunc(get_local_coords, self.xdg.ips), preboot.coords, True),
(StoredFunc(check_updates, self.version), preboot.updates, True),
]
self.results: list[Any] = []
self.failed = False
self.steps = iter(steps)
GLib.timeout_add(100, self.pulse_spinner)
def pulse_spinner(self) -> Literal[True]:
for row in self.store:
if row[2]:
if row[3] == 150:
row[3] = 0
else:
row[3] += 1
self.spinner_renderer.set_property("pulse", row[3])
return True
def run(self) -> None:
self.iter_step()
def _on_delete(self, widget: Self, event: Gdk.Event) -> Literal[True]:
return True
def iter_step(self) -> None:
if self.failed:
self.error_box.show()
self.error_label.set_text(self.exception)
return
try:
step, label, store_output = next(self.steps)
self.update_task(label)
self.background(step, store_output)
except StopIteration:
self.parent.set_results(self.results)
self.destroy()
@call_on_thread("", show_dialog=False)
def background(self, func: StoredFunc, store_output: bool) -> None:
try:
if store_output:
res = func.call()
self.results.append(res)
else:
func.call()
callback = StoredFunc(self.update_status, Success.OK)
self.thread_man.set_cleanup_func(callback)
except Exception as e:
self.failed = True
# TODO: generic exception wrapper
self.exception = f"{type(e).__name__}: {e}"
callback = StoredFunc(self.update_status, Success.FAIL)
self.thread_man.set_cleanup_func(callback)
def update_task(self, task: str) -> None:
self.store.append((task, "Running", True, 0))
def update_status(self, state: Success) -> None:
d = {Success.OK: "OK", Success.FAIL: "FAILED"}
msg = d[state]
self.store[len(self.store) - 1][1] = msg
self.iter_step()
def _set_spinner_vis(
self,
column: Gtk.TreeViewColumn,
cell: Gtk.CellRendererSpinner,
model: Gtk.TreeModel,
it: Gtk.TreeIter,
data: Any,
) -> None:
if model[it][1] == "Running":
cell.set_property("visible", True)
else:
cell.set_property("visible", False)
def _format_color(
self,
column: Gtk.TreeViewColumn,
cell: Gtk.CellRendererText,
model: Gtk.TreeModel,
it: Gtk.TreeIter,
data: Any,
) -> None:
prop = "foreground"
state = model[it][1]
if column.get_sort_column_id() != 1:
return
if state == "OK":
cell.set_property(prop, HEX_GREEN)
elif state == "FAILED":
cell.set_property(prop, HEX_RED)
else:
cell.set_property(prop, None)
class BootWindow(Gtk.Window):
def __init__(self, xdg: "Xdg", version: str) -> None:
super().__init__()
self.results: list[Any] = []
dialog = BootDialog(self, xdg, version)
dialog.run()
dialog.connect("destroy", self._on_destroy)
Gtk.main()
def set_results(self, res: list[Any]) -> None:
self.results = res
def get_results(self) -> tuple["Coords", str]:
coords, version_url = self.results
return (coords, version_url)
def _on_destroy(self, dialog: BootDialog) -> None:
Gtk.main_quit()
+2
View File
@@ -53,3 +53,5 @@ class EarlyIgnoreDialog(EarlyAlertDialog):
# TODO: reverse order
self.add_button("Ignore", Gtk.ResponseType.CANCEL)
# TODO: if exit, sys.exit(1), else pass
+7 -2
View File
@@ -31,8 +31,6 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402
# TODO: currently unused, corresponds to linear index
# TODO: use of pagenum enum and page_type attribute is redundant
class PageNum(Enum):
INTRO = 1
HAS_CONFIG = 2
@@ -93,6 +91,9 @@ class ScrolledWizardPage(Gtk.ScrolledWindow):
self.connect("map", self._on_map)
def get_enum(self) -> PageNum:
return self.enum
def get_progress_bar(self) -> Progress:
return self.prog
@@ -474,6 +475,7 @@ class Assistant(Gtk.Assistant):
def _advance_page(self, index: int) -> int:
page = self.get_nth_page(index)
# TODO: use enums
match page:
case self.page1:
pass
@@ -534,6 +536,9 @@ class Assistant(Gtk.Assistant):
bar.set_text(f"{page_num}/{total}")
# NOTE: disable forward action
# TODO: use page enums
if page == self.page5:
return
if page != self.page1:
EMITTER.emit("step_pending")
+2 -2
View File
@@ -11,5 +11,5 @@ class ModsMixin:
self, tree: TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn
) -> None:
path = self.get_focused_row_path() # type: ignore
self.controller.open_mod_page(path) # type: ignore
mod = self.get_value_at_index(2) # type: ignore
self.controller.open_workshop_page(mod) # type: ignore
+2 -1
View File
@@ -1,5 +1,6 @@
from typing import TYPE_CHECKING
from dzgui.const.constants import LOG_FILTERS
from dzgui.views.components.scrollable import NoOverlayScrolledWindow
from dzgui.views.mixins.cursor_mixin import CursorMixin
from dzgui.views.mixins.help_menu_mixin import HelpMenuMixin
from dzgui.views.trees.tree_log import LogTreeView
@@ -21,7 +22,7 @@ class Log(CursorMixin, HelpMenuMixin, Gtk.Box): # type: ignore
NOTE: LogTreeView is kept in a separate ScrolledWindow so that
checkboxes will be flush on bottom
"""
self.scrolled = Gtk.ScrolledWindow()
self.scrolled = NoOverlayScrolledWindow()
self.treeview = LogTreeView(controller)
self.scrolled.add(self.treeview)
+3 -1
View File
@@ -1,4 +1,5 @@
from typing import Self, TYPE_CHECKING
from dzgui.views.components.scrollable import NoOverlayScrolledWindow
from dzgui.views.trees.tree_mods import ModTreeView
import gi
@@ -10,7 +11,8 @@ if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
class Mods(Gtk.ScrolledWindow):
class Mods(NoOverlayScrolledWindow):
def __init__(self, controller: "Controller") -> None:
super().__init__()
+14 -2
View File
@@ -17,7 +17,7 @@ from dzgui.views.trees.tree_server_mods import ServerModTreeView
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk # type: ignore # noqa E402
from gi.repository import Gdk, Gtk # noqa E402
if TYPE_CHECKING:
@@ -106,6 +106,9 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
label=preconnect.back, halign=Gtk.Align.END, hexpand=True
)
self.ok = Gtk.Button(label=preconnect.update_mods, halign=Gtk.Align.END)
self.connect_last = Gtk.Button(
label=preconnect.connect_last, halign=Gtk.Align.END, tooltip_text=preconnect.connect_last_tooltip
)
# TODO: abstract
self.raise_window = Gtk.CheckButton(
@@ -126,13 +129,14 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
spacing=5,
)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
for button in self.back, self.ok:
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.back.connect("clicked", self._on_back_clicked)
self.ok.connect("clicked", self._on_ok_clicked)
self.connect_last.connect("clicked", self._on_connect_last_clicked)
self.title = Gtk.Label(label="")
add_class(self.title, "preconnect-heading")
@@ -213,6 +217,9 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
if event.keyval == Gdk.KEY_u:
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())
def _on_ok_clicked(self, button: Gtk.Button) -> None:
self.controller.update_and_connect(self.raise_window.get_active())
@@ -308,6 +315,11 @@ class PreConnectionAssistant(Gtk.ScrolledWindow):
prefix = preconnect.total_mods
self.mod_count.set_text(f"{prefix}{str(total_mods)}.{suffix}")
if prereqs.is_last_server:
self.connect_last.show()
else:
self.connect_last.hide()
self._process_warnings(prereqs)
if self.tree.is_visible():
self.tree.grab_focus()
+7 -14
View File
@@ -47,7 +47,13 @@ class ServerNotebook(Gtk.ScrolledWindow):
self.recent = ServerTreeView(
controller, ServerTab.RECENT, ContextMenuGroup.RECENT
)
self.lan = ServerTreeView(controller, ServerTab.LAN, ContextMenuGroup.SCAN_LAN)
self.lan = ServerTreeView(
controller,
ServerTab.LAN,
ContextMenuGroup.SCAN_LAN,
show_empty=True,
show_full=True,
)
tabs = [
(self.browser, server_labels.browser),
@@ -162,19 +168,6 @@ class ServerNotebook(Gtk.ScrolledWindow):
return tv
raise ValueError("No treeview set")
def add_notification(self) -> None:
saved = self.notebook.get_nth_page(1)
if saved is None:
return
text = self.notebook.get_tab_label_text(saved)
if text is None:
return
# TODO: strings
if "*" in text:
return
text += "*"
self.notebook.set_tab_label_text(saved, text)
def update_tab_widths(self, col: Gtk.TreeViewColumn) -> None:
# TODO: may cause pixel offsets when application is maximized
width = col.get_width()
+2 -4
View File
@@ -88,13 +88,12 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore
model: Gtk.TreeModel,
it: Gtk.TreeIter,
data: Any,
) -> Any:
) -> None:
state = model[it][4]
if state is True:
cell.set_property("foreground", HEX_RED)
else:
cell.set_property("foreground", None)
return
def _format_float(
self,
@@ -103,8 +102,7 @@ class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore
model: Gtk.TreeModel,
it: Gtk.TreeIter,
data: Any,
) -> Any:
) -> None:
val = model[it][3]
formatted = localize.number(val)
cell.set_property("text", formatted)
return
+13 -8
View File
@@ -38,7 +38,12 @@ QUEUE_CHECK_DELAY = 200
class ServerTreeView(ContextMixin, TreeView): # type: ignore
def __init__(
self, controller: "Controller", enum: ServerTab, menu: ContextMenuGroup
self,
controller: "Controller",
enum: ServerTab,
menu: ContextMenuGroup,
show_empty: bool = False,
show_full: bool = False,
) -> None:
super().__init__(controller, menu=menu)
@@ -48,7 +53,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
self.loaded = False
self.filter_man = FilterManager()
self.filter_man = FilterManager(show_empty, show_full)
self.proxy_man = ProxyModelManager(self.filter_man)
model = self.proxy_man.get_proxy_model()
self.set_model(model)
@@ -72,6 +77,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
logger.critical(e)
valid_json = False
# NOTE: reasonable defaults for long columns
width_map = {
"Name": 800,
"Map": 300,
@@ -96,8 +102,9 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
column.set_fixed_width(saved_size)
column.set_expand(True)
else:
w = width_map[column_title]
column.set_fixed_width(w)
if column_title in width_map:
w = width_map[column_title]
column.set_fixed_width(w)
if column_title == "Ping":
column.set_cell_data_func(renderer, self._get_ping)
@@ -162,8 +169,8 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
def _on_map(self, widget: Self) -> None:
# TODO: disable filter panel if current model is None
if self.get_enum() is ServerTab.LAN:
self.emitter.emit("lan_tab_toggled", True)
state = self.get_enum() is ServerTab.LAN
self.emitter.emit("lan_tab_toggled", state)
store = self.filter_man.get_map_store()
@@ -178,8 +185,6 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
# NOTE: removes queue checker for this tab
GLib.Source.remove(self.queue_id)
self.emitter.disconnect(self.handler_id)
if self.get_enum() is ServerTab.LAN:
self.emitter.emit("lan_tab_toggled", False)
def _on_col_width_changed(
self, col: Gtk.TreeViewColumn, width: GObject.ParamSpecInt
+1 -1
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [
{name = "aclist"}
]
version = "7.0.0b2"
version = "7.0.0b8"
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
readme = "README.md"
+84
View File
@@ -0,0 +1,84 @@
import os
import requests
import subprocess
import tarfile
from pathlib import Path
from urllib.parse import unquote
BUILD = "3.13.9"
DATE = "20251014"
CPYTHON_BUILD = f"cpython-{BUILD}"
root = Path(__file__).resolve().parents[1]
build_dir = root.joinpath("build")
cpython_dir = build_dir.joinpath(CPYTHON_BUILD)
def fetch(url: str, destination: str) -> None:
print(f"Fetching URL '{url}'")
res = requests.get(url)
code = res.status_code
if code != 200:
raise Exception(f"URL '{url}' returned status code {code}")
with open(build_dir.joinpath(destination), "wb") as f:
f.write(res.content)
def get_cpython() -> None:
source = f"https://github.com/astral-sh/python-build-standalone/releases/download/{DATE}/"
build = f"{CPYTHON_BUILD}%2B{DATE}-x86_64_v3-unknown-linux-gnu-install_only_stripped.tar.gz"
url = f"{source}{build}"
outfile = unquote(build)
fetch(url, outfile)
version = build.split("%")[0]
untar(outfile, "python", version)
def get_pyapp() -> None:
url = "https://github.com/ofek/pyapp/releases/latest/download/source.tar.gz"
outfile = "pyapp-latest.tar.gz"
fetch(url, outfile)
untar(outfile, "pyapp-*", "pyapp-latest")
def untar(file: str, glob: str, renamed: str) -> None:
full_path = build_dir.joinpath(file)
with tarfile.open(full_path, "r:gz") as tar:
tar.extractall(path=build_dir)
full_path.unlink()
for _dir in build_dir.glob(glob):
os.rename(_dir, build_dir.joinpath(renamed))
def install_deps() -> None:
pip = build_dir.joinpath(f"{CPYTHON_BUILD}/bin/pip")
proc = subprocess.run([pip, "install", root])
return proc.returncode
def repackage() -> None:
print("Preparing tar archive...")
outfile = build_dir.joinpath("airgapped.tar.gz")
with tarfile.open(outfile, "w:gz") as tar:
tar.add(build_dir.joinpath(CPYTHON_BUILD), arcname="python")
def rebuild_cpython() -> None:
if cpython_dir.is_dir() is False:
get_cpython()
code = install_deps()
if code != 0:
return
repackage()
return get_packaged_version()
def get_packaged_version() -> str:
proc = subprocess.run(
[cpython_dir.joinpath("bin/dzgui"), "-v"],
capture_output=True,
text=True,
check=True,
)
return proc.stdout.rstrip()
+31 -7
View File
@@ -5,10 +5,12 @@ import tarfile
from pathlib import Path
from prebuild import get_pyapp, rebuild_cpython
root = Path(__file__).resolve().parents[1]
output = root.joinpath("dist")
pyapp_dir = root.joinpath("pyapp-latest")
build_dir = root.joinpath("build")
builder = build.ProjectBuilder(root)
wheel = builder.build("wheel", output_directory=output)
@@ -19,23 +21,45 @@ metadata = stem.split("-")
appname = metadata[0]
version = metadata[1]
entrypoint = "dzgui.main:main"
pyapp_dir = build_dir.joinpath("pyapp-latest")
if pyapp_dir.is_dir() is False:
get_pyapp()
packaged_version = rebuild_cpython()
assert packaged_version == version
cpython = build_dir.joinpath("airgapped.tar.gz")
entrypoint = "dzgui.main:main"
env = os.environ
env["PYAPP_PROJECT_VERSION"] = version
env["PYAPP_PROJECT_NAME"] = appname
env["PYAPP_EXEC_SPEC"] = entrypoint
env["PYAPP_PROJECT_PATH"] = wheel
env["PYAPP_DISTRIBUTION_EMBED"] = "true"
env["PYAPP_PYTHON_VERSION"] = "3.13"
env["PYAPP_PASS_LOCATION"] = "true"
# NOTE: explicitly install all dependencies into distribution
env["PYAPP_SKIP_INSTALL"] = "true"
env["PYAPP_DISTRIBUTION_EMBED"] = "true"
env["PYAPP_FULL_ISOLATION"] = "true"
env["PYAPP_DISTRIBUTION_PATH"] = str(cpython)
env["PYAPP_DISTRIBUTION_PYTHON_PATH"] = "python/bin/python3"
proc = subprocess.run(["cargo", "build", "--release"], env=env, cwd=pyapp_dir)
if proc.returncode == 0:
output_exe = root.joinpath("pyapp-latest/target/release/pyapp")
output_exe = build_dir.joinpath("pyapp-latest/target/release/pyapp")
release_exe = output.joinpath(appname)
output_exe.rename(release_exe)
tarpath = output.joinpath(tarname)
with tarfile.open(tarpath, "w:gz") as tar:
tar.add(release_exe, arcname=appname)
info = tar.gettarinfo(release_exe)
info.uname = appname
info.name = appname
with open(release_exe, "rb") as f:
tar.addfile(info, f)
print(f"Wrote tarfile to '{tarpath}'")
proc = subprocess.run([release_exe, "-v"], capture_output=True, text=True)
assert proc.stdout.rstrip() == version
release_exe.unlink()
Path(wheel).unlink()
+14
View File
@@ -0,0 +1,14 @@
{
"cols": {
"Name": 1,
"Map": 1,
"Gametime": 1,
"Players": 1,
"Queue": 1,
"IP": 1,
"Qport": 1,
"Ping": 1,
"Maximum": 1,
"Perspective": 1
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"cols": {
"Name": 398,
"Map": 181,
"Gametime": 151,
"Players": 119,
"Queue": 96,
"IP": 247,
"Qport": 94,
"Ping": 253
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"cols": {
"Name": 1,
"Map": 1,
"Gametime": 1,
"Players": 1,
"Queue": 1,
"IP": 1,
"Qport": 1,
"Ping": 1,
"View": 1
}
}
+33
View File
@@ -0,0 +1,33 @@
import pytest
from dzgui.init.migrate import convert_cols_file
from tests.fixtures import fixture_path
@pytest.fixture
def columns_with_perspective():
return fixture_path("columns_1")
@pytest.fixture
def columns_without_perspective():
return fixture_path("columns_2")
@pytest.fixture
def columns_with_view():
return fixture_path("columns_3")
@pytest.mark.config
def test_columns_with_perspective(columns_with_perspective):
j = convert_cols_file(columns_with_perspective)
assert "View" in j["cols"]
assert "Max" in j["cols"]
@pytest.mark.config
def test_columns_without_perspective(columns_without_perspective):
j = convert_cols_file(columns_without_perspective)
assert "View" not in j["cols"]
assert "Max" not in j["cols"]
@pytest.mark.config
def test_columns_with_view(columns_with_view):
j = convert_cols_file(columns_with_view)
assert j is None
+27
View File
@@ -0,0 +1,27 @@
from dzgui.config.ipdb import get_ipdb
from pathlib import Path
import os
import pytest
import tempfile
@pytest.mark.slow
def test_stripping():
d = tempfile.TemporaryDirectory()
path = Path(d.name).joinpath("ips.csv")
get_ipdb(path)
with open(path, "rb") as f:
first = f.readline().decode().rstrip()
try:
f.seek(-2, os.SEEK_END)
while f.read(1) != b"\n":
f.seek(-2, os.SEEK_CUR)
except OSError:
f.seek(0)
last = f.read().decode().rstrip()
first_els = first.split(",")
last_els = last.split(",")
assert first_els[0] == "0.0.0.0"
assert last_els[0] == "224.0.0.0"
+3 -3
View File
@@ -2,14 +2,14 @@ from dzgui.util import localize
def test_de(monkeypatch):
monkeypatch.setenv("LC_CTYPE", "de_DE.UTF-8")
monkeypatch.setenv("LC_ALL", "de_DE.UTF-8")
localize.set_locale()
assert localize.number(9900) == "9.900"
assert localize.number(99.88) == "99,88"
def test_en(monkeypatch):
monkeypatch.setenv("LC_CTYPE", "en_US.UTF-8")
monkeypatch.setenv("LC_ALL", "en_US.UTF-8")
localize.set_locale()
assert localize.number(9900) == "9,900"
assert localize.number(99.88) == "99.88"
@@ -22,7 +22,7 @@ def test_none(monkeypatch):
def test_empty(monkeypatch):
monkeypatch.setenv("LC_CTYPE", "")
monkeypatch.setenv("LC_ALL", "")
localize.set_locale()
assert localize.number(9900) == "9,900"
assert localize.number(99.88) == "99.88"