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/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? diff --git a/dzgui/api/shortcuts.py b/dzgui/api/shortcuts.py new file mode 100644 index 0000000..03a691b --- /dev/null +++ b/dzgui/api/shortcuts.py @@ -0,0 +1,173 @@ +import binascii +import ctypes +import logging +import shutil +import vdf # type: ignore + +from dataclasses import dataclass +from importlib import resources +from pathlib import Path +from typing import Any + +from dzgui.api.steam import find_user_id_32 +from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH +from dzgui.util.dirs import copy_dzgui_to_xdg_data + +logger = logging.getLogger(APP_NAME) + + +@dataclass(slots=True, frozen=True) +class ShortcutMetadata: + appname: str + appid: int + exe_path: str + start_dir: str + icon: str + + +class Shortcuts: + def __init__(self, steam_path: Path) -> None: + self.user_config_path: Path + self.shortcuts_path = self.find_shortcuts_path(steam_path) + self._load_shortcuts(self.shortcuts_path) + + @classmethod + def gen_bpid(cls, uid: str) -> int: + u = uid.encode() + encoded = binascii.crc32(u) + return encoded | 0x80000000 + + @classmethod + def gen_signed_appid(cls, appid: int) -> int: + return ctypes.c_int(appid).value + + @classmethod + def gen_exe_uid(cls, appname: str, exe: Path) -> str: + wrapped_exe = f'"{exe}"' + return appname + wrapped_exe + + def get_shortcuts(self) -> Any: + return self.shortcuts + + def _load_shortcuts(self, path: Path) -> None: + try: + with open(path, "rb") as f: + shortcuts = vdf.binary_load(f) + self.shortcuts = shortcuts + logger.debug("Loaded shortcuts file") + except Exception as e: + logger.critical(e) + raise e + + def find_shortcuts_path(self, steam_path: Path) -> Path: + uid = find_user_id_32(steam_path) + self.user_config_path = steam_path.joinpath(f"userdata/{uid}/config") + return self.user_config_path.joinpath("shortcuts.vdf") + + def add_shortcut( + self, appname: str, start_dir: Path, exe_path: Path, icon: Path + ) -> None: + uid = self.gen_exe_uid(appname, exe_path) + self.bpid = self.gen_bpid(uid) + self.signed_appid = self.gen_signed_appid(self.bpid) + + meta = ShortcutMetadata( + appname, self.signed_appid, str(exe_path), str(start_dir), str(icon) + ) + entry = self._create_entry(meta) + self._insert_at_last_index(entry) + + def add_grid_images(self, images: Path) -> None: + """ + {BPID}_hero.png: hero (splash) + {BPID}p.ping: portrait boxart in library + {BPID}_logo.png: logo overlay on top of hero + {BPID}.png: legacy Big Picture/landscape image + """ + + grid_path = self.user_config_path.joinpath("grid") + for img in ("_hero", "p", "_logo"): + filename = str(self.bpid) + img + dest = grid_path.joinpath(filename).with_suffix(".png") + b = images.joinpath(img).with_suffix(".png").read_bytes() + dest.write_bytes(b) + + # NOTE: legacy Big Picture header + b = images.joinpath("_hero.png").read_bytes() + dest = grid_path.joinpath(str(self.bpid)).with_suffix(".png") + dest.write_bytes(b) + + def _insert_at_last_index(self, entry: dict[str, Any]) -> None: + try: + last = list(self.shortcuts["shortcuts"].keys())[-1] + n = int(last) + 1 + except Exception: + n = 0 + self.shortcuts["shortcuts"][str(n)] = entry + + @classmethod + def _create_entry(cls, meta: ShortcutMetadata) -> dict[str, Any]: + """ + https://developer.valvesoftware.com/wiki/Add_Non-Steam_Game + https://developer.valvesoftware.com/wiki/Steam_Library_Shortcuts + + Wiki variously lists keys with title case and lowercase, but keys actually do + not seem to be case-sensitive. Some entries generated by Steam do not match the wiki + + Keys are entered into the dictionary in a linear insertion order + + # TODO: try to replicate key case sensitivity as it is created by Steam with generic shortcut + + appid: signed int CRC + exe: absolute path to the executable, must be wrapped in literal quotes + StartDir: directory the executable starts in, generally the parent + """ + + NEW_ENTRY: dict[str, Any] = {} + NEW_ENTRY["appid"] = meta.appid + NEW_ENTRY["appname"] = meta.appname + NEW_ENTRY["exe"] = f'"{meta.exe_path}"' + NEW_ENTRY["StartDir"] = f"{meta.start_dir}" + NEW_ENTRY["icon"] = meta.icon + NEW_ENTRY["ShortcutPath"] = "" + NEW_ENTRY["LaunchOptions"] = "" + NEW_ENTRY["IsHidden"] = 0 + NEW_ENTRY["AllowDesktopConfig"] = 1 + NEW_ENTRY["AllowOverlay"] = 1 + NEW_ENTRY["openvr"] = 0 + NEW_ENTRY["Devkit"] = 0 + NEW_ENTRY["DevkitGameID"] = "" + NEW_ENTRY["DevkitOverrideAppID"] = 0 + NEW_ENTRY["LastPlayTime"] = 0 + NEW_ENTRY["FlatpakAppID"] = "" + NEW_ENTRY["sortas"] = "" + NEW_ENTRY["tags"] = {} + return NEW_ENTRY + + def save_shortcuts(self) -> None: + try: + backup = self.shortcuts_path.with_suffix(".vdf.bak") + shutil.copy(self.shortcuts_path, backup) + with open(self.shortcuts_path, "wb") as f: + vdf.binary_dump(self.shortcuts, f) + except Exception as e: + logger.critical(e) + + +def add_steam_shortcut(steam_path: Path, exe_path: Path) -> None: + try: + start_dir = exe_path.parent + + traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH) + images = Path(str(traversable)) + icon = images.joinpath("icon.png") + + shortcuts = Shortcuts(Path(steam_path)) + shortcuts.add_shortcut(APP_NAME, start_dir, exe_path, icon) + shortcuts.save_shortcuts() + shortcuts.add_grid_images(images) + + copy_dzgui_to_xdg_data(exe_path) + logger.debug("Finished creating Steam shortcut") + except Exception as e: + logger.critical(e) diff --git a/dzgui/api/steam.py b/dzgui/api/steam.py index 02f4718..99de2e6 100644 --- a/dzgui/api/steam.py +++ b/dzgui/api/steam.py @@ -218,6 +218,13 @@ def find_user_id(path: Path) -> str | None: 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]] = { "publishedfileid": mod, @@ -241,20 +248,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) @@ -346,9 +339,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: diff --git a/dzgui/app_init.py b/dzgui/app_init.py index 3502040..8a74466 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,10 @@ 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) - # NOTE: implies that setup wizard failed or was closed - if has_new_config(XDG.config) is False: - return + if not wizard.is_setup_complete(): + 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..4cebe98 100644 --- a/dzgui/config/freedesktop.py +++ b/dzgui/config/freedesktop.py @@ -1,18 +1,43 @@ +import stat import textwrap + from pathlib import Path +from dzgui.const.constants import APP_NAME +from dzgui.util.dirs import copy_dzgui_to_xdg_data, find_icon_resource, make_parents + + +def get_share_path(exe_path: Path) -> Path: + return exe_path.parent.parent + + +def write_desktop_file(exe_path: Path) -> Path: + icon = find_icon_resource() -def write_desktop_file(share_path: Path) -> None: template = f"""\ [Desktop Entry] Version=1.0 Type=Application Terminal=false - Exec={share_path}/launch.sh + Exec={exe_path} Name=DZGUI Comment=dzgui - Icon={share_path}/dzgui + Icon={icon} Categories=Game""" - file = share_path / "dzgui.desktop" - file.write_text(textwrap.dedent(template)) + copy_dzgui_to_xdg_data(exe_path) + + share_path = get_share_path(exe_path) + desktop_file = share_path.joinpath("applications/dzgui.desktop") + make_parents(desktop_file) + desktop_file.write_text(textwrap.dedent(template)) + desktop_file.chmod(desktop_file.stat().st_mode | stat.S_IEXEC) + return desktop_file + + +def write_desktop_shortcut(desktop_file: Path) -> None: + # NOTE: necessarily depends on the above (UI blocks creation without XDG entry first) + link = Path.home().joinpath(f"Desktop/{APP_NAME}.desktop") + if link.exists(): + link.unlink() + link.symlink_to(desktop_file) 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/CHANGELOG.md b/dzgui/data/CHANGELOG.md index 80ebab0..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 @@ -41,8 +42,10 @@ - Choose to jump into splash screen instead of server - Collapsible connection panel - Play offline (load mods directly) +- Generate Steam shortcuts and cover art +- Warn user if background downloads are disabled -## Changed +### Changed - Conform to PEP 440 versioning for beta versions - Reduce padding on keys button - Boldface breadcrumbs @@ -59,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 @@ -136,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 @@ -175,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 @@ -187,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 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..dd7207c 100644 --- a/dzgui/strings/wizard.py +++ b/dzgui/strings/wizard.py @@ -64,6 +64,25 @@ radio_mi = "mi (miles)" label_dist = "Distance display" label_client = "Steam client" +### Shortcuts page +heading_shortcuts = "Create shortcuts" +blurb_shortcuts = ( + "You can optionally create shortcuts to facilitate launching DZGUI faster." +) +button_shortcuts = "Create shortcuts" +checkbox_steam_shortcut = ( + "Add DZGUI to Steam library", + ( + "This creates a standalone copy of DZGUI and adds it to Steam. " + "You 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 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..5707f21 --- /dev/null +++ b/dzgui/util/dirs.py @@ -0,0 +1,32 @@ +import logging +import os +import shutil + +from importlib import resources +from pathlib import Path + +from dzgui.const.constants import APP_NAME, APP_NAME_LOWER, IMAGES_PATH + +logger = logging.getLogger(APP_NAME) + + +def make_parents(path: "Path") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def copy_dzgui_to_xdg_data(exe_path: Path) -> None: + dzgui = os.getenv("PYAPP") + if dzgui is None: + return + try: + make_parents(exe_path) + shutil.copy(dzgui, exe_path) + except Exception as e: + logger.critical(e) + + +def find_icon_resource() -> "Path": + traversable = resources.files(APP_NAME_LOWER).joinpath(IMAGES_PATH) + images = Path(str(traversable)) + icon = images.joinpath("icon.png") + return icon diff --git a/dzgui/views/dialogs/wizard.py b/dzgui/views/dialogs/wizard.py index bdc298c..651ec7a 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,9 @@ from dzgui.const.constants import ( ) from dzgui.const.boilerplate import config_boilerplate from dzgui.const.endpoints import BM_API_SETUP, STEAM_API_SETUP +from dzgui.const.enum import Preferences +from dzgui.config import freedesktop +from dzgui.config.query import lookup from dzgui.init.migrate import migrate_legacy_conf from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager from dzgui.strings import wizard @@ -30,6 +35,9 @@ import gi gi.require_version("Gtk", "3.0") from gi.repository import Gdk, Gtk, GLib, GObject, GdkPixbuf # noqa E402 +if TYPE_CHECKING: + from dzgui.config.xdg import Xdg + class PageNum(Enum): INTRO = 1 @@ -38,7 +46,8 @@ class PageNum(Enum): STEAM_API = 4 BM_API = 5 USER_PREFS = 6 - FINAL = 7 + SHORTCUTS = 7 + FINAL = 8 class DescriptionArea(Gtk.Box): @@ -58,7 +67,7 @@ class Progress(Gtk.ProgressBar): class ScrolledWizardPage(Gtk.ScrolledWindow): def __init__(self, enum: PageNum, heading: str, description: str): - super().__init__() + super().__init__(overlay_scrolling=False) self.enum = enum self.page_type: Gtk.AssistantPageType @@ -141,6 +150,10 @@ class NotificationFrame(Gtk.Frame): if error: add_class(self, "error-frame") + def set_text(self, text: str) -> None: + wrapped = textwrap.fill(text, width=80) + self.label.set_markup(wrapped) + class APIValidationPage(ScrolledWizardPage): def __init__( @@ -339,6 +352,7 @@ class ConfigMigrationPage(ScrolledWizardPage): def _on_import_clicked(self, button: Gtk.Button) -> None: self.grid.set_sensitive(False) try: + # TODO: this could be deferred to the final page (prevents accidental destruction of dialog via ESC) migrate_legacy_conf(self.config) self.migrated = True self.success_box.set_visible(True) @@ -423,24 +437,27 @@ class CompletionPage(ScrolledWizardPage): class Assistant(Gtk.Assistant): - def __init__(self, is_deck: bool, config: Path): + def __init__(self, is_deck: bool, XDG: "Xdg"): super().__init__() if is_deck: self.fullscreen() else: self.set_default_size(1500, 900) - self.config_path = config + self.config_path = XDG.config self.config_values: dict[str, Any] = config_boilerplate + self.setup_complete = False + self.page1 = IntroductionPage() - self.page2 = ConfigMigrationPage(config) + self.page2 = ConfigMigrationPage(XDG.config) self.page3 = SteamPathPage() self.page4 = SteamValidationPage() self.page5 = BMValidationPage() self.page6 = PreferencesPage() - self.page7 = CompletionPage() + self.page7 = ShortcutCreationPage(XDG.shortcut) + self.page8 = CompletionPage() self.set_forward_page_func(self._advance_page) @@ -458,9 +475,16 @@ class Assistant(Gtk.Assistant): self.page5, self.page6, self.page7, + self.page8, ): # NOTE: skip config migration page if no legacy config file - if page == self.page2 and self.has_legacy_config is False: + if ( + isinstance(page, ConfigMigrationPage) + and self.has_legacy_config is False + ): + continue + # NOTE: disabled for now on system-provided packages + if isinstance(page, ShortcutCreationPage) and os.getenv("PYAPP") is None: continue self._add_page(page, page.get_page_type()) @@ -475,13 +499,15 @@ class Assistant(Gtk.Assistant): def _advance_page(self, index: int) -> int: page = self.get_nth_page(index) - # TODO: use enums + # TODO: use enums/isinstance match page: case self.page1: pass case self.page2: if self.page2.is_migrated(): - return self.get_n_pages() - 1 + steam_path = lookup(self.config_path, Preferences.DEFAULT) + self.page7.set_steam_path(steam_path) + return self.get_n_pages() - 2 case self.page3: self.config_values["default_steam_path"] = page.get_path_from_radio() case self.page4: @@ -495,10 +521,17 @@ class Assistant(Gtk.Assistant): self.config_values["use_miles"] = use_miles self.config_values["client"] = client self.write_config() + self.page7.set_steam_path(self.config_values["default_steam_path"]) + case self.page7: + self.page7.create_shortcuts() + self.setup_complete = True case _: raise AttributeError("Trying to advance a non-canonical page") return index + 1 + def is_setup_complete(self) -> bool: + return self.setup_complete + def destroy_and_quit(self, widget: Self) -> None: self.destroy() Gtk.main_quit() @@ -543,6 +576,90 @@ class Assistant(Gtk.Assistant): EMITTER.emit("step_pending") +class CheckboxWithLabel(Gtk.Box): + def __init__(self, text: str, blurb_text: str) -> None: + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5) + + self.button = Gtk.CheckButton(label=text) + self.button.set_active(True) + label = Gtk.Label(label="", halign=Gtk.Align.START, margin_start=20) + wrapped = textwrap.fill(blurb_text, width=100) + label.set_markup(f"- {wrapped}") + + for el in self.button, label: + self.add(el) + + def get_checkbox(self) -> Gtk.CheckButton: + return self.button + + def get_active(self) -> bool: + return self.button.get_active() + + def set_active(self, state: bool) -> None: + self.button.set_active(state) + + +class ShortcutCreationPage(ScrolledWizardPage): + def __init__(self, shortcut: Path) -> None: + super().__init__( + enum=PageNum.SHORTCUTS, + heading=wizard.heading_shortcuts, + description=wizard.blurb_shortcuts, + ) + + self.steam_path: Path + self.shortcut_path = shortcut + self.checks_area = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=10, margin_top=20 + ) + self.page_type = Gtk.AssistantPageType.INTRO + + label, blurb = wizard.checkbox_steam_shortcut + self.steam_checkbox = CheckboxWithLabel(label, blurb) + + label, blurb = wizard.checkbox_start_menu + self.start_menu_checkbox = CheckboxWithLabel(label, blurb) + cb = self.start_menu_checkbox.get_checkbox() + cb.connect("toggled", self._on_start_menu_toggled) + + label, blurb = wizard.checkbox_desktop_shortcut + self.desktop_checkbox = CheckboxWithLabel(label, blurb) + + for el in ( + self.steam_checkbox, + self.start_menu_checkbox, + self.desktop_checkbox, + ): + self.checks_area.add(el) + + self.add_start(self.checks_area) + self.show_all() + self.connect("map", self._on_map) + + def _on_start_menu_toggled(self, button: Gtk.CheckButton) -> None: + state = button.get_active() + if not state: + self.desktop_checkbox.set_active(state) + self.desktop_checkbox.set_sensitive(state) + + def _on_map(self, page: "ScrolledWizardPage") -> None: + EMITTER.emit("step_complete") + + def set_steam_path(self, path: Path) -> None: + self.steam_path = path + + def create_shortcuts(self) -> None: + # NOTE: best-effort, permissive even on failure (page is already marked as complete) + if self.steam_checkbox.get_active(): + add_steam_shortcut(self.steam_path, self.shortcut_path) + + if self.start_menu_checkbox.get_active(): + desktop_file = freedesktop.write_desktop_file(self.shortcut_path) + + if self.desktop_checkbox.get_active(): + freedesktop.write_desktop_shortcut(desktop_file) + + class SteamPathPage(ScrolledWizardPage): def __init__(self) -> None: super().__init__( @@ -599,17 +716,20 @@ class SteamPathPage(ScrolledWizardPage): class SetupWizard(Gtk.Application): - def __init__(self, is_deck: bool, config: Path) -> None: + def __init__(self, is_deck: bool, XDG: "Xdg") -> None: super().__init__() GLib.set_prgname(APP_NAME) - Window(is_deck, config) + self.win = Window(is_deck, XDG) Gtk.main() + def is_setup_complete(self) -> int: + return self.win.assistant.is_setup_complete() + class Window(Gtk.Window): - def __init__(self, is_deck: bool, config: Path) -> None: + def __init__(self, is_deck: bool, XDG: "Xdg") -> None: super().__init__(title=APP_NAME, icon_name=APP_NAME) - Assistant(is_deck, config) + self.assistant = Assistant(is_deck, XDG) class Emitter(GObject.GObject): diff --git a/tests/fixtures/api/no_shortcuts.vdf b/tests/fixtures/api/no_shortcuts.vdf new file mode 100644 index 0000000..7ca09eb Binary files /dev/null and b/tests/fixtures/api/no_shortcuts.vdf differ 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