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 - Disable overlay scrollbars on server tables
- Reduce size of geolocation DB on disk (~100MB) - Reduce size of geolocation DB on disk (~100MB)
- Enable LAN page Empty/Full filters on first run of app - Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client
## Dropped ## Dropped
- Debug mode - 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 ( from dzgui.const.constants import (
APP_NAME, APP_NAME,
APPID_DAYZ, APPID_DAYZ,
DAYZ_COMMUNITY_ROOT,
LIBRARYFOLDERS_PATH, LIBRARYFOLDERS_PATH,
WORKSHOP_PATH, WORKSHOP_PATH,
) )
@@ -50,6 +51,9 @@ def get_local_mods(workshop_path: Path) -> list[Path]:
def is_mission(path: Path) -> bool: def is_mission(path: Path) -> bool:
# TODO: parse integrity of other files # TODO: parse integrity of other files
parent = path.parent.name
if parent != DAYZ_COMMUNITY_ROOT:
return False
file = path / "init.c" file = path / "init.c"
return file.exists() return file.exists()
@@ -81,6 +85,7 @@ def get_mod_size(path: Path) -> float:
def get_custom_mods(path: Path) -> list[Any]: def get_custom_mods(path: Path) -> list[Any]:
# TODO: rename this function for clarity ("get local vs. get custom")
mods = get_local_mods(path) mods = get_local_mods(path)
# TODO: error handling # TODO: error handling
return parse_mods(mods, use_custom=True) return parse_mods(mods, use_custom=True)
+51 -25
View File
@@ -3,20 +3,25 @@ import logging
import os import os
import requests import requests
import subprocess import subprocess
from typing import Union
from warnings import deprecated
from shlex import shlex from shlex import shlex
from pathlib import Path from pathlib import Path
from dzgui.init.prereqs import has_steam_client from dzgui.init.prereqs import has_steam_client
from dzgui.api.mods import _hash
from dzgui.const.constants import ( from dzgui.const.constants import (
APPID_DAYZ,
APP_NAME, APP_NAME,
DEBIAN_STEAM_PATH, DEBIAN_STEAM_PATH,
DEFAULT_STEAM_PATH, DEFAULT_STEAM_PATH,
FLATPAK_STEAM_PATH, FLATPAK_STEAM_PATH,
UBUNTU_STEAM_PATH, UBUNTU_STEAM_PATH,
REQUEST_TIMEOUT,
VDF_PATH, 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.strings import wizard
from dzgui.util.bash import concat_bash_args 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: def concat_mods(mods: list[str]) -> str:
from dzgui.util.symlink import _hash
hashes = [] hashes = []
for mod in mods: for mod in mods:
md5sum = _hash(mod) md5sum = _hash(mod)
@@ -68,31 +71,21 @@ def get_local_signatures(version_file: Path) -> dict[str, int]:
for line in lines: for line in lines:
data = line.split(",") data = line.split(",")
_id = data[0] _id = data[0]
_hash = int(data[1]) mod_hash = int(data[1])
hashes[_id] = _hash hashes[_id] = mod_hash
return hashes 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( def get_needs_update(
version_file: Path, remote_hashes: list[tuple[str, str, int, int]] version_file: Path, remote_hashes: list[tuple[str, str, int, int]]
) -> 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]] = [] needs_update: list[tuple[str, str, int, int]] = []
for title, _id, _hash, size in remote_hashes: for title, _id, stamp, size in remote_hashes:
if _id not in local_hashes: if _id not in local_stamps:
needs_update.append((title, _id, _hash, size)) needs_update.append((title, _id, stamp, size))
elif _hash != local_hashes[_id]: elif stamp != local_stamps[_id]:
needs_update.append((title, _id, _hash, size)) needs_update.append((title, _id, stamp, size))
else: else:
continue continue
return needs_update 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]) proc = subprocess.run([*client_args, *params])
return proc.returncode 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""" """Launch offline with specific mods/missions"""
concat = concat_mods(mods) symlinks = ";".join(mods)
client_args = concat_bash_args(client) client_args = concat_bash_args(client)
params = [ params = [
"-applaunch", "-applaunch",
@@ -173,12 +169,13 @@ def launch_offline(client: str, appid: int, name: str, mods: list[str], mission:
"-nosplash", "-nosplash",
"-skipintro", "-skipintro",
f"-name={name}", f"-name={name}",
f"-mod={concat}", f"-mod={symlinks}",
f"-mission={mission}" f"-mission={mission}",
] ]
proc = subprocess.run([*client_args, *params]) proc = subprocess.run([*client_args, *params])
return proc.returncode return proc.returncode
def find_user_id(path: Path) -> str | None: def find_user_id(path: Path) -> str | None:
resolved_path = path / "config" / "loginusers.vdf" resolved_path = path / "config" / "loginusers.vdf"
try: try:
@@ -233,6 +230,29 @@ def vdf2json(path: Path) -> str:
jbuf += "\n" 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: def gen_shortcut() -> None:
# TODO: # TODO:
""" """
@@ -245,3 +265,9 @@ def gen_shortcut() -> None:
# or get right-most 32 bits # or get right-most 32 bits
# STEAMID_64 & 0xFFFFFFFF # STEAMID_64 & 0xFFFFFFFF
pass 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" VM_FILE = "/proc/sys/vm/max_map_count"
MIN_COUNT = 1048576 MIN_COUNT = 1048576
RATE_LIMIT_THRESHOLD = 3
REQUEST_TIMEOUT = 10 REQUEST_TIMEOUT = 10
APPNAME_DAYZ = "DayZ" APPNAME_DAYZ = "DayZ"
@@ -32,10 +33,12 @@ HEX_GREEN = "#32CD32"
HEX_RED = "#FF0000" HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C" HEX_ORANGE = "#FFAC1C"
# FIXME: normalize icon names
CARET_DOWN = "go-down-symbolic" CARET_DOWN = "go-down-symbolic"
CARET_UP = "go-up-symbolic" CARET_UP = "go-up-symbolic"
CLIPBOARD = "edit-copy-symbolic" CLIPBOARD = "edit-copy-symbolic"
FOLDER = "folder-symbolic" FOLDER = "folder-symbolic"
EDIT_DELETE = "edit-delete-symbolic"
ERROR = "dialog-error-symbolic" ERROR = "dialog-error-symbolic"
HELP_BUBBLE = "help-about-symbolic" HELP_BUBBLE = "help-about-symbolic"
INPUT_KEYBOARD = "input-keyboard-symbolic" INPUT_KEYBOARD = "input-keyboard-symbolic"
@@ -81,3 +84,5 @@ LOG_FILTERS = ("CRITICAL", "WARNING", "INFO", "DEBUG")
TMP_PATH = "/tmp" TMP_PATH = "/tmp"
TMP_TARBALL = "/tmp/dzgui.tar.gz" TMP_TARBALL = "/tmp/dzgui.tar.gz"
TMP_EXE = "/tmp/dzgui/dzgui" TMP_EXE = "/tmp/dzgui/dzgui"
DAYZ_COMMUNITY_ROOT = "Missions"
+7 -2
View File
@@ -1,6 +1,11 @@
# internal # internal
STEAM_PUBLISHED_FILES = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/?format=json" STEAM_PUBLISHED_FILES = (
STEAM_SERVERS = "https://api.steampowered.com/IGameServersService/GetServerList/v1/?" "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?" BM_SERVERS = "https://api.battlemetrics.com/servers?"
GITHUB = "https://github.com/aclist" GITHUB = "https://github.com/aclist"
GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest" GITHUB_RELEASES = "https://api.github.com/repos/aclist/dztui/releases/latest"
+6 -5
View File
@@ -168,7 +168,7 @@ class ContextMenu(EnumWithAttrs):
COPY_LOG_CLIPBOARD = {"label": strings.copy_log} COPY_LOG_CLIPBOARD = {"label": strings.copy_log}
COPY_SERVER_IP = {"label": strings.copy_ip} COPY_SERVER_IP = {"label": strings.copy_ip}
COPY_SERVER_NAME = {"label": strings.copy_name} 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} OPEN_WORKSHOP = {"label": strings.open_workshop}
REFRESH_PLAYERS = {"label": strings.refresh_players} REFRESH_PLAYERS = {"label": strings.refresh_players}
REMOVE_HISTORY = {"label": strings.remove_history} REMOVE_HISTORY = {"label": strings.remove_history}
@@ -184,7 +184,8 @@ class ContextMenuGroup(Enum):
""" """
SERVER_MOD = (ContextMenu.OPEN_WORKSHOP,) 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,) LOG = (ContextMenu.COPY_LOG_CLIPBOARD,)
SERVER_BROWSER = ( SERVER_BROWSER = (
ContextMenu.CONNECT, ContextMenu.CONNECT,
@@ -249,9 +250,9 @@ class ModButton(EnumWithAttrs):
"label": strings.mod_panel.unhighlight_stale, "label": strings.mod_panel.unhighlight_stale,
"tooltip": strings.mod_panel.unhighlight_stale_tooltip, "tooltip": strings.mod_panel.unhighlight_stale_tooltip,
} }
DELETE_SELECTED = { UNSUB_SELECTED = {
"label": strings.mod_panel.delete_selected, "label": strings.mod_panel.unsub_selected,
"tooltip": strings.mod_panel.delete_selected_tooltip, "tooltip": strings.mod_panel.unsub_selected_tooltip,
} }
SELECT_STALE = { SELECT_STALE = {
"label": strings.mod_panel.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: if TYPE_CHECKING:
from dzgui.const.enum import NotebookPage, ServerTab from dzgui.const.enum import NotebookPage, ServerTab
from dzgui.model.model_factory import FastInsertListStore 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 # 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: def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None:
pass 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)) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, bool))
def custom_mission_loaded(self, folder: str, is_valid: bool) -> None: def custom_mission_loaded(self, folder: str, is_valid: bool) -> None:
pass 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 = self.mediator.modtreeview.get_mod_man()
mod_man.toggle_mod_selection(state) mod_man.toggle_mod_selection(state)
def delete_mods( def unsub_mods(
self, treeview: Union["ModTreeView", "OfflineModTreeView", None] = None self, treeview: Union["ModTreeView", "OfflineModTreeView", None] = None
) -> None: ) -> None:
if treeview is None: if treeview is None:
@@ -241,7 +241,7 @@ class Controller(GObject.GObject):
else: else:
view = treeview view = treeview
mod_man = view.get_mod_man() mod_man = view.get_mod_man()
mod_man.delete_mods() mod_man.unsub_mods()
def get_mod_store(self) -> Gtk.TreeModel | None: def get_mod_store(self) -> Gtk.TreeModel | None:
return self.mediator.modtreeview.get_model() return self.mediator.modtreeview.get_model()
@@ -523,11 +523,11 @@ class Controller(GObject.GObject):
ind = self.config_man.get_start_tab() ind = self.config_man.get_start_tab()
self.get_servers().notebook.set_current_page(ind) self.get_servers().notebook.set_current_page(ind)
def update_and_load_to_menu(self, raise_window: bool) -> None: def update_and_load_to_menu(self) -> None:
self.connection_man.update_and_connect(raise_window, menu_only=True) self.connection_man.update_and_connect(menu_only=True)
def update_and_connect(self, raise_window: bool) -> None: def update_and_connect(self) -> None:
self.connection_man.update_and_connect(raise_window) self.connection_man.update_and_connect()
def update_status(self) -> None: def update_status(self) -> None:
self.mediator.preconnect.mark_finished() self.mediator.preconnect.mark_finished()
+2
View File
@@ -40,6 +40,7 @@
- Preboot progress dialog - Preboot progress dialog
- Choose to jump into splash screen instead of server - Choose to jump into splash screen instead of server
- Collapsible connection panel - Collapsible connection panel
- Play offline (load mods directly)
## Changed ## Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
@@ -56,6 +57,7 @@
- Disable overlay scrollbars on server tables - Disable overlay scrollbars on server tables
- Reduce size of geolocation DB on disk (~100MB) - Reduce size of geolocation DB on disk (~100MB)
- Enable LAN page Empty/Full filters on first run of app - Enable LAN page Empty/Full filters on first run of app
- Propagate subscribed mods to Steam client
## Dropped ## Dropped
- Debug mode - Debug mode
+14 -14
View File
@@ -12,10 +12,10 @@ import dzgui.api.servers as Servers
from dzgui.api.steam import ( from dzgui.api.steam import (
connect, connect,
enqueue_mod,
get_remote_signatures, get_remote_signatures,
get_needs_update, get_needs_update,
load_to_menu, load_to_menu,
subscribe,
) )
from dzgui.api.mods import ( from dzgui.api.mods import (
@@ -30,6 +30,7 @@ from dzgui.const.constants import (
APPID_DAYZ_EXP, APPID_DAYZ_EXP,
APPNAME_DAYZ, APPNAME_DAYZ,
APPNAME_DAYZ_EXP_HUMAN, APPNAME_DAYZ_EXP_HUMAN,
RATE_LIMIT_THRESHOLD,
) )
from dzgui.const.enum import NotebookPage, Preferences from dzgui.const.enum import NotebookPage, Preferences
from dzgui.init.proc import is_dayz_running, is_steam_running from dzgui.init.proc import is_dayz_running, is_steam_running
@@ -88,6 +89,7 @@ class ConnectionManager:
self.record: Record self.record: Record
self.workshop: Path self.workshop: Path
self.client: str
self.remote_mod_ids: list[str] = [] self.remote_mod_ids: list[str] = []
self.missing_mods: list[tuple[str, str, int, int]] = [] self.missing_mods: list[tuple[str, str, int, int]] = []
@@ -171,6 +173,7 @@ class ConnectionManager:
client = self.controller.query_config(Preferences.CLIENT) client = self.controller.query_config(Preferences.CLIENT)
running = is_steam_running(client) running = is_steam_running(client)
steam_proc = SteamProcess(client_name, running) steam_proc = SteamProcess(client_name, running)
self.client = client
game_mode = prefs.is_game_mode game_mode = prefs.is_game_mode
@@ -263,11 +266,10 @@ class ConnectionManager:
def _connect_steam(self, menu_only: bool) -> None: def _connect_steam(self, menu_only: bool) -> None:
addr = f"{self.record.ip}:{self.record.gameport}" addr = f"{self.record.ip}:{self.record.gameport}"
playername = self.controller.query_config(Preferences.NAME) playername = self.controller.query_config(Preferences.NAME)
client = self.controller.query_config(Preferences.CLIENT)
if menu_only: 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: 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: if rc != 0:
# TODO: log/pop the error # TODO: log/pop the error
func = StoredFunc(self.controller.update_status) func = StoredFunc(self.controller.update_status)
@@ -277,6 +279,7 @@ class ConnectionManager:
self.thread_man.show_cancel(False) self.thread_man.show_cancel(False)
self.thread_man.update_dialog(waiting_for_launch) self.thread_man.update_dialog(waiting_for_launch)
while True: while True:
# FIXME: cancel should not be visible here per setting above
if self.controller.is_cancel_pending(): if self.controller.is_cancel_pending():
# TODO: some facility to also close spawned steam process # TODO: some facility to also close spawned steam process
return return
@@ -291,19 +294,16 @@ class ConnectionManager:
self.controller.add_to_history(self.history, self.record) self.controller.add_to_history(self.history, self.record)
self.controller.open_page(NotebookPage.SERVERS) self.controller.open_page(NotebookPage.SERVERS)
def _update_mods(self, raise_window: bool, menu_only: bool = False) -> None: def _update_mods(self, menu_only: bool = False) -> None:
# NOTE: fast enqueue all mods in auto mode
prefs = self.controller.get_prefs() 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: for title, mod, stamp, size in self.missing_mods:
if self.controller.is_cancel_pending(): if self.controller.is_cancel_pending():
return return
enqueue_mod(mod, self.appid) subscribe(key, int(mod))
time.sleep(3) time.sleep(RATE_LIMIT_THRESHOLD)
if raise_window is True:
logger.info("Bringing window to foreground")
GLib.idle_add(self.controller.present_window)
for title, mod, stamp, size in self.missing_mods: for title, mod, stamp, size in self.missing_mods:
mod_path = self.workshop / mod mod_path = self.workshop / mod
@@ -327,8 +327,8 @@ class ConnectionManager:
self._connect_steam(menu_only) self._connect_steam(menu_only)
@call_on_thread(waiting_for_mods, show_cancel=True) @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: if len(self.missing_mods) > 0:
self._update_mods(raise_window, menu_only) self._update_mods(menu_only)
else: else:
self._connect_steam(menu_only) self._connect_steam(menu_only)
+2 -2
View File
@@ -80,8 +80,8 @@ class ContextMenuManager:
if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)): if isinstance(self.treeview, (ModTreeView, OfflineModTreeView)):
match action: match action:
case ContextMenu.DELETE_MOD: case ContextMenu.UNSUB_MOD:
self.controller.delete_mods(self.treeview) self.controller.unsub_mods(self.treeview)
case ContextMenu.OPEN_WORKSHOP: case ContextMenu.OPEN_WORKSHOP:
self.open_mod_page() self.open_mod_page()
+2 -1
View File
@@ -89,8 +89,9 @@ class FilterManager:
continue continue
self.append_map([m]) self.append_map([m])
# TODO: currently unused
def get_unique_maps(self) -> list[str]: 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: def get_all_filters(self) -> tuple:
map_name = self.get_active_map_name() map_name = self.get_active_map_name()
+29 -16
View File
@@ -1,17 +1,22 @@
import logging import logging
import shutil import time
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from dzgui.api.steam import unsubscribe
from dzgui.api.mods import ( from dzgui.api.mods import (
get_delimited_mods, get_delimited_mods,
get_local_mod_path,
find_stale_mods, find_stale_mods,
_hash, _hash,
remove_stale_signatures, 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.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import FastInsertListStore, ModelFactory from dzgui.model.model_factory import FastInsertListStore, ModelFactory
@@ -66,6 +71,9 @@ class ModManager:
func = StoredFunc(self._on_mods_loaded) func = StoredFunc(self._on_mods_loaded)
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
def set_store(self, store: "FastInsertListStore") -> None:
self.store = store
def _on_mods_loaded(self) -> None: def _on_mods_loaded(self) -> None:
self.treeview.set_model(self.store) self.treeview.set_model(self.store)
if self.store is None: if self.store is None:
@@ -74,7 +82,7 @@ class ModManager:
total_mods = len(self.store) total_mods = len(self.store)
self.emitter.emit("mods_updated", msg, total_mods) self.emitter.emit("mods_updated", msg, total_mods)
def delete_mods(self) -> None: def unsub_mods(self) -> None:
sel = self.treeview.get_selection() sel = self.treeview.get_selection()
model, pathlist = sel.get_selected_rows() model, pathlist = sel.get_selected_rows()
# NOTE: reverse when multiple selection # NOTE: reverse when multiple selection
@@ -85,7 +93,7 @@ class ModManager:
continue continue
mod, _iter = res mod, _iter = res
mods.append((mod, _iter)) mods.append((mod, _iter))
self.delete_mods_on_system(mods) self.unsub_all_mods(mods)
def get_mod_from_tree_path( def get_mod_from_tree_path(
self, tree_path: Gtk.TreePath self, tree_path: Gtk.TreePath
@@ -98,24 +106,28 @@ class ModManager:
return mod, tree_iter return mod, tree_iter
@call_on_thread(dialogs.deleting_mods) @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: for mod, _iter in mods:
self.delete_single_mod(mod) self.unsub_atomic_mod(mod)
iters = [_iter for mod, _iter in mods] 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) 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) steam_path = Path(self.path)
mods_path = get_local_mod_path(steam_path)
app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) app_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
md5 = _hash(mod) try:
symlink = app_path / md5 md5 = _hash(mod)
symlink.unlink() symlink = app_path / md5
shutil.rmtree(mods_path / mod) symlink.unlink()
except Exception as e:
logger.warning(e)
# NOTE: second pass to unlink DAYZ_EXP mods # NOTE: second pass to unlink DAYZ_EXP mods
# TODO: test this with working APPID_DAYZ_EXP installation # TODO: test this with working APPID_DAYZ_EXP installation
try: try:
@@ -124,8 +136,9 @@ class ModManager:
symlink.unlink() symlink.unlink()
except PeFile.AppNotInstalledError: except PeFile.AppNotInstalledError:
pass 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: if self.store is None:
return return
for _iter in iters: for _iter in iters:
+71 -38
View File
@@ -1,17 +1,25 @@
import logging
import time
from pathlib import Path 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.mods import is_mission, get_custom_mods
from dzgui.api.steam import launch_offline 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.const.enum import Preferences
from dzgui.init.proc import is_dayz_running
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
from dzgui.strings import dialogs 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 from dzgui.views.dialogs.filepicker import FolderPicker
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
from dzgui.model.model_factory import FastInsertListStore
logger = logging.getLogger(APP_NAME)
class OfflineManager: class OfflineManager:
@@ -26,74 +34,99 @@ class OfflineManager:
self.thread_man = ThreadingManager(controller) self.thread_man = ThreadingManager(controller)
self.appid: int self.appid: int
self.mission_folder: str
self.local_mods: list[str] | None self.local_mods: list[str] | None
self.custom_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: def get_mission(self) -> None:
folder = self.open_folderpicker(dialogs.mission_dialog) folder = self.open_folderpicker(dialogs.mission_dialog)
if folder is None: if folder is None:
return return
is_valid = is_mission(folder) 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) 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) folder = self.open_folderpicker(dialogs.custom_mod_dialog)
window = self.controller.get_window()
window.set_sensitive(False)
if folder is None: if folder is None:
window.set_sensitive(True)
return 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) @call_on_thread(dialogs.parsing_mods, show_dialog=False)
def parse_custom_mods(self, local_mods: list[str], folder: str) -> None: def parse_custom_mods(self, folder: str) -> None:
mods = get_custom_mods(Path(folder)) mods = get_custom_mods(Path(folder))
store = ModelFactory().make_mod_store() store = ModelFactory().make_mod_store()
store.extend(mods) store.extend(mods)
func = StoredFunc(self.post_mod_loading, store, folder)
func = StoredFunc(
lambda: self.emitter.emit("custom_mods_loaded", store, folder)
)
self.thread_man.set_cleanup_func(func) self.thread_man.set_cleanup_func(func)
def setup( def post_mod_loading(self, store: "FastInsertListStore", folder: str) -> None:
self, window = self.controller.get_window()
appid: int, window.set_sensitive(True)
mission: str = "", self.emitter.emit("custom_mods_loaded", store, folder)
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()
@call_on_thread(dialogs.waiting_for_launch) @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) client = self.controller.query_config(Preferences.CLIENT)
name = self.controller.query_config(Preferences.NAME) name = self.controller.query_config(Preferences.NAME)
steam_path = self.controller.query_config(Preferences.DEFAULT) steam_path = self.controller.query_config(Preferences.DEFAULT)
new_symlinks: list[str] = []
combined_mods: 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: if len(local_mods) > 0:
# TODO: new function that creates symlinks in game path combined_mods.extend(local_mods)
# based on selected mods
clone_symlinks(Path(steam_path)) if len(custom_mods) > 0:
new_symlinks = create_custom_symlinks(
Path(steam_path), Path(custom_folder), custom_mods
)
combined_mods.extend(new_symlinks) 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]: def open_folderpicker(self, title: str) -> Union["Path", None]:
picker = FolderPicker(self.controller.get_window(), title) picker = FolderPicker(self.controller.get_window(), title)
folder = picker.pick_folder() folder = picker.pick_folder()
picker.destroy() picker.destroy()
return folder 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() proxy = self._get_proxy_man().get_proxy_model()
self.tv.set_model(proxy) 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) self.emitter.emit("servers_loaded", self.enum)
filter_man = self.tv.get_filter_man() 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." load_error_lan = "Failed to find any servers on your network.\nCheck the server query port or your firewall settings."
fetching_mods = "Fetching mod metadata" fetching_mods = "Fetching mod metadata"
deleting_mods = "Deleting mods" deleting_mods = "Unsubscribing mods"
scanning_mods = "Scanning mods" scanning_mods = "Scanning mods"
parsing_mods = "Parsing mods" parsing_mods = "Parsing mods"
+8
View File
@@ -10,5 +10,13 @@ custom_button = "Set custom mod folder"
local_frame = "Installed mods" local_frame = "Installed mods"
no_local_mods = "No local mods found"
no_mods = "No valid mods found" no_mods = "No valid mods found"
no_mission = "Not a valid mission" 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" show_details = "Show server details"
refresh_players = "Refresh player count" refresh_players = "Refresh player count"
open_workshop = "Open in Steam Workshop" open_workshop = "Open in Steam Workshop"
delete_mod = "Delete mod" unsub_mod = "Unsubscribe mod"
copy_name = "Copy name to clipboard" copy_name = "Copy name to clipboard"
copy_ip = "Copy IP to clipboard" copy_ip = "Copy IP to clipboard"
copy_log = "Copy record(s) to clipboard" copy_log = "Copy record(s) to clipboard"
@@ -215,8 +215,8 @@ class ModPanelStrings:
unhighlight_stale_tooltip: str unhighlight_stale_tooltip: str
highlight_stale: str highlight_stale: str
highlight_stale_tooltip: str highlight_stale_tooltip: str
delete_selected: str unsub_selected: str
delete_selected_tooltip: str unsub_selected_tooltip: str
unselect_all: str unselect_all: str
unselect_all_tooltip: str unselect_all_tooltip: str
select_all: str select_all: str
@@ -337,8 +337,8 @@ mod_panel = ModPanelStrings(
"Shows locally-installed mods which are not used by any server " "Shows locally-installed mods which are not used by any server "
"in your Saved Servers" "in your Saved Servers"
), ),
delete_selected="Delete selected", unsub_selected="Unsubscribe selected",
delete_selected_tooltip="Deletes selected mods from the system", unsub_selected_tooltip="Unsubscribes from selected mods",
unselect_all="Unselect all", unselect_all="Unselect all",
unselect_all_tooltip="Bulk unselects all mods", unselect_all_tooltip="Bulk unselects all mods",
select_all="Select all", select_all="Select all",
@@ -367,6 +367,7 @@ thanks = Thanks(
"Johnofwrong", "Johnofwrong",
"MatheusLasserr", "MatheusLasserr",
"nolan-perez", "nolan-perez",
"OnniSaarni",
"scandalouss", "scandalouss",
"SnackSBR", "SnackSBR",
"StevelDusa", "StevelDusa",
@@ -463,7 +464,7 @@ crumbs = Crumbs(
thanks="Help > Special thanks", thanks="Help > Special thanks",
developers="Options > Developers", developers="Options > Developers",
default="Servers > ", default="Servers > ",
offline="Mods > Play offline" offline="Mods > Play offline",
) )
no_mods = "No local mods found." no_mods = "No local mods found."
+44 -4
View File
@@ -2,7 +2,7 @@ import logging
from pathlib import Path from pathlib import Path
from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash from dzgui.api.mods import get_local_mod_path, get_local_mod_ids, _hash
from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME from dzgui.const.constants import APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, DAYZ_COMMUNITY_ROOT
from dzgui.const.enum import Preferences from dzgui.const.enum import Preferences
from dzgui.config.query import lookup from dzgui.config.query import lookup
@@ -11,14 +11,23 @@ import dzgui.api.pefile as PeFile
logger = logging.getLogger(APP_NAME) 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: def rebuild_symlinks(config: Path) -> None:
# TODO: pass direct path as argument
path = lookup(config, Preferences.DEFAULT) path = lookup(config, Preferences.DEFAULT)
steam_path = Path(path) steam_path = Path(path)
dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ) dayz_path = PeFile.get_nested_app_path(steam_path, APPID_DAYZ)
# NOTE: unlink stale symlinks
for file in dayz_path.iterdir(): for file in dayz_path.iterdir():
if file.is_symlink() and file.exists() is False: expunge_link(file)
file.unlink()
workshop = get_local_mod_path(steam_path) workshop = get_local_mod_path(steam_path)
# NOTE: create symlinks for missing mods # NOTE: create symlinks for missing mods
for mod_id in get_local_mod_ids(steam_path): for mod_id in get_local_mod_ids(steam_path):
@@ -30,6 +39,37 @@ def rebuild_symlinks(config: Path) -> None:
clone_symlinks(steam_path) 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: def clone_symlinks(steam_path: Path) -> None:
""" """
Shares symlinks between builds. Used after any symlink operation Shares symlinks between builds. Used after any symlink operation
-1
View File
@@ -70,7 +70,6 @@ class OuterWindow(Gtk.Window):
MainController.set_start_tab() MainController.set_start_tab()
self.grid.hide_widgets_on_init() self.grid.hide_widgets_on_init()
# TODO: POC, trigger page change here
MainController.loaded = True MainController.loaded = True
MainController.populate_model(MainController.get_active_treeview()) 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: def __init__(self) -> None:
super().__init__(label=buttons.workshop) super().__init__(label=buttons.workshop)
self.set_tooltip_text(buttons.workshop_tooltip) self.set_tooltip_text(buttons.workshop_tooltip)
self.set_margin_top(10)
self.set_margin_bottom(10)
class AddButton(IconTextButton): 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 from gi.repository import Gtk, Gdk # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller, Emitter from dzgui.controllers.mc import Controller
from dzgui.controllers.emitter import Emitter
COLS = 1 COLS = 1
ROWS = 1 ROWS = 1
+5 -4
View File
@@ -11,7 +11,8 @@ gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk # noqa E402
if TYPE_CHECKING: 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): class EnumeratedModButton(Gtk.Button):
@@ -48,7 +49,7 @@ class ModSelectionPanel(Gtk.Box):
buttons = ( buttons = (
ModButton.SELECT_ALL, ModButton.SELECT_ALL,
ModButton.UNSELECT_ALL, ModButton.UNSELECT_ALL,
ModButton.DELETE_SELECTED, ModButton.UNSUB_SELECTED,
) )
for button in buttons: for button in buttons:
b = EnumeratedModButton(button) b = EnumeratedModButton(button)
@@ -100,8 +101,8 @@ class ModSelectionPanel(Gtk.Box):
self.controller.toggle_mod_selection(True) self.controller.toggle_mod_selection(True)
case ModButton.UNSELECT_ALL: case ModButton.UNSELECT_ALL:
self.controller.toggle_mod_selection(False) self.controller.toggle_mod_selection(False)
case ModButton.DELETE_SELECTED: case ModButton.UNSUB_SELECTED:
self.controller.delete_mods() self.controller.unsub_mods()
case ModButton.HIGHLIGHT_STALE: case ModButton.HIGHLIGHT_STALE:
self.controller.highlight_stale() self.controller.highlight_stale()
case ModButton.UNHIGHLIGHT_STALE: case ModButton.UNHIGHLIGHT_STALE:
+12 -1
View File
@@ -25,6 +25,8 @@ class Statusbar(Gtk.Grid):
self.controller.register_widget("statusbar", self) self.controller.register_widget("statusbar", self)
self.emitter = controller.get_emitter() self.emitter = controller.get_emitter()
self.prior_enum: NotebookPage
self.prior_status: str
self.playercount = "" self.playercount = ""
self.statusbar = Gtk.Statusbar() self.statusbar = Gtk.Statusbar()
@@ -71,6 +73,7 @@ class Statusbar(Gtk.Grid):
NotebookPage.CONNECTION, NotebookPage.CONNECTION,
NotebookPage.OFFLINE, NotebookPage.OFFLINE,
): ):
self.prior_enum = enum
self.set_by_context(enum, esc_to_return) self.set_by_context(enum, esc_to_return)
return return
@@ -80,8 +83,9 @@ class Statusbar(Gtk.Grid):
case NotebookPage.KEYS: case NotebookPage.KEYS:
bar = question_to_return bar = question_to_return
case _: case _:
return bar = self.prior_status
self.prior_enum = enum
self.set_by_context(enum, bar) self.set_by_context(enum, bar)
def _on_server_row_changed(self, statusbar: Self) -> None: def _on_server_row_changed(self, statusbar: Self) -> None:
@@ -97,8 +101,13 @@ class Statusbar(Gtk.Grid):
context: Union["ServerTab", NotebookPage], context: Union["ServerTab", NotebookPage],
) -> None: ) -> None:
self.spinner.stop() 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 # FIXME: CalcDist is being called when table is not loaded
if dist is None: if dist is None:
self.set_by_context(context, "")
return return
else: else:
pretty = self.append_distance(dist) pretty = self.append_distance(dist)
@@ -121,6 +130,8 @@ class Statusbar(Gtk.Grid):
def set_by_context( def set_by_context(
self, context: Union[NotebookPage, "ServerTab"], string: str self, context: Union[NotebookPage, "ServerTab"], string: str
) -> None: ) -> None:
if context != NotebookPage.KEYS:
self.prior_status = string
meta = self.statusbar.get_context_id(str(context)) meta = self.statusbar.get_context_id(str(context))
self.statusbar.push(meta, string) self.statusbar.push(meta, string)
self.set_cache(string) self.set_cache(string)
+11 -3
View File
@@ -54,7 +54,15 @@ class FolderPicker(Gtk.FileChooserDialog):
return None return None
if res == Gtk.ResponseType.OK: if res == Gtk.ResponseType.OK:
folder = self.get_current_folder() folder = self.get_current_folder()
if folder is not None: if folder is None:
self.destroy() try:
return Path(folder) 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 return None
+30 -1
View File
@@ -1,4 +1,10 @@
from pathlib import Path
from typing import Self, TYPE_CHECKING 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.components.scrollable import NoOverlayScrolledWindow
from dzgui.views.trees.tree_mods import ModTreeView from dzgui.views.trees.tree_mods import ModTreeView
@@ -20,10 +26,22 @@ class Mods(Gtk.Box):
self.tree.set_vexpand(True) self.tree.set_vexpand(True)
self.box.add(self.tree) self.box.add(self.tree)
self.statusbar_cache = ""
self.controller = controller self.controller = controller
self.controller.register_widget("modtreeview", self.tree) self.controller.register_widget("modtreeview", self.tree)
self.emitter = controller.get_emitter() 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( self.offline_button = Gtk.Button(
label="Play offline", label="Play offline",
halign=Gtk.Align.START, halign=Gtk.Align.START,
@@ -32,13 +50,17 @@ class Mods(Gtk.Box):
) )
self.offline_button.connect("clicked", self._on_offline_clicked) 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.add(self.box)
self.connect("map", self._on_map) self.connect("map", self._on_map)
self.connect("unmap", self._on_unmap) self.connect("unmap", self._on_unmap)
def _on_offline_clicked(self, button: Gtk.Button) -> None: def _on_offline_clicked(self, button: Gtk.Button) -> None:
self.statusbar_cache = self.controller.get_statusbar().get_cache()
self.controller.open_offline() self.controller.open_offline()
def _on_unmap(self, widget: Self) -> None: def _on_unmap(self, widget: Self) -> None:
@@ -46,6 +68,13 @@ class Mods(Gtk.Box):
def _on_map(self, widget: Self) -> None: def _on_map(self, widget: Self) -> None:
self.emitter.emit("mod_page_toggled", True) 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: def grab_content_area(self) -> None:
self.tree.grab_focus() self.tree.grab_focus()
+222 -101
View File
@@ -1,20 +1,24 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from enum import Enum
from typing import Self, Sequence, TYPE_CHECKING, Union from typing import Self, TYPE_CHECKING, Union
from dzgui.util import css from dzgui.util import css
import dzgui.api.pefile as PeFile
from dzgui.const.constants import ( from dzgui.const.constants import (
APPID_DAYZ, APPID_DAYZ,
APPID_DAYZ_EXP, APPID_DAYZ_EXP,
APPNAME_DAYZ, APPNAME_DAYZ,
APPNAME_DAYZ_EXP_HUMAN, APPNAME_DAYZ_EXP_HUMAN,
EDIT_DELETE,
ERROR,
FOLDER, FOLDER,
WARNING,
) )
from dzgui.const.enum import NotebookPage, Preferences from dzgui.const.enum import ContextMenuGroup, NotebookPage
from dzgui.managers.offline import OfflineManager from dzgui.managers.offline import OfflineManager
from dzgui.strings import generic, offline 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.frame import HeadingFrame
from dzgui.views.components.scrollable import NoOverlayScrolledWindow from dzgui.views.components.scrollable import NoOverlayScrolledWindow
from dzgui.views.trees.tree_mods import OfflineModTreeView from dzgui.views.trees.tree_mods import OfflineModTreeView
@@ -23,7 +27,7 @@ from dzgui.views.trees.tree_mods import OfflineModTreeView
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, Gdk # noqa from gi.repository import Gtk, Gdk, GObject # noqa
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
@@ -31,23 +35,10 @@ if TYPE_CHECKING:
from dzgui.model.model_factory import FastInsertListStore from dzgui.model.model_factory import FastInsertListStore
class GenericBox(Gtk.Box): class FolderError(Enum):
def __init__(self, orientation: Gtk.Orientation, spacing: int = 0) -> None: NO_VALID_MODS = 1
super().__init__(orientation=orientation, spacing=spacing) NO_VALID_MISSION = 2
FOLDER_CHANGED = 3
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 PageHeading(Gtk.Label): class PageHeading(Gtk.Label):
@@ -57,37 +48,128 @@ class PageHeading(Gtk.Label):
css.add_class(self, "page-heading") 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): 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) super().__init__(spacing=10)
self.set_margin_start(10) self.set_margin_start(5)
self.set_margin_end(10) self.set_margin_end(10)
self.set_margin_bottom(5) 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 = IconTextButton(FOLDER, btn_label, Gtk.PositionType.LEFT)
self.button.set_halign(Gtk.Align.START) self.button.set_halign(Gtk.Align.START)
self.button.set_image_position(Gtk.PositionType.LEFT) self.button.set_image_position(Gtk.PositionType.LEFT)
self.set_can_focus(True)
self.scrolled_label = Gtk.ScrolledWindow( self.scrolled_label = Gtk.ScrolledWindow(
propagate_natural_width=True, halign=Gtk.Align.START propagate_natural_width=True, halign=Gtk.Align.START
) )
self.label = Gtk.Label() self.label = Gtk.Label()
self.scrolled_label.add(self.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: def get_button(self) -> Gtk.Button:
return self.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: def hide_label(self) -> None:
self.label.hide() 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): class ModFrame(HeadingFrame):
def __init__( def __init__(
@@ -114,45 +196,54 @@ class ModFrame(HeadingFrame):
self.tree_vbox = VBox() self.tree_vbox = VBox()
self.tree_vbox.extend([self.scrolled, self.status]) 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.vbox.pack_end(self.tree_vbox, expand=True, fill=True, padding=3)
self.frame.add(self.vbox) self.frame.add(self.vbox)
sel = self.tree.get_selection() sel = self.tree.get_selection()
sel.connect("changed", self._on_selection_changed) sel.connect("changed", self._on_selection_changed)
# TODO: inherit icons from warnings area in preconnect dialog self.connect("map", self._on_map)
# TODO: strings self.connect("unmap", self._on_unmap)
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()
def start_empty(self) -> None: def start_empty(self) -> None:
self.error_label.show() self.tree_vbox.hide()
self.collapse_tree() self.no_mods.show()
def hide_all(self) -> None: def _on_map(self, widget: Self) -> None:
self.error_label.hide() self.no_mods.hide()
self.collapse_tree() self.tree.set_model(None)
self.tree_vbox.hide()
# TODO: warnings area def _on_unmap(self, widget: Self) -> None:
# name collision within custom mods 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]: def get_mods(self) -> list[str]:
model, treeiters = self.tree.get_selection().get_selected_rows() model, treeiters = self.tree.get_selection().get_selected_rows()
if model is None: if model is None:
return [] 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: def collapse_tree(self) -> None:
self.tree_vbox.hide() self.tree_vbox.hide()
@@ -175,6 +266,8 @@ class ModFrame(HeadingFrame):
else: else:
status = f"Mods selected: {len(rows)}" status = f"Mods selected: {len(rows)}"
self.status.set_label(status) self.status.set_label(status)
# TODO: cleaner delegation
# simply send int value or use emitter
self.parent.check_button() self.parent.check_button()
def set_cursor(self) -> None: def set_cursor(self) -> None:
@@ -189,30 +282,49 @@ class CustomModFrame(ModFrame):
) -> None: ) -> None:
super().__init__(parent, controller, heading) super().__init__(parent, controller, heading)
self.tree.set_menu(ContextMenuGroup.MOD_OFFLINE)
self.parent = parent self.parent = parent
self.controller = controller self.controller = controller
self.emitter = controller.get_emitter() self.emitter = controller.get_emitter()
# TODO: descriptive text here explaining how this area works self.custom_hbox = FolderHBox(
self.custom_hbox = FolderHBox(offline.custom_button) controller, offline.custom_button, offline.custom_eventbox
)
self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked) self.custom_hbox.get_button().connect("clicked", self._on_custom_button_clicked)
self.pack(self.custom_hbox) self.pack(self.custom_hbox)
self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded) 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: 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.custom_hbox.hide_label()
self.tree.set_model(None)
self.tree_vbox.hide() 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: 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.set_model(store)
self.tree_vbox.show() self.tree_vbox.show()
self.hide_errors()
def _on_custom_mods_loaded( def _on_custom_mods_loaded(
self, self,
@@ -220,14 +332,19 @@ class CustomModFrame(ModFrame):
store: "FastInsertListStore", store: "FastInsertListStore",
folder: str, folder: str,
) -> None: ) -> None:
self.custom_hbox.stop_spinner()
if len(store) == 0: if len(store) == 0:
self.hide_tree() self.present_error(folder)
else: else:
self.present_tree(store, folder) self.present_tree(store, folder)
def _on_custom_button_clicked(self, button: Gtk.Button) -> None: def _on_custom_button_clicked(self, button: Gtk.Button) -> None:
local_mods = self.get_mods() callback = self.custom_hbox.start_spinner
self.parent.offline_man.get_custom_mods(local_mods) # 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): class MissionFrame(HeadingFrame):
@@ -238,35 +355,37 @@ class MissionFrame(HeadingFrame):
self.controller = controller self.controller = controller
self.emitter = controller.get_emitter() 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 = self.mission_hbox.get_button()
self.mission_button.connect("clicked", self._on_mission_button_clicked) self.mission_button.connect("clicked", self._on_mission_button_clicked)
self.warning = Gtk.Label( self.frame.add(self.mission_hbox)
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.emitter.connect("custom_mission_loaded", self._on_mission_loaded) self.emitter.connect("custom_mission_loaded", self._on_mission_loaded)
def _on_mission_loaded( def _on_mission_loaded(
self, emitter: "Emitter", folder: str, is_valid: bool self, emitter: "Emitter", folder: str, is_valid: bool
) -> None: ) -> None:
self.mission_hbox.set_label(folder) if is_valid:
if not is_valid: self.mission_hbox.set_folder(folder)
self.warning.show()
self.warning.set_label(offline.no_mission)
else: 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: def _on_mission_button_clicked(self, button: Gtk.Button) -> None:
self.parent.offline_man.get_mission() 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): class RadioFrame(HeadingFrame):
def __init__(self, controller: "Controller") -> None: def __init__(self, parent: OfflineLoader, controller: "Controller") -> None:
super().__init__(heading=offline.version) super().__init__(heading=offline.version)
self.controller = controller self.controller = controller
@@ -286,13 +405,10 @@ class RadioFrame(HeadingFrame):
self.frame.add(self.radio_box) self.frame.add(self.radio_box)
# TODO: abstract out of here # TODO: cleaner delegation
default_steam_path = self.controller.query_config(Preferences.DEFAULT) has_dayz_exp = parent.offline_man.has_dayz_exp()
steam_path = Path(default_steam_path)
dayz_exp = PeFile.get_pretty_version(steam_path, APPID_DAYZ_EXP)
self.dayz.connect("toggled", self._on_radio_toggled) 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) self.dayz_exp.set_sensitive(False)
def _on_radio_toggled(self, radio: Gtk.RadioButton) -> None: def _on_radio_toggled(self, radio: Gtk.RadioButton) -> None:
@@ -315,6 +431,7 @@ class OfflineLoader(Gtk.Box):
self.controller = controller self.controller = controller
self.controller.register_widget("offline_loader", self) self.controller.register_widget("offline_loader", self)
self.emitter = controller.get_emitter()
self.offline_man = OfflineManager(controller) self.offline_man = OfflineManager(controller)
self.add(PageHeading(offline.heading)) self.add(PageHeading(offline.heading))
@@ -324,7 +441,7 @@ class OfflineLoader(Gtk.Box):
self.custom_tree = OfflineModTreeView(controller) self.custom_tree = OfflineModTreeView(controller)
self.mission_frame = MissionFrame(self, controller) self.mission_frame = MissionFrame(self, controller)
self.radio_frame = RadioFrame(controller) self.radio_frame = RadioFrame(self, controller)
self.scrollable = Gtk.ScrolledWindow( self.scrollable = Gtk.ScrolledWindow(
vexpand=True, propagate_natural_height=True vexpand=True, propagate_natural_height=True
@@ -343,12 +460,13 @@ class OfflineLoader(Gtk.Box):
# TODO: share ConnectBox class with preconnect dialog? # TODO: share ConnectBox class with preconnect dialog?
self.button_box = HBox(spacing=5) self.button_box = HBox(spacing=5)
self.button_box.set_halign(Gtk.Align.END) 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.back = Gtk.Button(label="Back")
self.ok = Gtk.Button(label="Launch", sensitive=False) self.ok = Gtk.Button(label="Launch", sensitive=False)
self.back.connect("clicked", self._on_back_clicked) self.back.connect("clicked", self._on_back_clicked)
self.ok.connect("clicked", self._on_ok_clicked) self.ok.connect("clicked", self._on_ok_clicked)
self.connect("key-press-event", self._on_keypress) self.connect("key-press-event", self._on_keypress)
self.emitter.connect("custom_mods_unloaded", lambda _, __: self.check_button())
self.button_box.extend([self.back, self.ok]) self.button_box.extend([self.back, self.ok])
@@ -359,7 +477,8 @@ class OfflineLoader(Gtk.Box):
def check_button(self) -> None: def check_button(self) -> None:
local_mods = self.local_frame.get_mods() local_mods = self.local_frame.get_mods()
custom_mods = self.custom_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) self.ok.set_sensitive(False)
else: else:
self.ok.set_sensitive(True) self.ok.set_sensitive(True)
@@ -369,24 +488,26 @@ class OfflineLoader(Gtk.Box):
self.back.emit("clicked") self.back.emit("clicked")
def populate(self, store: Union["FastInsertListStore", None]) -> None: def populate(self, store: Union["FastInsertListStore", None]) -> None:
self.local_frame.set_model(store)
if store is None: if store is None:
self.local_frame.start_empty() self.local_frame.start_empty()
else: return
self.local_frame.hide_errors() self.local_frame.start(store)
# NOTE: suppress custom tree until explicitly loaded
self.custom_frame.hide_all()
def _on_back_clicked(self, button: Gtk.Button) -> None: def _on_back_clicked(self, button: Gtk.Button) -> None:
self.controller.open_page(NotebookPage.MODS) self.controller.open_page(NotebookPage.MODS)
def _on_ok_clicked(self, button: Gtk.Button) -> None: def _on_ok_clicked(self, button: Gtk.Button) -> None:
# TODO: grab all values in one pass appid = self.radio_frame.get_appid()
# appid = self.radio_frame.get_appid() mission = self.mission_frame.get_mission()
# mission = self.mission_frame.get_mission() local_mods = self.local_frame.get_mods()
# local_mods = self.local_frame.get_mods() custom_folder = self.custom_frame.get_folder()
# custom_mods = self.custom_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 if custom_folder and not self.offline_man.is_custom_folder_valid(custom_folder):
# self.offline_man.setup(appid, mission, local_mods, custom_mods) self.custom_frame.present_folder_changed(mission)
pass 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 typing import TYPE_CHECKING
from dzgui.api import pefile as PeFile from dzgui.api import pefile as PeFile
from dzgui.api.steam import find_user_id
from dzgui.config import query from dzgui.config import query
from dzgui.const.constants import ( from dzgui.const.constants import (
APPID_DAYZ, 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.const.enum import Preferences, ServerTab
from dzgui.strings import errors, options from dzgui.strings import errors, options
from dzgui.util import strings, css, open_links 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.labels import LeftLabel
from dzgui.views.components.eventbox import InfoEventBox
from dzgui.views.components.buttons import WebButton from dzgui.views.components.buttons import WebButton
from dzgui.views.components.frame import HeadingFrame from dzgui.views.components.frame import HeadingFrame
from dzgui.views.components.misc import ClientCombo from dzgui.views.components.misc import ClientCombo
@@ -134,16 +133,6 @@ class Options(Gtk.Box):
[LeftLabel(strings.options.name), self.player_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_version_label = Gtk.Label(label=strings.null)
self.dayz_exp_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) api_box.add(api_links_box)
prefs_grid = self._make_grid(pref_rows) prefs_grid = self._make_grid(pref_rows)
mods_grid = self._make_grid(mod_rows)
version_grid = self._make_grid(version_rows) version_grid = self._make_grid(version_rows)
col = 1 col = 1
@@ -190,7 +178,6 @@ class Options(Gtk.Box):
for pair in [ for pair in [
(api_box, strings.options.api_keys), (api_box, strings.options.api_keys),
(prefs_grid, strings.options.prefs), (prefs_grid, strings.options.prefs),
(mods_grid, strings.options.mods),
(version_grid, strings.options.version), (version_grid, strings.options.version),
]: ]:
@@ -416,9 +403,6 @@ class Options(Gtk.Box):
bm = self.controller.query_config(Preferences.BM) bm = self.controller.query_config(Preferences.BM)
steam_path = Path(default_steam_path) 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_steam = steam
self.old_bm = bm self.old_bm = bm
+4 -26
View File
@@ -112,18 +112,6 @@ class PreConnectionAssistant(Gtk.Box):
tooltip_text=preconnect.connect_last_tooltip, 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( self.button_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL,
valign=Gtk.Align.END, valign=Gtk.Align.END,
@@ -133,8 +121,7 @@ class PreConnectionAssistant(Gtk.Box):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
for button in self.back, self.ok, self.connect_last: for button in self.back, self.ok, self.connect_last:
box.add(button) box.add(button)
for el in self.raise_window, box: self.button_box.add(box)
self.button_box.add(el)
self.back.connect("clicked", self._on_back_clicked) self.back.connect("clicked", self._on_back_clicked)
self.ok.connect("clicked", self._on_ok_clicked) self.ok.connect("clicked", self._on_ok_clicked)
@@ -217,8 +204,6 @@ class PreConnectionAssistant(Gtk.Box):
for child in widgets: for child in widgets:
child.set_visible(True) child.set_visible(True)
self.raise_window.set_visible(False)
self.raise_window.set_sensitive(False)
self.ok.set_sensitive(True) self.ok.set_sensitive(True)
self.ok.set_label(preconnect.update_mods) self.ok.set_label(preconnect.update_mods)
@@ -230,10 +215,10 @@ class PreConnectionAssistant(Gtk.Box):
self.ok.emit("clicked") self.ok.emit("clicked")
def _on_connect_last_clicked(self, button: Gtk.Button) -> None: 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: 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: def _on_back_clicked(self, button: Gtk.Button) -> None:
self.controller.open_page(NotebookPage.SERVERS) self.controller.open_page(NotebookPage.SERVERS)
@@ -317,10 +302,6 @@ class PreConnectionAssistant(Gtk.Box):
if prereqs.required_space == 0: if prereqs.required_space == 0:
self.ok.set_label(preconnect.connect) 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) pretty = number(prereqs.required_space)
suffix = f" Need to download {pretty} MiB of mod updates." suffix = f" Need to download {pretty} MiB of mod updates."
@@ -333,10 +314,7 @@ class PreConnectionAssistant(Gtk.Box):
self.connect_last.hide() self.connect_last.hide()
self._process_warnings(prereqs) self._process_warnings(prereqs)
if self.tree.is_visible(): self.tree.grab_focus()
self.tree.grab_focus()
else:
self.grab_focus()
def mark_finished(self) -> None: def mark_finished(self) -> None:
self.mod_count.set_label(preconnect.all_updated) self.mod_count.set_label(preconnect.all_updated)
+1 -1
View File
@@ -16,7 +16,7 @@ logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.controllers.mc import Controller from dzgui.controllers.mc import Controller
from dzgui.controllers.mc import Emitter from dzgui.controllers.emitter import Emitter
class ScrollableTree(Gtk.ScrolledWindow): class ScrollableTree(Gtk.ScrolledWindow):
+4 -2
View File
@@ -23,8 +23,10 @@ logger = logging.getLogger(APP_NAME)
class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore class ModTreeView(ModsMixin, ContextMixin, TreeView): # type: ignore
def __init__(self, controller: "Controller") -> None: def __init__(
super().__init__(controller, menu=ContextMenuGroup.MOD) self, controller: "Controller", menu: ContextMenuGroup = ContextMenuGroup.MOD
) -> None:
super().__init__(controller, menu=menu)
self.controller = controller self.controller = controller
self.mod_man = ModManager(self, controller) self.mod_man = ModManager(self, controller)