Skip to content
Closed
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
73 changes: 67 additions & 6 deletions bottles/backend/umu/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ def __init__(
self.proton_resolver = proton_resolver or UmuProtonCatalog.validate_value
self._processes: dict[UUID, _TrackedProcess] = {}
self._process_lock = threading.Lock()
self._runtime_lock = threading.Lock()
self._prepared_sandbox_runtimes: set[tuple[str, Path]] = set()
self._termination_lock = threading.Lock()

@staticmethod
Expand Down Expand Up @@ -244,7 +246,70 @@ def _read_output(self, game: UmuGame, tracked: _TrackedProcess) -> None:
):
tracked.status = status

@staticmethod
def _runtime_root(environment: Mapping[str, str]) -> Path:
if folders_path := environment.get("UMU_FOLDERS_PATH"):
data_home = Path(folders_path)
else:
home = Path(environment.get("HOME", str(Path.home())))
if environment.get("container") == "flatpak":
data_home = Path(
environment.get(
"HOST_XDG_DATA_HOME", home.joinpath(".local", "share")
)
)
else:
data_home = Path(
environment.get(
"XDG_DATA_HOME", home.joinpath(".local", "share")
)
)
return data_home.expanduser().absolute().joinpath("umu")

def _ensure_sandbox_runtime(self, command: UmuCommand) -> None:
proton = command.env["PROTONPATH"]
runtime = self._runtime_root(command.env)
runtime_key = (proton, runtime)
with self._runtime_lock:
if runtime_key in self._prepared_sandbox_runtimes:
return

runtime.mkdir(parents=True, exist_ok=True)
marker = runtime.joinpath(
f".bottles-runtime-{os.getpid()}-{time.monotonic_ns()}"
)
environment = command.env.copy()
environment["UMU_NO_PROTON"] = "1"
process = subprocess.Popen(
[str(self.installation.path), "/usr/bin/touch", str(marker)],
env=environment,
shell=False,
start_new_session=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1,
)
if process.stdout is not None:
for line in process.stdout:
try:
sys.stdout.write(line)
sys.stdout.flush()
except (OSError, UnicodeError):
pass
return_code = process.wait()
runtime_ready = marker.is_file()
marker.unlink(missing_ok=True)
if return_code or not runtime_ready:
raise UmuProcessError("UMU runtime setup failed")
self._prepared_sandbox_runtimes.add(runtime_key)

