From 20b8baefee7994cfc8ff976566a2d7f47be2e293 Mon Sep 17 00:00:00 2001 From: I4cDeath Date: Thu, 16 Apr 2026 01:37:10 +0800 Subject: [PATCH] fix: detect tools on Bazzite and immutable distros (closes #10) Add tool_check.py with fallback path probing for Bazzite, SteamOS, Fedora Atomic, and NixOS where binaries live outside the default PATH. - Centralize all shutil.which() calls into find_tool() / is_tool_available() - Probe /usr/lib/extensions/, /var/usrlocal/bin, /usr/games, NixOS profile paths when normal PATH lookup fails - Augment PATH in Electron main process before spawning Python backend - Add Bazzite/SteamOS install hints to Gamescope and MangoHud UI - Bump version to 0.8.9 Made-with: Cursor --- electron/main.ts | 14 +++ electron/package.json | 2 +- electron/renderer/src/app/mangohud/page.tsx | 3 +- .../src/components/gamescope-builder.tsx | 3 +- pyproject.toml | 2 +- src/game_setup_hub/display.py | 18 ++-- src/game_setup_hub/gamescope.py | 5 +- src/game_setup_hub/mangohud.py | 5 +- src/game_setup_hub/presets.py | 19 +--- src/game_setup_hub/protontricks.py | 10 +- src/game_setup_hub/tool_check.py | 93 +++++++++++++++++++ 11 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 src/game_setup_hub/tool_check.py diff --git a/electron/main.ts b/electron/main.ts index 27a25d5..79c1306 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -28,8 +28,22 @@ function findPythonCmd(projectRoot: string): string { return "python3"; } +const EXTRA_PATH_DIRS = [ + "/usr/bin", + "/usr/local/bin", + "/var/usrlocal/bin", + "/usr/lib/extensions/vulkan/MangoHud/bin", + "/usr/lib64/extensions/vulkan/MangoHud/bin", + "/run/current-system/sw/bin", // NixOS +].join(":"); + function getPythonCommand(): { 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}`; + } if (isDev) { const projectRoot = path.resolve(__dirname, "..", ".."); diff --git a/electron/package.json b/electron/package.json index 76ae4e1..9616e6b 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "protonshift", - "version": "0.8.8", + "version": "0.8.9", "description": "Linux game configuration toolkit", "main": "dist/main.js", "scripts": { diff --git a/electron/renderer/src/app/mangohud/page.tsx b/electron/renderer/src/app/mangohud/page.tsx index 017b792..f83df67 100644 --- a/electron/renderer/src/app/mangohud/page.tsx +++ b/electron/renderer/src/app/mangohud/page.tsx @@ -203,8 +203,9 @@ export default function MangoHudPage() {

Install via your package manager:

{[ + { distro: "Bazzite / SteamOS", cmd: "Pre-installed — restart app or check PATH" }, { distro: "Ubuntu / Pop!_OS / Mint", cmd: "sudo apt install mangohud" }, - { distro: "Fedora", cmd: "sudo dnf install mangohud" }, + { distro: "Fedora / Fedora Atomic", cmd: "sudo dnf install mangohud" }, { distro: "Arch / Manjaro / EndeavourOS", cmd: "sudo pacman -S mangohud" }, { distro: "openSUSE", cmd: "sudo zypper install mangohud" }, { distro: "Flatpak (Steam)", cmd: "flatpak install flathub org.freedesktop.Platform.VulkanLayer.MangoHud" }, diff --git a/electron/renderer/src/components/gamescope-builder.tsx b/electron/renderer/src/components/gamescope-builder.tsx index 5d9780e..6c597e0 100644 --- a/electron/renderer/src/components/gamescope-builder.tsx +++ b/electron/renderer/src/components/gamescope-builder.tsx @@ -114,8 +114,9 @@ export function GamescopeBuilder({ onInsert }: GamescopeBuilderProps) {

Install via your package manager:

{[ + { distro: "Bazzite / SteamOS", cmd: "Pre-installed — restart app or check PATH" }, { distro: "Arch / Manjaro / EndeavourOS", cmd: "sudo pacman -S gamescope" }, - { distro: "Fedora", cmd: "sudo dnf install gamescope" }, + { distro: "Fedora / Fedora Atomic", cmd: "sudo dnf install gamescope" }, { distro: "openSUSE", cmd: "sudo zypper install gamescope" }, { distro: "NixOS", cmd: "nix-env -iA nixpkgs.gamescope" }, { distro: "Flatpak (Steam)", cmd: "Bundled with Steam Flatpak" }, diff --git a/pyproject.toml b/pyproject.toml index a89465b..1fbdeb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "protonshift" -version = "0.8.8" +version = "0.8.9" description = "Linux game configuration toolkit: GPU, launch options, Proton, env vars" readme = "README.md" requires-python = ">=3.12" diff --git a/src/game_setup_hub/display.py b/src/game_setup_hub/display.py index 4908c7e..3901a5f 100644 --- a/src/game_setup_hub/display.py +++ b/src/game_setup_hub/display.py @@ -4,10 +4,11 @@ import os import re -import shutil import subprocess from dataclasses import dataclass +from game_setup_hub.tool_check import find_tool + @dataclass class MonitorInfo: @@ -33,11 +34,12 @@ def get_session_type() -> str: def _parse_xrandr() -> list[MonitorInfo]: """Parse xrandr output for connected monitors.""" - if not shutil.which("xrandr"): + xrandr = find_tool("xrandr") + if not xrandr: return [] try: result = subprocess.run( - ["xrandr", "--current"], + [xrandr, "--current"], capture_output=True, text=True, timeout=5, @@ -93,11 +95,12 @@ def _parse_xrandr() -> list[MonitorInfo]: def _parse_wlr_randr() -> list[MonitorInfo]: """Parse wlr-randr output for Wayland monitors.""" - if not shutil.which("wlr-randr"): + wlr_randr = find_tool("wlr-randr") + if not wlr_randr: return [] try: result = subprocess.run( - ["wlr-randr"], + [wlr_randr], capture_output=True, text=True, timeout=5, @@ -158,9 +161,10 @@ def get_monitors() -> list[MonitorInfo]: def set_resolution(monitor: str, width: int, height: int, refresh: float = 0) -> bool: """Set resolution for a monitor using xrandr. Returns True on success.""" - if not shutil.which("xrandr"): + xrandr = find_tool("xrandr") + if not xrandr: return False - cmd = ["xrandr", "--output", monitor, "--mode", f"{width}x{height}"] + cmd = [xrandr, "--output", monitor, "--mode", f"{width}x{height}"] if refresh > 0: cmd.extend(["--rate", str(refresh)]) try: diff --git a/src/game_setup_hub/gamescope.py b/src/game_setup_hub/gamescope.py index 1d6fab9..bcf31b8 100644 --- a/src/game_setup_hub/gamescope.py +++ b/src/game_setup_hub/gamescope.py @@ -2,9 +2,10 @@ from __future__ import annotations -import shutil from dataclasses import dataclass +from game_setup_hub.tool_check import is_tool_available + @dataclass class GamescopeOptions: @@ -25,7 +26,7 @@ class GamescopeOptions: def is_gamescope_available() -> bool: """Check if gamescope is installed.""" - return shutil.which("gamescope") is not None + return is_tool_available("gamescope") def build_gamescope_cmd(opts: GamescopeOptions) -> str: diff --git a/src/game_setup_hub/mangohud.py b/src/game_setup_hub/mangohud.py index f4d6871..df411bd 100644 --- a/src/game_setup_hub/mangohud.py +++ b/src/game_setup_hub/mangohud.py @@ -2,9 +2,10 @@ from __future__ import annotations -import shutil from pathlib import Path +from game_setup_hub.tool_check import is_tool_available + MANGOHUD_CONFIG_DIR = Path.home() / ".config" / "MangoHud" MANGOHUD_GLOBAL_CONF = MANGOHUD_CONFIG_DIR / "MangoHud.conf" @@ -166,7 +167,7 @@ def is_mangohud_available() -> bool: """Check if MangoHud is installed.""" - return shutil.which("mangohud") is not None + return is_tool_available("mangohud") def read_mangohud_config(path: Path | None = None) -> dict[str, str]: diff --git a/src/game_setup_hub/presets.py b/src/game_setup_hub/presets.py index 1373a46..93bb556 100644 --- a/src/game_setup_hub/presets.py +++ b/src/game_setup_hub/presets.py @@ -2,9 +2,10 @@ from __future__ import annotations -import shutil from dataclasses import dataclass +from game_setup_hub.tool_check import is_tool_available + @dataclass class LaunchPreset: @@ -19,20 +20,10 @@ class LaunchPreset: def is_installed(self) -> bool: """Check if the tool/feature is available.""" if self.value == "gamemoderun": - return _check_gamemode() + return is_tool_available("gamemoderun") if "MANGOHUD" in self.value: - return _check_mangohud() - return True # No check for env vars, Proton Log, NVIDIA dGPU - - -def _check_gamemode() -> bool: - """Check if gamemode is installed.""" - return shutil.which("gamemoderun") is not None - - -def _check_mangohud() -> bool: - """Check if MangoHud is installed.""" - return shutil.which("mangohud") is not None + return is_tool_available("mangohud") + return True LAUNCH_PRESETS: list[LaunchPreset] = [ diff --git a/src/game_setup_hub/protontricks.py b/src/game_setup_hub/protontricks.py index 7f47d2b..ab9d6fc 100644 --- a/src/game_setup_hub/protontricks.py +++ b/src/game_setup_hub/protontricks.py @@ -2,9 +2,10 @@ from __future__ import annotations -import shutil import subprocess +from game_setup_hub.tool_check import find_tool + PROTONTRICKS_FLATPAK = "com.github.Matoking.protontricks" @@ -20,7 +21,7 @@ def is_protontricks_available() -> bool: """Check if Protontricks is available (native or Flatpak).""" - if shutil.which("protontricks"): + if find_tool("protontricks"): return True try: r = subprocess.run( @@ -39,8 +40,9 @@ def get_protontricks_cmd(app_id: str, verb: str | None = None) -> list[str] | No Returns [cmd, ...args] or None if not available. If verb is None, opens GUI (--gui). """ - if shutil.which("protontricks"): - cmd = ["protontricks", app_id] + pt = find_tool("protontricks") + if pt: + cmd = [pt, app_id] if verb: cmd.append(verb) else: diff --git a/src/game_setup_hub/tool_check.py b/src/game_setup_hub/tool_check.py new file mode 100644 index 0000000..746469f --- /dev/null +++ b/src/game_setup_hub/tool_check.py @@ -0,0 +1,93 @@ +"""Unified tool detection with fallback paths for immutable distros. + +On Bazzite, SteamOS, Fedora Atomic (Kinoite/Silverblue), NixOS, and +Flatpak-installed tools, binaries often live outside the default PATH +that shutil.which() searches. This module provides a single +``find_tool()`` helper that first tries the normal PATH lookup, then +probes well-known fallback locations. +""" + +from __future__ import annotations + +import os +import shutil +from functools import lru_cache +from pathlib import Path + +_EXTRA_BIN_DIRS: tuple[str, ...] = ( + "/usr/bin", + "/usr/local/bin", + "/usr/games", + "/var/usrlocal/bin", + # Flatpak host extensions / SDK merge dirs + "/usr/lib/extensions/vulkan/MangoHud/bin", + "/usr/lib64/extensions/vulkan/MangoHud/bin", + # NixOS profile links + os.path.expanduser("~/.nix-profile/bin"), + "/run/current-system/sw/bin", + # Homebrew / Linuxbrew + "/home/linuxbrew/.linuxbrew/bin", +) + +_TOOL_SPECIFIC_PATHS: dict[str, tuple[str, ...]] = { + "gamescope": ( + "/usr/bin/gamescope", + "/usr/local/bin/gamescope", + "/usr/lib/extensions/gamescope/bin/gamescope", + ), + "mangohud": ( + "/usr/bin/mangohud", + "/usr/local/bin/mangohud", + "/usr/lib/extensions/vulkan/MangoHud/bin/mangohud", + "/usr/lib64/extensions/vulkan/MangoHud/bin/mangohud", + ), + "gamemoderun": ( + "/usr/bin/gamemoderun", + "/usr/local/bin/gamemoderun", + "/usr/lib/extensions/gamemode/bin/gamemoderun", + ), + "protontricks": ( + "/usr/bin/protontricks", + "/usr/local/bin/protontricks", + os.path.expanduser("~/.local/bin/protontricks"), + ), + "xrandr": ( + "/usr/bin/xrandr", + "/usr/local/bin/xrandr", + ), + "wlr-randr": ( + "/usr/bin/wlr-randr", + "/usr/local/bin/wlr-randr", + ), +} + + +@lru_cache(maxsize=32) +def find_tool(name: str) -> str | None: + """Locate a tool binary, returning the absolute path or ``None``. + + 1. ``shutil.which(name)`` — uses the current process PATH. + 2. ``shutil.which(name, path=...)`` — searches extra bin directories + common on immutable/atomic distros. + 3. Direct file existence checks for tool-specific known paths. + """ + found = shutil.which(name) + if found: + return found + + extra_path = os.pathsep.join(_EXTRA_BIN_DIRS) + found = shutil.which(name, path=extra_path) + if found: + return found + + for candidate in _TOOL_SPECIFIC_PATHS.get(name, ()): + p = Path(candidate) + if p.is_file() and os.access(p, os.X_OK): + return str(p) + + return None + + +def is_tool_available(name: str) -> bool: + """Return True if *name* can be found anywhere on the system.""" + return find_tool(name) is not None