Skip to content

Commit 5c06e3f

Browse files
S1M0N38felipefl142charlesmerritt
committed
feat(platform): add linux support through proton
This only support linux through proton where steam is installed from the official package (no snap, no flatpack). I've tested this implementation in Omarchy 3.8 (x86 arch). Thanks for the following co-authors for the submitted PR which were the base of this commit. Closes #128 Closes #162 Closes #184 Co-authored-by: felipefl142 <felipefrl1@hotmail.com> Co-authored-by: charlesmerritt <chazmerritt4@gmail.com>
1 parent c160f04 commit 5c06e3f

4 files changed

Lines changed: 177 additions & 3 deletions

File tree

src/balatrobot/manager.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from balatrobot.config import Config
1212
from balatrobot.platforms import get_launcher
13+
from balatrobot.platforms.base import BaseLauncher
1314

1415
HEALTH_TIMEOUT = 30.0
1516

@@ -32,6 +33,7 @@ def __init__(
3233
self._process: subprocess.Popen | None = None
3334
self._log_path: Path | None = None
3435
self._session_id = session_id
36+
self._launcher: BaseLauncher | None = None
3537

3638
@property
3739
def port(self) -> int:
@@ -84,10 +86,10 @@ async def start(self) -> None:
8486
self._log_path = session_dir / f"{self._config.port}.log"
8587

8688
# Get launcher and start process
87-
launcher = get_launcher(self._config.platform)
89+
self._launcher = get_launcher(self._config.platform)
8890
print(f"Starting Balatro on port {self._config.port}...")
8991

90-
self._process = await launcher.start(self._config, session_dir)
92+
self._process = await self._launcher.start(self._config, session_dir)
9193

9294
# Wait for health
9395
print(f"Waiting for health check on {self._config.host}:{self._config.port}...")
@@ -109,6 +111,10 @@ async def stop(self) -> None:
109111

110112
print(f"Stopping instance on port {self._config.port}...")
111113

114+
# Platform-specific cleanup (e.g. wineserver -k on Linux/Proton)
115+
if self._launcher is not None:
116+
self._launcher.cleanup(self._config)
117+
112118
# Try graceful termination first
113119
process.terminate()
114120

src/balatrobot/platforms/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ def get_launcher(platform: str | None = None) -> "BaseLauncher":
4040

4141
return MacOSLauncher()
4242
case "linux":
43-
raise NotImplementedError("Linux launcher not yet implemented")
43+
from balatrobot.platforms.linux import LinuxLauncher
44+
45+
return LinuxLauncher()
4446
case "windows":
4547
from balatrobot.platforms.windows import WindowsLauncher
4648

src/balatrobot/platforms/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,12 @@ async def start(self, config: Config, session_dir: Path) -> subprocess.Popen:
6767
)
6868

6969
return process
70+
71+
def cleanup(self, config: Config) -> None:
72+
"""Platform-specific cleanup before terminating the process.
73+
74+
Called by the manager before process.terminate().
75+
Override in platform launchers that need special shutdown
76+
(e.g. wineserver -k for Proton on Linux).
77+
"""
78+
pass

