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] 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):