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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ body:
attributes:
label: ProtonShift version
description: "Check the app's About dialog or `electron/package.json`."
placeholder: "0.8.8"
placeholder: "0.9.5"
validations:
required: true

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ jobs:
org.electronjs.Electron2.BaseApp//24.08

- name: Install dependencies
env:
# @heroui-pro/react has a postinstall step that fetches the real dist
# from the HeroUI Pro registry. Without this token it stops at the
# skeleton package and the renderer build cannot resolve `@heroui-pro/react`.
HEROUI_AUTH_TOKEN: ${{ secrets.HEROUI_AUTH_TOKEN }}
run: pnpm install --frozen-lockfile

- name: Build Linux packages (AppImage, deb, rpm, flatpak)
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ jobs:
- name: Typecheck
run: pyright

python-linux-distros:
name: Python (${{ matrix.label }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- label: debian-bookworm
image: python:3.12-slim-bookworm
- label: alpine-musl
image: python:3.12-alpine
- label: ubuntu-24.04
image: ubuntu:24.04
steps:
- uses: actions/checkout@v6

- name: Run checks in container
env:
LINUX_MATRIX_IMAGE: ${{ matrix.image }}
CONTAINER_ENGINE: docker
run: bash scripts/ci/linux-matrix.sh

electron:
name: Electron
runs-on: ubuntu-latest
Expand All @@ -52,6 +74,11 @@ jobs:
cache-dependency-path: electron/pnpm-lock.yaml

- name: Install dependencies
env:
# @heroui-pro/react has a postinstall step that fetches the real dist
# from the HeroUI Pro registry. Without this token it stops at the
# skeleton package and tsc cannot resolve `@heroui-pro/react`.
HEROUI_AUTH_TOKEN: ${{ secrets.HEROUI_AUTH_TOKEN }}
run: pnpm install --frozen-lockfile

- name: Typecheck (main)
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ node_modules/
# Vendored Python wheels for packaged Electron (generated by pnpm run vendor-python)
electron/python-vendor/

# Bundled portable CPython runtime (generated by pnpm run fetch-python)
electron/python-runtime/

# Build outputs — Electron
electron/dist/
electron/out/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ flatpak install ProtonShift-*.flatpak
flatpak run io.github.protonshift
```

Official builds bundle the Python API stack (FastAPI, Uvicorn, VDF, etc.) beside the app. You only need **Python 3.12+** available as `python3` (the `.deb` already depends on it).
Official builds bundle a portable CPython 3.12 runtime *and* the Python API stack (FastAPI, Uvicorn, VDF, etc.) beside the app — so the only outside dependencies are the **game tools you actually want to manage** (Steam, Heroic, Lutris, MangoHud, Gamescope, Protontricks, GameMode). No `python3` or `python3-pydantic` needed on the host.

---

Expand Down
1 change: 1 addition & 0 deletions assets/io.github.protonshift.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<launchable type="desktop-id">io.github.protonshift.desktop</launchable>
<url type="homepage">https://github.com/I4cTime/protonshift</url>
<releases>
<release version="0.9.5" date="2026-05-12"/>
<release version="0.8.8" date="2026-04-15"/>
<release version="0.8.7" date="2026-04-15"/>
<release version="0.8.6" date="2026-04-15"/>
Expand Down
File renamed without changes.
82 changes: 70 additions & 12 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ const EXTRA_PATH_DIRS = [

function getPythonCommand(port: number): { cmd: string; args: string[]; env: NodeJS.ProcessEnv } {
const env = { ...process.env };
// Packaged trees (especially AppImages) live on read-only mounts; bytecode
// writes beside shipped *.py would raise PermissionError and exit before /health.
if (!isDev) {
env.PYTHONDONTWRITEBYTECODE = "1";
}
// Immutable distros (Bazzite, SteamOS, Fedora Atomic) and AppImage
// wrappers can strip PATH entries. Ensure common locations are present.
if (env.PATH && !env.PATH.includes("/var/usrlocal/bin")) {
Expand All @@ -89,6 +94,7 @@ function getPythonCommand(port: number): { cmd: string; args: string[]; env: Nod
const resourcesPath = process.resourcesPath;
const srcDir = path.join(resourcesPath, "python", "src");
const vendorDir = path.join(resourcesPath, "python", "vendor");
const bundledPython = path.join(resourcesPath, "python", "runtime", "bin", "python3");
const pyPathParts: string[] = [];
if (fs.existsSync(vendorDir)) {
pyPathParts.push(vendorDir);
Expand All @@ -98,11 +104,28 @@ function getPythonCommand(port: number): { cmd: string; args: string[]; env: Nod
pyPathParts.push(env.PYTHONPATH);
}
env.PYTHONPATH = pyPathParts.join(":");
// CPython treats PYTHONNOUSERSITE as truthy if the variable is *present*,
// even when empty. Setting it to "" disables user site-packages — the
// opposite of what we want. Unset it so user site-packages stay enabled
// and `_vendor_compat` can fall back to system pydantic_core if the
// vendored .so is ABI-incompatible with the runtime Python.

// Prefer the bundled interpreter (python-build-standalone). It is ABI-locked
// to our vendored wheels, so pydantic_core etc. always loads cleanly.
if (fs.existsSync(bundledPython)) {
// Ignore any user-site noise from the host Python install — we own this
// interpreter and the wheels live entirely under /resources/python.
env.PYTHONNOUSERSITE = "1";
// Make the bundled libpython resolvable for any subprocess we exec.
const bundledLib = path.join(resourcesPath, "python", "runtime", "lib");
env.LD_LIBRARY_PATH = env.LD_LIBRARY_PATH
? `${bundledLib}:${env.LD_LIBRARY_PATH}`
: bundledLib;
return {
cmd: bundledPython,
args: ["-m", "game_setup_hub.api", "--port", portArg],
env,
};
}

// Defensive fallback: if the runtime dir is missing (e.g. user manually
// unpacked just the python/src subset), fall back to system python3 and let
// _vendor_compat sort out ABI drift.
delete env.PYTHONNOUSERSITE;
return {
cmd: "python3",
Expand All @@ -120,6 +143,8 @@ async function startPython(): Promise<number> {
return new Promise((resolve, reject) => {
pythonProcess = spawn(cmd, args, { env, stdio: ["pipe", "pipe", "pipe"] });

let stderrTail = "";

const timeout = setTimeout(() => {
reject(new Error("Python backend did not start within 15 seconds"));
}, 15000);
Expand All @@ -130,7 +155,9 @@ async function startPython(): Promise<number> {
});

pythonProcess.stderr?.on("data", (data: Buffer) => {
console.error("[python]", data.toString().trim());
const chunk = data.toString();
stderrTail = (stderrTail + chunk).slice(-6000);
console.error("[python]", chunk.trimEnd());
});

pythonProcess.on("error", (err) => {
Expand All @@ -141,7 +168,8 @@ async function startPython(): Promise<number> {
pythonProcess.on("exit", (code) => {
if (code !== null && code !== 0) {
clearTimeout(timeout);
reject(new Error(`Python exited with code ${code}`));
const hint = stderrTail.trim() ? `\n${stderrTail.trim()}` : "";
reject(new Error(`Python exited with code ${code}${hint}`));
}
pythonProcess = null;
});
Expand Down Expand Up @@ -214,7 +242,20 @@ function mimeFor(filePath: string): string {
return map[ext] ?? "application/octet-stream";
}

/** Serves Next static export over http://127.0.0.1 — root-relative /_next/... URLs do not work with file:// */
/** Serves Next static export over http://127.0.0.1 — root-relative /_next/... URLs do not work with file://.
* Detects RSC requests (Next App Router client-side navigation) and serves the
* matching .txt payload Next writes alongside each .html during `output: "export"`.
* Without this, clicking nav links produced an HTML response that the router
* could not parse, so the URL changed but the page did not switch. */
function isRscRequest(req: http.IncomingMessage): boolean {
const h = req.headers;
if (h["rsc"] === "1" || h["rsc"] === "true") return true;
if (typeof h["next-router-prefetch"] !== "undefined") return true;
if (typeof h["next-router-segment-prefetch"] !== "undefined") return true;
if (typeof h["next-router-state-tree"] !== "undefined") return true;
return false;
}

function startStaticRendererServer(rootDir: string): Promise<number> {
const root = path.resolve(rootDir);
return new Promise((resolve, reject) => {
Expand All @@ -234,10 +275,20 @@ function startStaticRendererServer(rootDir: string): Promise<number> {
}
const rel = pathname.replace(/^\/+/, "");
const rootResolved = path.resolve(root);
const rsc = isRscRequest(req);
const hasExt = path.extname(rel) !== "";

const candidates: string[] = [];
if (rel === "" || rel === "/") {
if (rsc) candidates.push(path.join(rootResolved, "index.txt"));
candidates.push(path.join(rootResolved, "index.html"));
} else if (rsc && !hasExt) {
candidates.push(
path.join(rootResolved, `${rel}.txt`),
path.join(rootResolved, rel, "index.txt"),
path.join(rootResolved, `${rel}.html`),
path.join(rootResolved, rel, "index.html"),
);
} else {
candidates.push(
path.join(rootResolved, rel),
Expand All @@ -264,10 +315,13 @@ function startStaticRendererServer(rootDir: string): Promise<number> {
}

const body = fs.readFileSync(found);
const ext = path.extname(found).toLowerCase();
const contentType = rsc && ext === ".txt" ? "text/x-component" : mimeFor(found);
res.writeHead(200, {
"Content-Type": mimeFor(found),
"Content-Type": contentType,
"Content-Length": String(body.length),
"Cache-Control": "no-store",
Vary: "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch",
});
res.end(body);
} catch {
Expand All @@ -291,9 +345,9 @@ function startStaticRendererServer(rootDir: string): Promise<number> {

function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1200,
width: 1400,
height: 800,
minWidth: 900,
minWidth: 960,
minHeight: 600,
title: "ProtonShift",
icon: getIconPath(),
Expand All @@ -309,7 +363,9 @@ function createWindow(): void {

if (isDev) {
mainWindow.loadURL("http://localhost:3000");
mainWindow.webContents.openDevTools({ mode: "detach" });
if (process.env.PROTONSHIFT_DEVTOOLS === "1") {
mainWindow.webContents.openDevTools({ mode: "detach" });
}
} else if (staticRendererPort) {
mainWindow.loadURL(`http://127.0.0.1:${staticRendererPort}/`);
} else {
Expand All @@ -323,6 +379,8 @@ function createWindow(): void {

ipcMain.handle("get-api-port", () => apiPort);

ipcMain.handle("get-app-version", () => app.getVersion());

ipcMain.handle("window-close", () => {
mainWindow?.close();
});
Expand Down
33 changes: 28 additions & 5 deletions electron/package.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
{
"name": "protonshift",
"version": "0.9.0",
"version": "0.9.5",
"description": "Linux game configuration toolkit",
"main": "dist/main.js",
"scripts": {
"dev": "concurrently \"pnpm --filter protonshift-renderer dev\" \"pnpm run dev:electron\"",
"dev:electron": "tsc && electron . --log-level=1",
"dev:electron:debug": "tsc && PROTONSHIFT_DEVTOOLS=1 electron . --log-level=1",
"build:renderer": "pnpm --filter protonshift-renderer build",
"build:electron": "tsc",
"build": "pnpm run build:renderer && pnpm run build:electron",
"vendor-python": "bash scripts/vendor-python-deps.sh",
"fetch-python": "bash scripts/fetch-python.sh",
"vendor-python": "pnpm run fetch-python && bash scripts/vendor-python-deps.sh",
"dist": "pnpm run vendor-python && pnpm run build && electron-builder --publish never",
"dist:appimage": "pnpm run vendor-python && pnpm run build && electron-builder --linux AppImage --publish never",
"dist:deb": "pnpm run vendor-python && pnpm run build && electron-builder --linux deb --publish never",
Expand All @@ -18,7 +20,13 @@
},
"packageManager": "pnpm@10.32.1",
"pnpm": {
"onlyBuiltDependencies": ["electron", "sharp", "electron-winstaller"]
"onlyBuiltDependencies": [
"electron",
"sharp",
"electron-winstaller",
"@heroui-pro/react",
"heroui-pro"
]
},
"dependencies": {
"electron-is-dev": "^3.0.1"
Expand Down Expand Up @@ -70,6 +78,21 @@
"from": "python-vendor",
"to": "python/vendor",
"filter": ["**/*"]
},
{
"from": "python-runtime",
"to": "python/runtime",
"filter": [
"**/*",
"!bin/pip*",
"!bin/pydoc*",
"!bin/idle*",
"!bin/python*-config",
"!lib/python*/site-packages/pip",
"!lib/python*/site-packages/pip-*.dist-info",
"!share/man/**",
"!share/doc/**"
]
}
],
"linux": {
Expand All @@ -85,11 +108,11 @@
}
},
"deb": {
"depends": ["python3 (>= 3.12)", "python3-pydantic"],
"depends": [],
"maintainer": "I4cTime <I4cTime@users.noreply.github.com>"
},
"rpm": {
"depends": ["python3 >= 3.12", "python3-pydantic"],
"depends": [],
"fpm": ["--rpm-summary", "Linux game configuration toolkit"]
},
"flatpak": {
Expand Down
Loading
Loading