Compare commits

..
11 Commits
Author SHA1 Message Date
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
8 changed files with 215 additions and 13 deletions
+8 -12
View File
@@ -27,34 +27,30 @@ jobs:
- name: Package tarball - name: Package tarball
run: tar -czf psp-preserve-linux-x86_64.tar.gz -C dist psp-preserve run: 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()
+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
+1
View File
@@ -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(
+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