From caf071f997b6c34d5f356498bd8264739cda2960 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:33:52 +0900 Subject: [PATCH 01/19] feat: generate shortcuts --- dzgui/api/shortcuts.py | 166 ++++++++++++++++++++++ dzgui/api/steam.py | 20 +-- dzgui/app_init.py | 14 +- dzgui/config/freedesktop.py | 23 ++- dzgui/config/xdg.py | 7 +- dzgui/const/constants.py | 4 +- dzgui/data/images/{hero.png => _hero.png} | Bin dzgui/data/images/{logo.png => _logo.png} | Bin dzgui/data/images/{grid.png => p.png} | Bin dzgui/strings/wizard.py | 7 + dzgui/util/diag.py | 2 +- dzgui/util/dirs.py | 23 +++ dzgui/views/dialogs/wizard.py | 154 ++++++++++++++++++-- 13 files changed, 380 insertions(+), 40 deletions(-) create mode 100644 dzgui/api/shortcuts.py rename dzgui/data/images/{hero.png => _hero.png} (100%) rename dzgui/data/images/{logo.png => _logo.png} (100%) rename dzgui/data/images/{grid.png => p.png} (100%) create mode 100644 dzgui/util/dirs.py diff --git a/dzgui/api/shortcuts.py b/dzgui/api/shortcuts.py new file mode 100644 index 0000000..411b2a9 --- /dev/null +++ b/dzgui/api/shortcuts.py @@ -0,0 +1,166 @@ +import binascii +import ctypes +import logging +import shutil +import vdf # type: ignore + +from dataclasses import dataclass +from importlib import resources +from pathlib import Path +from typing import Any + +from dzgui.api.steam import find_user_id_32 +from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH +from dzgui.util.dirs import copy_dzgui_to_xdg_data + +logger = logging.getLogger(APP_NAME) + + +@dataclass(slots=True, frozen=True) +class ShortcutMetadata: + appname: str + appid: int + exe_path: str + start_dir: str + icon: str + + +class Shortcuts: + def __init__(self, steam_path: Path) -> None: + uid = find_user_id_32(steam_path) + self.uid = uid + self.user_config_path = steam_path.joinpath(f"userdata/{uid}/config") + self.shortcuts_path = self.user_config_path.joinpath("shortcuts.vdf") + self._load_shortcuts(self.shortcuts_path) + + @classmethod + def gen_bpid(cls, uid: str) -> int: + u = uid.encode() + encoded = binascii.crc32(u) + return encoded | 0x80000000 + + @classmethod + def gen_signed_appid(cls, appid: int) -> int: + return ctypes.c_int(appid).value + + @classmethod + def gen_exe_uid(cls, appname: str, exe: Path) -> str: + wrapped_exe = f'"{exe}"' + return appname + wrapped_exe + + def get_shortcuts(self) -> Any: + return self.shortcuts + + def _load_shortcuts(self, path: Path) -> None: + try: + with open(path, "rb") as f: + shortcuts = vdf.binary_load(f) + self.shortcuts = shortcuts + except Exception as e: + logger.warning(e) + raise e + + def add_shortcut( + self, appname: str, start_dir: Path, exe_path: Path, icon: Path + ) -> None: + uid = self.gen_exe_uid(appname, exe_path) + self.bpid = self.gen_bpid(uid) + self.signed_appid = self.gen_signed_appid(self.bpid) + + meta = ShortcutMetadata( + appname, self.signed_appid, str(exe_path), str(start_dir), str(icon) + ) + entry = self._create_entry(meta) + self._insert_at_last_index(entry) + + def add_grid_images(self, images: Path) -> None: + """ + {BPID}_hero.png: hero (splash) + {BPID}p.ping: portrait boxart in library + {BPID}_logo.png: logo overlay on top of hero + {BPID}.png: legacy Big Picture/landscape image + """ + + grid_path = self.user_config_path.joinpath("grid") + for img in ("_hero", "p", "_logo"): + filename = str(self.bpid) + img + dest = grid_path.joinpath(filename).with_suffix(".png") + b = images.joinpath(img).with_suffix(".png").read_bytes() + dest.write_bytes(b) + + # NOTE: legacy Big Picture header + b = images.joinpath("_hero.png").read_bytes() + dest = grid_path.joinpath(str(self.bpid)).with_suffix(".png") + dest.write_bytes(b) + + def _insert_at_last_index(self, entry: dict[str, Any]) -> None: + last = list(self.shortcuts["shortcuts"].keys())[-1] + n = int(last) + 1 + self.shortcuts["shortcuts"][str(n)] = entry + + @classmethod + def _create_entry(cls, meta: ShortcutMetadata) -> dict[str, Any]: + """ + https://developer.valvesoftware.com/wiki/Add_Non-Steam_Game + https://developer.valvesoftware.com/wiki/Steam_Library_Shortcuts + + Wiki variously lists keys with title case and lowercase, but keys actually do + not seem to be case-sensitive. Some entries generated by Steam do not match the wiki + + Keys are entered into the dictionary in a linear insertion order + + # TODO: try to replicate key case sensitivity as it is created by Steam with generic shortcut + + appid: signed int CRC + exe: absolute path to the executable, must be wrapped in literal quotes + StartDir: directory the executable starts in, generally the parent + """ + + NEW_ENTRY: dict[str, Any] = {} + NEW_ENTRY["appid"] = meta.appid + NEW_ENTRY["appname"] = meta.appname + NEW_ENTRY["exe"] = f'"{meta.exe_path}"' + NEW_ENTRY["StartDir"] = f"{meta.start_dir}" + NEW_ENTRY["icon"] = meta.icon + NEW_ENTRY["ShortcutPath"] = "" + NEW_ENTRY["LaunchOptions"] = "" + NEW_ENTRY["IsHidden"] = 0 + NEW_ENTRY["AllowDesktopConfig"] = 1 + NEW_ENTRY["AllowOverlay"] = 1 + NEW_ENTRY["openvr"] = 0 + NEW_ENTRY["Devkit"] = 0 + NEW_ENTRY["DevkitGameID"] = "" + NEW_ENTRY["DevkitOverrideAppID"] = 0 + NEW_ENTRY["LastPlayTime"] = 0 + NEW_ENTRY["FlatpakAppID"] = "" + NEW_ENTRY["sortas"] = "" + NEW_ENTRY["tags"] = {} + return NEW_ENTRY + + def save_shortcuts(self) -> None: + try: + backup = self.shortcuts_path.with_suffix(".vdf.bak") + shutil.copy(self.shortcuts_path, backup) + with open(self.shortcuts_path, "wb") as f: + vdf.binary_dump(self.shortcuts, f) + except Exception as e: + logger.critical(e) + + +def add_steam_shortcut(steam_path: Path, exe_path: Path) -> None: + try: + start_dir = exe_path.parent + + traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH) + images = Path(str(traversable)) + icon = images.joinpath("icon.png") + + # TEST: assert that record (start dir and exe combo) is in shortcuts after creation + shortcuts = Shortcuts(Path(steam_path)) + shortcuts.add_shortcut(APP_NAME, start_dir, exe_path, icon) + shortcuts.save_shortcuts() + shortcuts.add_grid_images(images) + + copy_dzgui_to_xdg_data(exe_path) + except Exception as e: + logger.warning(e) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 21acd26..7ec1155 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -219,6 +219,12 @@ def find_user_id(path: Path) -> str | None: logger.warn(e) return None +def find_user_id_32(path: Path) -> int: + uid = find_user_id(path) + if uid is None: + raise ValueError("Failed to parse a valid Steam32 ID") + return int(uid) & 0xFFFFFFFF + def update_workshop(key: str, mod: int, endpoint: str) -> None: payload: dict[str, Union[int, str]] = { @@ -243,20 +249,6 @@ def unsubscribe(key: str, mod: int) -> None: update_workshop(key, mod, UNSUB_ENDPOINT) -def gen_shortcut() -> None: - # TODO: - """ - during setup, prompt user to select matching user id from loginusers - show user account name, select outer steam id - steam/userdata//config/shortcuts.vdf - """ - # STEAMID_MAGIC = 76561197960265728 - # STEAMID_64 - STEAMID_MAGIC = STEAMID32 - # 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) diff --git a/dzgui/app_init.py b/dzgui/app_init.py index 3502040..71b7a6f 100644 --- a/dzgui/app_init.py +++ b/dzgui/app_init.py @@ -20,6 +20,7 @@ from dzgui.strings import boot # from dzgui.util.map_count import get_map_count from dzgui.util.deck import is_steam_deck, is_game_mode +from dzgui.util.dirs import make_parents from dzgui.util.localize import set_locale from dzgui.util.redact import RedactionFilter, REDACTION_PATTERNS from dzgui.util.strings import init @@ -34,10 +35,7 @@ if TYPE_CHECKING: logger = logging.getLogger(APP_NAME) - # TODO: profile load time -def make_parents(path: "Path") -> None: - path.parent.mkdir(parents=True, exist_ok=True) def setup_logger(log_path: "Path") -> None: @@ -124,11 +122,15 @@ def load_gui(version: str, is_debug: bool) -> None: migrate_cols_file(XDG.columns) copy_state_files(xdg_paths["XDG_STATE_HOME"]) # TODO: add logging inside wizard - SetupWizard(_is_steam_deck, XDG.config) + wizard = SetupWizard(_is_steam_deck, XDG) + if not wizard.is_setup_complete(): + return + + # TODO: drop # NOTE: implies that setup wizard failed or was closed - if has_new_config(XDG.config) is False: - return + #if has_new_config(XDG.config) is False: + # return setup_logger(XDG.debug) with open(XDG.debug, "w") as f: diff --git a/dzgui/config/freedesktop.py b/dzgui/config/freedesktop.py index 147d3b9..6ae7ba8 100644 --- a/dzgui/config/freedesktop.py +++ b/dzgui/config/freedesktop.py @@ -1,18 +1,33 @@ import textwrap + from pathlib import Path +from dzgui.util.dirs import copy_dzgui_to_xdg_data, find_icon_resource, make_parents + + +def write_desktop_file(exe_path: Path) -> None: + icon = find_icon_resource() -def write_desktop_file(share_path: Path) -> None: template = f"""\ [Desktop Entry] Version=1.0 Type=Application Terminal=false - Exec={share_path}/launch.sh + Exec={exe_path} Name=DZGUI Comment=dzgui - Icon={share_path}/dzgui + Icon={icon} Categories=Game""" - file = share_path / "dzgui.desktop" + copy_dzgui_to_xdg_data(exe_path) + + share_path = exe_path.parent.parent + file = share_path.joinpath("applications/dzgui.desktop") + make_parents(file) file.write_text(textwrap.dedent(template)) + + +def write_desktop_shortcut(exe_path: Path) -> None: + # TODO: symlink the above, /Desktop + # TODO: dynamically link to prior option in UI + pass diff --git a/dzgui/config/xdg.py b/dzgui/config/xdg.py index 5b78423..47f747a 100644 --- a/dzgui/config/xdg.py +++ b/dzgui/config/xdg.py @@ -18,6 +18,7 @@ class Xdg: debug: Path ips: Path filters: Path + shortcut: Path def is_writeable(path_str: str) -> bool: @@ -67,6 +68,7 @@ def get_xdg_paths() -> dict: def parse_filepaths(xdg: dict) -> Xdg: config = xdg["XDG_CONFIG_HOME"] state = xdg["XDG_STATE_HOME"] + share = xdg["XDG_DATA_HOME"] config = config / "config.json" @@ -81,6 +83,8 @@ def parse_filepaths(xdg: dict) -> Xdg: ips = state / "ips.csv" filters = state / "dzg.filters.json" + shortcut = share / APP_NAME_LOWER + return Xdg( config, columns, @@ -91,5 +95,6 @@ def parse_filepaths(xdg: dict) -> Xdg: system, debug, ips, - filters + filters, + shortcut, ) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 58af7f2..d0843fc 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -72,7 +72,9 @@ LEGACY_IPS_PATH = ".local/share/dzgui/helpers/ips.csv" DEBUG_LOG = f"{APP_NAME}_DEBUG.LOG" SYSTEM_LOG = f"{APP_NAME}_SYSTEM.LOG" CHANGELOG_PATH = "data/CHANGELOG.md" -HERO_PATH = "data/images/hero.png" +IMAGES_PATH = "data/images" + +HERO_PATH = "data/images/_hero.png" CSS_PATH = "data/app.css" VDF_PATH = "steamapps/libraryfolders.vdf" DEFAULT_STEAM_PATH = ".local/share/Steam" diff --git a/dzgui/data/images/hero.png b/dzgui/data/images/_hero.png similarity index 100% rename from dzgui/data/images/hero.png rename to dzgui/data/images/_hero.png diff --git a/dzgui/data/images/logo.png b/dzgui/data/images/_logo.png similarity index 100% rename from dzgui/data/images/logo.png rename to dzgui/data/images/_logo.png diff --git a/dzgui/data/images/grid.png b/dzgui/data/images/p.png similarity index 100% rename from dzgui/data/images/grid.png rename to dzgui/data/images/p.png diff --git a/dzgui/strings/wizard.py b/dzgui/strings/wizard.py index 2298155..96310af 100644 --- a/dzgui/strings/wizard.py +++ b/dzgui/strings/wizard.py @@ -64,6 +64,13 @@ radio_mi = "mi (miles)" label_dist = "Distance display" label_client = "Steam client" +### Shortcuts page +heading_shortcuts = "Create shortcuts" +blurb_shortcuts = "You can optionally create shortcuts to facilitate launching DZGUI faster. Note that if you create a Steam shortcut, you must restart Steam for these changes to take effect." +button_shortcuts = "Create shortcuts" +checkbox_steam_shortcut = ("Add a shortcut to Steam", "This will not replace or update existing shortcuts.") +checkbox_start_menu = ("Add a start menu shortcut", "Depends on local window manager settings. Will work on Steam Deck.") +checkbox_desktop_shortcut = ("Add a desktop shortcut", "Depends on the option above.") ### Completion page heading_completion = "Setup complete" diff --git a/dzgui/util/diag.py b/dzgui/util/diag.py index 77342e3..354234f 100644 --- a/dzgui/util/diag.py +++ b/dzgui/util/diag.py @@ -41,10 +41,10 @@ def print_mods(mods: list[int]) -> str: def write_diagnostic(config: Path, outfile: Path) -> None: - # TODO: test availability on other distros date = datetime.now().isoformat() try: + # FIXME: ID_LIKE not available on CachyOS distro = platform.freedesktop_os_release()["ID_LIKE"] except Exception as e: logger.warn(e) diff --git a/dzgui/util/dirs.py b/dzgui/util/dirs.py new file mode 100644 index 0000000..d317457 --- /dev/null +++ b/dzgui/util/dirs.py @@ -0,0 +1,23 @@ +import os +import shutil + +from importlib import resources +from pathlib import Path + +from dzgui.const.constants import APP_NAME_LOWER, IMAGES_PATH + +def make_parents(path: "Path") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + +def copy_dzgui_to_xdg_data(exe_path: Path) -> None: + dzgui = os.getenv("PYAPP") + if dzgui is None: + return + make_parents(exe_path) + shutil.copy(dzgui, exe_path) + +def find_icon_resource() -> "Path": + traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH) + images = Path(str(traversable)) + icon = images.joinpath("icon.png") + return icon diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py index bdc298c..3f68c28 100644 --- a/dzgui/views/dialogs/wizard.py +++ b/dzgui/views/dialogs/wizard.py @@ -1,11 +1,13 @@ +import os import textwrap from enum import Enum from importlib import resources from pathlib import Path -from typing import Any, Callable, Self +from typing import Any, Callable, Self, TYPE_CHECKING from dzgui.api.probe import test_steam_api, test_bm_api +from dzgui.api.shortcuts import add_steam_shortcut from dzgui.api.steam import get_steam_paths from dzgui.const.constants import ( APP_NAME, @@ -15,6 +17,8 @@ from dzgui.const.constants import ( ) from dzgui.const.boilerplate import config_boilerplate from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP +from dzgui.const.enum import Preferences +from dzgui.config.query import lookup from dzgui.init.migrate import migrate_legacy_conf from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.strings import wizard @@ -30,6 +34,9 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402 +if TYPE_CHECKING: + from dzgui.config.xdg import Xdg + class PageNum(Enum): INTRO = 1 @@ -38,7 +45,14 @@ class PageNum(Enum): STEAM_API = 4 BM_API = 5 USER_PREFS = 6 - FINAL = 7 + SHORTCUTS = 7 + FINAL = 8 + + +class Shortcut(Enum): + STEAM = 1 + DESKTOP = 2 + START = 3 class DescriptionArea(Gtk.Box): @@ -141,6 +155,10 @@ class NotificationFrame(Gtk.Frame): if error: add_class(self, "error-frame") + def set_text(self, text: str) -> None: + wrapped = textwrap.fill(text, width=80) + self.label.set_markup(wrapped) + class APIValidationPage(ScrolledWizardPage): def __init__( @@ -339,6 +357,7 @@ class ConfigMigrationPage(ScrolledWizardPage): def _on_import_clicked(self, button: Gtk.Button) -> None: self.grid.set_sensitive(False) try: + # TODO: this could be deferred to the final page (prevents accidental destruction of dialog via ESC) migrate_legacy_conf(self.config) self.migrated = True self.success_box.set_visible(True) @@ -423,24 +442,27 @@ class CompletionPage(ScrolledWizardPage): class Assistant(Gtk.Assistant): - def __init__(self, is_deck: bool, config: Path): + def __init__(self, is_deck: bool, XDG: "Xdg"): super().__init__() if is_deck: self.fullscreen() else: self.set_default_size(1500, 900) - self.config_path = config + self.config_path = XDG.config self.config_values: dict[str, Any] = config_boilerplate + self.setup_complete = False + self.page1 = IntroductionPage() - self.page2 = ConfigMigrationPage(config) + self.page2 = ConfigMigrationPage(XDG.config) self.page3 = SteamPathPage() self.page4 = SteamValidationPage() self.page5 = BMValidationPage() self.page6 = PreferencesPage() - self.page7 = CompletionPage() + self.page7 = ShortcutCreationPage(XDG.shortcut) + self.page8 = CompletionPage() self.set_forward_page_func(self._advance_page) @@ -458,9 +480,18 @@ class Assistant(Gtk.Assistant): self.page5, self.page6, self.page7, + self.page8, ): # NOTE: skip config migration page if no legacy config file - if page == self.page2 and self.has_legacy_config is False: + if ( + isinstance(page, ConfigMigrationPage) + and self.has_legacy_config is False + ): + continue + # NOTE: disabled for now on system-provided packages + # TODO: drop, for testing purposes + os.environ["PYAPP"] = str(Path.home().joinpath("dzgui/dzgui")) + if isinstance(page, ShortcutCreationPage) and os.getenv("PYAPP") is None: continue self._add_page(page, page.get_page_type()) @@ -475,13 +506,15 @@ class Assistant(Gtk.Assistant): def _advance_page(self, index: int) -> int: page = self.get_nth_page(index) - # TODO: use enums + # TODO: use enums/isinstance match page: case self.page1: pass case self.page2: if self.page2.is_migrated(): - return self.get_n_pages() - 1 + steam_path = lookup(self.config_path, Preferences.DEFAULT) + self.page7.set_steam_path(steam_path) + return self.get_n_pages() - 2 case self.page3: self.config_values["default_steam_path"] = page.get_path_from_radio() case self.page4: @@ -495,10 +528,17 @@ class Assistant(Gtk.Assistant): self.config_values["use_miles"] = use_miles self.config_values["client"] = client self.write_config() + self.page7.set_steam_path(self.config_values["default_steam_path"]) + case self.page7: + self.page7.create_shortcuts() + self.setup_complete = True case _: raise AttributeError("Trying to advance a non-canonical page") return index + 1 + def is_setup_complete(self) -> bool: + return self.setup_complete + def destroy_and_quit(self, widget: Self) -> None: self.destroy() Gtk.main_quit() @@ -543,6 +583,91 @@ class Assistant(Gtk.Assistant): EMITTER.emit("step_pending") +class CheckboxWithLabel(Gtk.Box): + def __init__(self, text: str, blurb_text: str, enum: Shortcut) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5) + + self.enum = enum + self.button = Gtk.CheckButton(label=text) + self.button.set_active(True) + blurb = Gtk.Label(label=f"- {blurb_text}", halign=Gtk.Align.START, margin_start=20) + + for el in self.button, blurb: + self.add(el) + + def get_enum(self) -> Shortcut: + return self.enum + + def get_active(self) -> bool: + return self.button.get_active() + + +class ShortcutCreationPage(ScrolledWizardPage): + def __init__(self, shortcut: Path) -> None: + super().__init__( + enum=PageNum.SHORTCUTS, + heading=wizard.heading_shortcuts, + description=wizard.blurb_shortcuts, + ) + + self.steam_path: Path + self.shortcut_path = shortcut + self.checks_area = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=20 + ) + self.page_type = Gtk.AssistantPageType.INTRO + + # TODO: pass enums + for checkbox in ( + (wizard.checkbox_steam_shortcut, Shortcut.STEAM), + (wizard.checkbox_desktop_shortcut, Shortcut.DESKTOP), + (wizard.checkbox_start_menu, Shortcut.START), + ): + box, enum = checkbox + label, blurb = box + checkbox = CheckboxWithLabel(label, blurb, enum) + self.checks_area.add(checkbox) + + self.add_start(self.checks_area) + self.show_all() + self.connect("map", self._on_map) + + """ + TODO: checkboxes for: + [] steam shortcut (adds a new shortcut. it will not replace or update existing DZGUI shortcuts) + [] add to desktop (PATH) + [] add to start menu (free desktop) + checkbox with blurb below + # TODO: warn about how to back up + """ + + def _on_map(self, page: "ScrolledWizardPage") -> None: + EMITTER.emit("step_complete") + + def set_steam_path(self, path: Path) -> None: + self.steam_path = path + + # TODO: move elsewhere + def write_desktop_file(self) -> None: + # .local/share/applications/dzgui/dzgui.desktop + pass + + def create_shortcuts(self) -> None: + # NOTE: best-effort, permissive even on failure (page is already marked as complete) + for box in self.checks_area.get_children(): + if not box.get_active(): + continue + match box.get_enum(): + case Shortcut.STEAM: + add_steam_shortcut(self.steam_path, self.shortcut_path) + case Shortcut.DESKTOP: + pass + case Shortcut.START: + pass + case _: + pass + + class SteamPathPage(ScrolledWizardPage): def __init__(self) -> None: super().__init__( @@ -599,17 +724,20 @@ class SteamPathPage(ScrolledWizardPage): class SetupWizard(Gtk.Application): - def __init__(self, is_deck: bool, config: Path) -> None: + def __init__(self, is_deck: bool, XDG: "Xdg") -> None: super().__init__() GLib.set_prgname(APP_NAME) - Window(is_deck, config) + self.win = Window(is_deck, XDG) Gtk.main() + def is_setup_complete(self) -> int: + return self.win.assistant.is_setup_complete() + class Window(Gtk.Window): - def __init__(self, is_deck: bool, config: Path) -> None: + def __init__(self, is_deck: bool, XDG: "Xdg") -> None: super().__init__(title=APP_NAME, icon_name=APP_NAME) - Assistant(is_deck, config) + self.assistant = Assistant(is_deck, XDG) class Emitter(GObject.GObject): From 0084af49594c14c0079d0f5437e0537333a00ede Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:32:24 +0900 Subject: [PATCH 02/19] feat: write desktop file --- dzgui/config/freedesktop.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/dzgui/config/freedesktop.py b/dzgui/config/freedesktop.py index 6ae7ba8..edad06f 100644 --- a/dzgui/config/freedesktop.py +++ b/dzgui/config/freedesktop.py @@ -1,10 +1,15 @@ +import stat import textwrap from pathlib import Path +from dzgui.const.constants import APP_NAME from dzgui.util.dirs import copy_dzgui_to_xdg_data, find_icon_resource, make_parents +def get_share_path(exe_path: Path) -> Path: + return exe_path.parent.parent + def write_desktop_file(exe_path: Path) -> None: icon = find_icon_resource() @@ -21,13 +26,17 @@ def write_desktop_file(exe_path: Path) -> None: copy_dzgui_to_xdg_data(exe_path) - share_path = exe_path.parent.parent - file = share_path.joinpath("applications/dzgui.desktop") - make_parents(file) - file.write_text(textwrap.dedent(template)) + share_path = get_share_path(exe_path) + desktop_file = share_path.joinpath("applications/dzgui.desktop") + make_parents(desktop_file) + desktop_file.write_text(textwrap.dedent(template)) + desktop_file.chmod(desktop_file.stat().st_mode | stat.S_IEXEC) + return desktop_file -def write_desktop_shortcut(exe_path: Path) -> None: - # TODO: symlink the above, /Desktop - # TODO: dynamically link to prior option in UI - pass +def write_desktop_shortcut(desktop_file: Path) -> None: + # NOTE: necessarily depends on the above (UI blocks creation without XDG entry first) + link = Path.home().joinpath(f"Desktop/{APP_NAME}.desktop") + if link.exists(): + link.unlink() + link.symlink_to(desktop_file) From 9d14b7f219108abac06e40e55226e5decaad2c61 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:32:52 +0900 Subject: [PATCH 03/19] chore: error handling --- dzgui/api/shortcuts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/api/shortcuts.py b/dzgui/api/shortcuts.py index 411b2a9..87fd534 100644 --- a/dzgui/api/shortcuts.py +++ b/dzgui/api/shortcuts.py @@ -57,7 +57,7 @@ class Shortcuts: shortcuts = vdf.binary_load(f) self.shortcuts = shortcuts except Exception as e: - logger.warning(e) + logger.critical(e) raise e def add_shortcut( @@ -163,4 +163,4 @@ def add_steam_shortcut(steam_path: Path, exe_path: Path) -> None: copy_dzgui_to_xdg_data(exe_path) except Exception as e: - logger.warning(e) + logger.critical(e) From 873309426708fabf4fc3d4d69e1640f47358b6f3 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:33:15 +0900 Subject: [PATCH 04/19] chore: wrap in try/except statement --- dzgui/util/dirs.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dzgui/util/dirs.py b/dzgui/util/dirs.py index d317457..70fdaa2 100644 --- a/dzgui/util/dirs.py +++ b/dzgui/util/dirs.py @@ -13,8 +13,11 @@ def copy_dzgui_to_xdg_data(exe_path: Path) -> None: dzgui = os.getenv("PYAPP") if dzgui is None: return - make_parents(exe_path) - shutil.copy(dzgui, exe_path) + try: + make_parents(exe_path) + shutil.copy(dzgui, exe_path) + except Exception as e: + logger.critical(e) def find_icon_resource() -> "Path": traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH) From 2f61cc7ff7b1c5899f8f1671ba2842a413f5db86 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:33:40 +0900 Subject: [PATCH 05/19] feat: make checkboxes in lock-step --- dzgui/views/dialogs/wizard.py | 77 +++++++++++++++-------------------- 1 file changed, 32 insertions(+), 45 deletions(-) diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py index 3f68c28..1bfaf47 100644 --- a/dzgui/views/dialogs/wizard.py +++ b/dzgui/views/dialogs/wizard.py @@ -18,6 +18,7 @@ from dzgui.const.constants import ( from dzgui.const.boilerplate import config_boilerplate from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP from dzgui.const.enum import Preferences +from dzgui.config import freedesktop from dzgui.config.query import lookup from dzgui.init.migrate import migrate_legacy_conf from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager @@ -49,12 +50,6 @@ class PageNum(Enum): FINAL = 8 -class Shortcut(Enum): - STEAM = 1 - DESKTOP = 2 - START = 3 - - class DescriptionArea(Gtk.Box): def __init__(self, text: str): super().__init__(orientation=Gtk.Orientation.VERTICAL) @@ -584,10 +579,9 @@ class Assistant(Gtk.Assistant): class CheckboxWithLabel(Gtk.Box): - def __init__(self, text: str, blurb_text: str, enum: Shortcut) -> None: + def __init__(self, text: str, blurb_text: str) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5) - self.enum = enum self.button = Gtk.CheckButton(label=text) self.button.set_active(True) blurb = Gtk.Label(label=f"- {blurb_text}", halign=Gtk.Align.START, margin_start=20) @@ -595,12 +589,14 @@ class CheckboxWithLabel(Gtk.Box): for el in self.button, blurb: self.add(el) - def get_enum(self) -> Shortcut: - return self.enum + def get_checkbox(self) -> Gtk.CheckButton: + return self.button def get_active(self) -> bool: return self.button.get_active() + def set_active(self, state: bool) -> None: + self.button.set_active(state) class ShortcutCreationPage(ScrolledWizardPage): def __init__(self, shortcut: Path) -> None: @@ -617,29 +613,29 @@ class ShortcutCreationPage(ScrolledWizardPage): ) self.page_type = Gtk.AssistantPageType.INTRO - # TODO: pass enums - for checkbox in ( - (wizard.checkbox_steam_shortcut, Shortcut.STEAM), - (wizard.checkbox_desktop_shortcut, Shortcut.DESKTOP), - (wizard.checkbox_start_menu, Shortcut.START), - ): - box, enum = checkbox - label, blurb = box - checkbox = CheckboxWithLabel(label, blurb, enum) - self.checks_area.add(checkbox) + label, blurb = wizard.checkbox_steam_shortcut + self.steam_checkbox = CheckboxWithLabel(label, blurb) + + label, blurb = wizard.checkbox_start_menu + self.start_menu_checkbox = CheckboxWithLabel(label, blurb) + cb = self.start_menu_checkbox.get_checkbox() + cb.connect("toggled", self._on_start_menu_toggled) + + label, blurb = wizard.checkbox_desktop_shortcut + self.desktop_checkbox = CheckboxWithLabel(label, blurb) + + for el in (self.steam_checkbox, self.start_menu_checkbox, self.desktop_checkbox): + self.checks_area.add(el) self.add_start(self.checks_area) self.show_all() self.connect("map", self._on_map) - """ - TODO: checkboxes for: - [] steam shortcut (adds a new shortcut. it will not replace or update existing DZGUI shortcuts) - [] add to desktop (PATH) - [] add to start menu (free desktop) - checkbox with blurb below - # TODO: warn about how to back up - """ + def _on_start_menu_toggled(self, button: Gtk.CheckButton) -> None: + state = button.get_active() + if not state: + self.desktop_checkbox.set_active(state) + self.desktop_checkbox.set_sensitive(state) def _on_map(self, page: "ScrolledWizardPage") -> None: EMITTER.emit("step_complete") @@ -647,25 +643,16 @@ class ShortcutCreationPage(ScrolledWizardPage): def set_steam_path(self, path: Path) -> None: self.steam_path = path - # TODO: move elsewhere - def write_desktop_file(self) -> None: - # .local/share/applications/dzgui/dzgui.desktop - pass - def create_shortcuts(self) -> None: # NOTE: best-effort, permissive even on failure (page is already marked as complete) - for box in self.checks_area.get_children(): - if not box.get_active(): - continue - match box.get_enum(): - case Shortcut.STEAM: - add_steam_shortcut(self.steam_path, self.shortcut_path) - case Shortcut.DESKTOP: - pass - case Shortcut.START: - pass - case _: - pass + if self.steam_checkbox.get_active(): + add_steam_shortcut(self.steam_path, self.shortcut_path) + + if self.start_menu_checkbox.get_active(): + desktop_file = freedesktop.write_desktop_file(self.shortcut_path) + + if self.desktop_checkbox.get_active(): + freedesktop.write_desktop_shortcut(desktop_file) class SteamPathPage(ScrolledWizardPage): From 5121e6d005795f985a1b7fcca1b71c0cfcb4adaf Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:51:09 +0900 Subject: [PATCH 06/19] docs: update changelog --- CHANGELOG.md | 2 ++ dzgui/data/CHANGELOG.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ebab0..668eb8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ - Choose to jump into splash screen instead of server - Collapsible connection panel - Play offline (load mods directly) +- Generate Steam shortcuts and cover art +- Warn user if background downloads are disabled ## Changed - Conform to PEP 440 versioning for beta versions diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 80ebab0..668eb8e 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -41,6 +41,8 @@ - Choose to jump into splash screen instead of server - Collapsible connection panel - Play offline (load mods directly) +- Generate Steam shortcuts and cover art +- Warn user if background downloads are disabled ## Changed - Conform to PEP 440 versioning for beta versions From 35680b1373c8129b1f19dc0074c3da72e4704295 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:51:32 +0900 Subject: [PATCH 07/19] chore: clarify dialog strings --- dzgui/strings/wizard.py | 18 +++++++++++++++--- dzgui/views/dialogs/wizard.py | 15 +++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/dzgui/strings/wizard.py b/dzgui/strings/wizard.py index 96310af..dd7207c 100644 --- a/dzgui/strings/wizard.py +++ b/dzgui/strings/wizard.py @@ -66,10 +66,22 @@ label_client = "Steam client" ### Shortcuts page heading_shortcuts = "Create shortcuts" -blurb_shortcuts = "You can optionally create shortcuts to facilitate launching DZGUI faster. Note that if you create a Steam shortcut, you must restart Steam for these changes to take effect." +blurb_shortcuts = ( + "You can optionally create shortcuts to facilitate launching DZGUI faster." +) button_shortcuts = "Create shortcuts" -checkbox_steam_shortcut = ("Add a shortcut to Steam", "This will not replace or update existing shortcuts.") -checkbox_start_menu = ("Add a start menu shortcut", "Depends on local window manager settings. Will work on Steam Deck.") +checkbox_steam_shortcut = ( + "Add DZGUI to Steam library", + ( + "This creates a standalone copy of DZGUI and adds it to Steam. " + "You must restart Steam for the shortcut to appear. This will not replace or update existing shortcuts. " + "If you update DZGUI when it is launched via Steam, updates will be applied to the Steam version." + ), +) +checkbox_start_menu = ( + "Add a start menu shortcut", + "Depends on local window manager settings. Will work on Steam Deck.", +) checkbox_desktop_shortcut = ("Add a desktop shortcut", "Depends on the option above.") ### Completion page diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py index 1bfaf47..4261cf8 100644 --- a/dzgui/views/dialogs/wizard.py +++ b/dzgui/views/dialogs/wizard.py @@ -67,7 +67,7 @@ class Progress(Gtk.ProgressBar): class ScrolledWizardPage(Gtk.ScrolledWindow): def __init__(self, enum: PageNum, heading: str, description: str): - super().__init__() + super().__init__(overlay_scrolling=False) self.enum = enum self.page_type: Gtk.AssistantPageType @@ -584,9 +584,11 @@ class CheckboxWithLabel(Gtk.Box): self.button = Gtk.CheckButton(label=text) self.button.set_active(True) - blurb = Gtk.Label(label=f"- {blurb_text}", halign=Gtk.Align.START, margin_start=20) + label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=20) + wrapped = textwrap.fill(blurb_text, width=100) + label.set_markup(f"- {wrapped}") - for el in self.button, blurb: + for el in self.button, label: self.add(el) def get_checkbox(self) -> Gtk.CheckButton: @@ -598,6 +600,7 @@ class CheckboxWithLabel(Gtk.Box): def set_active(self, state: bool) -> None: self.button.set_active(state) + class ShortcutCreationPage(ScrolledWizardPage): def __init__(self, shortcut: Path) -> None: super().__init__( @@ -624,7 +627,11 @@ class ShortcutCreationPage(ScrolledWizardPage): label, blurb = wizard.checkbox_desktop_shortcut self.desktop_checkbox = CheckboxWithLabel(label, blurb) - for el in (self.steam_checkbox, self.start_menu_checkbox, self.desktop_checkbox): + for el in ( + self.steam_checkbox, + self.start_menu_checkbox, + self.desktop_checkbox, + ): self.checks_area.add(el) self.add_start(self.checks_area) From 72b10ad543bc779ee6b739c4cd08a124869b5b60 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:52:21 +0900 Subject: [PATCH 08/19] chore: drop unused code --- dzgui/app_init.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dzgui/app_init.py b/dzgui/app_init.py index 71b7a6f..8a74466 100644 --- a/dzgui/app_init.py +++ b/dzgui/app_init.py @@ -127,11 +127,6 @@ def load_gui(version: str, is_debug: bool) -> None: if not wizard.is_setup_complete(): return - # TODO: drop - # NOTE: implies that setup wizard failed or was closed - #if has_new_config(XDG.config) is False: - # return - setup_logger(XDG.debug) with open(XDG.debug, "w") as f: f.truncate(0) From 207d8fd4079d5a7659eb59f1640bbf204022502f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:52:39 +0900 Subject: [PATCH 09/19] chore: add logging --- dzgui/util/dirs.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dzgui/util/dirs.py b/dzgui/util/dirs.py index 70fdaa2..8e4938e 100644 --- a/dzgui/util/dirs.py +++ b/dzgui/util/dirs.py @@ -1,3 +1,4 @@ +import logging import os import shutil @@ -6,9 +7,13 @@ from pathlib import Path from dzgui.const.constants import APP_NAME_LOWER, IMAGES_PATH +logger = logging.getLogger(APP_NAME) + + def make_parents(path: "Path") -> None: path.parent.mkdir(parents=True, exist_ok=True) + def copy_dzgui_to_xdg_data(exe_path: Path) -> None: dzgui = os.getenv("PYAPP") if dzgui is None: @@ -19,6 +24,7 @@ def copy_dzgui_to_xdg_data(exe_path: Path) -> None: except Exception as e: logger.critical(e) + def find_icon_resource() -> "Path": traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH) images = Path(str(traversable)) From da993db2e224f3b73a0f777e4de1044d4991d21a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:53:13 +0900 Subject: [PATCH 10/19] fix: update imports --- dzgui/util/dirs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/util/dirs.py b/dzgui/util/dirs.py index 8e4938e..5707f21 100644 --- a/dzgui/util/dirs.py +++ b/dzgui/util/dirs.py @@ -5,7 +5,7 @@ import shutil from importlib import resources from pathlib import Path -from dzgui.const.constants import APP_NAME_LOWER, IMAGES_PATH +from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH logger = logging.getLogger(APP_NAME) From fc1311579cc7b0edfe02f212f208ec9134af6929 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:53:55 +0900 Subject: [PATCH 11/19] fix: clear typehinting errors --- dzgui/config/freedesktop.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dzgui/config/freedesktop.py b/dzgui/config/freedesktop.py index edad06f..4cebe98 100644 --- a/dzgui/config/freedesktop.py +++ b/dzgui/config/freedesktop.py @@ -10,7 +10,8 @@ from dzgui.util.dirs import copy_dzgui_to_xdg_data, find_icon_resource, make_par def get_share_path(exe_path: Path) -> Path: return exe_path.parent.parent -def write_desktop_file(exe_path: Path) -> None: + +def write_desktop_file(exe_path: Path) -> Path: icon = find_icon_resource() template = f"""\ From 6fdf5547dc4b66bbb1a249f045f07a93cf1543fe Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:10:30 +0900 Subject: [PATCH 12/19] fix: handle zero-length shortcuts file --- dzgui/api/shortcuts.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/dzgui/api/shortcuts.py b/dzgui/api/shortcuts.py index 87fd534..2f0e286 100644 --- a/dzgui/api/shortcuts.py +++ b/dzgui/api/shortcuts.py @@ -27,10 +27,8 @@ class ShortcutMetadata: class Shortcuts: def __init__(self, steam_path: Path) -> None: - uid = find_user_id_32(steam_path) - self.uid = uid - self.user_config_path = steam_path.joinpath(f"userdata/{uid}/config") - self.shortcuts_path = self.user_config_path.joinpath("shortcuts.vdf") + self.user_config_path: Path + self.shortcuts_path = self.find_shortcuts_path(steam_path) self._load_shortcuts(self.shortcuts_path) @classmethod @@ -56,10 +54,16 @@ class Shortcuts: with open(path, "rb") as f: shortcuts = vdf.binary_load(f) self.shortcuts = shortcuts + logger.debug("Loaded shortcuts file") except Exception as e: logger.critical(e) raise e + def find_shortcuts_path(self, steam_path: Path) -> None: + uid = find_user_id_32(steam_path) + self.user_config_path = steam_path.joinpath(f"userdata/{uid}/config") + return self.user_config_path.joinpath("shortcuts.vdf") + def add_shortcut( self, appname: str, start_dir: Path, exe_path: Path, icon: Path ) -> None: @@ -94,8 +98,11 @@ class Shortcuts: dest.write_bytes(b) def _insert_at_last_index(self, entry: dict[str, Any]) -> None: - last = list(self.shortcuts["shortcuts"].keys())[-1] - n = int(last) + 1 + try: + last = list(self.shortcuts["shortcuts"].keys())[-1] + n = int(last) + 1 + except Exception: + n = 0 self.shortcuts["shortcuts"][str(n)] = entry @classmethod @@ -162,5 +169,6 @@ def add_steam_shortcut(steam_path: Path, exe_path: Path) -> None: shortcuts.add_grid_images(images) copy_dzgui_to_xdg_data(exe_path) + logger.debug("Finished creating Steam shortcut") except Exception as e: logger.critical(e) From 5c84b5ae0ab6545dd8bc51135e488067245242ba Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:23:37 +0900 Subject: [PATCH 13/19] chore: drop test code --- dzgui/views/dialogs/wizard.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py index 4261cf8..651ec7a 100644 --- a/dzgui/views/dialogs/wizard.py +++ b/dzgui/views/dialogs/wizard.py @@ -484,8 +484,6 @@ class Assistant(Gtk.Assistant): ): continue # NOTE: disabled for now on system-provided packages - # TODO: drop, for testing purposes - os.environ["PYAPP"] = str(Path.home().joinpath("dzgui/dzgui")) if isinstance(page, ShortcutCreationPage) and os.getenv("PYAPP") is None: continue self._add_page(page, page.get_page_type()) From dd69bc88b1d39f16830cdba7586668adcf343d58 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:04:45 +0900 Subject: [PATCH 14/19] chore: add shortcut tests --- tests/fixtures/api/no_shortcuts.vdf | Bin 0 -> 13 bytes tests/test_shortcuts.py | 67 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/fixtures/api/no_shortcuts.vdf create mode 100644 tests/test_shortcuts.py diff --git a/tests/fixtures/api/no_shortcuts.vdf b/tests/fixtures/api/no_shortcuts.vdf new file mode 100644 index 0000000000000000000000000000000000000000..7ca09eb675aa0b38626e4e55558a4f94f3ae331e GIT binary patch literal 13 UcmZQ5&d4t+NiHoZX5ioe03Un=00000 literal 0 HcmV?d00001 diff --git a/tests/test_shortcuts.py b/tests/test_shortcuts.py new file mode 100644 index 0000000..60ab7b9 --- /dev/null +++ b/tests/test_shortcuts.py @@ -0,0 +1,67 @@ +import pytest +import tempfile + +from pathlib import Path +from _pytest.monkeypatch import MonkeyPatch + +from dzgui.api.shortcuts import Shortcuts +from tests.fixtures import fixture_path + +pytestmark = pytest.mark.apitest + + +def mock_find_shortcuts(self, steam_path: Path) -> Path: + return Path(fixture_path("api/no_shortcuts.vdf")) + + +@pytest.fixture(scope="module", autouse=True) +def patch_api() -> None: + mp = MonkeyPatch() + mp.setattr("dzgui.api.shortcuts.Shortcuts.find_shortcuts_path", mock_find_shortcuts) + yield + mp.undo() + + +def test_no_shortcuts(monkeypatch) -> None: + s = Shortcuts(Path("")) + assert len(s.shortcuts["shortcuts"]) == 0 + + +@pytest.fixture +def dummy_app() -> None: + d = { + "appname": "TEST APP", + "StartDir": "TEST_DIR", + "exe": "TEST_DIR/TEST_EXE.EXE", + "icon": "IMAGES_DIR/TEST_IMAGE.PNG", + } + return d + + +def test_wrap_exe(dummy_app) -> None: + s = Shortcuts(Path("")) + s.add_shortcut(*dummy_app.values()) + assert s.shortcuts["shortcuts"]["0"]["exe"][0] == '"' + assert s.shortcuts["shortcuts"]["0"]["exe"][-1] == '"' + + +def test_add_shortcut(dummy_app) -> None: + s = Shortcuts(Path("")) + s.add_shortcut(*dummy_app.values()) + new = s.shortcuts["shortcuts"] + ind = str(len(new) - 1) + for k, v in dummy_app.items(): + if k == "exe": + v = f'"{v}"' + assert new[ind][k] == v + + +def test_save_shortcut(dummy_app) -> None: + s = Shortcuts(Path("")) + s.add_shortcut(*dummy_app.values()) + with tempfile.NamedTemporaryFile() as f: + tmp = Path(f.name) + s.shortcuts_path = tmp + s.save_shortcuts() + s._load_shortcuts(tmp) + assert len(s.shortcuts["shortcuts"]) == 1 From 50cf3471645ad8d5937f58608a845b02a289bb44 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:10:25 +0900 Subject: [PATCH 15/19] fix: typo --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dad896b..e5ba222 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ If you are a developer, you can skip to the end. # Requesting help If you encounter a problem with DZGUI, you can submit tickets on the GitHub -(issue tracker)[https://github.com/aclist/dztui/issues] under the +[issue tracker](https://github.com/aclist/dztui/issues) under the `troubleshooting` tag. # How can I help the project? @@ -24,7 +24,7 @@ accordingly. ## Submitting a ticket -Navigate to the GitHub (issue tracker)[https://github.com/aclist/dztui/issues]. +Navigate to the GitHub [issue tracker](https://github.com/aclist/dztui/issues). From there, follow the onscreen prompts. You will be asked questions such as: - What version are you using? From 26a23621724680d51861e3db58265d1a680dd122 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:11:18 +0900 Subject: [PATCH 16/19] fix: clear typehinting errors --- dzgui/api/shortcuts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/api/shortcuts.py b/dzgui/api/shortcuts.py index 2f0e286..bc20007 100644 --- a/dzgui/api/shortcuts.py +++ b/dzgui/api/shortcuts.py @@ -59,7 +59,7 @@ class Shortcuts: logger.critical(e) raise e - def find_shortcuts_path(self, steam_path: Path) -> None: + def find_shortcuts_path(self, steam_path: Path) -> Path: uid = find_user_id_32(steam_path) self.user_config_path = steam_path.joinpath(f"userdata/{uid}/config") return self.user_config_path.joinpath("shortcuts.vdf") From 2e41856348232045327e07c46e7c522084691ed5 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:14:15 +0900 Subject: [PATCH 17/19] chore: backport changelog fixes --- dzgui/data/CHANGELOG.md | 57 +++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/dzgui/data/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 668eb8e..85bd68d 100644 --- a/dzgui/data/CHANGELOG.md +++ b/dzgui/data/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog -## Added +## [7.0.0] Unreleased +### Added - Setup wizard - Changelog text wrapping and formatting - Changelog ships with source @@ -44,7 +45,7 @@ - Generate Steam shortcuts and cover art - Warn user if background downloads are disabled -## Changed +### Changed - Conform to PEP 440 versioning for beta versions - Reduce padding on keys button - Boldface breadcrumbs @@ -61,58 +62,58 @@ - Enable LAN page Empty/Full filters on first run of app - Propagate subscribed mods to Steam client -## Dropped +### Dropped - Debug mode - Branch switching - Manual mod install mode (describe rationale) - Force update mods -## Fixed +### Fixed - Longstanding issue with left clicks not registering as tree selection changes after spamming keyboard input - Center server title text on server dialogs - Rare segfaults when changing maps (threading) - Moved dialogs out of threads -## Unreleased +### Unreleased - Load offline mods - Local documentation - Raw debug command in context menu -## Developer-facing +### Developer-facing - Add pyproject.toml file - Show deprecation warnings - Options -> Dev page ## [6.0.5] 2026-05-17 -## Changed +### Changed - Update symlink hash logic to align with DZGUI 7 beta - Update A2S helper to latest commit ## [6.0.4] 2026-04-13 -## Fixed +### Fixed - Update A2S module checksums to support servers with missing description field ## [6.0.3] 2026-04-12 -## Fixed +### Fixed - Use GLibUnix namespace where available when setting up SIGINT callback ## [6.0.2] 2026-01-26 -## Fixed +### Fixed - Explicitly use Python 3.13 when calling subprocesses - Raise error correctly from module - Do not spawn dialogs from inside of thread when fetching prereqs -## Changed +### Changed - Optimize time complexity of startup checks - Optimize coordinate calculation by using local records first - Rename some internal functions - Wrap entire startup process in dialog ## [6.0.1] 2026-01-16 -## Fixed +### Fixed - Explicitly use Python 3.13 when calling subprocesses ## [6.0.0] 2026-01-14 -## Added +### Added - Support DayZ Experimental - Ping display on server tables - Save descriptive text notes on a per-server basis @@ -138,7 +139,7 @@ - Commandline usage help text (GaryBlackbourne) - More descriptive help dialogs when initial dependencies are missing -## Fixed +### Fixed - Script failing to start when remote endpoints are unavailable (GaryBlackbourne) - Key stickiness when quickly navigating through entries in tables - Servers returning malformed A2S_INFO blocking server browser from loading @@ -177,7 +178,7 @@ - Path discovery during first-time setup when parsing filepaths with whitespaces - First-time setup dialog continuously triggering when DayZ install path had whitespaces in it -## Changed +### Changed - Require Python 3.13 - Reduced global API cooldown from 60s to 30s - Clarify dialog messages when DayZ path could not be found @@ -189,50 +190,50 @@ - Rewrote distance calculation module (GaryBlackbourne) - Changed preferred client setting from radio toggle to combobox -## Dropped +### Dropped - Ping readout in statusbar - Extraneous information from right statusbar ## [5.8.3] 2026-01-04 -## Fixed +### Fixed - Normalize checksum numbers and dates ## [5.8.2] 2026-01-04 -## Fixed +### Fixed - Fix regression in symlink routine introduced by previous hotfix ## [5.8.1] 2025-12-27 -## Fixed +### Fixed - Path discovery during first-time setup when parsing filepaths with whitespaces - First-time setup dialog continuously triggering when DayZ install path had whitespaces in it ## [5.8.0] 2025-07-06 -## Added +### Added - Filter servers by official/unofficial status - Automatically fetch geolocation records -## Changed +### Changed - Updated internal versioning of helper files -## Fixed +### Fixed - Corrected erroneous 2024 date in prior changelog entries ## [5.7.0] 2025-04-17 -## Added +### Added - Save application dimensions when quitting and restore on subsequent boot -## Fixed +### Fixed - Issues with the window exceeding the bounds of the screen when displaying some table contexts -## Changed +### Changed - More direct server ping query method that should return marginally more accurate times ## [5.6.7] 2025-04-04 -## Dropped +### Dropped - Removed extraneous pre-boot API checks that could cause error messages to be printed if the user had not set up an API key yet ## [5.6.6] 2025-03-16 -## Changed +### Changed - Update IP database records for 2025-03 ## [5.6.5] 2025-03-04 -## Fixed +### Fixed - Livonia server results being dropped from batch queries ## [5.6.4] 2025-02-10 From e664f68624c89a18547407ebd64c344892b89e4c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:14:55 +0900 Subject: [PATCH 18/19] fix: restore prior proc monitor logic (avoids conflicts) --- dzgui/api/steam.py | 50 ++++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 7ec1155..dcaa760 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -15,6 +15,7 @@ from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, APP_NAME, + DAYZ_BINARY, DEBIAN_STEAM_PATH, DEFAULT_STEAM_PATH, FLATPAK_STEAM_PATH, @@ -219,6 +220,7 @@ def find_user_id(path: Path) -> str | None: logger.warn(e) return None + def find_user_id_32(path: Path) -> int: uid = find_user_id(path) if uid is None: @@ -301,21 +303,25 @@ def get_running_app() -> int | None: PROC_NAME = "steam" SUBPROC_NAME = "reaper" FLAG = "AppId" - - for proc in psutil.process_iter(): - if proc.name() == PROC_NAME: - subprocs = proc.children() - filtered = (proc for proc in subprocs if proc.name() == SUBPROC_NAME) - try: - proc = next(filtered) - except StopIteration: - return None - args = proc.cmdline() - appid = (row for row in args if FLAG in row) - try: - return int(next(appid).split("=")[1]) - except StopIteration: - return None + # NOTE: may cause conflicts if multiple apps are running + try: + for proc in psutil.process_iter(): + if proc.name() == PROC_NAME: + subprocs = proc.children() + filtered = (proc for proc in subprocs if proc.name() == SUBPROC_NAME) + try: + proc = next(filtered) + except StopIteration: + return None + args = proc.cmdline() + appid = (row for row in args if FLAG in row) + try: + return int(next(appid).split("=")[1]) + except StopIteration: + return None + except Exception as e: + logger.debug(e) + return None return None @@ -337,9 +343,11 @@ def get_app_allows_downloads(path: Path, appid: int) -> bool: case _: return True + def get_config(path: Path) -> Path: return path.joinpath("config/config.vdf") + def get_client_allows_downloads(path: Path) -> bool: config = get_config(path) try: @@ -360,8 +368,16 @@ def get_client_allows_downloads(path: Path) -> bool: def is_dayz_running() -> bool: - appid = get_running_app() - return appid in (APPID_DAYZ, APPID_DAYZ_EXP) + """Subprocesses spawned from Steam will not show up in regular process tree""" + procs = [] + substring = DAYZ_BINARY + for proc in psutil.process_iter(): + try: + procs.append(proc.cmdline()) + except Exception as e: + logger.warning(e) + continue + return any(substring in item for sublist in procs for item in sublist) def get_app_path(folders_path: Path, appid: int) -> Path: From 2586306323bfedbeea26b8459fcd8a11f081479c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:24:45 +0900 Subject: [PATCH 19/19] chore: drop comment --- dzgui/api/shortcuts.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dzgui/api/shortcuts.py b/dzgui/api/shortcuts.py index bc20007..03a691b 100644 --- a/dzgui/api/shortcuts.py +++ b/dzgui/api/shortcuts.py @@ -162,7 +162,6 @@ def add_steam_shortcut(steam_path: Path, exe_path: Path) -> None: images = Path(str(traversable)) icon = images.joinpath("icon.png") - # TEST: assert that record (start dir and exe combo) is in shortcuts after creation shortcuts = Shortcuts(Path(steam_path)) shortcuts.add_shortcut(APP_NAME, start_dir, exe_path, icon) shortcuts.save_shortcuts()