Polish settings, add matching defaults, drop pkg2zip, externalize infra config

- Rework Settings tab into grouped sections
- Add preferred-region matching and full-hash-by-default setting
- Replace combo-box square arrow with a proper chevron
- Fix UMD Refresh button (clicked bool was clearing the view)
- Remove pkg2zip.exe fallback (pure-Python decryptor is the primary path)
- Move LAN IP/share paths to .env; use example defaults in config
This commit is contained in:
2026-09-13 20:03:44 -04:00
parent 1ec2e81e15
commit c19717402c
14 changed files with 249 additions and 265 deletions
+4 -91
View File
@@ -9,14 +9,12 @@ folder — no Wine or ``pkg2zip`` required.
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from typing import List
from . import config
@@ -167,31 +165,6 @@ def fetch_items(content_type: str, url: str = "", timeout: int = 60, force: bool
raise
def find_pkg2zip(configured_path: str = "") -> Optional[Path]:
"""Locate pkg2zip: configured path, then the bundled binary, then PATH."""
if configured_path:
p = Path(configured_path)
return p if p.is_file() else None
bundled = config.DATA_DIR / "bin" / "pkg2zip.exe"
if bundled.is_file():
return bundled
found = shutil.which("pkg2zip")
return Path(found) if found else None
def _pkg2zip_cmd(pkg2zip: Path) -> List[str]:
"""Command prefix for pkg2zip, wrapping the bundled .exe in Wine on Linux."""
if pkg2zip.suffix.lower() == ".exe" and os.name != "nt":
wine = shutil.which("wine") or shutil.which("wine64")
if not wine:
raise RuntimeError(
"The bundled pkg2zip is a Windows binary and Wine is not installed — "
"install Wine (or point Settings → Store at a native pkg2zip)."
)
return [wine, str(pkg2zip)]
return [str(pkg2zip)]
def _safe_folder(value: str) -> str:
import re
cleaned = re.sub(r"[^A-Za-z0-9]", "", value or "").upper()
@@ -211,12 +184,11 @@ def _ensure_keys_bin(folder, kind):
pass
def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
def extract_pkg(pkg_path, title_id: str, dest_dir,
kind_hint: str = None, progress_cb=None, cancel_event=None) -> Path:
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls
back to pkg2zip only if that fails and a binary is available. Returns the
Uses the bundled pure-Python decryptor (no Wine or pkg2zip). Returns the
destination game folder path.
"""
from . import pkg as pkgmod
@@ -227,7 +199,6 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
if dest.is_dir() and any(dest.iterdir()):
return dest
# 1. Pure-Python decryptor (primary).
try:
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, kind_hint,
progress_cb, cancel_event)
@@ -236,62 +207,4 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
except TransferCancelled:
raise
except Exception as exc: # noqa: BLE001
py_exc = exc
log.warning("python pkg decrypt failed (%s); trying pkg2zip fallback", exc)
# 2. pkg2zip fallback (needs a binary; usually Wine + the bundled .exe).
if pkg2zip is None:
pkg2zip = find_pkg2zip()
if pkg2zip is None:
raise RuntimeError(
f"could not decrypt {pkg_path.name} (no pkg2zip available): {py_exc}"
)
folder_name = _safe_folder(title_id)
tmp = Path(dest_dir).with_name(".nps-tmp-" + folder_name)
if tmp.exists():
shutil.rmtree(tmp, ignore_errors=True)
tmp.mkdir(parents=True, exist_ok=True)
if progress_cb is not None:
progress_cb("Extracting", 0.0, "")
try:
cmd = _pkg2zip_cmd(Path(pkg2zip)) + ["-x", str(pkg_path)]
env = dict(os.environ)
env["WINEDEBUG"] = "-all" # silence Wine's console chatter
proc = subprocess.Popen(
cmd,
cwd=str(tmp), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, env=env,
)
assert proc.stdout is not None
for _line in proc.stdout:
if cancel_event is not None and cancel_event.is_set():
proc.kill()
raise TransferCancelled()
proc.wait()
if proc.returncode != 0:
raise RuntimeError(f"pkg2zip failed (exit {proc.returncode}) for {pkg_path.name}")
finally:
if cancel_event is not None and cancel_event.is_set():
shutil.rmtree(tmp, ignore_errors=True)
raise TransferCancelled()
eboots = list(tmp.rglob("EBOOT.PBP"))
if not eboots:
shutil.rmtree(tmp, ignore_errors=True)
raise RuntimeError(f"pkg2zip produced no EBOOT.PBP from {pkg_path.name}")
src_folder = eboots[0].parent
dest.mkdir(parents=True, exist_ok=True)
for p in sorted(src_folder.rglob("*")):
if not p.is_file():
continue
rel = p.relative_to(src_folder)
target = dest / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(p, target)
if progress_cb is not None:
progress_cb("Extracting", 1.0, "")
shutil.rmtree(tmp, ignore_errors=True)
return dest
raise RuntimeError(f"could not decrypt {pkg_path.name}: {exc}") from exc