def _start(self, game: UmuGame, command: UmuCommand) -> subprocess.Popen[str]:
sandbox = self._sandbox_manager(game, command) if game.sandbox else None
if sandbox is not None:
self._ensure_sandbox_runtime(command)
with self._process_lock:
running = self._processes.get(game.id)
if running is not None:
Expand All @@ -255,8 +320,7 @@ def _start(self, game: UmuGame, command: UmuCommand) -> subprocess.Popen[str]:
raise UmuProcessError(f"The UMU prefix is already in use: {game.id}")
argv: list[str] | str = list(command.argv)
shell = False
if game.sandbox:
sandbox = self._sandbox_manager(game, command)
if sandbox is not None:
argv = sandbox.get_cmd(shlex.join(command.argv))
shell = True
process = subprocess.Popen(
Expand Down Expand Up @@ -286,10 +350,7 @@ def _sandbox_manager(self, game: UmuGame, command: UmuCommand) -> SandboxManager
prefix = game.prefix.resolve(self.data_root)
prefix.mkdir(parents=True, exist_ok=True)

data_home = Path(
self.base_environment.get("XDG_DATA_HOME", Paths.xdg_data_home)
).expanduser()
runtime = data_home.joinpath("umu").resolve(strict=False)
runtime = self._runtime_root(command.env)
runtime.mkdir(parents=True, exist_ok=True)

sandbox_cwd = (command.cwd or prefix).resolve(strict=False)
Expand Down
129 changes: 129 additions & 0 deletions bottles/tests/backend/umu/test_executor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from dataclasses import replace
from pathlib import Path
from uuid import uuid4

import pytest
Expand Down Expand Up @@ -324,16 +325,20 @@ def poll():
return None

calls = []
runtime_commands = []

def popen(argv, **kwargs):
calls.append((argv, kwargs))
return Process()

monkeypatch.setattr(executor, "_ensure_sandbox_runtime", runtime_commands.append)
monkeypatch.setattr(executor_module.subprocess, "Popen", popen)

executor.run(game)

argv, kwargs = calls[0]
assert len(runtime_commands) == 1
assert runtime_commands[0].env["PROTONPATH"] == "GE-Proton"
prefix = repository.prefix_path(game)
assert argv.startswith("bwrap --clearenv")
assert f"--bind {prefix} {prefix}" in argv
Expand Down Expand Up @@ -378,6 +383,7 @@ def popen(argv, **kwargs):
return Process()

monkeypatch.setenv("FLATPAK_ID", "com.usebottles.bottles")
monkeypatch.setattr(executor, "_ensure_sandbox_runtime", lambda _command: None)
monkeypatch.setattr(executor_module.subprocess, "Popen", popen)

executor.run(game)
Expand All @@ -386,6 +392,129 @@ def popen(argv, **kwargs):
assert f"--sandbox-expose-path-ro={proton}" in argv


def test_dedicated_sandbox_uses_flatpak_umu_path(monkeypatch, tmp_path):
repository = UmuGameRepository(tmp_path / "umu")
game_folder = tmp_path / "Game Files"
game_folder.mkdir()
game = _game(repository, tmp_path, sandbox=True)
home = tmp_path / "home"
executor = _executor(
repository,
tmp_path,
{
"container": "flatpak",
"HOME": str(home),
"XDG_DATA_HOME": str(tmp_path / "flatpak-data"),
},
)

class Process:
pid = 123

@staticmethod
def poll():
return None

calls = []

def popen(argv, **kwargs):
calls.append((argv, kwargs))
return Process()

monkeypatch.setenv("FLATPAK_ID", "com.usebottles.bottles")
monkeypatch.setattr(executor, "_ensure_sandbox_runtime", lambda _command: None)
monkeypatch.setattr(executor_module.subprocess, "Popen", popen)

executor.run(game)

argv, _kwargs = calls[0]
runtime = home / ".local" / "share" / "umu"
assert f"--sandbox-expose-path={runtime}" in argv
assert f"--sandbox-expose-path={tmp_path / 'flatpak-data' / 'umu'}" not in argv
assert "--no-network" in argv


def test_runtime_root_uses_flatpak_host_data_home(tmp_path):
host_data_home = tmp_path / "host-data"

runtime = UmuExecutor._runtime_root(
{
"container": "flatpak",
"HOME": str(tmp_path / "home"),
"HOST_XDG_DATA_HOME": str(host_data_home),
"XDG_DATA_HOME": str(tmp_path / "flatpak-data"),
}
)

assert runtime == host_data_home / "umu"


def test_dedicated_sandbox_prepares_runtime_outside_sandbox(monkeypatch, tmp_path):
repository = UmuGameRepository(tmp_path / "umu")
game = _game(repository, tmp_path, sandbox=True)
home = tmp_path / "home"
executor = _executor(
repository,
tmp_path,
{"container": "flatpak", "HOME": str(home)},
)
calls = []

class Process:
stdout = iter(())

@staticmethod
def wait():
return 0

def popen(argv, **kwargs):
calls.append((argv, kwargs))
Path(argv[-1]).touch()
return Process()

monkeypatch.setattr(executor_module.subprocess, "Popen", popen)
command = executor.prepare(game)

executor._ensure_sandbox_runtime(command)
executor._ensure_sandbox_runtime(command)
other_command = replace(
command,
env={**command.env, "UMU_FOLDERS_PATH": str(tmp_path / "other-data")},
)
executor._ensure_sandbox_runtime(other_command)

assert len(calls) == 2
argv, kwargs = calls[0]
assert argv[:2] == [str(executor.installation.path), "/usr/bin/touch"]
assert Path(argv[-1]).parent == home / ".local" / "share" / "umu"
assert not Path(argv[-1]).exists()
assert kwargs["env"]["UMU_NO_PROTON"] == "1"
assert kwargs["shell"] is False
assert Path(calls[1][0][-1]).parent == tmp_path / "other-data" / "umu"


def test_dedicated_sandbox_rejects_incomplete_runtime_setup(monkeypatch, tmp_path):
repository = UmuGameRepository(tmp_path / "umu")
game = _game(repository, tmp_path, sandbox=True)
executor = _executor(repository, tmp_path)

class Process:
stdout = iter(("ERROR: umu has not been setup for the user\n",))

@staticmethod
def wait():
return 0

monkeypatch.setattr(
executor_module.subprocess,
"Popen",
lambda _argv, **_kwargs: Process(),
)

with pytest.raises(UmuProcessError, match="UMU runtime setup failed"):
executor._ensure_sandbox_runtime(executor.prepare(game))


def test_dedicated_sandbox_rejects_filesystem_root_as_prefix(monkeypatch, tmp_path):
game = UmuGame(
id=uuid4(),
Expand Down
Loading