Skip to content

Commit

Permalink
Support for non-Steam applications. Fixes #22
Browse files Browse the repository at this point in the history
  • Loading branch information
Matoking committed Nov 5, 2019
1 parent 6085d4d commit b326ae2
Show file tree
Hide file tree
Showing 6 changed files with 272 additions and 11 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]
### Added
- Non-Steam applications are now detected.

## [1.2.5] - 2019-09-17
### Fixed
- Fix regression in 1.2.3 that broke detection of custom Proton installations.
Expand Down
5 changes: 4 additions & 1 deletion src/protontricks/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ def main(args=None):
steam_lib_paths = get_steam_lib_paths(steam_path)

# 5. Find any Steam apps
steam_apps = get_steam_apps(steam_root, steam_lib_paths)
steam_apps = get_steam_apps(
steam_root=steam_root, steam_path=steam_path,
steam_lib_paths=steam_lib_paths
)

# It's too early to find Proton here,
# as it cannot be found if no globally active Proton version is set.
Expand Down
120 changes: 117 additions & 3 deletions src/protontricks/steam.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
import string
import struct
import zlib

from pathlib import Path

Expand All @@ -27,7 +28,9 @@

class SteamApp(object):
"""
SteamApp represents an installed Steam app
SteamApp represents an installed Steam app or whatever is close enough to
one (eg. a custom Proton installation or a Windows shortcut with its own
Proton prefix)
"""
__slots__ = ("appid", "name", "prefix_path", "install_path")

Expand Down Expand Up @@ -587,7 +590,117 @@ def get_custom_proton_installations(steam_root):
return custom_proton_apps


def get_steam_apps(steam_root, steam_lib_paths):
def find_current_steamid3(steam_path):
def to_steamid3(steamid64):
"""Convert a SteamID64 into the SteamID3 format"""
return int(steamid64) & 0xffffffff

loginusers_path = os.path.join(steam_path, "config", "loginusers.vdf")
try:
with open(loginusers_path, "r") as f:
content = f.read()
vdf_data = vdf.loads(content)
except IOError:
return None

users = [
{
"steamid3": to_steamid3(user_id),
"account_name": user_data["AccountName"],
"timestamp": user_data.get("Timestamp", 0)
}
for user_id, user_data in vdf_data["users"].items()
]

# Return the user with the highest timestamp, as that's likely to be the
# currently logged-in user
if users:
user = max(users, key=lambda u: u["timestamp"])
logger.info(
"Currently logged-in Steam user: %s", user["account_name"]
)
return user["steamid3"]

return None


def get_appid_from_shortcut(target, name):
"""
Get the identifier used for the Proton prefix from a shortcut's
target and name
"""
# First, calculate the screenshot ID Steam uses for shortcuts
data = b"".join([
target.encode("utf-8"),
name.encode("utf-8")
])
result = zlib.crc32(data) & 0xffffffff
result = result | 0x80000000
result = (result << 32) | 0x02000000

# Derive the prefix ID from the screenshot ID
return result >> 32


def get_custom_windows_shortcuts(steam_path):
"""
Get a list of custom shortcuts for Windows applications as a list
of SteamApp objects
"""
# Get the Steam ID3 for the currently logged-in user
steamid3 = find_current_steamid3(steam_path)

shortcuts_path = os.path.join(
steam_path, "userdata", str(steamid3), "config", "shortcuts.vdf"
)

try:
with open(shortcuts_path, "rb") as f:
content = f.read()
vdf_data = vdf.binary_loads(content)
except IOError:
logger.info(
"Couldn't find custom shortcuts. Maybe none have been created yet?"
)
return []

steam_apps = []

for shortcut_id, shortcut_data in vdf_data["shortcuts"].items():
# The "exe" field can also be "Exe". Account for this by making
# all field names lowercase
shortcut_data = {k.lower(): v for k, v in shortcut_data.items()}
shortcut_id = int(shortcut_id)

appid = get_appid_from_shortcut(
target=shortcut_data["exe"], name=shortcut_data["appname"]
)

