diff --git a/.github/workflows/build-umu-debian-12.yml b/.github/workflows/build-umu-debian-12.yml index ed655d0a9..9878d9f0f 100644 --- a/.github/workflows/build-umu-debian-12.yml +++ b/.github/workflows/build-umu-debian-12.yml @@ -4,6 +4,8 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + release: + types: [published] jobs: build: @@ -15,7 +17,7 @@ jobs: options: --privileged -it steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Copy debian packaging folder to the repository root run: cp -rvf ./packaging/deb/debian ./debian @@ -41,5 +43,5 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@v4.0.0 with: - name: Binary DEB files + name: Debian-12 path: results/ diff --git a/.github/workflows/build-umu-fedora.yml b/.github/workflows/build-umu-fedora.yml index e29e7897b..8c910a880 100644 --- a/.github/workflows/build-umu-fedora.yml +++ b/.github/workflows/build-umu-fedora.yml @@ -5,6 +5,8 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + release: + types: [published] jobs: build: @@ -17,7 +19,7 @@ jobs: run: dnf install -y git - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Configure Git safe directory run: git config --global --add safe.directory "$GITHUB_WORKSPACE" @@ -33,7 +35,7 @@ jobs: cp -r . ~/rpmbuild/SOURCES/umu-launcher rpmbuild -ba packaging/rpm/umu-launcher.spec - - name: Upload RPM + - name: Fedora-40 uses: actions/upload-artifact@v4.0.0 with: name: umu-launcher-rpm diff --git a/.github/workflows/build-umu-ubuntu-noble.yml b/.github/workflows/build-umu-ubuntu-noble.yml index 1636c0685..bab37e21b 100644 --- a/.github/workflows/build-umu-ubuntu-noble.yml +++ b/.github/workflows/build-umu-ubuntu-noble.yml @@ -4,6 +4,8 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + release: + types: [published] jobs: build: @@ -15,7 +17,7 @@ jobs: options: --privileged -it steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Copy debian packaging folder to the repository root run: cp -rvf ./packaging/deb/ubuntu ./debian @@ -41,5 +43,5 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@v4.0.0 with: - name: Binary DEB files + name: Ubuntu-24 path: results/ diff --git a/.github/workflows/build-umu-zipapp.yml b/.github/workflows/build-umu-zipapp.yml new file mode 100644 index 000000000..03007f32a --- /dev/null +++ b/.github/workflows/build-umu-zipapp.yml @@ -0,0 +1,44 @@ +name: UMU Zipapp Build +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + container: + image: debian:bookworm + volumes: + - /proc:/proc + options: --privileged -it + + steps: + - uses: actions/checkout@v4 + + - name: Update APT Cache + run: apt update -y + + - name: Install build dependencies + run: apt install -y python3-venv python3-all bash make scdoc python3-hatchling python3-installer python3-build + + - name: Configure + run: ./configure.sh --user-install + + - name: Build + run: make all + + - name: Move DEB files to upload artifact path + run: mkdir -p results && cp -rvf builddir/umu-run results/ + + - name: Create symlink for launchers + run: cd results && ln -s umu-run umu_run.py && cd .. + + - name: Upload artifact + uses: actions/upload-artifact@v4.0.0 + with: + name: Zipapp + path: results/ diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 000000000..f75d5d6af --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,83 @@ +name: Create Release + +on: + release: + types: [published] # Trigger when a new release is published + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download Debian 12 Artifact + uses: actions/download-artifact@v3 + with: + name: Debian-12 + + - name: Download Ubuntu 24 Artifact + uses: actions/download-artifact@v3 + with: + name: Ubuntu-24 + + - name: Download Fedora RPM Artifact + uses: actions/download-artifact@v3 + with: + name: umu-launcher-rpm + + - name: Download Zipapp Artifact + uses: actions/download-artifact@v3 + with: + name: Zipapp + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.event.release.tag_name }} + release_name: ${{ github.event.release.name }} + draft: false + prerelease: false + + - name: Upload Debian 12 Artifact to Release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: results/*.deb + asset_name: Debian-12.deb + asset_content_type: application/octet-stream + + - name: Upload Ubuntu 24 Artifact to Release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: results/*.deb + asset_name: Ubuntu-24.deb + asset_content_type: application/octet-stream + + - name: Upload Fedora RPM Artifact to Release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ~/rpmbuild/RPMS/noarch/*.rpm + asset_name: umu-launcher-rpm.rpm + asset_content_type: application/octet-stream + + - name: Upload Zipapp Artifact to Release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: results/umu-run + asset_name: umu-run.zip + asset_content_type: application/octet-stream diff --git a/packaging/flatpak/org.openwinecomponents.umu.umu-launcher.yml b/packaging/flatpak/org.openwinecomponents.umu.umu-launcher.yml index 8e45d2a78..9d64e3090 100644 --- a/packaging/flatpak/org.openwinecomponents.umu.umu-launcher.yml +++ b/packaging/flatpak/org.openwinecomponents.umu.umu-launcher.yml @@ -26,10 +26,6 @@ finish-args: - --filesystem=~/Games:rw - --filesystem=~/.local/share/Steam:rw - --filesystem=~/.var/app/com.valvesoftware.Steam:rw - - --filesystem=~/.var/app/org.openwinecomponents.umu.umu-launcher:rw - - --filesystem=home - - --filesystem=xdg-documents - - --filesystem=xdg-desktop - --filesystem=xdg-download - --env=TZ= - --unset-env=TZ diff --git a/packaging/nix/umu-launcher.nix b/packaging/nix/umu-launcher.nix index fe0635605..965203339 100644 --- a/packaging/nix/umu-launcher.nix +++ b/packaging/nix/umu-launcher.nix @@ -10,7 +10,14 @@ python3Packages.buildPythonPackage { pkgs.scdoc pkgs.git pkgs.python3Packages.installer - pkgs.hatch + (pkgs.hatch.overrideAttrs (prev: { + disabledTests = prev.disabledTests ++ [ + "test_field_readme" + "test_field_string" + "test_field_complex" + "test_plugin_dependencies_unmet" + ]; + })) pkgs.python3Packages.build ]; propagatedBuildInputs = [ diff --git a/tests/test_update.sh b/tests/test_update.sh index d1f14d3f4..bad74e9c0 100644 --- a/tests/test_update.sh +++ b/tests/test_update.sh @@ -6,6 +6,7 @@ curl -LJO "https://repo.steampowered.com/steamrt3/images/0.20240916.101795/Steam tar xaf SteamLinuxRuntime_sniper.tar.xz mv SteamLinuxRuntime_sniper/* "$HOME/.local/share/umu" mv "$HOME/.local/share/umu/_v2-entry-point" "$HOME/.local/share/umu/umu" +echo "$@" > "$HOME/.local/share/umu/umu-shim" && chmod 700 "$HOME/.local/share/umu/umu-shim" # Perform a preflight step, where we ensure everything is in order and create '$HOME/.local/share/umu/var' # Afterwards, run a 2nd time to perform the runtime update and ensure '$HOME/.local/share/umu/var' is removed diff --git a/umu/umu_consts.py b/umu/umu_consts.py index 6ddde76ff..7f0c3d22b 100644 --- a/umu/umu_consts.py +++ b/umu/umu_consts.py @@ -7,6 +7,8 @@ ".local", "share", "Steam", "compatibilitytools.d" ) +STEAM_WINDOW_ID: int = 769 + PROTON_VERBS = { "waitforexitandrun", "run", diff --git a/umu/umu_run.py b/umu/umu_run.py index 099c76722..7e0c4da8b 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -8,6 +8,7 @@ from _ctypes import CFuncPtr from argparse import ArgumentParser, Namespace, RawTextHelpFormatter from concurrent.futures import Future, ThreadPoolExecutor +from contextlib import suppress from ctypes import CDLL, c_int, c_ulong from errno import ENETUNREACH @@ -24,16 +25,19 @@ from subprocess import Popen from typing import Any +from filelock import FileLock from Xlib import X, Xatom, display from Xlib.error import DisplayConnectionError from Xlib.protocol.request import GetProperty from Xlib.protocol.rq import Event from Xlib.xobject.drawable import Window +from umu import __version__ from umu.umu_consts import ( PR_SET_CHILD_SUBREAPER, PROTON_VERBS, STEAM_COMPAT, + STEAM_WINDOW_ID, UMU_LOCAL, ) from umu.umu_log import CustomFormatter, console_handler, log @@ -59,7 +63,9 @@ def parse_args() -> Namespace | tuple[str, list[str]]: # noqa: D103 ), formatter_class=RawTextHelpFormatter, ) - parser.add_argument("--config", help=("path to TOML file (requires Python 3.11+)")) + parser.add_argument( + "--config", help=("path to TOML file (requires Python 3.11+)") + ) parser.add_argument( "winetricks", help=("run winetricks verbs (requires UMU-Proton or GE-Proton)"), @@ -79,7 +85,9 @@ def parse_args() -> Namespace | tuple[str, list[str]]: # noqa: D103 sys.exit(1) # Exit if argument is not a verb - if sys.argv[1].endswith("winetricks") and not is_winetricks_verb(sys.argv[2:]): + if sys.argv[1].endswith("winetricks") and not is_winetricks_verb( + sys.argv[2:] + ): sys.exit(1) if sys.argv[1:][0] in opt_args: @@ -96,7 +104,9 @@ def parse_args() -> Namespace | tuple[str, list[str]]: # noqa: D103 def setup_pfx(path: str) -> None: """Prepare a Proton compatible WINE prefix.""" pfx: Path = Path(path).joinpath("pfx").expanduser() - steam: Path = Path(path).expanduser().joinpath("drive_c", "users", "steamuser") + steam: Path = ( + Path(path).expanduser().joinpath("drive_c", "users", "steamuser") + ) # Login name of the user as determined by the password database (pwd) user: str = getpwuid(os.getuid()).pw_name wineuser: Path = Path(path).expanduser().joinpath("drive_c", "users", user) @@ -161,7 +171,9 @@ def check_env( os.environ.get("PROTONPATH") and Path(STEAM_COMPAT, os.environ["PROTONPATH"]).is_dir() ): - os.environ["PROTONPATH"] = str(STEAM_COMPAT.joinpath(os.environ["PROTONPATH"])) + os.environ["PROTONPATH"] = str( + STEAM_COMPAT.joinpath(os.environ["PROTONPATH"]) + ) # GE-Proton if os.environ.get("PROTONPATH") == "GE-Proton": @@ -190,13 +202,17 @@ def set_env( ) -> dict[str, str]: """Set various environment variables for the Steam Runtime.""" pfx: Path = Path(env["WINEPREFIX"]).expanduser().resolve(strict=True) - protonpath: Path = Path(env["PROTONPATH"]).expanduser().resolve(strict=True) + protonpath: Path = ( + Path(env["PROTONPATH"]).expanduser().resolve(strict=True) + ) # Command execution usage is_cmd: bool = isinstance(args, tuple) # Command execution usage, but client wants to create a prefix. When an # empty string is the executable, Proton is expected to create the prefix # but will fail because the executable is not found - is_createpfx: bool = is_cmd and not args[0] # type: ignore + is_createpfx: bool = ( + is_cmd and not args[0] or (is_cmd and args[0] == "createprefix") # type: ignore + ) # Command execution usage, but client wants to run winetricks verbs is_winetricks: bool = is_cmd and args[0] == "winetricks" # type: ignore @@ -211,12 +227,13 @@ def set_env( if is_createpfx: env["EXE"] = "" env["STEAM_COMPAT_INSTALL_PATH"] = "" - env["PROTON_VERB"] = "waitforexitandrun" elif is_winetricks: # Make an absolute path to winetricks within GE-Proton or UMU-Proton. # The launcher will change to the winetricks parent directory before # creating the subprocess - exe: Path = Path(protonpath, "protonfixes", "winetricks").resolve(strict=True) + exe: Path = Path(protonpath, "protonfixes", "winetricks").resolve( + strict=True + ) env["EXE"] = str(exe) args = (env["EXE"], args[1]) # type: ignore env["STEAM_COMPAT_INSTALL_PATH"] = str(exe.parent) @@ -248,7 +265,9 @@ def set_env( env["STEAM_COMPAT_APP_ID"] = "0" if match(r"^umu-[\d\w]+$", env["UMU_ID"]): - env["STEAM_COMPAT_APP_ID"] = env["UMU_ID"][env["UMU_ID"].find("-") + 1 :] + env["STEAM_COMPAT_APP_ID"] = env["UMU_ID"][ + env["UMU_ID"].find("-") + 1 : + ] env["SteamAppId"] = env["STEAM_COMPAT_APP_ID"] env["SteamGameId"] = env["SteamAppId"] @@ -256,7 +275,9 @@ def set_env( env["WINEPREFIX"] = str(pfx) env["PROTONPATH"] = str(protonpath) env["STEAM_COMPAT_DATA_PATH"] = env["WINEPREFIX"] - env["STEAM_COMPAT_SHADER_PATH"] = f"{env['STEAM_COMPAT_DATA_PATH']}/shadercache" + env["STEAM_COMPAT_SHADER_PATH"] = ( + f"{env['STEAM_COMPAT_DATA_PATH']}/shadercache" + ) env["STEAM_COMPAT_TOOL_PATHS"] = f"{env['PROTONPATH']}:{UMU_LOCAL}" env["STEAM_COMPAT_MOUNTS"] = env["STEAM_COMPAT_TOOL_PATHS"] @@ -316,6 +337,7 @@ def build_command( opts: list[str] = [], ) -> tuple[Path | str, ...]: """Build the command to be executed.""" + shim: Path = local.joinpath("umu-shim") proton: Path = Path(env["PROTONPATH"], "proton") entry_point: Path = local.joinpath("umu") @@ -354,6 +376,7 @@ def build_command( "--verb", env["PROTON_VERB"], "--", + shim, proton, env["PROTON_VERB"], env["EXE"], @@ -367,7 +390,9 @@ def get_window_client_ids(d: display.Display) -> set[str] | None: event: Event = d.next_event() if event.type == X.CreateNotify: - return {child.id for child in d.screen().root.query_tree().children} + return { + child.id for child in d.screen().root.query_tree().children + } except Exception as e: log.exception(e) @@ -414,7 +439,9 @@ def get_gamescope_baselayer_order( atom = d.get_atom("GAMESCOPECTRL_BASELAYER_APPID") # Get the property value - prop: GetProperty | None = root_primary.get_full_property(atom, Xatom.CARDINAL) + prop: GetProperty | None = root_primary.get_full_property( + atom, Xatom.CARDINAL + ) if prop: # Extract and return the value @@ -431,21 +458,35 @@ def rearrange_gamescope_baselayer_order( sequence: list[int], ) -> tuple[list[int], int] | None: """Rearrange a gamescope base layer sequence retrieved from a window.""" - rearranged: list[int] + # Note: 'sequence' is actually an array type with unsigned integers + rearranged: list[int] = list(sequence) + steam_layer_id: int = get_steam_layer_id() + + log.debug("Base layer sequence: %s", sequence) - # Gamescope identifies Steam's window by the App ID 769 or by the atom - # STEAM_BIGPICTURE. This id must be the last element in the sequence - if sequence and sequence[-1] == 769: + if not steam_layer_id: return None - rearranged = [sequence[0], sequence[-1], *sequence[1:-1]] + try: + rearranged.remove(steam_layer_id) + except ValueError as e: + # Case when the layer ID isn't in GAMESCOPECTRL_BASELAYER_APPID + # One case this can occur is if the client overrides Steam's env vars + # that we get the layer ID from + log.exception(e) + return None + + # Steam's window should be last, while assigned layer 2nd to last + rearranged = [*rearranged[:-1], steam_layer_id, STEAM_WINDOW_ID] log.debug("Rearranging base layer sequence") log.debug("'%s' -> '%s'", sequence, rearranged) - return rearranged, rearranged[1] + return rearranged, steam_layer_id -def set_gamescope_baselayer_order(d: display.Display, rearranged: list[int]) -> None: +def set_gamescope_baselayer_order( + d: display.Display, rearranged: list[int] +) -> None: """Set a new gamescope base layer seq on the primary root window.""" try: # Intern the atom for GAMESCOPECTRL_BASELAYER_APPID @@ -462,26 +503,28 @@ def set_gamescope_baselayer_order(d: display.Display, rearranged: list[int]) -> log.exception(e) -def window_setup( # noqa - d_primary: display.Display, - d_secondary: display.Display, - gamescope_baselayer_sequence: list[int], - game_window_ids: set[str], -) -> None: - rearranged_gamescope_baselayer: tuple[list[int], int] | None = None +def get_steam_layer_id() -> int: + """Get the Steam layer ID from the host environment variables.""" + steam_layer_id: int = 0 - if gamescope_baselayer_sequence: - rearranged_gamescope_baselayer = rearrange_gamescope_baselayer_order( - gamescope_baselayer_sequence - ) + if path := os.environ.get("STEAM_COMPAT_TRANSCODED_MEDIA_PATH"): + # Suppress cases when value is not a number or empty tuple + with suppress(ValueError, IndexError): + return int(Path(path).parts[-1]) - if rearranged_gamescope_baselayer: - rearranged_sequence, steam_assigned_layer_id = rearranged_gamescope_baselayer + if path := os.environ.get("STEAM_COMPAT_MEDIA_PATH"): + with suppress(ValueError, IndexError): + return int(Path(path).parts[-2]) + + if path := os.environ.get("STEAM_FOSSILIZE_DUMP_PATH"): + with suppress(ValueError, IndexError): + return int(Path(path).parts[-3]) - # Assign our window a STEAM_GAME id - set_steam_game_property(d_secondary, game_window_ids, steam_assigned_layer_id) + if path := os.environ.get("DXVK_STATE_CACHE_PATH"): + with suppress(ValueError, IndexError): + return int(Path(path).parts[-2]) - set_gamescope_baselayer_order(d_primary, rearranged_sequence) + return steam_layer_id def monitor_baselayer( @@ -494,7 +537,21 @@ def monitor_baselayer( atom = d_primary.get_atom("GAMESCOPECTRL_BASELAYER_APPID") root_primary.change_attributes(event_mask=X.PropertyChangeMask) - log.debug("Monitoring base layers") + log.debug( + "Monitoring base layers under display '%s'...", + d_primary.get_display_name(), + ) + + # Get a rearranged sequence from GAMESCOPECTRL_BASELAYER_APPID. + rearranged_gamescope_baselayer = rearrange_gamescope_baselayer_order( + gamescope_baselayer_sequence + ) + + # Set the rearranged sequence from GAMESCOPECTRL_BASELAYER_APPID. + if rearranged_gamescope_baselayer: + rearranged, _ = rearranged_gamescope_baselayer + set_gamescope_baselayer_order(d_primary, rearranged) + rearranged_gamescope_baselayer = None while True: event: Event = d_primary.next_event() @@ -504,11 +561,11 @@ def monitor_baselayer( prop = root_primary.get_full_property(atom, Xatom.CARDINAL) # Check if the layer sequence has changed to the broken one - if prop and prop.value == gamescope_baselayer_sequence: + if prop and prop.value[-1] != STEAM_WINDOW_ID: log.debug("Broken base layer sequence detected") log.debug("Property value for atom '%s': %s", atom, prop.value) - rearranged_gamescope_baselayer = rearrange_gamescope_baselayer_order( - prop.value + rearranged_gamescope_baselayer = ( + rearrange_gamescope_baselayer_order(prop.value) ) if rearranged_gamescope_baselayer: @@ -522,23 +579,39 @@ def monitor_baselayer( def monitor_windows( d_secondary: display.Display, - gamescope_baselayer_sequence: list[int], - game_window_ids: set[str], ) -> None: """Monitor for new windows and assign them Steam's layer ID.""" - window_ids: set[str] = game_window_ids.copy() - steam_assigned_layer_id: int = gamescope_baselayer_sequence[-1] + window_ids: set[str] | None = None + steam_assigned_layer_id: int = get_steam_layer_id() - log.debug("Monitoring windows") + log.debug( + "Waiting for windows under display '%s'...", + d_secondary.get_display_name(), + ) + + while not window_ids: + window_ids = get_window_client_ids(d_secondary) + + set_steam_game_property(d_secondary, window_ids, steam_assigned_layer_id) + + log.debug( + "Monitoring for new windows under display '%s'...", + d_secondary.get_display_name(), + ) # Check if the window sequence has changed while True: - current_window_ids: set[str] | None = get_window_client_ids(d_secondary) + current_window_ids: set[str] | None = get_window_client_ids( + d_secondary + ) if not current_window_ids: continue - if diff := window_ids.symmetric_difference(current_window_ids): + if diff := current_window_ids.difference(window_ids): + log.debug("Seen windows: %s", window_ids) + log.debug("Current windows: %s", current_window_ids) + log.debug("Difference: %s", diff) log.debug("New windows detected") window_ids |= diff set_steam_game_property(d_secondary, diff, steam_assigned_layer_id) @@ -555,8 +628,6 @@ def run_in_steammode(proc: Popen) -> int: """ # GAMESCOPECTRL_BASELAYER_APPID value on the primary's window gamescope_baselayer_sequence: list[int] | None = None - # Windows that will be assigned Steam's layer ID - window_client_list: set[str] | None = None # Currently, steamos creates two xwayland servers at :0 and :1 # Despite the socket for display :0 being hidden at /tmp/.x11-unix in @@ -568,36 +639,26 @@ def run_in_steammode(proc: Popen) -> int: xdisplay(":0") as d_primary, xdisplay(":1") as d_secondary, ): - gamescope_baselayer_sequence = get_gamescope_baselayer_order(d_primary) + gamescope_baselayer_sequence = get_gamescope_baselayer_order( + d_primary + ) # Dont do window fuckery if we're not inside gamescope - if gamescope_baselayer_sequence and not os.environ.get("EXE", "").endswith( - "winetricks" + if ( + gamescope_baselayer_sequence + and os.environ.get("PROTON_VERB") == "waitforexitandrun" ): + # Note: If the executable is one that exists in the WINE prefix + # or container it is possible that umu wil hang when running a + # game within a gamescope session d_secondary.screen().root.change_attributes( event_mask=X.SubstructureNotifyMask ) - # Get new windows under the client display's window - while not window_client_list: - window_client_list = get_window_client_ids(d_secondary) - - # Setup the windows - window_setup( - d_primary, - d_secondary, - gamescope_baselayer_sequence, - window_client_list, - ) - # Monitor for new windows window_thread = threading.Thread( target=monitor_windows, - args=( - d_secondary, - gamescope_baselayer_sequence, - window_client_list, - ), + args=(d_secondary,), ) window_thread.daemon = True window_thread.start() @@ -626,10 +687,16 @@ def run_command(command: tuple[Path | str, ...]) -> int: ret: int = 0 prctl_ret: int = 0 libc: str = get_libc() + + is_gamescope_session: bool = ( + os.environ.get("XDG_CURRENT_DESKTOP") == "gamescope" + or os.environ.get("XDG_SESSION_DESKTOP") == "gamescope" + ) + # Note: STEAM_MULTIPLE_XWAYLANDS is steam mode specific and is # documented to be a legacy env var. is_steammode: bool = ( - os.environ.get("XDG_CURRENT_DESKTOP") == "gamescope" + is_gamescope_session and os.environ.get("STEAM_MULTIPLE_XWAYLANDS") == "1" ) @@ -725,6 +792,10 @@ def main() -> int: # noqa: D103 console_handler.setFormatter(CustomFormatter(DEBUG)) log.addHandler(console_handler) log.setLevel(level=DEBUG) + for key, val in os.environ.items(): + log.debug("%s=%s", key, val) + + log.console(f"umu-launcher version {__version__} ({sys.version})") with ThreadPoolExecutor() as thread_pool: try: @@ -765,7 +836,9 @@ def main() -> int: # noqa: D103 raise RuntimeError(err) # Setup the launcher and runtime files - future: Future = thread_pool.submit(setup_umu, root, UMU_LOCAL, thread_pool) + future: Future = thread_pool.submit( + setup_umu, root, UMU_LOCAL, thread_pool + ) if isinstance(args, Namespace): env, opts = set_env_toml(env, args) @@ -773,8 +846,11 @@ def main() -> int: # noqa: D103 opts = args[1] # Reference the executable options check_env(env, thread_pool) + UMU_LOCAL.mkdir(parents=True, exist_ok=True) + # Prepare the prefix - setup_pfx(env["WINEPREFIX"]) + with FileLock(f"{UMU_LOCAL}/pfx.lock"): + setup_pfx(env["WINEPREFIX"]) # Configure the environment set_env(env, args) diff --git a/umu/umu_runtime.py b/umu/umu_runtime.py index 556bd2b67..648a11e70 100644 --- a/umu/umu_runtime.py +++ b/umu/umu_runtime.py @@ -33,6 +33,49 @@ has_data_filter: bool = False +def create_shim(file_path: Path | None = None): + """Create a shell script shim at the specified file path. + + This script sets the DISPLAY environment variable if certain conditions + are met and executes the passed command. + + Args: + file_path (Path, optional): The path where the shim script will be created. + Defaults to UMU_LOCAL.joinpath("umu-shim"). + + """ + # Set the default path if none is provided + if file_path is None: + file_path = UMU_LOCAL.joinpath("umu-shim") + # Define the content of the shell script + script_content = """#!/bin/sh + + if [ "${XDG_CURRENT_DESKTOP}" = "gamescope" ] || [ "${XDG_SESSION_DESKTOP}" = "gamescope" ]; then + # Check if STEAM_MULTIPLE_XWAYLANDS is set to 1 + if [ "${STEAM_MULTIPLE_XWAYLANDS}" = "1" ]; then + # Check if DISPLAY is set, if not, set it to ":1" + if [ -z "${DISPLAY}" ]; then + export DISPLAY=":1" + fi + fi + fi + + # Execute the passed command + "$@" + + # Capture the exit status + status=$? + echo "Command exited with status: $status" + exit $status + """ + + # Write the script content to the specified file path + with file_path.open('w') as file: + file.write(script_content) + + # Make the script executable + file_path.chmod(0o700) + def _install_umu( json: dict[str, Any], thread_pool: ThreadPoolExecutor, @@ -177,6 +220,7 @@ def _install_umu( # Rename _v2-entry-point log.debug("Renaming: _v2-entry-point -> umu") UMU_LOCAL.joinpath("_v2-entry-point").rename(UMU_LOCAL.joinpath("umu")) + create_shim() # Validate the runtime after moving the files check_runtime(UMU_LOCAL, json) @@ -368,6 +412,10 @@ def _update_umu( rmtree(str(runtime)) log.debug("Released file lock '%s'", lock.lock_file) + # Restore shim + if not local.joinpath("umu-shim").exists(): + create_shim() + log.console("steamrt is up to date") @@ -474,6 +522,9 @@ def check_runtime(src: Path, json: dict[str, Any]) -> int: return ret log.console(f"{runtime.name}: mtree is OK") + if not UMU_LOCAL.joinpath("umu-shim").exists(): + create_shim() + return ret @@ -491,3 +542,6 @@ def _restore_umu( return _install_umu(json, thread_pool, client_session) log.debug("Released file lock '%s'", lock.lock_file) + + if not UMU_LOCAL.joinpath("umu-shim").exists(): + create_shim() diff --git a/umu/umu_test.py b/umu/umu_test.py index b2ac1cb2f..99e81f5a7 100644 --- a/umu/umu_test.py +++ b/umu/umu_test.py @@ -52,6 +52,7 @@ def setUp(self): "WINETRICKS_SUPER_QUIET": "", "UMU_NO_RUNTIME": "", "UMU_RUNTIME_UPDATE": "", + "STEAM_COMPAT_TRANSCODED_MEDIA_PATH": "", } self.user = getpwuid(os.getuid()).pw_name self.test_opts = "-foo -bar" @@ -125,15 +126,21 @@ def setUp(self): # Mock the runtime files Path(self.test_user_share, "sniper_platform_0.20240125.75305").mkdir() - Path(self.test_user_share, "sniper_platform_0.20240125.75305", "foo").touch() + Path( + self.test_user_share, "sniper_platform_0.20240125.75305", "foo" + ).touch() Path(self.test_user_share, "run").touch() Path(self.test_user_share, "run-in-sniper").touch() Path(self.test_user_share, "umu").touch() # Mock pressure vessel - Path(self.test_user_share, "pressure-vessel", "bin").mkdir(parents=True) + Path(self.test_user_share, "pressure-vessel", "bin").mkdir( + parents=True + ) Path(self.test_user_share, "pressure-vessel", "foo").touch() - Path(self.test_user_share, "pressure-vessel", "bin", "pv-verify").touch() + Path( + self.test_user_share, "pressure-vessel", "bin", "pv-verify" + ).touch() # Mock the proton file in the dir self.test_proton_dir.joinpath("proton").touch(exist_ok=True) @@ -186,40 +193,51 @@ def tearDown(self): if self.test_cache_home.exists(): rmtree(self.test_cache_home.as_posix()) - def test_rearrange_gamescope_baselayer_none(self): - """Test rearrange_gamescope_baselayer_order when passed correct seq. + def test_rearrange_gamescope_baselayer_order_broken(self): + """Test rearrange_gamescope_baselayer_order when passed broken seq. - A rearranged sequence should only be returned when the last element - is 769. Otherwise, None should be returned + When the Steam client's window ID is not the last element in + the atom GAMESCOPECTRL_BASELAYER_APPID, then a rearranged sequence + should be returned where the last element is Steam's window ID and + the 2nd to last is the assigned layer ID. """ - baselayer = [1, 2, 3, 769] + steam_window_id = 769 + os.environ["STEAM_COMPAT_TRANSCODED_MEDIA_PATH"] = "/123" + steam_layer_id = umu_run.get_steam_layer_id() + baselayer = [1, steam_window_id, steam_layer_id] + expected = ( + [baselayer[0], steam_layer_id, steam_window_id], + steam_layer_id, + ) result = umu_run.rearrange_gamescope_baselayer_order(baselayer) - self.assertTrue( - result is None, - f"Expected None to be returned for sequence {baselayer}", + self.assertEqual( + result, + expected, + f"Expected {expected}, received {result}", ) - def test_rearrange_gamescope_baselayer_order_err(self): - """Test rearrange_gamescope_baselayer_order for unexpected seq.""" + def test_rearrange_gamescope_baselayer_order_invalid(self): + """Test rearrange_gamescope_baselayer_order for invalid seq.""" baselayer = [] - with self.assertRaises(IndexError): - umu_run.rearrange_gamescope_baselayer_order(baselayer) + self.assertTrue( + umu_run.rearrange_gamescope_baselayer_order(baselayer) is None, + "Expected None", + ) def test_rearrange_gamescope_baselayer_order(self): """Test rearrange_gamescope_baselayer_order when passed a sequence.""" - baselayer = [1, 2, 3, 4] - expected = ( - [baselayer[0], baselayer[-1], *baselayer[1:-1]], - baselayer[-1], - ) + steam_window_id = 769 + os.environ["STEAM_COMPAT_TRANSCODED_MEDIA_PATH"] = "/123" + steam_layer_id = umu_run.get_steam_layer_id() + baselayer = [1, steam_layer_id, steam_window_id] result = umu_run.rearrange_gamescope_baselayer_order(baselayer) - self.assertEqual( - result, - expected, - f"Expected {expected}, received {result}", + # Original sequence should be returned when Steam's window ID is last + self.assertTrue( + result == (baselayer, steam_layer_id), + f"Expected {baselayer}, received {result}", ) def test_run_command(self): @@ -267,7 +285,9 @@ def test_run_command_none(self): def test_get_libc(self): """Test get_libc.""" - self.assertIsInstance(umu_util.get_libc(), str, "Value is not a string") + self.assertIsInstance( + umu_util.get_libc(), str, "Value is not a string" + ) def test_is_installed_verb_noverb(self): """Test is_installed_verb when passed an empty verb.""" @@ -344,7 +364,9 @@ def test_is_winetricks_verb(self): verbs = [line.strip() for line in file] result = umu_util.is_winetricks_verb(verbs) - self.assertTrue(result, f"Expected {verbs} to only contain winetricks verbs") + self.assertTrue( + result, f"Expected {verbs} to only contain winetricks verbs" + ) def test_check_runtime(self): """Test check_runtime when pv-verify does not exist. @@ -356,14 +378,20 @@ def test_check_runtime(self): If the pv-verify binary does not exist, a warning should be logged and the function should return """ - json_root = umu_runtime._get_json(self.test_user_share, "umu_version.json") - self.test_user_share.joinpath("pressure-vessel", "bin", "pv-verify").unlink() + json_root = umu_runtime._get_json( + self.test_user_share, "umu_version.json" + ) + self.test_user_share.joinpath( + "pressure-vessel", "bin", "pv-verify" + ).unlink() result = umu_runtime.check_runtime(self.test_user_share, json_root) self.assertEqual(result, 1, "Expected the exit code 1") def test_check_runtime_success(self): """Test check_runtime when runtime validation succeeds.""" - json_root = umu_runtime._get_json(self.test_user_share, "umu_version.json") + json_root = umu_runtime._get_json( + self.test_user_share, "umu_version.json" + ) mock = CompletedProcess(["foo"], 0) with patch.object(umu_runtime, "run", return_value=mock): result = umu_runtime.check_runtime(self.test_user_share, json_root) @@ -371,8 +399,12 @@ def test_check_runtime_success(self): def test_check_runtime_dir(self): """Test check_runtime when passed a BUILD_ID that does not exist.""" - runtime = Path(self.test_user_share, "sniper_platform_0.20240125.75305") - json_root = umu_runtime._get_json(self.test_user_share, "umu_version.json") + runtime = Path( + self.test_user_share, "sniper_platform_0.20240125.75305" + ) + json_root = umu_runtime._get_json( + self.test_user_share, "umu_version.json" + ) # Mock the removal of the runtime directory # In the real usage when updating the runtime, this should not happen @@ -401,7 +433,9 @@ def test_move(self): self.test_user_share.joinpath("qux").symlink_to(test_file) # Directory - umu_runtime._move(test_dir, self.test_user_share, self.test_local_share) + umu_runtime._move( + test_dir, self.test_user_share, self.test_local_share + ) self.assertFalse( self.test_user_share.joinpath("foo").exists(), "foo did not move from src", @@ -412,7 +446,9 @@ def test_move(self): ) # File - umu_runtime._move(test_file, self.test_user_share, self.test_local_share) + umu_runtime._move( + test_file, self.test_user_share, self.test_local_share + ) self.assertFalse( self.test_user_share.joinpath("bar").exists(), "bar did not move from src", @@ -450,7 +486,9 @@ def test_ge_proton(self): self.assertRaises(FileNotFoundError), patch.object(umu_proton, "_fetch_releases", return_value=None), patch.object(umu_proton, "_get_latest", return_value=None), - patch.object(umu_proton, "_get_from_steamcompat", return_value=None), + patch.object( + umu_proton, "_get_from_steamcompat", return_value=None + ), ThreadPoolExecutor() as thread_pool, ): os.environ["WINEPREFIX"] = self.test_file @@ -460,7 +498,9 @@ def test_ge_proton(self): self.assertEqual( self.env["PROTONPATH"], self.test_compat.joinpath( - self.test_archive.name[: self.test_archive.name.find(".tar.gz")] + self.test_archive.name[ + : self.test_archive.name.find(".tar.gz") + ] ).as_posix(), "Expected PROTONPATH to be proton dir in compat", ) @@ -477,14 +517,18 @@ def test_ge_proton_none(self): self.assertRaises(FileNotFoundError), patch.object(umu_proton, "_fetch_releases", return_value=None), patch.object(umu_proton, "_get_latest", return_value=None), - patch.object(umu_proton, "_get_from_steamcompat", return_value=None), + patch.object( + umu_proton, "_get_from_steamcompat", return_value=None + ), ThreadPoolExecutor() as thread_pool, ): os.environ["WINEPREFIX"] = self.test_file os.environ["GAMEID"] = self.test_file os.environ["PROTONPATH"] = "GE-Proton" umu_run.check_env(self.env, thread_pool) - self.assertFalse(os.environ.get("PROTONPATH"), "Expected empty string") + self.assertFalse( + os.environ.get("PROTONPATH"), "Expected empty string" + ) def test_get_json_err(self): """Test _get_json when specifying a corrupted umu_version.json file. @@ -566,7 +610,9 @@ def test_get_json_steamrt(self): } test_config = json.dumps(config, indent=4) - self.test_user_share.joinpath("umu_version.json").unlink(missing_ok=True) + self.test_user_share.joinpath("umu_version.json").unlink( + missing_ok=True + ) self.test_user_share.joinpath("umu_version.json").write_text( test_config, encoding="utf-8" ) @@ -592,7 +638,9 @@ def test_get_json(self): "Expected umu_version.json to exist", ) - result = umu_runtime._get_json(self.test_user_share, "umu_version.json") + result = umu_runtime._get_json( + self.test_user_share, "umu_version.json" + ) self.assertIsInstance(result, dict, "Expected a dict") def test_latest_interrupt(self): @@ -620,7 +668,9 @@ def test_latest_interrupt(self): result = umu_proton._get_latest( self.env, self.test_compat, tmpdirs, files, thread_pool ) - self.assertFalse(self.env["PROTONPATH"], "Expected PROTONPATH to be empty") + self.assertFalse( + self.env["PROTONPATH"], "Expected PROTONPATH to be empty" + ) self.assertFalse(result, "Expected None on KeyboardInterrupt") def test_latest_val_err(self): @@ -653,7 +703,9 @@ def test_latest_val_err(self): result = umu_proton._get_latest( self.env, self.test_compat, tmpdirs, files, thread_pool ) - self.assertFalse(self.env["PROTONPATH"], "Expected PROTONPATH to be empty") + self.assertFalse( + self.env["PROTONPATH"], "Expected PROTONPATH to be empty" + ) self.assertFalse(result, "Expected None when a ValueError occurs") def test_latest_offline(self): @@ -675,8 +727,12 @@ def test_latest_offline(self): result = umu_proton._get_latest( self.env, self.test_compat, tmpdirs, files, thread_pool ) - self.assertFalse(self.env["PROTONPATH"], "Expected PROTONPATH to be empty") - self.assertFalse(result, "Expected None to be returned from _get_latest") + self.assertFalse( + self.env["PROTONPATH"], "Expected PROTONPATH to be empty" + ) + self.assertFalse( + result, "Expected None to be returned from _get_latest" + ) def test_link_umu(self): """Test _get_latest for recreating the UMU-Latest link. @@ -827,8 +883,12 @@ def test_steamcompat_nodir(self): result = umu_proton._get_from_steamcompat(self.env, self.test_compat) - self.assertFalse(result, "Expected None after calling _get_from_steamcompat") - self.assertFalse(self.env["PROTONPATH"], "Expected PROTONPATH to not be set") + self.assertFalse( + result, "Expected None after calling _get_from_steamcompat" + ) + self.assertFalse( + self.env["PROTONPATH"], "Expected PROTONPATH to not be set" + ) def test_steamcompat(self): """Test _get_from_steamcompat. @@ -847,7 +907,9 @@ def test_steamcompat(self): self.assertEqual( self.env["PROTONPATH"], self.test_compat.joinpath( - self.test_archive.name[: self.test_archive.name.find(".tar.gz")] + self.test_archive.name[ + : self.test_archive.name.find(".tar.gz") + ] ).as_posix(), "Expected PROTONPATH to be proton dir in compat", ) @@ -887,7 +949,9 @@ def test_extract(self): "Expected proton dir to exists in compat", ) self.assertTrue( - self.test_compat.joinpath(self.test_proton_dir).joinpath("proton").exists(), + self.test_compat.joinpath(self.test_proton_dir) + .joinpath("proton") + .exists(), "Expected 'proton' file to exists in the proton dir", ) @@ -934,7 +998,9 @@ def test_game_drive_libpath_empty(self): os.environ[key] = val # Game drive - self.assertTrue(result_gamedrive is self.env, "Expected the same reference") + self.assertTrue( + result_gamedrive is self.env, "Expected the same reference" + ) self.assertTrue( self.env["STEAM_RUNTIME_LIBRARY_PATH"], "Expected two elements in STEAM_RUNTIME_LIBRARY_PATHS", @@ -955,7 +1021,9 @@ def test_game_drive_libpath_empty(self): self.env["STEAM_COMPAT_INSTALL_PATH"], "Expected STEAM_COMPAT_INSTALL_PATH to be empty", ) - self.assertFalse(self.env["EXE"], "Expected EXE to be empty on empty string") + self.assertFalse( + self.env["EXE"], "Expected EXE to be empty on empty string" + ) def test_game_drive_libpath(self): """Test enable_steam_game_drive for duplicate paths. @@ -1012,7 +1080,9 @@ def test_game_drive_libpath(self): os.environ[key] = val # Game drive - self.assertTrue(result_gamedrive is self.env, "Expected the same reference") + self.assertTrue( + result_gamedrive is self.env, "Expected the same reference" + ) self.assertTrue( self.env["STEAM_RUNTIME_LIBRARY_PATH"], "Expected two elements in STEAM_RUNTIME_LIBRARY_PATHS", @@ -1040,7 +1110,9 @@ def test_game_drive_libpath(self): self.env["STEAM_COMPAT_INSTALL_PATH"], "Expected STEAM_COMPAT_INSTALL_PATH to be empty", ) - self.assertFalse(self.env["EXE"], "Expected EXE to be empty on empty string") + self.assertFalse( + self.env["EXE"], "Expected EXE to be empty on empty string" + ) def test_game_drive_empty(self): """Test enable_steam_game_drive. @@ -1097,7 +1169,9 @@ def test_game_drive_empty(self): os.environ[key] = val # Game drive - self.assertTrue(result_gamedrive is self.env, "Expected the same reference") + self.assertTrue( + result_gamedrive is self.env, "Expected the same reference" + ) self.assertTrue( self.env["STEAM_RUNTIME_LIBRARY_PATH"], "Expected two elements in STEAM_RUNTIME_LIBRARY_PATHS", @@ -1122,7 +1196,9 @@ def test_game_drive_empty(self): self.env["STEAM_COMPAT_INSTALL_PATH"], "Expected STEAM_COMPAT_INSTALL_PATH to be empty", ) - self.assertFalse(self.env["EXE"], "Expected EXE to be empty on empty string") + self.assertFalse( + self.env["EXE"], "Expected EXE to be empty on empty string" + ) def test_build_command_noruntime(self): """Test build_command when disabling the Steam Runtime. @@ -1230,7 +1306,9 @@ def test_build_command_nopv(self): f"Expected 3 elements, received {len(test_command)}", ) proton, verb, exe, *_ = [*test_command] - self.assertIsInstance(proton, os.PathLike, "Expected proton to be PathLike") + self.assertIsInstance( + proton, os.PathLike, "Expected proton to be PathLike" + ) self.assertEqual( proton, Path(self.env["PROTONPATH"], "proton"), @@ -1290,6 +1368,10 @@ def test_build_command(self): # Mock the proton file Path(self.test_file, "proton").touch() + # Mock the shim file + shim_path = Path(self.test_local_share, "umu-shim") + shim_path.touch() + with ( patch("sys.argv", ["", self.test_exe]), ThreadPoolExecutor() as thread_pool, @@ -1313,11 +1395,17 @@ def test_build_command(self): os.environ[key] = val # Mock setting up the runtime - with (patch.object(umu_runtime, "_install_umu", return_value=None),): - umu_runtime.setup_umu(self.test_user_share, self.test_local_share, None) + with ( + patch.object(umu_runtime, "_install_umu", return_value=None), + ): + umu_runtime.setup_umu( + self.test_user_share, self.test_local_share, None + ) copytree( Path(self.test_user_share, "sniper_platform_0.20240125.75305"), - Path(self.test_local_share, "sniper_platform_0.20240125.75305"), + Path( + self.test_local_share, "sniper_platform_0.20240125.75305" + ), dirs_exist_ok=True, symlinks=True, ) @@ -1341,10 +1429,12 @@ def test_build_command(self): ) self.assertEqual( len(test_command), - 7, - f"Expected 7 elements, received {len(test_command)}", + 8, + f"Expected 8 elements, received {len(test_command)}", ) - entry_point, opt1, verb, opt2, proton, verb2, exe = [*test_command] + entry_point, opt1, verb, opt2, shim, proton, verb2, exe = [ + *test_command + ] # The entry point dest could change. Just check if there's a value self.assertTrue(entry_point, "Expected an entry point") self.assertIsInstance( @@ -1353,7 +1443,13 @@ def test_build_command(self): self.assertEqual(opt1, "--verb", "Expected --verb") self.assertEqual(verb, self.test_verb, "Expected a verb") self.assertEqual(opt2, "--", "Expected --") - self.assertIsInstance(proton, os.PathLike, "Expected proton to be PathLike") + self.assertIsInstance( + shim, os.PathLike, "Expected shim to be PathLike" + ) + self.assertEqual(shim, shim_path, "Expected the shim file") + self.assertIsInstance( + proton, os.PathLike, "Expected proton to be PathLike" + ) self.assertEqual( proton, Path(self.env["PROTONPATH"], "proton"), @@ -1419,7 +1515,9 @@ def test_set_env_opts(self): path_exe, "Expected EXE to be normalized and expanded", ) - self.assertEqual(self.env["STORE"], test_str, "Expected STORE to be set") + self.assertEqual( + self.env["STORE"], test_str, "Expected STORE to be set" + ) self.assertEqual( self.env["PROTONPATH"], path_file, @@ -1430,7 +1528,9 @@ def test_set_env_opts(self): path_file, "Expected WINEPREFIX to be normalized and expanded", ) - self.assertEqual(self.env["GAMEID"], test_str, "Expected GAMEID to be set") + self.assertEqual( + self.env["GAMEID"], test_str, "Expected GAMEID to be set" + ) self.assertEqual( self.env["PROTON_VERB"], self.test_verb, @@ -1485,7 +1585,9 @@ def test_set_env_id(self): Path(path_exe).parent.as_posix(), "Expected STEAM_COMPAT_INSTALL_PATH to be set", ) - self.assertEqual(self.env["STORE"], test_str, "Expected STORE to be set") + self.assertEqual( + self.env["STORE"], test_str, "Expected STORE to be set" + ) self.assertEqual( self.env["PROTONPATH"], path_file, @@ -1496,7 +1598,9 @@ def test_set_env_id(self): path_file, "Expected WINEPREFIX to be normalized and expanded", ) - self.assertEqual(self.env["GAMEID"], umu_id, "Expected GAMEID to be set") + self.assertEqual( + self.env["GAMEID"], umu_id, "Expected GAMEID to be set" + ) self.assertEqual( self.env["PROTON_VERB"], self.test_verb, @@ -1600,7 +1704,9 @@ def test_set_env_exe(self): self.env["STEAM_COMPAT_INSTALL_PATH"], "Expected STEAM_COMPAT_INSTALL_PATH to be empty", ) - self.assertEqual(self.env["STORE"], test_str, "Expected STORE to be set") + self.assertEqual( + self.env["STORE"], test_str, "Expected STORE to be set" + ) self.assertEqual( self.env["PROTONPATH"], path_file, @@ -1611,7 +1717,9 @@ def test_set_env_exe(self): path_file, "Expected WINEPREFIX to be normalized and expanded", ) - self.assertEqual(self.env["GAMEID"], test_str, "Expected GAMEID to be set") + self.assertEqual( + self.env["GAMEID"], test_str, "Expected GAMEID to be set" + ) self.assertEqual( self.env["PROTON_VERB"], self.test_verb, @@ -1718,7 +1826,9 @@ def test_set_env(self): Path(path_exe).parent.as_posix(), "Expected STEAM_COMPAT_INSTALL_PATH to be set", ) - self.assertEqual(self.env["STORE"], test_str, "Expected STORE to be set") + self.assertEqual( + self.env["STORE"], test_str, "Expected STORE to be set" + ) self.assertEqual( self.env["PROTONPATH"], path_file, @@ -1729,7 +1839,9 @@ def test_set_env(self): path_file, "Expected WINEPREFIX to be normalized and expanded", ) - self.assertEqual(self.env["GAMEID"], test_str, "Expected GAMEID to be set") + self.assertEqual( + self.env["GAMEID"], test_str, "Expected GAMEID to be set" + ) self.assertEqual( self.env["PROTON_VERB"], self.test_verb, @@ -1788,6 +1900,7 @@ def test_set_env_winetricks(self): result = None test_str = "foo" verb = "foo" + proton_verb = "run" test_exe = "winetricks" # Mock a Proton directory that contains winetricks @@ -1805,7 +1918,7 @@ def test_set_env_winetricks(self): os.environ["PROTONPATH"] = test_dir.as_posix() os.environ["GAMEID"] = test_str os.environ["STORE"] = test_str - os.environ["PROTON_VERB"] = self.test_verb + os.environ["PROTON_VERB"] = proton_verb # Args result = umu_run.parse_args() # Check @@ -1850,7 +1963,9 @@ def test_set_env_winetricks(self): Path(path_exe).parent.as_posix(), "Expected STEAM_COMPAT_INSTALL_PATH to be set", ) - self.assertEqual(self.env["STORE"], test_str, "Expected STORE to be set") + self.assertEqual( + self.env["STORE"], test_str, "Expected STORE to be set" + ) self.assertEqual( self.env["PROTONPATH"], Path(path_exe).parent.parent.as_posix(), @@ -1861,10 +1976,12 @@ def test_set_env_winetricks(self): path_file, "Expected WINEPREFIX to be normalized and expanded", ) - self.assertEqual(self.env["GAMEID"], test_str, "Expected GAMEID to be set") + self.assertEqual( + self.env["GAMEID"], test_str, "Expected GAMEID to be set" + ) self.assertEqual( self.env["PROTON_VERB"], - self.test_verb, + proton_verb, "Expected PROTON_VERB to be set", ) # umu @@ -2020,11 +2137,15 @@ def test_setup_pfx_symlinks_unixuser(self): # Verify steamuser -> unix user self.assertTrue( - Path(self.test_file).joinpath("drive_c/users/steamuser").is_symlink(), + Path(self.test_file) + .joinpath("drive_c/users/steamuser") + .is_symlink(), "Expected steamuser to be a symbolic link", ) self.assertEqual( - Path(self.test_file).joinpath("drive_c/users/steamuser").readlink(), + Path(self.test_file) + .joinpath("drive_c/users/steamuser") + .readlink(), Path(self.user), "Expected steamuser -> user", ) @@ -2070,7 +2191,9 @@ def test_setup_pfx_symlinks_steamuser(self): "Expected symbolic link for unixuser", ) self.assertEqual( - Path(self.test_file).joinpath(f"drive_c/users/{self.user}").readlink(), + Path(self.test_file) + .joinpath(f"drive_c/users/{self.user}") + .readlink(), Path("steamuser"), "Expected unixuser -> steamuser", ) @@ -2235,7 +2358,9 @@ def test_parse_args(self): result = umu_run.parse_args() self.assertIsInstance(result, tuple, "Expected a tuple") self.assertIsInstance(result[0], str, "Expected a string") - self.assertIsInstance(result[1], list, "Expected a list as options") + self.assertIsInstance( + result[1], list, "Expected a list as options" + ) self.assertEqual( *result[1], test_opt, @@ -2414,7 +2539,9 @@ def test_env_vars_proton(self): os.environ["WINEPREFIX"] = self.test_file os.environ["GAMEID"] = self.test_file result = umu_run.check_env(self.env, thread_pool) - self.assertTrue(result is self.env, "Expected the same reference") + self.assertTrue( + result is self.env, "Expected the same reference" + ) self.assertFalse(os.environ["PROTONPATH"]) def test_env_vars_wine(self): diff --git a/umu/umu_test_plugins.py b/umu/umu_test_plugins.py index b7cd3d149..1e63020d4 100644 --- a/umu/umu_test_plugins.py +++ b/umu/umu_test_plugins.py @@ -332,6 +332,10 @@ def test_build_command_toml(self): Path(self.test_file + "/proton").touch() Path(toml_path).touch() + # Mock the shim file + shim_path = Path(self.test_local_share, "umu-shim") + shim_path.touch() + with Path(toml_path).open(mode="w", encoding="utf-8") as file: file.write(toml_str) @@ -380,7 +384,7 @@ def test_build_command_toml(self): test_command = umu_run.build_command(self.env, self.test_local_share) # Verify contents of the command - entry_point, opt1, verb, opt2, proton, verb2, exe = [*test_command] + entry_point, opt1, verb, opt2, shim, proton, verb2, exe = [*test_command] # The entry point dest could change. Just check if there's a value self.assertTrue(entry_point, "Expected an entry point") self.assertIsInstance( @@ -389,6 +393,8 @@ def test_build_command_toml(self): self.assertEqual(opt1, "--verb", "Expected --verb") self.assertEqual(verb, self.test_verb, "Expected a verb") self.assertEqual(opt2, "--", "Expected --") + self.assertIsInstance(shim, os.PathLike, "Expected shim to be PathLike") + self.assertEqual(shim, shim_path, "Expected the shim file") self.assertIsInstance(proton, os.PathLike, "Expected proton to be PathLike") self.assertEqual( proton,