Compare commits

...
46 Commits
Author SHA1 Message Date
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 20dab38fff Merge pull request #351 from aclist/fix/enqueue-mods
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
fix: enqueue mods
2026-06-07 21:59:49 +09:00
aclist 1ddc4a5ff1 chore: bump version 2026-06-07 21:58:44 +09:00
aclist d605447fca chore: clear typehinting errors 2026-06-07 21:51:55 +09:00
aclist 94fe3f9dff fix: suppress search popup (#353) 2026-06-07 21:49:41 +09:00
aclist a966993999 fix: invoke steam client directly (#348) 2026-06-07 21:21:49 +09:00
aclist 8ee3b36816 fix: restore mod statusbar when returning from other pages 2026-06-07 21:20:56 +09:00
aclist 3af53775cd fix: grab focus on tree 2026-06-07 21:20:32 +09:00
aclist e2d6a462db fix: suppress headers if no model is loaded 2026-06-07 20:23:13 +09:00
33 changed files with 687 additions and 280 deletions

No files matched your search

+1
View File
@@ -57,6 +57,7 @@
- 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
- Debug mode
+96
View File
@@ -0,0 +1,96 @@
import re
from collections.abc import Iterator
from typing import Any
from warnings import deprecated
@deprecated("Use dzgui.api.steam.unsubscribe()")
class WorkshopACF:
def __init__(self, file: str) -> 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: str) -> None:
delimiter = r"\t\t"
lines = []
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))
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 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: str) -> None:
s = self.unpack(self.dict)
with open(file, "w") as f:
f.write(s)
@classmethod
def enquote(cls, s: str) -> str:
return f'"{s}"'
@classmethod
def dequote(cls, s: str) -> str:
return s.rstrip('"').lstrip('"')
def delete(self, modid: int) -> None:
del self.dict["AppWorkshop"]["WorkshopItemsInstalled"][modid]
del self.dict["AppWorkshop"]["WorkshopItemDetails"][modid]
+5
View File
@@ -12,6 +12,7 @@ from dzgui.api.servers import get_rules, fqip_to_record
from dzgui.const.constants import (
APP_NAME,
APPID_DAYZ,
DAYZ_COMMUNITY_ROOT,
LIBRARYFOLDERS_PATH,
WORKSHOP_PATH,
)
@@ -50,6 +51,9 @@ def get_local_mods(workshop_path: Path) -> list[Path]:
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()
@@ -81,6 +85,7 @@ def get_mod_size(path: Path) -> float:
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)
+51 -25
View File
@@ -3,20 +3,25 @@ import logging
import os
import requests
import subprocess
from typing import Union
from warnings import deprecated
from shlex import shlex
from pathlib import Path
from dzgui.init.prereqs import has_steam_client
from dzgui.api.mods import _hash
from dzgui.const.constants import (
APPID_DAYZ,
APP_NAME,
DEBIAN_STEAM_PATH,
DEFAULT_STEAM_PATH,
FLATPAK_STEAM_PATH,
UBUNTU_STEAM_PATH,
REQUEST_TIMEOUT,
VDF_PATH,
)
from dzgui.const.endpoints import STEAM_PUBLISHED_FILES
from dzgui.const.endpoints import SUB_ENDPOINT, STEAM_PUBLISHED_FILES, UNSUB_ENDPOINT
from dzgui.strings import wizard
from dzgui.util.bash import concat_bash_args
@@ -53,8 +58,6 @@ def get_steam_paths() -> list[tuple[Path, str]]:
def concat_mods(mods: list[str]) -> str:
from dzgui.util.symlink import _hash
hashes = []
for mod in mods:
md5sum = _hash(mod)
@@ -68,31 +71,21 @@ def get_local_signatures(version_file: Path) -> dict[str, int]:
for line in lines:
data = line.split(",")
_id = data[0]
_hash = int(data[1])
hashes[_id] = _hash
mod_hash = int(data[1])
hashes[_id] = mod_hash
return hashes
def enqueue_mod(mod: str, appid: int) -> None:
args = [
"steam",
f"steam://url/CommunityFilePage/{mod}+workshop_download_item",
str(appid),
mod,
]
subprocess.Popen(["/usr/bin/env", "bash", *args])
def get_needs_update(
version_file: Path, remote_hashes: list[tuple[str, str, int, int]]
) -> list[tuple[str, str, int, int]]:
local_hashes = get_local_signatures(version_file)
local_stamps = get_local_signatures(version_file)
needs_update: list[tuple[str, str, int, int]] = []
for title, _id, _hash, size in remote_hashes:
if _id not in local_hashes:
needs_update.append((title, _id, _hash, size))
elif _hash != local_hashes[_id]:
needs_update.append((title, _id, _hash, size))
for title, _id, stamp, size in remote_hashes:
if _id not in local_stamps:
needs_update.append((title, _id, stamp, size))
elif stamp != local_stamps[_id]:
needs_update.append((title, _id, stamp, size))
else:
continue
return needs_update
@@ -162,9 +155,12 @@ def load_to_menu(client: str, appid: int, name: str, mods: list[str]) -> int:
proc = subprocess.run([*client_args, *params])
return proc.returncode
def launch_offline(client: str, appid: int, name: str, mods: list[str], mission: str) -> int:
def launch_offline(
client: str, appid: int, name: str, mods: list[str], mission: str
) -> int:
"""Launch offline with specific mods/missions"""
concat = concat_mods(mods)
symlinks = ";".join(mods)
client_args = concat_bash_args(client)
params = [
"-applaunch",
@@ -173,12 +169,13 @@ def launch_offline(client: str, appid: int, name: str, mods: list[str], mission:
"-nosplash",
"-skipintro",
f"-name={name}",
f"-mod={concat}",
f"-mission={mission}"
f"-mod={symlinks}",
f"-mission={mission}",
]
proc = subprocess.run([*client_args, *params])
return proc.returncode
def find_user_id(path: Path) -> str | None:
resolved_path = path / "config" / "loginusers.vdf"
try:
@@ -233,6 +230,29 @@ def vdf2json(path: Path) -> str:
jbuf += "\n"
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)
def gen_shortcut() -> None:
# TODO:
"""
@@ -245,3 +265,9 @@ def gen_shortcut() -> None:
# or get right-most 32 bits
# STEAMID_64 & 0xFFFFFFFF
pass
@deprecated("Use subscribe()")
def enqueue_mod(client: str, mod: str, appid: int) -> None:
client_args = concat_bash_args(client)
subprocess.Popen([*client_args, "+workshop_download_item", str(appid), mod])
+5
View File
@@ -4,6 +4,7 @@ UDP_PORT = 27016
VM_FILE = "/proc/sys/vm/max_map_count"
MIN_COUNT = 1048576
RATE_LIMIT_THRESHOLD = 3
REQUEST_TIMEOUT = 10
APPNAME_DAYZ = "DayZ"
@@ -32,10 +33,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"
@@ -81,3 +84,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"
+7 -2
View File
@@ -1,6 +1,11 @@
# 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"
BM_SERVERS = "https://api.battlemetrics.com/servers?"
GITHUB = "https://github.com/aclist"
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
+6 -5
View File
@@ -168,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}
@@ -184,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,
@@ -249,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,
+9
View File
@@ -9,6 +9,7 @@ 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
@@ -160,6 +161,14 @@ class Emitter(GObject.GObject):
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
+6 -6
View File
@@ -233,7 +233,7 @@ class Controller(GObject.GObject):
mod_man = self.mediator.modtreeview.get_mod_man()
mod_man.toggle_mod_selection(state)
def delete_mods(
def unsub_mods(
self, treeview: Union["ModTreeView", "OfflineModTreeView", None] = None
) -> None:
if treeview is None:
@@ -241,7 +241,7 @@ class Controller(GObject.GObject):
else:
view = treeview
mod_man = view.get_mod_man()
mod_man.delete_mods()
mod_man.unsub_mods()
def get_mod_store(self) -> Gtk.TreeModel | None:
return self.mediator.modtreeview.get_model()
@@ -523,11 +523,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()
+2
View File
@@ -40,6 +40,7 @@
- Preboot progress dialog
- Choose to jump into splash screen instead of server
- Collapsible connection panel
- Play offline (load mods directly)
## Changed
- Conform to PEP 440 versioning for beta versions
@@ -56,6 +57,7 @@
- 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
- Debug mode
+14 -14
View File
@@ -12,10 +12,10 @@ import dzgui.api.servers as Servers
from dzgui.api.steam import (
connect,
enqueue_mod,
get_remote_signatures,
get_needs_update,
load_to_menu,
subscribe,
)
from dzgui.api.mods import (
@@ -30,6 +30,7 @@ from dzgui.const.constants import (
APPID_DAYZ_EXP,
APPNAME_DAYZ,
APPNAME_DAYZ_EXP_HUMAN,
RATE_LIMIT_THRESHOLD,
)
from dzgui.const.enum import NotebookPage, Preferences
from dzgui.init.proc import is_dayz_running, is_steam_running
@@ -88,6 +89,7 @@ class ConnectionManager:
self.record: Record
self.workshop: Path
self.client: str
self.remote_mod_ids: list[str] = []
self.missing_mods: list[tuple[str, str, int, int]] = []
@@ -171,6 +173,7 @@ class ConnectionManager:
client = self.controller.query_config(Preferences.CLIENT)
running = is_steam_running(client)
steam_proc = SteamProcess(client_name, running)
self.client = client
game_mode = prefs.is_game_mode
@@ -263,11 +266,10 @@ class ConnectionManager:
def _connect_steam(self, menu_only: bool) -> None:
addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME)
client = self.controller.query_config(Preferences.CLIENT)
if menu_only:
rc = load_to_menu(client, self.appid, playername, self.remote_mod_ids)
rc = load_to_menu(self.client, self.appid, playername, self.remote_mod_ids)
else:
rc = connect(client, addr, self.appid, playername, self.remote_mod_ids)
rc = connect(self.client, addr, self.appid, playername, self.remote_mod_ids)
if rc != 0:
# TODO: log/pop the error
func = StoredFunc(self.controller.update_status)
@@ -277,6 +279,7 @@ class ConnectionManager:
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
@@ -291,19 +294,16 @@ class ConnectionManager:
self.controller.add_to_history(self.history, self.record)
self.controller.open_page(NotebookPage.SERVERS)
def _update_mods(self, raise_window: bool, menu_only: bool = False) -> None:
# NOTE: fast enqueue all mods in auto mode
def _update_mods(self, menu_only: bool = False) -> None:
prefs = self.controller.get_prefs()
config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.STEAM)
for title, mod, stamp, size in self.missing_mods:
if self.controller.is_cancel_pending():
return
enqueue_mod(mod, self.appid)
time.sleep(3)
if raise_window is True:
logger.info("Bringing window to foreground")
GLib.idle_add(self.controller.present_window)
subscribe(key, int(mod))
time.sleep(RATE_LIMIT_THRESHOLD)
for title, mod, stamp, size in self.missing_mods:
mod_path = self.workshop / mod
@@ -327,8 +327,8 @@ class ConnectionManager:
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)
+2 -2
View File
@@ -80,8 +80,8 @@ class ContextMenuManager:
if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)):
match action:
case ContextMenu.DELETE_MOD:
self.controller.delete_mods(self.treeview)
case ContextMenu.UNSUB_MOD:
self.controller.unsub_mods(self.treeview)
case ContextMenu.OPEN_WORKSHOP:
self.open_mod_page()
+2 -1
View File
@@ -89,8 +89,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()
+29 -16
View File
@@ -1,17 +1,22 @@
import logging
import shutil
import time
from pathlib import Path
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 (
APP_NAME,
APPID_DAYZ,
APPID_DAYZ_EXP,
RATE_LIMIT_THRESHOLD,
)
from dzgui.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import FastInsertListStore, ModelFactory
@@ -66,6 +71,9 @@ class ModManager:
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:
@@ -74,7 +82,7 @@ class ModManager:
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
@@ -85,7 +93,7 @@ class ModManager:
continue
mod, _iter = res
mods.append((mod, _iter))
self.delete_mods_on_system(mods)
self.unsub_all_mods(mods)
def get_mod_from_tree_path(
self, tree_path: Gtk.TreePath
@@ -98,24 +106,28 @@ 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)
iters = [_iter for mod, _iter in mods]
func = StoredFunc(self._on_mods_deleted, iters)
func = StoredFunc(self._on_mods_unsubbed, iters)
self.thread_man.set_cleanup_func(func)
def delete_single_mod(self, mod: str) -> None:
def unsub_atomic_mod(self, mod: str) -> None:
config_man = self.controller.get_config_man()
key = config_man.lookup(Preferences.STEAM)
unsubscribe(key, int(mod))
steam_path = Path(self.path)
mods_path = get_local_mod_path(steam_path)
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
md5 = _hash(mod)
symlink = app_path / md5
symlink.unlink()
shutil.rmtree(mods_path / mod)
try:
md5 = _hash(mod)
symlink = app_path / md5
symlink.unlink()
except Exception as e:
logger.warning(e)
# NOTE: second pass to unlink DAYZ_EXP mods
# TODO: test this with working APPID_DAYZ_EXP installation
try:
@@ -124,8 +136,9 @@ class ModManager:
symlink.unlink()
except PeFile.AppNotInstalledError:
pass
time.sleep(RATE_LIMIT_THRESHOLD)
def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None:
def _on_mods_unsubbed(self, iters: list[Gtk.TreeIter]) -> None:
if self.store is None:
return
for _iter in iters:
+71 -38
View File
@@ -1,17 +1,25 @@
import logging
import time
from pathlib import Path
from typing import TYPE_CHECKING, Union
from typing import Callable, TYPE_CHECKING, Union
import dzgui.api.pefile as PeFile
from dzgui.api.mods import is_mission, get_custom_mods
from dzgui.api.steam import launch_offline
from dzgui.const.constants import APP_NAME, APPID_DAYZ_EXP
from dzgui.const.enum import Preferences
from dzgui.init.proc import is_dayz_running
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import ModelFactory
from dzgui.strings import dialogs
from dzgui.util.symlink import clone_symlinks
from dzgui.util.symlink import create_custom_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:
@@ -26,74 +34,99 @@ class OfflineManager:
self.thread_man = ThreadingManager(controller)
self.appid: int
self.mission_folder: str
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)
# TODO: stop storing values in here
if is_valid:
# TODO: str
self.mission_folder = str(folder)
self.emitter.emit("custom_mission_loaded", str(folder), is_valid)
def get_custom_mods(self, local_mods: list[str]) -> None:
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
self.parse_custom_mods(local_mods, folder)
# NOTE: starts spinner in calling UI
callback()
self.parse_custom_mods(folder)
@call_on_thread(dialogs.parsing_mods)
def parse_custom_mods(self, local_mods: list[str], folder: str) -> None:
@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(
lambda: self.emitter.emit("custom_mods_loaded", store, folder)
)
func = StoredFunc(self.post_mod_loading, store, folder)
self.thread_man.set_cleanup_func(func)
def setup(
self,
appid: int,
mission: str = "",
local_mods: list[str] | None = None,
custom_mods: list[str] | None = None,
) -> None:
self.appid = appid
self.local_mods = local_mods
self.custom_mods = custom_mods
self.launch()
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)
def launch(self) -> None:
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)
new_symlinks: list[str] = []
combined_mods: list[str] = []
if self.local_mods is not None:
combined_mods.extend(self.local_mods)
if self.custom_mods is not None:
# TODO: new function that creates symlinks in game path
# based on selected mods
clone_symlinks(Path(steam_path))
if len(local_mods) > 0:
combined_mods.extend(local_mods)
if len(custom_mods) > 0:
new_symlinks = create_custom_symlinks(
Path(steam_path), Path(custom_folder), custom_mods
)
combined_mods.extend(new_symlinks)
launch_offline(client, self.appid, name, combined_mods, self.mission_folder)
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
+1 -1
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()
+1 -1
View File
@@ -7,7 +7,7 @@ update_success = "Updated successfully. Please exit and relaunch."
load_error_lan = "Failed to find any servers on your network.\nCheck the server query port or your firewall settings."
fetching_mods = "Fetching mod metadata"
deleting_mods = "Deleting mods"
deleting_mods = "Unsubscribing mods"
scanning_mods = "Scanning mods"
parsing_mods = "Parsing mods"
+8
View File
@@ -10,5 +10,13 @@ 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"
+7 -6
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"
@@ -215,8 +215,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
@@ -337,8 +337,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",
@@ -367,6 +367,7 @@ thanks = Thanks(
"Johnofwrong",
"MatheusLasserr",
"nolan-perez",
"OnniSaarni",
"scandalouss",
"SnackSBR",
"StevelDusa",
@@ -463,7 +464,7 @@ crumbs = Crumbs(
thanks="Help > Special thanks",
developers="Options > Developers",
default="Servers > ",
offline="Mods > Play offline"
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
-1
View File
@@ -70,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())
+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)
+2
View File
@@ -208,6 +208,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):
+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
+5 -4
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)
@@ -100,8 +101,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:
+12 -1
View File
@@ -25,6 +25,8 @@ class Statusbar(Gtk.Grid):
self.controller.register_widget("statusbar", self)
self.emitter = controller.get_emitter()
self.prior_enum: NotebookPage
self.prior_status: str
self.playercount = ""
self.statusbar = Gtk.Statusbar()
@@ -71,6 +73,7 @@ class Statusbar(Gtk.Grid):
NotebookPage.CONNECTION,
NotebookPage.OFFLINE,
):
self.prior_enum = enum
self.set_by_context(enum, esc_to_return)
return
@@ -80,8 +83,9 @@ class Statusbar(Gtk.Grid):
case NotebookPage.KEYS:
bar = question_to_return
case _:
return
bar = self.prior_status
self.prior_enum = enum
self.set_by_context(enum, bar)
def _on_server_row_changed(self, statusbar: Self) -> None:
@@ -97,8 +101,13 @@ class Statusbar(Gtk.Grid):
context: Union["ServerTab", NotebookPage],
) -> None:
self.spinner.stop()
if type(context) is NotebookPage:
self.prior_enum = context
self.prior_status = ""
# FIXME: CalcDist is being called when table is not loaded
if dist is None:
self.set_by_context(context, "")
return
else:
pretty = self.append_distance(dist)
@@ -121,6 +130,8 @@ class Statusbar(Gtk.Grid):
def set_by_context(
self, context: Union[NotebookPage, "ServerTab"], string: str
) -> None:
if context != NotebookPage.KEYS:
self.prior_status = string
meta = self.statusbar.get_context_id(str(context))
self.statusbar.push(meta, string)
self.set_cache(string)
+11 -3
View File
@@ -54,7 +54,15 @@ class FolderPicker(Gtk.FileChooserDialog):
return None
if res == Gtk.ResponseType.OK:
folder = self.get_current_folder()
if folder is not None:
self.destroy()
return Path(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
+30 -1
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
@@ -20,10 +26,22 @@ class Mods(Gtk.Box):
self.tree.set_vexpand(True)
self.box.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,
@@ -32,13 +50,17 @@ class Mods(Gtk.Box):
)
self.offline_button.connect("clicked", self._on_offline_clicked)
self.add(self.offline_button)
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:
@@ -46,6 +68,13 @@ class Mods(Gtk.Box):
def _on_map(self, widget: Self) -> None:
self.emitter.emit("mod_page_toggled", True)
# 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()
+222 -101
View File
@@ -1,20 +1,24 @@
from __future__ import annotations
from pathlib import Path
from typing import Self, Sequence, TYPE_CHECKING, Union
from enum import Enum
from typing import Self, TYPE_CHECKING, Union
from dzgui.util import css
import dzgui.api.pefile as PeFile
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 NotebookPage, Preferences
from dzgui.const.enum import ContextMenuGroup, NotebookPage
from dzgui.managers.offline import OfflineManager
from dzgui.strings import generic, offline
from dzgui.views.components.buttons import IconTextButton
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
@@ -23,7 +27,7 @@ from dzgui.views.trees.tree_mods import OfflineModTreeView
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa
from gi.repository import Gtk, Gdk, GObject # noqa
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
@@ -31,23 +35,10 @@ if TYPE_CHECKING:
from dzgui.model.model_factory import FastInsertListStore
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)
class FolderError(Enum):
NO_VALID_MODS = 1
NO_VALID_MISSION = 2
FOLDER_CHANGED = 3
class PageHeading(Gtk.Label):
@@ -57,37 +48,128 @@ class PageHeading(Gtk.Label):
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, btn_label: str) -> None:
def __init__(self, controller: "Controller", btn_label: str, eb_text: str) -> None:
super().__init__(spacing=10)
self.set_margin_start(10)
self.set_margin_start(5)
self.set_margin_end(10)
self.set_margin_bottom(5)
# TODO: alternate class for left-aligned icons
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.extend([self.button, self.scrolled_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 set_label(self, label: str) -> None:
# TODO: strings
prefix = "Current folder: "
self.label.set_label(prefix + label)
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__(
@@ -114,45 +196,54 @@ class ModFrame(HeadingFrame):
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)
# TODO: inherit icons from warnings area in preconnect dialog
# TODO: strings
self.error_label = Gtk.Label(
label="No mods found",
halign=Gtk.Align.START,
margin_start=10,
margin_bottom=5,
)
self.vbox.pack_end(self.error_label, expand=True, fill=True, padding=3)
def set_error(self, msg: str) -> None:
self.error_label.set_label(msg)
self.error_label.show()
def hide_errors(self) -> None:
self.error_label.hide()
self.connect("map", self._on_map)
self.connect("unmap", self._on_unmap)
def start_empty(self) -> None:
self.error_label.show()
self.collapse_tree()
self.tree_vbox.hide()
self.no_mods.show()
def hide_all(self) -> None:
self.error_label.hide()
self.collapse_tree()
def _on_map(self, widget: Self) -> None:
self.no_mods.hide()
self.tree.set_model(None)
self.tree_vbox.hide()
# TODO: warnings area
# name collision within custom mods
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 []
return [model[_iter][1] for _iter in treeiters]
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()
@@ -175,6 +266,8 @@ class ModFrame(HeadingFrame):
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:
@@ -189,30 +282,49 @@ class CustomModFrame(ModFrame):
) -> None:
super().__init__(parent, controller, heading)
self.tree.set_menu(ContextMenuGroup.MOD_OFFLINE)
self.parent = parent
self.controller = controller
self.emitter = controller.get_emitter()
# TODO: descriptive text here explaining how this area works
self.custom_hbox = FolderHBox(offline.custom_button)
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:
# TODO: no mods message is different from collision message
self.set_error(offline.no_mods)
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_label(folder)
self.custom_hbox.set_folder(folder)
self.tree.set_model(store)
self.tree_vbox.show()
self.hide_errors()
def _on_custom_mods_loaded(
self,
@@ -220,14 +332,19 @@ class CustomModFrame(ModFrame):
store: "FastInsertListStore",
folder: str,
) -> None:
self.custom_hbox.stop_spinner()
if len(store) == 0:
self.hide_tree()
self.present_error(folder)
else:
self.present_tree(store, folder)
def _on_custom_button_clicked(self, button: Gtk.Button) -> None:
local_mods = self.get_mods()
self.parent.offline_man.get_custom_mods(local_mods)
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):
@@ -238,35 +355,37 @@ class MissionFrame(HeadingFrame):
self.controller = controller
self.emitter = controller.get_emitter()
self.mission_hbox = FolderHBox(offline.mission_button)
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.warning = Gtk.Label(
halign=Gtk.Align.START, margin_start=10, margin_bottom=5
)
self.vbox = VBox()
self.vbox.extend([self.mission_hbox, self.warning])
self.frame.add(self.vbox)
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:
self.mission_hbox.set_label(folder)
if not is_valid:
self.warning.show()
self.warning.set_label(offline.no_mission)
if is_valid:
self.mission_hbox.set_folder(folder)
else:
self.warning.hide()
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, controller: "Controller") -> None:
def __init__(self, parent: OfflineLoader, controller: "Controller") -> None:
super().__init__(heading=offline.version)
self.controller = controller
@@ -286,13 +405,10 @@ class RadioFrame(HeadingFrame):
self.frame.add(self.radio_box)
# TODO: abstract out of here
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)
# TODO: cleaner delegation
has_dayz_exp = parent.offline_man.has_dayz_exp()
self.dayz.connect("toggled", self._on_radio_toggled)
if dayz_exp is None:
if has_dayz_exp is False:
self.dayz_exp.set_sensitive(False)
def _on_radio_toggled(self, radio: Gtk.RadioButton) -> None:
@@ -315,6 +431,7 @@ class OfflineLoader(Gtk.Box):
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))
@@ -324,7 +441,7 @@ class OfflineLoader(Gtk.Box):
self.custom_tree = OfflineModTreeView(controller)
self.mission_frame = MissionFrame(self, controller)
self.radio_frame = RadioFrame(controller)
self.radio_frame = RadioFrame(self, controller)
self.scrollable = Gtk.ScrolledWindow(
vexpand=True, propagate_natural_height=True
@@ -343,12 +460,13 @@ class OfflineLoader(Gtk.Box):
# TODO: share ConnectBox class with preconnect dialog?
self.button_box = HBox(spacing=5)
self.button_box.set_halign(Gtk.Align.END)
self.button_box.set_margin_top(5)
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])
@@ -359,7 +477,8 @@ class OfflineLoader(Gtk.Box):
def check_button(self) -> None:
local_mods = self.local_frame.get_mods()
custom_mods = self.custom_frame.get_mods()
if len(local_mods) == 0 and len(custom_mods) == 0:
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)
@@ -369,24 +488,26 @@ class OfflineLoader(Gtk.Box):
self.back.emit("clicked")
def populate(self, store: Union["FastInsertListStore", None]) -> None:
self.local_frame.set_model(store)
if store is None:
self.local_frame.start_empty()
else:
self.local_frame.hide_errors()
# NOTE: suppress custom tree until explicitly loaded
self.custom_frame.hide_all()
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:
# TODO: grab all values in one pass
# appid = self.radio_frame.get_appid()
# mission = self.mission_frame.get_mission()
# local_mods = self.local_frame.get_mods()
# custom_mods = self.custom_frame.get_mods()
# cf. api.mods._hash(uid, use_custom=True)
# TODO: set up all symlinks prior to launch, including custom ones
# self.offline_man.setup(appid, mission, local_mods, custom_mods)
pass
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)
+2 -18
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
@@ -190,7 +178,6 @@ class Options(Gtk.Box):
for pair in [
(api_box, strings.options.api_keys),
(prefs_grid, strings.options.prefs),
(mods_grid, strings.options.mods),
(version_grid, strings.options.version),
]:
@@ -416,9 +403,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
+4 -26
View File
@@ -112,18 +112,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 +121,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)
@@ -217,8 +204,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)
@@ -230,10 +215,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)
@@ -317,10 +302,6 @@ class PreConnectionAssistant(Gtk.Box):
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)
pretty = number(prereqs.required_space)
suffix = f" Need to download {pretty} MiB of mod updates."
@@ -333,10 +314,7 @@ class PreConnectionAssistant(Gtk.Box):
self.connect_last.hide()
self._process_warnings(prereqs)
if self.tree.is_visible():
self.tree.grab_focus()
else:
self.grab_focus()
self.tree.grab_focus()
def mark_finished(self) -> None:
self.mod_count.set_label(preconnect.all_updated)
+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):
+4 -2
View File
@@ -23,8 +23,10 @@ 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)