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() {
{[
+ { 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