mirror of
https://github.com/aclist/dztui.git
synced 2026-08-27 02:07:18 +02:00
Merge pull request #400 from aclist/feat/shortcuts
feat: generate Steam shortcut
This commit is contained in:
commit
7c37fd8b6e
@ -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
|
||||
|
||||
@ -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?
|
||||
|
||||
173
dzgui/api/shortcuts.py
Normal file
173
dzgui/api/shortcuts.py
Normal file
@ -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)
|
||||
@ -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/<steamid_32>/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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 81 B After Width: | Height: | Size: 81 B |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
@ -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 <b>must restart Steam</b> 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"
|
||||
|
||||
@ -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)
|
||||
|
||||
32
dzgui/util/dirs.py
Normal file
32
dzgui/util/dirs.py
Normal file
@ -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
|
||||
@ -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):
|
||||
|
||||
BIN
tests/fixtures/api/no_shortcuts.vdf
vendored
Normal file
BIN
tests/fixtures/api/no_shortcuts.vdf
vendored
Normal file
Binary file not shown.
67
tests/test_shortcuts.py
Normal file
67
tests/test_shortcuts.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user