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
Binary file added assets/social-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
744 changes: 744 additions & 0 deletions docs/python-review.md

Large diffs are not rendered by default.

80 changes: 64 additions & 16 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,41 @@ import { app, BrowserWindow, ipcMain } from "electron";
import { ChildProcess, spawn } from "child_process";
import * as path from "path";
import * as http from "http";
import * as net from "net";
import * as crypto from "crypto";

let mainWindow: BrowserWindow | null = null;
let pythonProcess: ChildProcess | null = null;
let apiPort: number | null = null;
let staticServer: http.Server | null = null;
let staticRendererPort: number | null = null;

// Per-launch bearer token. Both the Python backend and Electron see it via
// PROTONSHIFT_API_TOKEN, and every renderer-originated fetch attaches it.
const apiToken = crypto.randomBytes(32).toString("base64url");

const isDev = !app.isPackaged;

import * as fs from "fs";

function pickFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.unref();
srv.on("error", reject);
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
if (addr && typeof addr === "object") {
const port = addr.port;
srv.close(() => resolve(port));
} else {
srv.close();
reject(new Error("Failed to bind ephemeral port"));
}
});
});
}

function findPythonCmd(projectRoot: string): string {
// Prefer the project venv if it exists (dev workflow)
const candidates = [
Expand All @@ -37,21 +61,27 @@ const EXTRA_PATH_DIRS = [
"/run/current-system/sw/bin", // NixOS
].join(":");

function getPythonCommand(): { cmd: string; args: string[]; env: NodeJS.ProcessEnv } {
function getPythonCommand(port: number): { cmd: string; args: string[]; env: NodeJS.ProcessEnv } {
const env = { ...process.env };
// 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")) {
env.PATH = `${env.PATH}:${EXTRA_PATH_DIRS}`;
}

// Hand the backend the auth token. compare_digest on the Python side will
// reject any other Authorization header.
env.PROTONSHIFT_API_TOKEN = apiToken;

const portArg = String(port);

if (isDev) {
const projectRoot = path.resolve(__dirname, "..", "..");
const srcDir = path.join(projectRoot, "src");
env.PYTHONPATH = srcDir + (env.PYTHONPATH ? `:${env.PYTHONPATH}` : "");
return {
cmd: findPythonCmd(projectRoot),
args: ["-m", "game_setup_hub.api", "--port", "0"],
args: ["-m", "game_setup_hub.api", "--port", portArg],
env,
};
}
Expand All @@ -68,32 +98,35 @@ function getPythonCommand(): { cmd: string; args: string[]; env: NodeJS.ProcessE
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 = "";
// 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.
delete env.PYTHONNOUSERSITE;
return {
cmd: "python3",
args: ["-m", "game_setup_hub.api", "--port", "0"],
args: ["-m", "game_setup_hub.api", "--port", portArg],
env,
};
}

function startPython(): Promise<number> {
async function startPython(): Promise<number> {
// Pick the port on the Node side so we know it before spawning. Avoids the
// old stdout-regex dance, and we can pass it straight to `--port`.
const port = await pickFreePort();
const { cmd, args, env } = getPythonCommand(port);

return new Promise((resolve, reject) => {
const { cmd, args, env } = getPythonCommand();
pythonProcess = spawn(cmd, args, { env, stdio: ["pipe", "pipe", "pipe"] });

const timeout = setTimeout(() => {
reject(new Error("Python backend did not start within 15 seconds"));
}, 15000);

pythonProcess.stdout?.on("data", (data: Buffer) => {
const output = data.toString();
const match = output.match(/PORT:(\d+)/);
if (match) {
clearTimeout(timeout);
resolve(parseInt(match[1], 10));
}
// Stdout is informational only now; readiness comes from /health.
console.log("[python]", data.toString().trim());
});

pythonProcess.stderr?.on("data", (data: Buffer) => {
Expand All @@ -112,6 +145,18 @@ function startPython(): Promise<number> {
}
pythonProcess = null;
});

// Resolve as soon as /health responds. waitForHealth handles retries.
waitForHealth(port).then(
() => {
clearTimeout(timeout);
resolve(port);
},
(err) => {
clearTimeout(timeout);
reject(err);
},
);
});
}

Expand Down Expand Up @@ -301,7 +346,11 @@ ipcMain.handle("api-fetch", async (_event, urlPath: string, init?: RequestInit)
try {
const response = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiToken}`,
...init?.headers,
},
});
const body = await response.text();
return {
Expand All @@ -321,7 +370,6 @@ ipcMain.handle("api-fetch", async (_event, urlPath: string, init?: RequestInit)
app.on("ready", async () => {
try {
apiPort = await startPython();
await waitForHealth(apiPort);
if (!isDev) {
const outDir = path.join(__dirname, "..", "renderer", "out");
await startStaticRendererServer(outDir);
Expand Down
4 changes: 2 additions & 2 deletions electron/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "protonshift",
"version": "0.8.11",
"version": "0.9.0",
"description": "Linux game configuration toolkit",
"main": "dist/main.js",
"scripts": {
Expand Down Expand Up @@ -56,7 +56,7 @@
{
"from": "../src",
"to": "python/src",
"filter": ["**/*.py", "**/*.css"]
"filter": ["**/*.py", "**/data/*.json"]
},
{
"from": "../pyproject.toml",
Expand Down
11 changes: 9 additions & 2 deletions electron/python-runtime-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
# Bundled into the AppImage/deb for packaged runs (see scripts/vendor-python-deps.sh).
#
# Keep native (.so) deps to a minimum so the bundle survives running on a
# different Python minor version. Today the only unavoidable native dep is
# `pydantic_core` (transitive from FastAPI). `_vendor_compat.py` falls back
# to system site-packages when the vendored .so is ABI-incompatible.
fastapi>=0.115,<1
uvicorn[standard]>=0.34,<1
vdf>=1.2
pydantic>=2.0,<3
# NOT [standard] — that pulls uvloop, httptools, watchfiles (3 more .so files).
uvicorn>=0.34,<1
vdf>=1.2,<4
63 changes: 63 additions & 0 deletions flatpak/io.github.protonshift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
app-id: io.github.protonshift
runtime: org.freedesktop.Platform
runtime-version: "24.08"
sdk: org.freedesktop.Sdk
base: org.electronjs.Electron2.BaseApp
base-version: "24.08"
command: protonshift
separate-locales: false

finish-args:
- --share=ipc
- --share=network
- --socket=x11
- --socket=wayland
- --socket=pulseaudio
- --device=dri
# Home read-only for game library discovery
- --filesystem=home:ro
# Steam paths (native + Flatpak)
- --filesystem=~/.steam:ro
- --filesystem=~/.local/share/Steam:ro
- --filesystem=~/.var/app/com.valvesoftware.Steam:ro
# Heroic paths (native + Flatpak)
- --filesystem=~/.config/heroic:ro
- --filesystem=~/.var/app/com.heroicgameslauncher.hgl:ro
# Lutris paths (native + Flatpak)
- --filesystem=~/.local/share/lutris:ro
- --filesystem=~/.var/app/net.lutris.Lutris:ro
# Writable config paths
- --filesystem=~/.config/MangoHud
- --filesystem=~/.config/environment.d
- --filesystem=~/.config/protonshift
# Talk to Flatpak for launching games via steam:// URIs
- --talk-name=org.freedesktop.Flatpak

modules:
- name: python3-deps
buildsystem: simple
build-commands:
- pip3 install --no-build-isolation --prefix=/app vdf fastapi "uvicorn[standard]"
sources:
- type: file
url: https://files.pythonhosted.org/packages/source/v/vdf/vdf-3.4.tar.gz
sha256: FIXME

- name: protonshift-python
buildsystem: simple
build-commands:
- pip3 install --no-build-isolation --prefix=/app .
sources:
- type: dir
path: ..

- name: protonshift
buildsystem: simple
build-commands:
- cp -r electron-dist/* /app/
- install -Dm644 assets/io.github.protonshift.svg /app/share/icons/hicolor/scalable/apps/io.github.protonshift.svg
- install -Dm644 assets/io.github.protonshift.desktop /app/share/applications/io.github.protonshift.desktop
- install -Dm644 assets/io.github.protonshift.metainfo.xml /app/share/metainfo/io.github.protonshift.metainfo.xml
sources:
- type: dir
path: ..
34 changes: 27 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,45 @@ build-backend = "setuptools.build_meta"

[project]
name = "protonshift"
version = "0.8.11"
description = "Linux game configuration toolkit: GPU, launch options, Proton, env vars"
readme = "README.md"
requires-python = ">=3.12"
dynamic = ["version"]
dependencies = [
"vdf>=1.2",
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
"vdf>=1.2,<4",
"fastapi>=0.115,<1",
"pydantic>=2.0,<3",
# Use bare uvicorn (not [standard]). The "standard" extras pull uvloop,
# httptools, and watchfiles — three more native extensions that break
# AppImage portability. Pure-asyncio + h11 is plenty for localhost.
"uvicorn>=0.34,<1",
]

[project.optional-dependencies]
dev = ["ruff", "pyright"]
dev = ["ruff", "pyright", "pytest", "pytest-asyncio", "httpx"]

[project.scripts]
protonshift = "game_setup_hub.app:main"
protonshift-api = "game_setup_hub.api:cli"

[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.dynamic]
version = { attr = "game_setup_hub.__version__" }

[tool.setuptools.package-data]
game_setup_hub = ["theme.css"]
game_setup_hub = ["data/*.json"]

[tool.ruff]
line-length = 110
target-version = "py312"

[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "SIM"]
# SIM105: try/except/pass with narrow exception is clearer than contextlib.suppress.
# SIM102: nested-if collapse hurts readability when guards differ in intent.
ignore = ["E501", "SIM105", "SIM102"]

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
5 changes: 3 additions & 2 deletions pyrightconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"include": ["src"],
"exclude": ["src/game_setup_hub/app.py"],
"include": ["src", "tests"],
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.12",
"typeCheckingMode": "standard"
}
4 changes: 2 additions & 2 deletions src/game_setup_hub/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""ProtonShift — game configuration for Pop!_OS, Ubuntu, and related distros."""
"""ProtonShift — Linux game configuration toolkit."""

__version__ = "0.8.8"
__version__ = "0.9.0"
6 changes: 0 additions & 6 deletions src/game_setup_hub/__main__.py

This file was deleted.

Loading
Loading