Compare commits

..
12 Commits
Author SHA1 Message Date
quoije f367bbe298 Clean /usr/bin install: contents dir 'lib' + install.sh
build / Linux x86_64 (push) Successful in 13m11s
build / Windows x64 (Wine) (push) Successful in 20m38s
2026-09-13 21:47:32 -04:00
quoije c61d8e9d3f Revert contents_directory (collides with exe name)
build / Linux x86_64 (push) Failing after 3m8s
build / Windows x64 (Wine) (push) Canceled after 0s
2026-09-13 21:43:28 -04:00
quoije 78f6d44c73 Fix frozen-build SSL certs, taskbar icon, and rename _internal to psp-preserve
build / Linux x86_64 (push) Failing after 7m17s
build / Windows x64 (Wine) (push) Canceled after 0s
- config: point SSL at certifi bundle when frozen (fixes store/NPS/TMDB SSL errors)
- gui: set app name + window icon for the taskbar
- spec: contents_directory=psp-preserve; bundle a .desktop file for Wayland launchers
2026-09-13 21:19:56 -04:00
quoije a7f90cd5a7 Runner image: use WineHQ wine-staging + embeddable Python (Wine 8.0 too old for Py3.12) 2026-09-13 21:13:30 -04:00
quoije c9575817fe Runner image: use full Python installer (bundles pip + OpenSSL) 2026-09-13 21:11:45 -04:00
quoije b576ff5747 Settings: add Storage section showing state dir, DB, cover cache, and user data paths 2026-09-13 21:07:23 -04:00
quoije 6510229b07 Add cover-grid sorting to Library and Store
Library: Title / Release date / Size / Region / Type / Serial
Store: Newest first / Title / Region / Size / In library first
2026-09-13 21:03:49 -04:00
quoije e61d91418e Runner image: add wine32 (winetricks regedit needs 32-bit Wine) 2026-09-13 20:52:42 -04:00
quoije a1c461f6aa Runner image: use embeddable Python (avoid 32-bit installer bootstrap) 2026-09-13 20:50:57 -04:00
quoije 985c8df2cc Runner image: add xauth (xvfb-run requirement) 2026-09-13 20:38:48 -04:00
quoije 6eab2eca9c Use upload-artifact v3 (Gitea lacks v4 artifact protocol)
build / Linux x86_64 (push) Successful in 13m34s
build / Windows x64 (Wine) (push) Canceled after 0s
2026-09-13 20:28:57 -04:00
quoije eac29c428d Add Wine-based runner image for Windows builds
- runner/Dockerfile: Debian + Wine + Windows CPython 3.12 + PyInstaller + act_runner
- runner/entrypoint.sh: self-register with the windows:host label
- workflow windows job now builds via winpython under Wine
2026-09-13 20:18:06 -04:00
9 changed files with 303 additions and 15 deletions
+13 -13
View File
@@ -25,36 +25,36 @@ jobs:
run: pyinstaller --clean --noconfirm psp-preserve.spec run: pyinstaller --clean --noconfirm psp-preserve.spec
- name: Package tarball - name: Package tarball
run: tar -czf psp-preserve-linux-x86_64.tar.gz -C dist psp-preserve run: |
cp packaging/install.sh dist/psp-preserve/
cp packaging/psp-preserve.desktop dist/psp-preserve/
cp img/app-icon.png dist/psp-preserve/
tar -czf psp-preserve-linux-x86_64.tar.gz -C dist psp-preserve
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v3
with: with:
name: psp-preserve-linux-x86_64 name: psp-preserve-linux-x86_64
path: psp-preserve-linux-x86_64.tar.gz path: psp-preserve-linux-x86_64.tar.gz
windows: windows:
name: Windows x64 name: Windows x64 (Wine)
runs-on: windows runs-on: windows
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build dependencies - name: Install build dependencies
run: | run: |
python -m pip install --upgrade pip winpython -m pip install --upgrade pip
pip install -r requirements-build.txt winpython -m pip install -r requirements-build.txt
- name: Build - name: Build
run: pyinstaller --clean --noconfirm psp-preserve.spec run: winpython -m PyInstaller --clean --noconfirm psp-preserve.spec
- name: Package zip - name: Package zip
shell: pwsh run: |
run: Compress-Archive -Path dist\psp-preserve -DestinationPath psp-preserve-windows-x64.zip cd dist && zip -rq ../psp-preserve-windows-x64.zip psp-preserve
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v3
with: with:
name: psp-preserve-windows-x64 name: psp-preserve-windows-x64
path: psp-preserve-windows-x64.zip path: psp-preserve-windows-x64.zip
+18
View File
@@ -110,6 +110,24 @@ act_runner.exe register \
The registration token is generated in Gitea under The registration token is generated in Gitea under
**Site Administration → Actions → Runners → Create new runner**. **Site Administration → Actions → Runners → Create new runner**.
#### Windows builds without a Windows box (Wine runner)
The `windows` job targets a runner labelled `windows` that builds under Wine on
Linux. Build it from `runner/`:
```bash
docker build -f runner/Dockerfile -t psp-wine-runner .
docker run -d --name psp-wine-runner --restart unless-stopped \
-e GITEA_INSTANCE_URL=https://git.ceaf.dev \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<registration-token> \
-v /mnt/user/appdata/psp-wine-runner:/data \
psp-wine-runner
```
The image ships Wine + Windows CPython 3.12 + PyInstaller, and registers itself
with the `windows:host` label. (If you have a real Windows machine instead,
register a native `act_runner.exe` with the same `windows` label.)
## First-time setup ## First-time setup
1. Put your DAT files in `data/dats/`: 1. Put your DAT files in `data/dats/`:
+13
View File
@@ -11,6 +11,19 @@ _BUNDLE_DIR = getattr(sys, "_MEIPASS", None)
PROJECT_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent
# Frozen bundles don't ship the system CA store, so Python's urllib would fail
# TLS verification against the store server / NoPayStation / TMDB / archive.org.
# Point OpenSSL at the certifi bundle (a curl_cffi dependency) when frozen only;
# source runs keep using the OS CA store.
if IS_FROZEN:
try:
import certifi
_CA_BUNDLE = certifi.where()
os.environ.setdefault("SSL_CERT_FILE", _CA_BUNDLE)
os.environ.setdefault("REQUESTS_CA_BUNDLE", _CA_BUNDLE)
except Exception: # noqa: BLE001
pass
def resource_path(*parts): def resource_path(*parts):
"""Absolute path to a read-only bundled resource (works when frozen).""" """Absolute path to a read-only bundled resource (works when frozen)."""
+84 -1
View File
@@ -1280,6 +1280,22 @@ class SettingsPage(QWidget):
form.addRow(self.clear_covers_btn) form.addRow(self.clear_covers_btn)
col.addWidget(box) col.addWidget(box)
# -- Storage ---------------------------------------------------------- #
box, form = self._group("Storage")
self._path_row(form, "State directory", str(config.STATE_DIR))
self._path_row(form, "Library database", str(config.DB_PATH))
self._path_row(form, "Cover cache", str(config.COVERS_DIR))
self._path_row(form, "User data (credentials, NPS cache…)", str(config.USER_DATA_DIR))
note = QLabel(
"Your library, covers, and credentials live here — outside the app "
"bundle, so they survive upgrades. On Linux this is the "
"~/.local/state/psp-preserve folder."
)
note.setWordWrap(True)
note.setStyleSheet("color: #9a9ba5;")
form.addRow(note)
col.addWidget(box)
col.addStretch(1) col.addStretch(1)
def _group(self, title): def _group(self, title):
@@ -1342,6 +1358,29 @@ class SettingsPage(QWidget):
form.addRow(label, edit) form.addRow(label, edit)
return edit return edit
def _path_row(self, form, label, path):
edit = QLineEdit(path)
edit.setReadOnly(True)
open_btn = QPushButton("Open")
open_btn.clicked.connect(lambda _=False, p=Path(path): self._open_path(p))
row = QWidget()
h = QHBoxLayout(row)
h.setContentsMargins(0, 0, 0, 0)
h.addWidget(edit, 1)
h.addWidget(open_btn)
form.addRow(label, row)
return edit
@staticmethod
def _open_path(path):
path = Path(path)
target = path if path.is_dir() else path.parent
if target.is_dir():
QDesktopServices.openUrl(QUrl.fromLocalFile(str(target)))
else:
QMessageBox.information(None, "Not found",
f"Folder not created yet:\n{target}\n\nIt is created on first use.")
def games_dir(self): def games_dir(self):
return self.games_edit.text().strip() return self.games_edit.text().strip()
@@ -1789,6 +1828,11 @@ class StorePage(QWidget):
self.psp_filter.setToolTip("Filter by whether the archive matches a disc installed on the connected PSP") self.psp_filter.setToolTip("Filter by whether the archive matches a disc installed on the connected PSP")
self.psp_filter.currentIndexChanged.connect(self._apply_filter) self.psp_filter.currentIndexChanged.connect(self._apply_filter)
bar.addWidget(self.psp_filter) bar.addWidget(self.psp_filter)
self.store_sort = QComboBox()
self.store_sort.addItems(["Newest first", "Title (A–Z)", "Region (A–Z)", "Size (largest)", "In library first"])
self.store_sort.setToolTip("Sort the cover grid")
self.store_sort.currentTextChanged.connect(self._apply_filter)
bar.addWidget(self.store_sort)
refresh_btn = QPushButton("Refresh") refresh_btn = QPushButton("Refresh")
refresh_btn.clicked.connect(lambda: self.refresh(force=True)) refresh_btn.clicked.connect(lambda: self.refresh(force=True))
bar.addWidget(refresh_btn) bar.addWidget(refresh_btn)
@@ -2046,7 +2090,7 @@ class StorePage(QWidget):
if not it.get("release_date"): if not it.get("release_date"):
it["release_date"] = dates.get((str(rid), system)) or "" it["release_date"] = dates.get((str(rid), system)) or ""
filtered.append(it) filtered.append(it)
filtered.sort(key=self._store_sort_key) self._sort_store_items(filtered)
self._filtered = filtered self._filtered = filtered
if self.all_check.isChecked(): if self.all_check.isChecked():
show = len(filtered) show = len(filtered)
@@ -2054,6 +2098,19 @@ class StorePage(QWidget):
show = min(self.RECENT_LIMIT, len(filtered)) show = min(self.RECENT_LIMIT, len(filtered))
self._populate(filtered, show) self._populate(filtered, show)
def _sort_store_items(self, items):
mode = self.store_sort.currentText()
if mode == "Title (A–Z)":
items.sort(key=lambda it: store.display_name(it["name"]).lower())
elif mode == "Region (A–Z)":
items.sort(key=lambda it: (store.region(it["name"]) or "").lower())
elif mode == "Size (largest)":
items.sort(key=lambda it: it.get("size") or 0, reverse=True)
elif mode == "In library first":
items.sort(key=lambda it: (not it.get("in_library"), store.display_name(it["name"]).lower()))
else: # Newest first
items.sort(key=self._store_sort_key)
@staticmethod @staticmethod
def _store_sort_key(it): def _store_sort_key(it):
d = it.get("release_date") or "" d = it.get("release_date") or ""
@@ -4366,6 +4423,11 @@ class MainWindow(QMainWindow):
self.library_view = QComboBox() self.library_view = QComboBox()
self.library_view.addItems(["Cover view", "List view"]) self.library_view.addItems(["Cover view", "List view"])
bar.addWidget(self.library_view) bar.addWidget(self.library_view)
self.library_sort = QComboBox()
self.library_sort.addItems(["Title", "Release date", "Size", "Region", "Type", "Serial"])
self.library_sort.setToolTip("Sort the cover grid")
self.library_sort.currentTextChanged.connect(self._apply_library_filter)
bar.addWidget(self.library_sort)
reload_btn = QPushButton("Reload") reload_btn = QPushButton("Reload")
reload_btn.clicked.connect(self.load_library) reload_btn.clicked.connect(self.load_library)
bar.addWidget(reload_btn) bar.addWidget(reload_btn)
@@ -4633,12 +4695,30 @@ class MainWindow(QMainWindow):
if psp_f == "Not on PSP" and it.get("on_psp"): if psp_f == "Not on PSP" and it.get("on_psp"):
continue continue
items.append(it) items.append(it)
self._sort_library_items(items)
self._library_filtered = items self._library_filtered = items
self._populate_library_table(items) self._populate_library_table(items)
self.cover_grid.set_items(items) self.cover_grid.set_items(items)
if hasattr(self, "psp_page"): if hasattr(self, "psp_page"):
self.psp_page.set_library_items(self._library_cache) self.psp_page.set_library_items(self._library_cache)
def _sort_library_items(self, items):
mode = self.library_sort.currentText()
title = lambda it: (it.get("title_override") or it.get("canonical") or it["name"] or "").lower()
if mode == "Release date":
items.sort(key=lambda it: it.get("release_date") or "", reverse=True)
elif mode == "Size":
items.sort(key=lambda it: it.get("size") or 0, reverse=True)
elif mode == "Region":
items.sort(key=lambda it: (it.get("region") or "").lower())
elif mode == "Type":
order = {"games": 0, "videos": 1, "ps1": 2, "theme": 3, "dlc": 4}
items.sort(key=lambda it: (order.get(it.get("system"), 9), title(it)))
elif mode == "Serial":
items.sort(key=lambda it: (it.get("serial") or "").lower())
else: # Title
items.sort(key=title)
def _populate_library_table(self, items): def _populate_library_table(self, items):
self.library_table.setUpdatesEnabled(False) self.library_table.setUpdatesEnabled(False)
self.library_table.setSortingEnabled(False) self.library_table.setSortingEnabled(False)
@@ -5599,10 +5679,13 @@ def main():
log.info("starting psp-preserve (db: %s)", config.DB_PATH) log.info("starting psp-preserve (db: %s)", config.DB_PATH)
app = QApplication(sys.argv) app = QApplication(sys.argv)
style.apply_theme(app) style.apply_theme(app)
app.setApplicationName("PSP Preservation Tool")
app.setDesktopFileName("psp-preserve")
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png" icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
if icon_path.is_file(): if icon_path.is_file():
app.setWindowIcon(QIcon(str(icon_path))) app.setWindowIcon(QIcon(str(icon_path)))
window = MainWindow() window = MainWindow()
window.setWindowIcon(app.windowIcon())
window.show() window.show()
log.info("main window ready") log.info("main window ready")
threading.Thread(target=_start_bundle_check, args=(window,), daemon=True).start() threading.Thread(target=_start_bundle_check, args=(window,), daemon=True).start()
+81
View File
@@ -0,0 +1,81 @@
#!/bin/sh
# Install the PSP Preservation Tool to a system prefix.
#
# FHS-friendly layout:
# $PREFIX/bin/psp-preserve symlink -> payload binary
# $PREFIX/lib/psp-preserve/ app payload (binary + lib/)
# $PREFIX/share/applications/ desktop entry
# $PREFIX/share/icons/hicolor/256x256/apps/ app icon
#
# The PyInstaller onedir build keeps the executable and its `lib/` contents
# directory side by side, so both live under $PREFIX/lib/psp-preserve/ and a
# symlink exposes the binary at $PREFIX/bin/psp-preserve.
#
# Usage (run from inside the extracted tarball):
# sudo ./install.sh # install to /usr/local (default)
# sudo ./install.sh --prefix=/usr # install to /usr
# sudo ./install.sh --uninstall # remove from the default prefix
set -eu
PREFIX=/usr/local
MODE=install
for arg in "$@"; do
case "$arg" in
--prefix=*) PREFIX=${arg#--prefix=} ;;
--uninstall) MODE=uninstall ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
SRC_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
LIBDIR="$PREFIX/lib/psp-preserve"
BINDIR="$PREFIX/bin"
APPDIR="$PREFIX/share/applications"
ICONDIR="$PREFIX/share/icons/hicolor/256x256/apps"
APP_BIN="$LIBDIR/psp-preserve"
require_root() {
if [ "$(id -u)" -ne 0 ]; then
echo "run as root (e.g. sudo)" >&2
exit 1
fi
}
uninstall() {
rm -f "$BINDIR/psp-preserve"
rm -f "$APPDIR/psp-preserve.desktop"
rm -f "$ICONDIR/psp-preserve.png"
rm -rf "$LIBDIR"
echo "Uninstalled from $PREFIX"
}
if [ "$MODE" = uninstall ]; then
require_root
uninstall
exit 0
fi
require_root
# The tarball ships these next to this script.
for f in psp-preserve lib psp-preserve.desktop app-icon.png; do
if [ ! -e "$SRC_DIR/$f" ]; then
echo "missing $f (run install.sh from inside the extracted tarball)" >&2
exit 1
fi
done
mkdir -p "$LIBDIR" "$BINDIR" "$APPDIR" "$ICONDIR"
rm -rf "$LIBDIR"
mkdir -p "$LIBDIR"
cp -a "$SRC_DIR/psp-preserve" "$LIBDIR/psp-preserve"
cp -a "$SRC_DIR/lib" "$LIBDIR/lib"
install -m 0644 "$SRC_DIR/psp-preserve.desktop" "$APPDIR/psp-preserve.desktop"
install -m 0644 "$SRC_DIR/app-icon.png" "$ICONDIR/psp-preserve.png"
ln -sfn "$APP_BIN" "$BINDIR/psp-preserve"
echo "Installed to $PREFIX"
echo " binary: $BINDIR/psp-preserve -> $APP_BIN"
echo " payload: $LIBDIR"
echo " desktop: $APPDIR/psp-preserve.desktop"
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=PSP Preservation Tool
Comment=Preserve and manage your PSP library
Exec=psp-preserve
Icon=psp-preserve
Terminal=false
Categories=Utility;AudioVideo;
StartupWMClass=psp-preserve
+3 -1
View File
@@ -2,7 +2,7 @@
"""PyInstaller spec: build the PSP Preservation Tool (onedir). """PyInstaller spec: build the PSP Preservation Tool (onedir).
Bundles the read-only resources (DATs, PS1 loader, offline cover bundles, Bundles the read-only resources (DATs, PS1 loader, offline cover bundles,
icons) under ``_internal/`` next to the executable. User-writable data icons) under ``lib/`` next to the executable. User-writable data
(credentials, manual covers, PS1 templates, NPS cache, library DB, cover (credentials, manual covers, PS1 templates, NPS cache, library DB, cover
cache) lives in the OS state dir at runtime — see ``app/config.py``. cache) lives in the OS state dir at runtime — see ``app/config.py``.
@@ -24,6 +24,7 @@ datas = [
("data/covers/UMD_GAME_COVER_TEMPLATE.png", "data/covers"), ("data/covers/UMD_GAME_COVER_TEMPLATE.png", "data/covers"),
("img/app-icon.png", "img"), ("img/app-icon.png", "img"),
("img/chevron.png", "img"), ("img/chevron.png", "img"),
("packaging/psp-preserve.desktop", "."),
] ]
a = Analysis( a = Analysis(
@@ -62,4 +63,5 @@ coll = COLLECT(
strip=False, strip=False,
upx=False, upx=False,
name="psp-preserve", name="psp-preserve",
contents_directory="lib",
) )
+68
View File
@@ -0,0 +1,68 @@
# Wine-based Gitea Actions runner: builds Windows binaries on a Linux host.
#
# Jobs run in "host" mode inside this container, where a Windows CPython 3.12
# (running under Wine) plus PyInstaller produce the .exe. Node.js and git are
# included so the stock JS actions (actions/checkout, actions/upload-artifact)
# work as-is.
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive \
WINEPREFIX=/root/.wine \
WINEDEBUG=-all \
WINEDLLOVERRIDES="mscoree,mshtml=" \
PYTHON_VERSION=3.12.8
# WineHQ wine-staging (newer than Debian's 8.0, needed for Python 3.12),
# node + git for JS actions, and build/package tooling.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl wget gnupg2 \
&& mkdir -p /etc/apt/keyrings \
&& wget -qO- https://dl.winehq.org/wine-builds/winehq.key | gpg --dearmor -o /etc/apt/keyrings/winehq.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/winehq.gpg] https://dl.winehq.org/wine-builds/debian/ bookworm main" > /etc/apt/sources.list.d/winehq.list \
&& apt-get update \
&& apt-get install -y --install-recommends winehq-staging \
&& apt-get install -y --no-install-recommends unzip zip xvfb xauth cabextract nodejs git \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# winetricks (standalone script; not packaged in Debian main).
RUN wget -qO /usr/local/bin/winetricks \
"https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks" \
&& chmod +x /usr/local/bin/winetricks
# Gitea Actions runner (linux-amd64 binary).
RUN wget -qO /usr/local/bin/act_runner \
"https://gitea.com/gitea/act_runner/releases/download/v0.6.1/act_runner-0.6.1-linux-amd64" \
&& chmod +x /usr/local/bin/act_runner
# MSVC runtime required by PySide6's Qt DLLs (also initializes the Wine prefix).
RUN xvfb-run -a winetricks --unattended vcrun2022
# Windows CPython 3.12 (embeddable zip) + pip.
RUN wget -qO /tmp/pyembed.zip \
"https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
&& mkdir -p /root/.wine/drive_c/Python312 \
&& cd /root/.wine/drive_c/Python312 \
&& unzip -q /tmp/pyembed.zip \
&& rm -f /tmp/pyembed.zip \
&& printf 'python312.zip\n.\nimport site\nLib\\site-packages\n' > python312._pth \
&& wine "C:\\Python312\\python.exe" --version
# Convenience wrapper: winpython -> wine C:\Python312\python.exe
RUN printf '#!/bin/sh\nexec wine "C:\\Python312\\python.exe" "$@"\n' > /usr/local/bin/winpython \
&& chmod +x /usr/local/bin/winpython
# Bootstrap pip, then pre-install the Windows build deps so CI jobs start fast.
RUN wget -qO /tmp/get-pip.py https://bootstrap.pypa.io/get-pip.py \
&& wine "C:\\Python312\\python.exe" /tmp/get-pip.py \
&& rm -f /tmp/get-pip.py
COPY requirements-build.txt /tmp/requirements-build.txt
RUN winpython -m pip install --upgrade pip \
&& winpython -m pip install -r /tmp/requirements-build.txt \
&& rm -f /tmp/requirements-build.txt
COPY runner/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
WORKDIR /data
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -e
# Register on first run, then run the daemon (host mode).
if [ ! -f /data/.runner ]; then
echo "Registering Wine runner with Gitea…"
act_runner register --no-interactive \
--instance "${GITEA_INSTANCE_URL:?GITEA_INSTANCE_URL is required}" \
--token "${GITEA_RUNNER_REGISTRATION_TOKEN:?GITEA_RUNNER_REGISTRATION_TOKEN is required}" \
--name "${RUNNER_NAME:-windows-builder}" \
--labels "${RUNNER_LABELS:-windows:host}"
fi
exec act_runner daemon