prefix_path = os.path.join(
steam_path, "steamapps", "compatdata", str(appid), "pfx"
)
install_path = shortcut_data["startdir"]

if not os.path.isdir(prefix_path):
breakpoint()
continue

steam_apps.append(
SteamApp(
appid=appid,
name="Non-Steam shortcut: {}".format(shortcut_data["appname"]),
prefix_path=prefix_path, install_path=install_path
)
)

logger.info(
"Found %d Steam shortcuts running under Proton", len(steam_apps)
)

return steam_apps


def get_steam_apps(steam_root, steam_path, steam_lib_paths):
"""
Find all the installed Steam apps and return them as a list of SteamApp
objects
Expand All @@ -612,8 +725,9 @@ def get_steam_apps(steam_root, steam_lib_paths):
if steam_app:
steam_apps.append(steam_app)

# Get the custom Proton installations as well
# Get the custom Proton installations and non-Steam shortcuts as well
steam_apps += get_custom_proton_installations(steam_root=steam_root)
steam_apps += get_custom_windows_shortcuts(steam_path=steam_path)

# Exclude games that haven't been launched yet
steam_apps = [
Expand Down
113 changes: 110 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import os
import random
import shutil
import struct
import zlib
from collections import defaultdict
from pathlib import Path

import vdf

import pytest
from protontricks.cli import main
from protontricks.steam import (APPINFO_STRUCT_HEADER, APPINFO_STRUCT_SECTION,
SteamApp)
SteamApp, get_appid_from_shortcut)

import pytest


@pytest.fixture(scope="function", autouse=True)
Expand Down Expand Up @@ -60,6 +63,15 @@ def steam_dir(home_dir):
yield steam_path / "steam"


@pytest.fixture(scope="function")
def steam_root(steam_dir):
"""
Fake Steam directory. Compared to "steam_dir", it points to
"~/.steam/root" instead of "~/.steam/steam"
"""
yield steam_dir.parent / "root"


@pytest.fixture(scope="function", autouse=True)
def steam_runtime_dir(steam_dir):
"""
Expand All @@ -79,6 +91,101 @@ def steam_runtime_dir(steam_dir):
yield steam_dir.parent / "root" / "ubuntu12_32"


@pytest.fixture(scope="function")
def steam_user_factory(steam_dir):
"""
Factory function for creating fake Steam users
"""
steam_users = []

def func(name, steamid64=None):
if not steamid64:
steamid64 = random.randint((2**32), (2**64)-1)
steam_users.append({
"name": name,
"steamid64": steamid64
})

loginusers_path = steam_dir / "config" / "loginusers.vdf"
data = {"users": {}}
for i, user in enumerate(steam_users):
data["users"][str(user["steamid64"])] = {
"AccountName": user["name"],
# This ensures the newest Steam user is assumed to be logged-in
"Timestamp": i
}

loginusers_path.write_text(vdf.dumps(data))

return steamid64

return func


@pytest.fixture(scope="function", autouse=True)
def steam_user(steam_user_factory):
return steam_user_factory(name="TestUser", steamid64=(2**32)+42)


@pytest.fixture(scope="function")
def shortcut_factory(steam_dir, steam_user):
"""
Factory function for creating fake shortcuts
"""
shortcuts_by_user = defaultdict(list)

def func(install_dir, name, steamid64=None):
if not steamid64:
steamid64 = steam_user

# Update shortcuts.vdf first
steamid3 = int(steamid64) & 0xffffffff
shortcuts_by_user[steamid3].append({
"install_dir": install_dir, "name": name
})

shortcut_path = (
steam_dir / "userdata" / str(steamid3) / "config"
/ "shortcuts.vdf"
)
shortcut_path.parent.mkdir(parents=True, exist_ok=True)
data = {"shortcuts": {}}
for shortcut_data in shortcuts_by_user[steamid3]:
install_dir_ = shortcut_data["install_dir"]
name_ = shortcut_data["name"]