src/balatrobot/platforms/linux.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Linux platform launcher (Steam/Proton)."""
2+
3+
import os
4+
import subprocess
5+
from pathlib import Path
6+
7+
from balatrobot.config import Config
8+
from balatrobot.platforms.base import BaseLauncher
9+
10+
BALATRO_APP_ID = "2379780"
11+
12+
13+
def _detect_steam_root() -> Path | None:
14+
"""Detect the Steam installation directory."""
15+
home = Path.home()
16+
candidates = [
17+
home / ".local/share/Steam",
18+
home / ".steam/steam",
19+
]
20+
for p in candidates:
21+
if (p / "steamapps").is_dir():
22+
return p
23+
return None
24+
25+
26+
def _detect_proton_path(steam_root: Path) -> Path | None:
27+
"""Find the first available Proton executable."""
28+
common = steam_root / "steamapps/common"
29+
if not common.is_dir():
30+
return None
31+
for d in sorted(common.iterdir()):
32+
proton = d / "proton"
33+
if proton.is_file() and "proton" in d.name.lower():
34+
return proton
35+
return None
36+
37+
38+
def _detect_compat_data_path(steam_root: Path) -> Path | None:
39+
"""Detect the Steam compatibility data directory for Balatro."""
40+
p = steam_root / f"steamapps/compatdata/{BALATRO_APP_ID}"
41+
return p if p.is_dir() else None
42+
43+
44+
class LinuxLauncher(BaseLauncher):
45+
"""Linux-specific Balatro launcher via Steam/Proton."""
46+
47+
def validate_paths(self, config: Config) -> None:
48+
"""Validate paths, auto-detect Steam/Proton/Balatro paths."""
49+
# Proton needs a display server to render the game window
50+
if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
51+
raise RuntimeError(
52+
"No display server found. "
53+
"Set DISPLAY or WAYLAND_DISPLAY in your environment."
54+
)
55+
56+
steam_root = _detect_steam_root()
57+
if not steam_root:
58+
raise RuntimeError(
59+
"Steam installation not found. "
60+
"Searched: ~/.local/share/Steam, ~/.steam/steam"
61+
)
62+
63+
# Balatro game directory
64+
if config.balatro_path is None:
65+
candidate = steam_root / "steamapps/common/Balatro"
66+
if candidate.is_dir():
67+
config.balatro_path = str(candidate)
68+
69+
if config.balatro_path is None:
70+
raise RuntimeError(
71+
"Balatro game directory not found under Steam root. "
72+
"Set --balatro-path or BALATROBOT_BALATRO_PATH."
73+
)
74+
75+
balatro = Path(config.balatro_path)
76+
if not balatro.is_dir() or not (balatro / "Balatro.exe").is_file():
77+
raise RuntimeError(f"Balatro game directory not found: {balatro}")
78+
79+
# Lovely (version.dll)
80+
if config.lovely_path is None:
81+
candidate = balatro / "version.dll"
82+
if candidate.is_file():
83+
config.lovely_path = str(candidate)
84+
85+
if config.lovely_path is None:
86+
raise RuntimeError(
87+
"lovely-injector version.dll not found. "
88+
"Set --lovely-path or BALATROBOT_LOVELY_PATH."
89+
)
90+
91+
# Proton executable
92+
if config.love_path is None:
93+
detected = _detect_proton_path(steam_root)
94+
if detected:
95+
config.love_path = str(detected)
96+
97+
if config.love_path is None:
98+
raise RuntimeError(
99+
"Proton executable not found. Set --love-path or BALATROBOT_LOVE_PATH."
100+
)
101+
102+
def build_env(self, config: Config) -> dict[str, str]:
103+
"""Build environment with Proton-required variables."""
104+
env = os.environ.copy()
105+
env["WINEDLLOVERRIDES"] = "version=n,b"
106+
107+
steam_root = _detect_steam_root()
108+
if steam_root:
109+
env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = str(steam_root)
110+
compat_data = _detect_compat_data_path(steam_root)
111+
if compat_data:
112+
env["STEAM_COMPAT_DATA_PATH"] = str(compat_data)
113+
114+
env.update(config.to_env())
115+
return env
116+
117+
def build_cmd(self, config: Config) -> list[str]:
118+
"""Build Linux launch command via Proton."""
119+
assert config.love_path is not None
120+
assert config.balatro_path is not None
121+
balatro_exe = str(Path(config.balatro_path) / "Balatro.exe")
122+
return [config.love_path, "run", balatro_exe]
123+
124+
def cleanup(self, config: Config) -> None:
125+
"""Shut down the Wine prefix via wineserver -k.
126+
127+
Proton/Wine double-forks its children away from the original
128+
process group, so process.terminate() alone leaves orphans.
129+
wineserver -k cleanly terminates all Wine processes and
130+
closes display connections so the compositor removes windows.
131+
"""
132+
if config.love_path is None:
133+
return
134+
135+
# wineserver lives next to the proton script
136+
proton_dir = Path(config.love_path).parent
137+
wineserver = proton_dir / "files" / "bin" / "wineserver"
138+
if not wineserver.is_file():
139+
return
140+
141+
# WINEPREFIX is inside the Steam compat data directory
142+
steam_root = _detect_steam_root()
143+
if not steam_root:
144+
return
145+
compat_data = _detect_compat_data_path(steam_root)
146+
if not compat_data:
147+
return
148+
wineprefix = compat_data / "pfx"
149+
if not wineprefix.is_dir():
150+
return
151+
152+
subprocess.run(
153+
[str(wineserver), "-k"],
154+
env={"WINEPREFIX": str(wineprefix)},
155+
capture_output=True,
156+
timeout=10,
157+
)

0 commit comments

Comments
 (0)