Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- uses: pnpm/action-setup@v5
with:
version: latest
Expand Down
8 changes: 7 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ function getPythonCommand(): { cmd: string; args: string[]; env: NodeJS.ProcessE
pyPathParts.push(vendorDir);
}
pyPathParts.push(srcDir);
env.PYTHONPATH = pyPathParts.join(":") + (env.PYTHONPATH ? `:${env.PYTHONPATH}` : "");
if (env.PYTHONPATH) {
pyPathParts.push(env.PYTHONPATH);
}
env.PYTHONPATH = pyPathParts.join(":");
// Ensure system site-packages are available as fallback for native
// extensions (.so) that may not match the vendored Python version.
env.PYTHONNOUSERSITE = "";
return {
cmd: "python3",
args: ["-m", "game_setup_hub.api", "--port", "0"],
Expand Down
6 changes: 3 additions & 3 deletions electron/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "protonshift",
"version": "0.8.10",
"version": "0.8.11",
"description": "Linux game configuration toolkit",
"main": "dist/main.js",
"scripts": {
Expand Down Expand Up @@ -85,11 +85,11 @@
}
},
"deb": {
"depends": ["python3 (>= 3.12)"],
"depends": ["python3 (>= 3.12)", "python3-pydantic"],
"maintainer": "I4cTime <I4cTime@users.noreply.github.com>"
},
"rpm": {
"depends": ["python3 >= 3.12"],
"depends": ["python3 >= 3.12", "python3-pydantic"],
"fpm": ["--rpm-summary", "Linux game configuration toolkit"]
},
"flatpak": {
Expand Down
27 changes: 25 additions & 2 deletions electron/scripts/vendor-python-deps.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
#!/usr/bin/env bash
# Vendor Python runtime dependencies for AppImage/deb/rpm packaging.
#
# Native extensions (.so) are version-locked to the build Python.
# The Electron main process appends system site-packages as a fallback
# so users on a different Python minor version still get native deps
# from their system packages.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARGET="${ROOT}/python-vendor"
REQ="${ROOT}/python-runtime-requirements.txt"

rm -rf "${TARGET}"
python3 -m pip install -r "${REQ}" -t "${TARGET}" --upgrade
echo "Vendored Python deps into ${TARGET}"
python3 -m pip install \
-r "${REQ}" \
-t "${TARGET}" \
--upgrade \
--no-cache-dir

# Remove unnecessary bloat from vendor dir
find "${TARGET}" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
find "${TARGET}" -type d -name "*.dist-info" -exec sh -c '
for d; do
# Keep METADATA and top_level.txt, delete the rest
find "$d" -maxdepth 1 -type f ! -name METADATA ! -name top_level.txt ! -name RECORD -delete 2>/dev/null
done
' _ {} + 2>/dev/null || true

PY_VER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
SO_COUNT="$(find "${TARGET}" -name '*.so' | wc -l)"
echo "Vendored Python deps into ${TARGET} (Python ${PY_VER}, ${SO_COUNT} native extensions)"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "protonshift"
version = "0.8.10"
version = "0.8.11"
description = "Linux game configuration toolkit: GPU, launch options, Proton, env vars"
readme = "README.md"
requires-python = ">=3.12"
Expand Down
70 changes: 70 additions & 0 deletions src/game_setup_hub/_vendor_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Ensure vendored native extensions are compatible with the running Python.

When the app is packaged (AppImage/deb/rpm), Python dependencies are vendored
into a directory added to PYTHONPATH. Native extensions (.so) are ABI-locked
to the Python version used at build time. If the user runs a different Python
minor version, those .so files won't load.

This module detects the mismatch early and removes incompatible vendored
packages from sys.path so Python falls back to system site-packages.
"""

from __future__ import annotations

import sys
import sysconfig
from pathlib import Path

_NATIVE_PACKAGES = [
"pydantic_core",
"uvloop",
"httptools",
"watchfiles",
"yaml", # PyYAML
]

_SOABI = sysconfig.get_config_var("SOABI") or ""


def _has_compatible_so(pkg_dir: Path) -> bool:
"""Check if a vendored package dir has .so files matching this Python."""
so_files = list(pkg_dir.glob("*.so"))
if not so_files:
return True # pure-Python package, always compatible
return any(_SOABI in f.name for f in so_files)


def fixup_vendor_path() -> None:
"""Remove vendored native-extension dirs that are ABI-incompatible."""
vendor_dirs = [
p for p in sys.path
if "vendor" in p and Path(p).is_dir()
]
if not vendor_dirs:
return

for vendor_dir in vendor_dirs:
vp = Path(vendor_dir)
for pkg_name in _NATIVE_PACKAGES:
pkg_dir = vp / pkg_name
if pkg_dir.is_dir() and not _has_compatible_so(pkg_dir):
_remove_vendored_package(vp, pkg_name)


def _remove_vendored_package(vendor_dir: Path, pkg_name: str) -> None:
"""Remove a single incompatible package from the vendor dir at runtime."""
import shutil

pkg_dir = vendor_dir / pkg_name
if pkg_dir.exists():
shutil.rmtree(pkg_dir, ignore_errors=True)

for dist_info in vendor_dir.glob(f"{pkg_name}-*.dist-info"):
shutil.rmtree(dist_info, ignore_errors=True)

# Clear any cached import state
if pkg_name in sys.modules:
del sys.modules[pkg_name]


fixup_vendor_path()
2 changes: 2 additions & 0 deletions src/game_setup_hub/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import game_setup_hub._vendor_compat # noqa: F401 # must be first

import argparse
import asyncio
import socket
Expand Down
Loading