diff --git a/l4d2host/steam_install.py b/l4d2host/steam_install.py index 98c3df3..c689603 100644 --- a/l4d2host/steam_install.py +++ b/l4d2host/steam_install.py @@ -1,3 +1,4 @@ +import shutil from pathlib import Path from typing import Callable @@ -6,6 +7,57 @@ from l4d2host.process import run_command from l4d2host.logging import emit_step +def _resolve_steamcmd_siblings(steamcmd: str) -> tuple[Path, Path] | None: + resolved = shutil.which(steamcmd) + base = Path(resolved).resolve() if resolved else Path(steamcmd) + if base.is_symlink(): + base = base.resolve() + parent = base.parent + linux32 = parent / "linux32" + linux64 = parent / "linux64" + if linux32.is_dir() and linux64.is_dir(): + return linux32, linux64 + return None + + +def _set_symlink(link: Path, target: Path, on_stderr: Callable[[str], None] | None) -> None: + if link.is_symlink(): + try: + current = link.resolve() + except OSError: + current = None + if current == target.resolve(): + return + link.unlink() + elif link.exists(): + if on_stderr is not None: + on_stderr(f"refusing to replace non-symlink at {link}") + return + link.symlink_to(target) + + +def _ensure_steam_sdk_symlinks( + install_dir: Path, + steamcmd: str, + *, + on_stdout: Callable[[str], None] | None, + on_stderr: Callable[[str], None] | None, + passthrough: bool, +) -> None: + emit_step("ensuring steam sdk symlinks...", on_stdout, passthrough) + siblings = _resolve_steamcmd_siblings(steamcmd) + if siblings is not None: + sdk32_target, sdk64_target = siblings + else: + fallback = install_dir / "bin" + sdk32_target, sdk64_target = fallback, fallback + + steam_dir = Path.home() / ".steam" + steam_dir.mkdir(parents=True, exist_ok=True) + _set_symlink(steam_dir / "sdk32", sdk32_target, on_stderr) + _set_symlink(steam_dir / "sdk64", sdk64_target, on_stderr) + + class SteamInstaller: def __init__(self, install_dir: Path | None = None, steamcmd: str = "steamcmd"): self.install_dir = get_left4me_root() / "installation" if install_dir is None else Path(install_dir) @@ -40,4 +92,11 @@ class SteamInstaller: passthrough=passthrough, should_cancel=should_cancel, ) + _ensure_steam_sdk_symlinks( + self.install_dir, + self.steamcmd, + on_stdout=on_stdout, + on_stderr=on_stderr, + passthrough=passthrough, + ) emit_step("installation complete.", on_stdout, passthrough) diff --git a/l4d2host/tests/test_install.py b/l4d2host/tests/test_install.py index 5f7fa30..d444459 100644 --- a/l4d2host/tests/test_install.py +++ b/l4d2host/tests/test_install.py @@ -46,8 +46,8 @@ def test_default_install_dir_uses_left4me_root(tmp_path, monkeypatch): def test_steam_installer_emits_steps(tmp_path, monkeypatch) -> None: - from pathlib import Path monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path / "home")) monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: None) steps: list[str] = [] @@ -58,5 +58,82 @@ def test_steam_installer_emits_steps(tmp_path, monkeypatch) -> None: assert steps == [ "Step: downloading windows platform payload...", "Step: downloading linux platform payload...", + "Step: ensuring steam sdk symlinks...", "Step: installation complete." ] + + +def test_install_creates_steam_sdk_symlinks_to_steamcmd_siblings(tmp_path, monkeypatch) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + + steamcmd_dir = tmp_path / "opt" / "steamcmd" + (steamcmd_dir / "linux32").mkdir(parents=True) + (steamcmd_dir / "linux64").mkdir(parents=True) + fake_steamcmd = steamcmd_dir / "steamcmd.sh" + fake_steamcmd.write_text("#!/bin/sh\nexit 0\n") + fake_steamcmd.chmod(0o755) + + monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: None) + + from l4d2host.steam_install import SteamInstaller + SteamInstaller(steamcmd=str(fake_steamcmd)).install_or_update() + + sdk32 = home / ".steam" / "sdk32" + sdk64 = home / ".steam" / "sdk64" + assert sdk32.is_symlink() + assert sdk64.is_symlink() + assert sdk32.resolve() == (steamcmd_dir / "linux32").resolve() + assert sdk64.resolve() == (steamcmd_dir / "linux64").resolve() + + +def test_install_creates_steam_sdk_symlinks_falls_back_to_install_bin(tmp_path, monkeypatch) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + + install_bin = tmp_path / "installation" / "bin" + install_bin.mkdir(parents=True) + + isolated_dir = tmp_path / "no-siblings" + isolated_dir.mkdir() + fake_steamcmd = isolated_dir / "steamcmd.sh" + fake_steamcmd.write_text("#!/bin/sh\nexit 0\n") + fake_steamcmd.chmod(0o755) + + monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: None) + + from l4d2host.steam_install import SteamInstaller + SteamInstaller(steamcmd=str(fake_steamcmd)).install_or_update() + + sdk32 = home / ".steam" / "sdk32" + sdk64 = home / ".steam" / "sdk64" + assert sdk32.resolve() == install_bin.resolve() + assert sdk64.resolve() == install_bin.resolve() + + +def test_install_steam_sdk_symlinks_is_idempotent(tmp_path, monkeypatch) -> None: + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + + steamcmd_dir = tmp_path / "opt" / "steamcmd" + (steamcmd_dir / "linux32").mkdir(parents=True) + (steamcmd_dir / "linux64").mkdir(parents=True) + fake_steamcmd = steamcmd_dir / "steamcmd.sh" + fake_steamcmd.write_text("#!/bin/sh\nexit 0\n") + fake_steamcmd.chmod(0o755) + + monkeypatch.setattr("l4d2host.steam_install.run_command", lambda cmd, **kwargs: None) + + from l4d2host.steam_install import SteamInstaller + installer = SteamInstaller(steamcmd=str(fake_steamcmd)) + installer.install_or_update() + installer.install_or_update() + + sdk32 = home / ".steam" / "sdk32" + assert sdk32.resolve() == (steamcmd_dir / "linux32").resolve()