entry = {
"AppName": name_,
"StartDir": install_dir_,
"exe": str(Path(install_dir_) / name_)
}
# Derive the shortcut ID
crc_data = b"".join([
entry["exe"].encode("utf-8"),
entry["AppName"].encode("utf-8")
])
result = zlib.crc32(crc_data) & 0xffffffff
result = result | 0x80000000
shortcut_id = (result << 32) | 0x02000000

data["shortcuts"][str(shortcut_id)] = entry

shortcut_path.write_bytes(vdf.binary_dumps(data))

appid = get_appid_from_shortcut(
target=str(Path(install_dir) / name), name=name
)

# Create the fake prefix
(steam_dir / "steamapps" / "compatdata" / str(appid) / "pfx").mkdir(
parents=True)
(steam_dir / "steamapps" / "compatdata" / str(appid) / "pfx.lock").touch()

return shortcut_id

return func


@pytest.fixture(scope="function", autouse=True)
def steam_config_path(steam_dir):
"""
Expand Down
30 changes: 30 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ def test_run_winetricks(
str(proton_install_path / "dist" / "lib" / "wine")
)

def test_run_winetricks_shortcut(
self, cli, shortcut_factory, default_proton, command,
steam_dir):
"""
Perform a Protontricks command for a non-Steam shortcut
"""
proton_install_path = Path(default_proton.install_path)
shortcut_factory(install_dir="fake/path/", name="fakegame.exe")

cli(["4149337689", "winecfg"])

# Default Proton is used
assert command.env["WINE"] == str(
proton_install_path / "dist" / "bin" / "wine")
assert command.env["WINEPREFIX"] == str(
steam_dir / "steamapps" / "compatdata" / "4149337689" / "pfx")

def test_run_winetricks_select_proton(
self, cli, steam_app_factory, default_proton,
custom_proton_factory, command, home_dir):
Expand Down Expand Up @@ -329,3 +346,16 @@ def test_search_multiple_library_folders(
assert "Fake game 1" in result
assert "Fake game 2" in result
assert "Fake game 3" in result

def test_search_shortcut(
self, cli, shortcut_factory):
"""
Create two non-Steam shortcut and ensure they can be found
"""
shortcut_factory(install_dir="fake/path/", name="fakegame.exe")
shortcut_factory(install_dir="fake/path2/", name="fakegame.exe")

result = cli(["-v", "-s", "steam"])

assert "Non-Steam shortcut: fakegame.exe (4149337689)" in result
assert "Non-Steam shortcut: fakegame.exe (4136117770)" in result
11 changes: 7 additions & 4 deletions tests/test_steam.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,17 @@ def test_find_steam_specific_app_proton(

class TestGetSteamApps:
def test_get_steam_apps_custom_proton(
self, default_proton, custom_proton_factory, steam_dir):
self, default_proton, custom_proton_factory, steam_dir,
steam_root):
"""
Create a custom Proton installation and ensure
'get_steam_apps' can find it
"""
custom_proton = custom_proton_factory(name="Custom Proton")

steam_apps = get_steam_apps(
steam_root=str(steam_dir.parent / "root"),
steam_root=str(steam_root),
steam_path=str(steam_dir),
steam_lib_paths=[str(steam_dir)]
)

Expand All @@ -101,7 +103,7 @@ def test_get_steam_apps_custom_proton(

def test_get_steam_apps_in_library_folder(
self, default_proton, steam_library_factory, steam_app_factory,
steam_dir):
steam_dir, steam_root):
"""
Create two games, one installed in the Steam installation directory
and another in a Steam library folder
Expand All @@ -112,7 +114,8 @@ def test_get_steam_apps_in_library_folder(
name="Fake game 2", appid=20, library_dir=library_dir)

steam_apps = get_steam_apps(
steam_root=str(steam_dir.parent / "root"),
steam_root=str(steam_root),
steam_path=str(steam_dir),
steam_lib_paths=[str(steam_dir), str(library_dir)]
)

Expand Down

0 comments on commit b326ae2

Please sign in to comment.